Add initial invitation framework for chat channels (#528)
* Add initial invitation framework for chat channels * Update chat_channel specs * Add DIY concept of accepting invite * Add some js and fix spec * Add full invite accept functionality * Remove console.log from chat
This commit is contained in:
parent
a3aa5b5bd2
commit
94e5154668
27 changed files with 520 additions and 41 deletions
|
|
@ -188,6 +188,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chatNonChatView{
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
padding: 80px 7%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.chatNonChatView_exitbutton{
|
||||
position: absolute;
|
||||
left: 4%;
|
||||
top: 60px;
|
||||
font-size:25px;
|
||||
background: white;
|
||||
border:0px;
|
||||
}
|
||||
|
||||
.chat__videocallexitbutton{
|
||||
border: 0px;
|
||||
font-size: 20px;
|
||||
|
|
@ -343,6 +362,18 @@
|
|||
border-top: 1px solid $light-medium-gray;
|
||||
}
|
||||
|
||||
.chat__channelinvitationsindicator button {
|
||||
background: $green;
|
||||
text-align: center;
|
||||
padding: 10px 0px;
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
margin: 3px 0px;
|
||||
border: 0px;
|
||||
width: 93%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chatchannels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ module Api
|
|||
end
|
||||
|
||||
def show
|
||||
@user = User.find(params[:id])
|
||||
if params[:id] == "by_username"
|
||||
@user = User.find_by_username(params[:url])
|
||||
else
|
||||
@user = User.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
def less_than_one_day_old?(user)
|
||||
|
|
|
|||
26
app/controllers/chat_channel_memberships_controller.rb
Normal file
26
app/controllers/chat_channel_memberships_controller.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
class ChatChannelMembershipsController < ApplicationController
|
||||
def create
|
||||
@chat_channel = ChatChannel.find(params[:chat_channel_id])
|
||||
authorize @chat_channel, :update?
|
||||
ChatChannelMembership.create(
|
||||
user_id: params[:user_id],
|
||||
chat_channel_id: @chat_channel.id,
|
||||
status: "pending"
|
||||
)
|
||||
end
|
||||
|
||||
def update
|
||||
@chat_channel_membership = ChatChannelMembership.find(params[:id])
|
||||
authorize @chat_channel_membership
|
||||
if params[:user_action] == "accept"
|
||||
@chat_channel_membership.update(status: "active")
|
||||
else
|
||||
@chat_channel_membership.update(status: "rejected")
|
||||
end
|
||||
@chat_channels_memberships = current_user.
|
||||
chat_channel_memberships.includes(:chat_channel).
|
||||
where(status: "pending").
|
||||
order("chat_channel_memberships.updated_at DESC")
|
||||
render "chat_channels/index.json"
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,9 @@ class ChatChannelsController < ApplicationController
|
|||
if params[:state] == "unopened"
|
||||
authorize ChatChannel
|
||||
render_unopened_json_response
|
||||
elsif params[:state] == "pending"
|
||||
authorize ChatChannel
|
||||
render_pending_json_response
|
||||
else
|
||||
skip_authorization
|
||||
render_channels_html
|
||||
|
|
@ -17,6 +20,27 @@ class ChatChannelsController < ApplicationController
|
|||
authorize @chat_channel
|
||||
end
|
||||
|
||||
def create
|
||||
authorize ChatChannel
|
||||
@chat_channel = ChatChannelCreationService.new(current_user, params[:chat_channel]).create
|
||||
if @chat_channel.valid?
|
||||
render json: { status: "success", chat_channel: @chat_channel.to_json(only: [:channel_name,:slug]) }, status: 200
|
||||
else
|
||||
render json: { errors: @chat_channel.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@chat_channel = ChatChannel.find(params[:id])
|
||||
authorize @chat_channel
|
||||
ChatChannelUpdateService.new(@chat_channel, chat_channel_params).update
|
||||
if @chat_channel.valid?
|
||||
render json: { status: "success", chat_channel: @chat_channel.to_json(only: [:channel_name,:slug]) }, status: 200
|
||||
else
|
||||
render json: { errors: @chat_channel.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def open
|
||||
@chat_channel = ChatChannel.find(params[:id])
|
||||
authorize @chat_channel
|
||||
|
|
@ -60,7 +84,7 @@ class ChatChannelsController < ApplicationController
|
|||
private
|
||||
|
||||
def chat_channel_params
|
||||
params.require(:chat_channel).permit(:command)
|
||||
params.require(:chat_channel).permit(policy(ChatChannel).permitted_attributes)
|
||||
end
|
||||
|
||||
def render_unopened_json_response
|
||||
|
|
@ -75,6 +99,19 @@ class ChatChannelsController < ApplicationController
|
|||
render "index.json"
|
||||
end
|
||||
|
||||
def render_pending_json_response
|
||||
if current_user
|
||||
@chat_channels_memberships = current_user.
|
||||
chat_channel_memberships.includes(:chat_channel).
|
||||
where(status: "pending").
|
||||
order("chat_channel_memberships.updated_at DESC")
|
||||
else
|
||||
@chat_channels_memberships = []
|
||||
end
|
||||
render "index.json"
|
||||
end
|
||||
|
||||
|
||||
def render_channels_html
|
||||
return unless current_user
|
||||
if params[:slug]
|
||||
|
|
|
|||
|
|
@ -142,4 +142,33 @@ export function getJSONContents(url, successCb, failureCb) {
|
|||
.then(response => response.json())
|
||||
.then(successCb)
|
||||
.catch(failureCb);
|
||||
}
|
||||
|
||||
export function getChannelInvites(successCb, failureCb) {
|
||||
fetch('/chat_channels?state=pending', {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(successCb)
|
||||
.catch(failureCb);
|
||||
};
|
||||
|
||||
export function sendChannelInviteAction(id, action, successCb, failureCb) {
|
||||
fetch('/chat_channel_memberships/'+id, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_action: action,
|
||||
}),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(successCb)
|
||||
.catch(failureCb);
|
||||
}
|
||||
31
app/javascript/chat/channelDetails.jsx
Normal file
31
app/javascript/chat/channelDetails.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
export default class ChannelDetails extends Component {
|
||||
|
||||
render() {
|
||||
const channel = this.props.channel
|
||||
const users = Object.values(channel.channel_users).map((user) => {
|
||||
return <div>
|
||||
<a
|
||||
href={'/'+user.username}
|
||||
style={{color:user.darker_color,padding: "3px 0px"}}
|
||||
data-content={`users/by_username?url=${user.username}`}
|
||||
>
|
||||
{user.name}
|
||||
</a>
|
||||
</div>
|
||||
});
|
||||
let subHeader = ''
|
||||
if (users.length === 80) {
|
||||
subHeader = <h3>Recently Active Members</h3>
|
||||
}
|
||||
return <div>
|
||||
<h1>{channel.channel_name}</h1>
|
||||
{subHeader}
|
||||
{users}
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { h, Component } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { conductModeration, getAllMessages, sendMessage, sendOpen, getChannels, getContent } from './actions';
|
||||
import { conductModeration, getAllMessages, sendMessage, sendOpen, getChannels, getContent, getChannelInvites, sendChannelInviteAction } from './actions';
|
||||
import { hideMessages, scrollToBottom, setupObserver, setupNotifications, getNotificationState } from './util';
|
||||
import Alert from './alert';
|
||||
import Channels from './channels';
|
||||
|
|
@ -8,6 +8,7 @@ import Compose from './compose';
|
|||
import Message from './message';
|
||||
import Content from './content';
|
||||
import Video from './video';
|
||||
import View from './view';
|
||||
|
||||
import setupPusher from '../src/utils/pusher';
|
||||
|
||||
|
|
@ -42,7 +43,9 @@ export default class Chat extends Component {
|
|||
isMobileDevice: typeof window.orientation !== "undefined",
|
||||
subscribedPusherChannels: [],
|
||||
activeVideoChannelId: null,
|
||||
incomingVideoCallChannelIds: []
|
||||
incomingVideoCallChannelIds: [],
|
||||
nonChatView: null,
|
||||
inviteChannels: []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +80,9 @@ export default class Chat extends Component {
|
|||
if (document.getElementById('chatchannels__channelslist')) {
|
||||
document.getElementById('chatchannels__channelslist').addEventListener('scroll', this.handleChannelScroll);
|
||||
}
|
||||
getChannelInvites(this.handleChannelInvites,null);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (!this.state.scrolled) {
|
||||
scrollToBottom();
|
||||
|
|
@ -320,6 +325,10 @@ export default class Chat extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
handleChannelInvites = response => {
|
||||
this.setState({inviteChannels: response})
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
const enterPressed = e.keyCode === 13;
|
||||
const targetValue = e.target.value;
|
||||
|
|
@ -446,21 +455,26 @@ export default class Chat extends Component {
|
|||
}
|
||||
|
||||
triggerActiveContent = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.target
|
||||
let newActiveContent = this.state.activeContent
|
||||
if (e.target.dataset.content && e.target.dataset.content != "exit") {
|
||||
e.preventDefault();
|
||||
if (target.dataset.content === 'channel-details') {
|
||||
newActiveContent[this.state.activeChannelId] = {
|
||||
type_of: "channel-details",
|
||||
channel: this.state.activeChannel
|
||||
}
|
||||
} else if (target.dataset.content && target.dataset.content.startsWith('users/')) {
|
||||
newActiveContent[this.state.activeChannelId] = {type_of: "loading-user"}
|
||||
getContent('/api/'+target.dataset.content, this.setActiveContent, null)
|
||||
} else if (target.tagName.toLowerCase() === 'a' && target.href.startsWith('https://dev.to/')) {
|
||||
e.preventDefault();
|
||||
newActiveContent[this.state.activeChannelId] = {type_of: "loading-post"}
|
||||
getContent(`/api/articles/by_path?url=${target.href.split('https://dev.to')[1]}`, this.setActiveContent, null)
|
||||
} else if (target.dataset.content === "exit") {
|
||||
e.preventDefault();
|
||||
newActiveContent[this.state.activeChannelId] = null
|
||||
}
|
||||
this.setState({activeContent: newActiveContent})
|
||||
this.setState({activeContent: newActiveContent})
|
||||
return false;
|
||||
}
|
||||
|
||||
setActiveContent = response => {
|
||||
|
|
@ -482,6 +496,20 @@ export default class Chat extends Component {
|
|||
this.setState({ chatChannels: newChannelsObj });
|
||||
};
|
||||
|
||||
handleInvitationAccept = e => {
|
||||
const id = e.target.dataset.content
|
||||
sendChannelInviteAction(id, 'accept', this.handleChannelInviteResult, null)
|
||||
}
|
||||
|
||||
handleInvitationDecline = e => {
|
||||
const id = e.target.dataset.content
|
||||
sendChannelInviteAction(id, 'decline', this.handleChannelInviteResult, null)
|
||||
}
|
||||
|
||||
handleChannelInviteResult = response => {
|
||||
this.setState({inviteChannels: response})
|
||||
}
|
||||
|
||||
triggerChannelTypeFilter = e => {
|
||||
const type = e.target.dataset.channelType;
|
||||
this.setState({
|
||||
|
|
@ -492,6 +520,14 @@ export default class Chat extends Component {
|
|||
getChannels(this.state.filterQuery, null, this.props, 0, filters, this.loadChannels);
|
||||
}
|
||||
|
||||
triggerNonChatView = e => {
|
||||
this.setState({nonChatView: e.target.dataset.content})
|
||||
}
|
||||
|
||||
triggerExitView = () => {
|
||||
this.setState({nonChatView: null})
|
||||
}
|
||||
|
||||
handleFailure = err => {
|
||||
console.error(err);
|
||||
};
|
||||
|
|
@ -538,6 +574,7 @@ export default class Chat extends Component {
|
|||
const notificationsPermission = this.state.notificationsPermission;
|
||||
let notificationsButton = "";
|
||||
let notificationsState = "";
|
||||
let invitesButton = ''
|
||||
if (notificationsPermission === "waiting-permission") {
|
||||
notificationsButton = <div><button className="chat__notificationsbutton " onClick={this.triggerNotificationRequest}>Turn on Notifications</button></div>;
|
||||
} else if (notificationsPermission === "granted") {
|
||||
|
|
@ -545,12 +582,18 @@ export default class Chat extends Component {
|
|||
} else if (notificationsPermission === "denied") {
|
||||
notificationsState = <div className="chat_chatconfig chat_chatconfig--off">Notificatins Off</div>
|
||||
}
|
||||
if (this.state.inviteChannels.length > 0) {
|
||||
invitesButton = <div class='chat__channelinvitationsindicator'><button onClick={this.triggerNonChatView} data-content='invitations'>
|
||||
New Invitations!
|
||||
</button></div>
|
||||
}
|
||||
if (this.state.expanded) {
|
||||
return (
|
||||
<div className="chat__channels chat__channels--expanded">
|
||||
{notificationsButton}
|
||||
<button className="chat__channelstogglebutt" onClick={this.toggleExpand}>{"<"}</button>
|
||||
<input placeholder='Filter' onKeyUp={this.triggerChannelFilter} />
|
||||
{invitesButton}
|
||||
<div className='chat__channeltypefilter'>
|
||||
<button data-channel-type='all' onClick={this.triggerChannelTypeFilter} className={`chat__channeltypefilterbutton chat__channeltypefilterbutton--${this.state.channelTypeFilter === 'all' ? 'active' : 'inactive'}`}>
|
||||
all
|
||||
|
|
@ -619,8 +662,8 @@ export default class Chat extends Component {
|
|||
</div>
|
||||
</div>
|
||||
<Content
|
||||
onTriggerContent={this.triggerActiveContent}
|
||||
resource={this.state.activeContent[this.state.activeChannelId]}
|
||||
onExit={this.triggerActiveContent}
|
||||
activeChannelId={this.state.activeChannelId}
|
||||
pusherKey={this.props.pusherKey}
|
||||
githubToken={this.props.githubToken}
|
||||
|
|
@ -638,10 +681,10 @@ export default class Chat extends Component {
|
|||
let channelHeaderInner = ''
|
||||
if (currentChannel.channel_type === "direct") {
|
||||
const username = currentChannel.slug.replace(`${window.currentUser.username}/`, '').replace(`/${window.currentUser.username}`, '');
|
||||
channelHeaderInner = <a href={'/'+username}>@{username}</a>
|
||||
channelHeaderInner = <a href={'/'+username} onClick={this.triggerActiveContent} data-content={`users/by_username?url=${username}`}>@{username}</a>
|
||||
}
|
||||
else {
|
||||
channelHeaderInner = currentChannel.channel_name;
|
||||
channelHeaderInner = <a href={'/'+currentChannel.adjusted_slug} onClick={this.triggerActiveContent} data-content='channel-details'>{currentChannel.channel_name}</a>;
|
||||
}
|
||||
channelHeader = <div className="activechatchannel__header">
|
||||
{channelHeaderInner}
|
||||
|
|
@ -654,11 +697,21 @@ export default class Chat extends Component {
|
|||
} else if (this.state.incomingVideoCallChannelIds.includes(this.state.activeChannelId) ) {
|
||||
incomingCall = <div className="activechatchannel__incomingcall" onClick={this.answerVideoCall}>👋 Incoming Video Call </div>
|
||||
}
|
||||
let nonChatView = ''
|
||||
if (this.state.nonChatView) {
|
||||
nonChatView = <View
|
||||
channels={this.state.inviteChannels}
|
||||
onViewExit={this.triggerExitView}
|
||||
onAcceptInvitation={this.handleInvitationAccept}
|
||||
onDeclineInvitation={this.handleInvitationDecline}
|
||||
/>
|
||||
}
|
||||
return (
|
||||
<div className={"chat chat--" + (this.state.expanded ? "expanded" : "contracted")}>
|
||||
<div className={"chat chat--" + (this.state.expanded ? "expanded" : "contracted")} data-no-instant>
|
||||
{this.renderChatChannels()}
|
||||
<div className="chat__activechat">
|
||||
{vid}
|
||||
{nonChatView}
|
||||
{this.renderActiveChatChannel(channelHeader, incomingCall)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { h, Component } from 'preact';
|
|||
import PropTypes from 'prop-types';
|
||||
import CodeEditor from './codeEditor';
|
||||
import GithubRepo from './githubRepo';
|
||||
import ChannelDetails from './channelDetails';
|
||||
import UserDetails from './userDetails';
|
||||
|
||||
export default class Content extends Component {
|
||||
static propTypes = {
|
||||
resource: PropTypes.object,
|
||||
onExit: PropTypes.func,
|
||||
activeChannelId: PropTypes.number,
|
||||
pusherKey: PropTypes.string,
|
||||
};
|
||||
|
|
@ -15,10 +16,12 @@ export default class Content extends Component {
|
|||
return ""
|
||||
} else {
|
||||
return (
|
||||
<div className="activechatchannel__activecontent" id="chat_activecontent">
|
||||
<div
|
||||
className="activechatchannel__activecontent" id="chat_activecontent"
|
||||
onClick={this.props.onTriggerContent}
|
||||
>
|
||||
<button
|
||||
class="activechatchannel__activecontentexitbutton"
|
||||
onClick={this.props.onExit}
|
||||
data-content="exit"
|
||||
>×</button>
|
||||
{display(this.props)}
|
||||
|
|
@ -43,20 +46,7 @@ function display(props) {
|
|||
display: "block",
|
||||
backgroundColor: "#f5f6f7"}}></div>
|
||||
} else if (props.resource.type_of === "user") {
|
||||
return <div><img
|
||||
src={props.resource.profile_image}
|
||||
style={{height: "210px",
|
||||
width: "210px",
|
||||
margin:" 15px auto",
|
||||
display: "block",
|
||||
borderRadius: "500px"}} />
|
||||
<h1 style={{textAlign: "center"}}>
|
||||
<a href={"/"+props.resource.username}>{props.resource.name}</a>
|
||||
</h1>
|
||||
<div style={{fontStyle: "italic"}}>
|
||||
{props.resource.summary}
|
||||
</div>
|
||||
</div>
|
||||
return <UserDetails user={props.resource} />
|
||||
} else if (props.resource.type_of === "article") {
|
||||
return (
|
||||
<div class="container">
|
||||
|
|
@ -80,6 +70,11 @@ function display(props) {
|
|||
githubToken={props.githubToken}
|
||||
resource={props.resource}
|
||||
/>
|
||||
} else if (props.resource.type_of === "channel-details") {
|
||||
return <ChannelDetails
|
||||
channel={props.resource.channel}
|
||||
activeChannelId={props.activeChannelId}
|
||||
/>
|
||||
} else if (props.resource.type_of === "code_editor") {
|
||||
return <CodeEditor
|
||||
activeChannelId={props.activeChannelId}
|
||||
|
|
|
|||
24
app/javascript/chat/userDetails.jsx
Normal file
24
app/javascript/chat/userDetails.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
export default class UserDetails extends Component {
|
||||
render() {
|
||||
const user = this.props.user
|
||||
return <div><img
|
||||
src={user.profile_image}
|
||||
style={{height: "210px",
|
||||
width: "210px",
|
||||
margin:" 15px auto",
|
||||
display: "block",
|
||||
borderRadius: "500px"}} />
|
||||
<h1 style={{textAlign: "center"}}>
|
||||
<a href={"/"+user.username}>{user.name}</a>
|
||||
</h1>
|
||||
<div style={{fontStyle: "italic"}}>
|
||||
{user.summary}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
26
app/javascript/chat/view.jsx
Normal file
26
app/javascript/chat/view.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
|
||||
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
export default class View extends Component {
|
||||
|
||||
render() {
|
||||
const channels = this.props.channels.map((channel) => {
|
||||
return <div>{channel.channel_name}
|
||||
<button onClick={this.props.onAcceptInvitation} data-content={channel.membership_id}>Accept</button>
|
||||
<button onClick={this.props.onDeclineInvitation} data-content={channel.membership_id}>Decline</button>
|
||||
</div>
|
||||
});
|
||||
return <div className="chatNonChatView">
|
||||
<button
|
||||
class="chatNonChatView_exitbutton"
|
||||
data-content="exit"
|
||||
onClick={this.props.onViewExit}
|
||||
>×</button>
|
||||
<h1>Channel Invitations</h1>
|
||||
<h2>Invitations are a work in progress ❤️</h2>
|
||||
{channels}
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -5,6 +5,13 @@ class ChatChannel < ApplicationRecord
|
|||
has_many :messages
|
||||
has_many :chat_channel_memberships
|
||||
has_many :users, through: :chat_channel_memberships
|
||||
|
||||
has_many :active_memberships, -> { where status: "active" }, class_name: 'ChatChannelMembership'
|
||||
has_many :pending_memberships, -> { where status: "pending" }, class_name: 'ChatChannelMembership'
|
||||
has_many :rejected_memberships, -> { where status: "rejected" }, class_name: 'ChatChannelMembership'
|
||||
has_many :active_users, :through => :active_memberships, class_name: "User", :source => :user
|
||||
has_many :pending_users, :through => :pending_memberships, class_name: "User", :source => :user
|
||||
has_many :rejected_users, :through => :rejected_memberships, class_name: "User", :source => :user
|
||||
|
||||
validates :channel_type, presence: true, inclusion: { in: %w(open invite_only direct) }
|
||||
validates :status, presence: true, inclusion: { in: %w(active inactive) }
|
||||
|
|
@ -28,7 +35,7 @@ class ChatChannel < ApplicationRecord
|
|||
end
|
||||
|
||||
def has_member?(user)
|
||||
users.include?(user)
|
||||
active_users.include?(user)
|
||||
end
|
||||
|
||||
def last_opened_at(user = nil)
|
||||
|
|
@ -79,7 +86,6 @@ class ChatChannel < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
|
||||
def adjusted_slug(user = nil, caller_type="reciever")
|
||||
user ||= current_user
|
||||
if channel_type == "direct" && caller_type == "reciever"
|
||||
|
|
@ -92,7 +98,7 @@ class ChatChannel < ApplicationRecord
|
|||
end
|
||||
|
||||
def viewable_by
|
||||
chat_channel_memberships.pluck(:user_id)
|
||||
active_memberships.pluck(:user_id)
|
||||
end
|
||||
|
||||
def messages_count
|
||||
|
|
@ -100,15 +106,16 @@ class ChatChannel < ApplicationRecord
|
|||
end
|
||||
|
||||
def channel_human_names
|
||||
chat_channel_memberships.
|
||||
order("last_opened_at DESC").limit(20).includes(:user).map do |m|
|
||||
active_memberships.
|
||||
order("last_opened_at DESC").limit(5).includes(:user).map do |m|
|
||||
m.user.name
|
||||
end
|
||||
end
|
||||
|
||||
def channel_users
|
||||
# Purely for algolia indexing
|
||||
pics_obj = {}
|
||||
chat_channel_memberships.
|
||||
active_memberships.
|
||||
order("last_opened_at DESC").limit(80).includes(:user).each_with_index do |m, i|
|
||||
pics_obj[m.user.username] = {
|
||||
profile_image: i < 11 ? ProfileImage.new(m.user).get(90) : nil,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ class ChatChannelMembership < ApplicationRecord
|
|||
|
||||
validates :user_id, presence: true, uniqueness: { scope: :chat_channel_id }
|
||||
validates :chat_channel_id, presence: true, uniqueness: { scope: :user_id }
|
||||
validates :status, inclusion: { in: %w{active inactive pending rejected left_channel} }
|
||||
validate :permission
|
||||
|
||||
private
|
||||
|
|
|
|||
6
app/policies/chat_channel_membership_policy.rb
Normal file
6
app/policies/chat_channel_membership_policy.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class ChatChannelMembershipPolicy < ApplicationPolicy
|
||||
|
||||
def update?
|
||||
record.present? && user.id == record.user_id
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,14 @@ class ChatChannelPolicy < ApplicationPolicy
|
|||
user
|
||||
end
|
||||
|
||||
def create?
|
||||
true
|
||||
end
|
||||
|
||||
def update?
|
||||
user_can_edit_channel
|
||||
end
|
||||
|
||||
def moderate?
|
||||
!user_is_banned? && user_is_admin?
|
||||
end
|
||||
|
|
@ -16,8 +24,17 @@ class ChatChannelPolicy < ApplicationPolicy
|
|||
user_part_of_channel
|
||||
end
|
||||
|
||||
def permitted_attributes
|
||||
%i[channel_name slug command]
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def user_can_edit_channel
|
||||
record.present? && user.has_role?(:super_admin)
|
||||
end
|
||||
|
||||
def user_part_of_channel_or_open
|
||||
record.present? && (record.channel_type == "open" || record.has_member?(user))
|
||||
end
|
||||
|
|
|
|||
13
app/services/chat_channel_creation_service.rb
Normal file
13
app/services/chat_channel_creation_service.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
class ChatChannelCreationService
|
||||
attr_accessor :user, :params
|
||||
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@params = params
|
||||
end
|
||||
|
||||
def create
|
||||
user.chat_channels.create(channel_type: "invite_only",
|
||||
channel_name: params[:channel_name], slug: params[:slug])
|
||||
end
|
||||
end
|
||||
12
app/services/chat_channel_update_service.rb
Normal file
12
app/services/chat_channel_update_service.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
class ChatChannelUpdateService
|
||||
attr_accessor :chat_channel, :params
|
||||
|
||||
def initialize(chat_channel, params)
|
||||
@chat_channel = chat_channel
|
||||
@params = params
|
||||
end
|
||||
|
||||
def update
|
||||
chat_channel.update(params)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
json.type_of "user"
|
||||
json.id @user.id
|
||||
json.username @user.username
|
||||
json.name @user.name
|
||||
json.summary @user.summary
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ json.array! (@chat_channels_memberships.sort_by { |m| m.chat_channel.last_messag
|
|||
json.last_opened_at membership.chat_channel.last_opened_at
|
||||
json.last_message_at membership.chat_channel.last_message_at
|
||||
json.adjusted_slug membership.chat_channel.adjusted_slug(current_user)
|
||||
json.membership_id membership.id
|
||||
end
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ Rails.application.routes.draw do
|
|||
end
|
||||
|
||||
resources :messages, only: [:create]
|
||||
resources :chat_channels, only: [:index, :show]
|
||||
resources :chat_channels, only: [:index, :show, :create, :update]
|
||||
resources :chat_channel_memberships, only: [ :create, :update]
|
||||
resources :articles, only: [:update,:create,:destroy]
|
||||
resources :comments, only: [:create,:update,:destroy]
|
||||
resources :users, only: [:update]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddStatusToChatChannelMemberships < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :chat_channel_memberships, :status, :string, default: "active"
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20180624230435) do
|
||||
ActiveRecord::Schema.define(version: 20180629201047) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -167,6 +167,7 @@ ActiveRecord::Schema.define(version: 20180624230435) do
|
|||
t.datetime "created_at", null: false
|
||||
t.boolean "has_unopened_messages", default: false
|
||||
t.datetime "last_opened_at", default: "2017-01-01 05:00:00"
|
||||
t.string "status", default: "active"
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.index ["chat_channel_id"], name: "index_chat_channel_memberships_on_chat_channel_id"
|
||||
|
|
|
|||
4
spec/factories/chat_channel_memberships.rb
Normal file
4
spec/factories/chat_channel_memberships.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
FactoryBot.define do
|
||||
factory :chat_channel_membership do
|
||||
end
|
||||
end
|
||||
|
|
@ -17,4 +17,13 @@ RSpec.describe ChatChannel, type: :model do
|
|||
expect(chat_channel.users.size).to eq(2)
|
||||
expect(chat_channel.has_member?(User.first)).to eq(true)
|
||||
end
|
||||
|
||||
it "lists active memberships" do
|
||||
chat_channel = ChatChannel.create_with_users([create(:user),create(:user)])
|
||||
expect(chat_channel.active_users.size).to eq(2)
|
||||
expect(chat_channel.channel_users.size).to eq(2)
|
||||
ChatChannelMembership.last.update(status: "left_channel")
|
||||
expect(chat_channel.active_users.size).to eq(1)
|
||||
expect(chat_channel.channel_users.size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
27
spec/policies/chat_channel_membership_policy_spec.rb
Normal file
27
spec/policies/chat_channel_membership_policy_spec.rb
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe ChatChannelMembershipPolicy do
|
||||
subject { described_class.new(user, chat_channel_membership) }
|
||||
|
||||
let(:chat_channel_membership) { build(:chat_channel_membership) }
|
||||
|
||||
|
||||
context "when user is not signed-in" do
|
||||
let(:user) { nil }
|
||||
it { within_block_is_expected.to raise_error(Pundit::NotAuthorizedError) }
|
||||
end
|
||||
|
||||
context "when user belongs to membership" do
|
||||
let(:user) { create(:user) }
|
||||
let(:chat_channel) { create(:chat_channel ) }
|
||||
let(:chat_channel_membership) { create(:chat_channel_membership, user_id: user.id, chat_channel_id: chat_channel.id ) }
|
||||
it { is_expected.to permit_actions(%i[update]) }
|
||||
end
|
||||
context "when user does not belong to membership" do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:chat_channel) { create(:chat_channel ) }
|
||||
let(:chat_channel_membership) { create(:chat_channel_membership, user_id: other_user.id, chat_channel_id: chat_channel.id ) }
|
||||
it { is_expected.to forbid_actions(%i[update]) }
|
||||
end
|
||||
end
|
||||
|
|
@ -14,20 +14,20 @@ RSpec.describe ChatChannelPolicy do
|
|||
context "when user is not a part of channel" do
|
||||
let(:user) { build(:user) }
|
||||
it { is_expected.to permit_actions(%i[index]) }
|
||||
it { is_expected.to forbid_actions(%i[show open moderate]) }
|
||||
it { is_expected.to forbid_actions(%i[show open moderate update]) }
|
||||
end
|
||||
|
||||
context "when user is a part of channel" do
|
||||
let(:user) { create(:user) }
|
||||
before { chat_channel.add_users [user] }
|
||||
it { is_expected.to permit_actions(%i[index show open]) }
|
||||
it { is_expected.to forbid_actions(%i[moderate]) }
|
||||
it { is_expected.to forbid_actions(%i[moderate update]) }
|
||||
end
|
||||
|
||||
context "when user is an admin but not part of channel" do
|
||||
let(:user) { create(:user) }
|
||||
before { user.add_role(:super_admin) }
|
||||
it { is_expected.to permit_actions(%i[index moderate]) }
|
||||
it { is_expected.to permit_actions(%i[index moderate update]) }
|
||||
it { is_expected.to forbid_actions(%i[show open]) }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
53
spec/requests/chat_channel_memberships_spec.rb
Normal file
53
spec/requests/chat_channel_memberships_spec.rb
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe "ChatChannelMemberships", type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:second_user) { create(:user) }
|
||||
let(:chat_channel) { create(:chat_channel) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
chat_channel.add_users([user])
|
||||
end
|
||||
|
||||
describe "POST /chat_channel_memberships" do
|
||||
it "creates chat channel invitation" do
|
||||
user.add_role(:super_admin)
|
||||
mems_num = ChatChannelMembership.all.size
|
||||
post "/chat_channel_memberships", params: {user_id: second_user.id, chat_channel_id: chat_channel.id}
|
||||
expect(ChatChannelMembership.all.size).to eq(mems_num + 1)
|
||||
expect(ChatChannelMembership.last.status).to eq("pending")
|
||||
end
|
||||
it "denies chat channel invitation to non-authorized user" do
|
||||
expect do
|
||||
post "/chat_channel_memberships", params: {user_id: second_user.id, chat_channel_id: chat_channel.id}
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /chat_channel_memberships/:id" do
|
||||
before do
|
||||
user.add_role(:super_admin)
|
||||
post "/chat_channel_memberships", params: {user_id: second_user.id, chat_channel_id: chat_channel.id}
|
||||
end
|
||||
it "accepts chat channel invitation" do
|
||||
membership = ChatChannelMembership.last
|
||||
sign_in second_user
|
||||
put "/chat_channel_memberships/#{membership.id}", params: {user_action: "accept"}
|
||||
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
|
||||
end
|
||||
it "rejects chat channel invitation" do
|
||||
membership = ChatChannelMembership.last
|
||||
sign_in second_user
|
||||
put "/chat_channel_memberships/#{membership.id}", params: {user_action: "reject"}
|
||||
expect(ChatChannelMembership.find(membership.id).status).to eq("rejected")
|
||||
end
|
||||
it "disallows non-logged-user" do
|
||||
membership = ChatChannelMembership.last
|
||||
expect do
|
||||
put "/chat_channel_memberships/#{membership.id}", params: {user_action: "accept"}
|
||||
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -49,6 +49,29 @@ RSpec.describe "ChatChannels", type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
describe "get /chat_channels?state=pending" do
|
||||
it "returns pending channels" do
|
||||
pending_membership = ChatChannelMembership.create(chat_channel_id: invite_channel.id,
|
||||
user_id:user.id, status: "pending")
|
||||
sign_in user
|
||||
get "/chat_channels?state=pending"
|
||||
expect(response.body).to include(invite_channel.slug)
|
||||
end
|
||||
it "returns no pending channels if no invites" do
|
||||
sign_in user
|
||||
get "/chat_channels?state=pending"
|
||||
expect(response.body).not_to include(invite_channel.slug)
|
||||
end
|
||||
it "returns no pending channels if not pending" do
|
||||
pending_membership = ChatChannelMembership.create(chat_channel_id: invite_channel.id,
|
||||
user_id:user.id, status: "rejected")
|
||||
sign_in user
|
||||
get "/chat_channels?state=pending"
|
||||
expect(response.body).not_to include(invite_channel.slug)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
describe "GET /chat_channels/:id" do
|
||||
context "when request is valid" do
|
||||
before do
|
||||
|
|
@ -71,6 +94,48 @@ RSpec.describe "ChatChannels", type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
describe "POST /chat_channels" do
|
||||
it "creates chat_channel for current user" do
|
||||
post "/chat_channels",
|
||||
params: { chat_channel: { channel_name: "Hello Channel", slug: "hello-channel" } },
|
||||
headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(ChatChannel.last.slug).to eq("hello-channel")
|
||||
expect(ChatChannel.last.active_users).to include(user)
|
||||
end
|
||||
it "returns errors if channel is invalid" do
|
||||
#slug should be taken
|
||||
post "/chat_channels",
|
||||
params: { chat_channel: { channel_name: "HEy hey hoho", slug: chat_channel.slug } },
|
||||
headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.body).to include("Slug has already been taken")
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /chat_channels/:id" do
|
||||
it "updates channel for valid user" do
|
||||
user.add_role(:super_admin)
|
||||
put "/chat_channels/#{chat_channel.id}",
|
||||
params: { chat_channel: { channel_name: "Hello Channel", slug: "hello-channelly" } },
|
||||
headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(ChatChannel.last.slug).to eq("hello-channelly")
|
||||
end
|
||||
it "dissallows invalid users" do
|
||||
expect do
|
||||
put "/chat_channels/#{chat_channel.id}",
|
||||
params: { chat_channel: { channel_name: "Hello Channel", slug: "hello-channelly" } },
|
||||
headers: { HTTP_ACCEPT: "application/json" }
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
it "returns errors if channel is invalid" do
|
||||
#slug should be taken
|
||||
user.add_role(:super_admin)
|
||||
put "/chat_channels/#{chat_channel.id}",
|
||||
params: { chat_channel: { channel_name: "HEy hey hoho", slug: invite_channel.slug } },
|
||||
headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.body).to include("Slug has already been taken")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /chat_channels/:id/moderate" do
|
||||
it "raises NotAuthorizedError if user is not logged in" do
|
||||
expect do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue