Add chat_channel_membership roles and invite functionality (#541)
This commit is contained in:
parent
b45dd391b6
commit
424be43865
16 changed files with 163 additions and 28 deletions
2
Gemfile
2
Gemfile
|
|
@ -21,7 +21,7 @@ gem "bourbon", "~> 1.4"
|
|||
gem "buffer", github: "bufferapp/buffer-ruby"
|
||||
gem "carrierwave", "~> 1.2"
|
||||
gem "carrierwave-bombshelter", "~> 0.2"
|
||||
gem "cloudinary", "~> 1.8"
|
||||
gem "cloudinary", "~> 1.9"
|
||||
gem "counter_culture", "~> 1.9"
|
||||
gem "csv_shaper", "~> 1.3"
|
||||
gem "dalli", "~> 2.7"
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ GEM
|
|||
chromedriver-helper (1.2.0)
|
||||
archive-zip (~> 0.10)
|
||||
nokogiri (~> 1.8)
|
||||
cloudinary (1.8.3)
|
||||
cloudinary (1.9.1)
|
||||
aws_cf_signer
|
||||
rest-client
|
||||
coderay (1.1.2)
|
||||
|
|
@ -930,7 +930,7 @@ DEPENDENCIES
|
|||
carrierwave (~> 1.2)
|
||||
carrierwave-bombshelter (~> 0.2)
|
||||
chromedriver-helper (~> 1.2)
|
||||
cloudinary (~> 1.8)
|
||||
cloudinary (~> 1.9)
|
||||
counter_culture (~> 1.9)
|
||||
csv_shaper (~> 1.3)
|
||||
dalli (~> 2.7)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ module Admin
|
|||
request.origin.nil? || request.origin.gsub("https", "http") == request.base_url.gsub("https", "http")
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def authorize_admin
|
||||
|
|
|
|||
|
|
@ -1,26 +1,34 @@
|
|||
class ChatChannelMembershipsController < ApplicationController
|
||||
after_action :verify_authorized
|
||||
|
||||
def create
|
||||
@chat_channel = ChatChannel.find(params[:chat_channel_id])
|
||||
@chat_channel = ChatChannel.find(permitted_params[:chat_channel_id])
|
||||
authorize @chat_channel, :update?
|
||||
ChatChannelMembership.create(
|
||||
user_id: params[:user_id],
|
||||
user_id: permitted_params[:user_id],
|
||||
chat_channel_id: @chat_channel.id,
|
||||
status: "pending"
|
||||
)
|
||||
@chat_channel.index!
|
||||
end
|
||||
|
||||
def update
|
||||
@chat_channel_membership = ChatChannelMembership.find(params[:id])
|
||||
authorize @chat_channel_membership
|
||||
if params[:user_action] == "accept"
|
||||
if permitted_params[:user_action] == "accept"
|
||||
@chat_channel_membership.update(status: "active")
|
||||
else
|
||||
@chat_channel_membership.update(status: "rejected")
|
||||
end
|
||||
@chat_channel_membership.chat_channel.index!
|
||||
@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
|
||||
|
||||
def permitted_params
|
||||
params.require(:chat_channel_membership).permit(:user_id, :chat_channel_id, :user_action, :id)
|
||||
end
|
||||
end
|
||||
|
|
@ -91,7 +91,7 @@ class ChatChannelsController < ApplicationController
|
|||
if current_user
|
||||
@chat_channels_memberships = current_user.
|
||||
chat_channel_memberships.includes(:chat_channel).
|
||||
where(chat_channels: {channel_type: "direct"}, has_unopened_messages: true).
|
||||
where(has_unopened_messages: true).
|
||||
order("chat_channel_memberships.updated_at DESC")
|
||||
else
|
||||
@chat_channels_memberships = []
|
||||
|
|
|
|||
|
|
@ -164,7 +164,9 @@ export function sendChannelInviteAction(id, action, successCb, failureCb) {
|
|||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_action: action,
|
||||
chat_channel_membership: {
|
||||
user_action: action,
|
||||
}
|
||||
}),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,72 @@
|
|||
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
const algoliaId = document.querySelector("meta[name='algolia-public-id']").content
|
||||
const algoliaKey = document.querySelector("meta[name='algolia-public-key']").content
|
||||
const env = document.querySelector("meta[name='environment']").content
|
||||
const client = algoliasearch(algoliaId, algoliaKey);
|
||||
const index = client.initIndex('searchables_'+env);
|
||||
|
||||
export default class ChannelDetails extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
searchedUsers: [],
|
||||
invitations: []
|
||||
}
|
||||
}
|
||||
|
||||
triggerUserSearch = e => {
|
||||
const component = this;
|
||||
const query = e.target.value;
|
||||
const filters = {
|
||||
hitsPerPage: 20,
|
||||
attributesToRetrieve: [
|
||||
'id',
|
||||
'title',
|
||||
'path'
|
||||
],
|
||||
attributesToHighlight: [],
|
||||
filters: 'class_name:User',
|
||||
}
|
||||
if (query.length > 0) {
|
||||
index.search(query, filters)
|
||||
.then(function(content) {
|
||||
component.setState({searchedUsers:content.hits})
|
||||
});
|
||||
} else {
|
||||
component.setState({searchedUsers:[]})
|
||||
}
|
||||
}
|
||||
|
||||
triggerInvite = e => {
|
||||
const component = this;
|
||||
const id = e.target.dataset.content;
|
||||
console.log(e.target)
|
||||
e.target.style.display = 'none'
|
||||
fetch(`/chat_channel_memberships`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_channel_membership: {
|
||||
user_id: id,
|
||||
chat_channel_id: component.props.channel.id,
|
||||
}
|
||||
}),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(response => response)
|
||||
.then(this.handleInvitationSuccess)
|
||||
.catch(null);
|
||||
}
|
||||
|
||||
handleInvitationSuccess = response => {
|
||||
console.log(response)
|
||||
}
|
||||
|
||||
render() {
|
||||
const channel = this.props.channel
|
||||
|
|
@ -21,10 +86,30 @@ export default class ChannelDetails extends Component {
|
|||
if (users.length === 80) {
|
||||
subHeader = <h3>Recently Active Members</h3>
|
||||
}
|
||||
let modSection = ''
|
||||
let searchedUsers = [];
|
||||
let pendingInvites = [];
|
||||
if (channel.channel_mod_ids.includes(window.currentUser.id)) {
|
||||
searchedUsers = this.state.searchedUsers.map((user) => {
|
||||
return <div><a href={user.path} target='_blank'>{user.title}</a> <button onClick={this.triggerInvite} data-content={user.id}>Invite</button></div>
|
||||
})
|
||||
pendingInvites = channel.pending_usernames.map((username) => {
|
||||
return <div><a href={'/'+username} target='_blank'>@{username}</a></div>
|
||||
})
|
||||
modSection = <div>
|
||||
<h2>Invite Members</h2>
|
||||
<input onKeyUp={this.triggerUserSearch} placeholder="Find users"/>
|
||||
{searchedUsers}
|
||||
<h2>Pending Invites:</h2>
|
||||
{pendingInvites}
|
||||
<div>Channels can have a maximum of 128 members, including outstanding invites. All functionality is early beta. Contact us if you need help with anything or to have any restrictions lifted.</div>
|
||||
</div>
|
||||
}
|
||||
return <div>
|
||||
<h1>{channel.channel_name}</h1>
|
||||
{subHeader}
|
||||
{users}
|
||||
{modSection}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export default class Chat extends Component {
|
|||
document.getElementById('chatchannels__channelslist').addEventListener('scroll', this.handleChannelScroll);
|
||||
}
|
||||
getChannelInvites(this.handleChannelInvites,null);
|
||||
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
|
|
@ -674,6 +675,7 @@ export default class Chat extends Component {
|
|||
|
||||
|
||||
render() {
|
||||
console.log(this.state.activeChannel)
|
||||
let channelHeader = <div className="activechatchannel__header"> </div>
|
||||
let channelHeaderInner = ''
|
||||
const currentChannel = this.state.activeChannel
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
class ProfileImage
|
||||
include CloudinaryHelper
|
||||
attr_accessor :resource, :image_link, :backup_link
|
||||
|
||||
def initialize(resource)
|
||||
|
|
@ -8,7 +9,7 @@ class ProfileImage
|
|||
end
|
||||
|
||||
def get(width = 120)
|
||||
CloudinaryHelper.cl_image_path(get_link,
|
||||
cl_image_path(get_link,
|
||||
type: "fetch",
|
||||
crop: "fill",
|
||||
width: width,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ class ChatChannel < ApplicationRecord
|
|||
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 :mod_memberships, -> { where role: "mod" }, 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
|
||||
has_many :mod_users, :through => :mod_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) }
|
||||
|
|
@ -20,7 +22,7 @@ class ChatChannel < ApplicationRecord
|
|||
algoliasearch index_name: "SecuredChatChannel_#{Rails.env}" do
|
||||
attribute :id, :viewable_by, :slug, :channel_type,
|
||||
:channel_name, :channel_users, :last_message_at, :status,
|
||||
:messages_count, :channel_human_names
|
||||
:messages_count, :channel_human_names, :channel_mod_ids, :pending_usernames
|
||||
searchableAttributes [:channel_name, :channel_slug, :channel_human_names]
|
||||
attributesForFaceting ["filterOnly(viewable_by)", "filterOnly(status)", "filterOnly(channel_type)"]
|
||||
ranking ["desc(last_message_at)"]
|
||||
|
|
@ -114,17 +116,30 @@ class ChatChannel < ApplicationRecord
|
|||
|
||||
def channel_users
|
||||
# Purely for algolia indexing
|
||||
pics_obj = {}
|
||||
obj = {}
|
||||
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,
|
||||
darker_color: m.user.decorate.darker_color,
|
||||
name: m.user.name,
|
||||
last_opened_at: m.last_opened_at,
|
||||
username: m.user.username,
|
||||
}
|
||||
obj[m.user.username] = user_obj(m, i)
|
||||
end
|
||||
pics_obj
|
||||
obj
|
||||
end
|
||||
|
||||
def pending_usernames
|
||||
pending_users.pluck(:username)
|
||||
end
|
||||
|
||||
def channel_mod_ids
|
||||
mod_users.pluck(:id)
|
||||
end
|
||||
|
||||
def user_obj(m, i)
|
||||
{
|
||||
profile_image: i < 11 ? ProfileImage.new(m.user).get(90) : nil,
|
||||
darker_color: m.user.decorate.darker_color,
|
||||
name: m.user.name,
|
||||
last_opened_at: m.last_opened_at,
|
||||
username: m.user.username,
|
||||
id: m.user_id,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,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} }
|
||||
validates :role, inclusion: { in: %w{member mod} }
|
||||
validate :permission
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ class ChatChannelPolicy < ApplicationPolicy
|
|||
private
|
||||
|
||||
def user_can_edit_channel
|
||||
record.present? && user.has_role?(:super_admin)
|
||||
record.present? &&
|
||||
(user.has_role?(:super_admin) || record.channel_mod_ids.include?(user.id))
|
||||
end
|
||||
|
||||
def user_part_of_channel_or_open
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
<% else %>
|
||||
<meta name="last-updated" content="<%= Time.now %>">
|
||||
<meta name="user-signed-in" content="<%= user_signed_in? %>">
|
||||
<meta name="algolia-public-id" content="<%= ENV["ALGOLIASEARCH_APPLICATION_ID"] %>">
|
||||
<meta name="algolia-public-key" content="<%= ENV["ALGOLIASEARCH_PUBLIC_SEARCH_ONLY_KEY"] %>">
|
||||
<meta name="environment" content="<%= Rails.env %>">
|
||||
|
||||
<%= render "layouts/styles" %>
|
||||
<style>
|
||||
.home {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddRoleToChatChannelMemberships < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :chat_channel_memberships, :role, :string, default: "member"
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20180629201047) do
|
||||
ActiveRecord::Schema.define(version: 20180703142743) 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: 20180629201047) 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 "role", default: "member"
|
||||
t.string "status", default: "active"
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
|
|
|
|||
|
|
@ -14,13 +14,19 @@ RSpec.describe "ChatChannelMemberships", type: :request 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}
|
||||
post "/chat_channel_memberships", params: {
|
||||
chat_channel_membership: {
|
||||
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}
|
||||
post "/chat_channel_memberships", params: {
|
||||
chat_channel_membership: {
|
||||
user_id: second_user.id, chat_channel_id: chat_channel.id}
|
||||
}
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
end
|
||||
|
|
@ -28,24 +34,29 @@ RSpec.describe "ChatChannelMemberships", type: :request do
|
|||
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}
|
||||
post "/chat_channel_memberships", params: {
|
||||
chat_channel_membership: {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"}
|
||||
put "/chat_channel_memberships/#{membership.id}", params: {
|
||||
chat_channel_membership: {
|
||||
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"}
|
||||
put "/chat_channel_memberships/#{membership.id}", params: {
|
||||
chat_channel_membership: {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"}
|
||||
put "/chat_channel_memberships/#{membership.id}", params: {
|
||||
chat_channel_membership: {user_action: "accept"}}
|
||||
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue