diff --git a/app/assets/stylesheets/chat.scss b/app/assets/stylesheets/chat.scss index 719df0d4b..b103b6a84 100644 --- a/app/assets/stylesheets/chat.scss +++ b/app/assets/stylesheets/chat.scss @@ -531,7 +531,8 @@ } } -.chat__channelinvitationsindicator a { +.chat__channelinvitationsindicator a, +.chat__channelinvitationsindicator button { background: linear-gradient(10deg, darken($green, 25%), darken($green, 15%)); text-align: center; padding: 30px 0px; @@ -1265,3 +1266,11 @@ border-radius: '500px'; background-color: '#f5f6f7'; } +.request-card { + display: grid; + grid-template-columns: 200px 1fr; +} + +.action button:nth-child(2n) { + margin-left: 10px; +} diff --git a/app/controllers/chat_channel_memberships_controller.rb b/app/controllers/chat_channel_memberships_controller.rb index 66c6647e6..e89865227 100644 --- a/app/controllers/chat_channel_memberships_controller.rb +++ b/app/controllers/chat_channel_memberships_controller.rb @@ -60,6 +60,7 @@ class ChatChannelMembershipsController < ApplicationController @chat_channel = ChatChannel.find(params[:chat_channel_id]) authorize @chat_channel, :update? @chat_channel_membership = @chat_channel.chat_channel_memberships.find(params[:membership_id]) + membership = ChatChannelMembership.find_by!(chat_channel_id: params[:chat_channel_id], user: current_user) if params[:status] == "pending" @chat_channel_membership.destroy flash[:settings_notice] = "Invitation removed." @@ -67,8 +68,11 @@ class ChatChannelMembershipsController < ApplicationController send_chat_action_message("@#{current_user.username} removed @#{@chat_channel_membership.user.username} from #{@chat_channel_membership.channel_name}", current_user, @chat_channel_membership.chat_channel_id, "removed_from_channel") @chat_channel_membership.update(status: "removed_from_channel") flash[:settings_notice] = "Removed #{@chat_channel_membership.user.name}" + respond_to do |format| + format.html { redirect_to edit_chat_channel_membership_path(membership) } + format.json { render json: { status: "success", message: "Membership removed" } } + end && return end - membership = ChatChannelMembership.find_by!(chat_channel_id: params[:chat_channel_id], user: current_user) redirect_to edit_chat_channel_membership_path(membership) end @@ -120,7 +124,10 @@ class ChatChannelMembershipsController < ApplicationController NotifyMailer.channel_invite_email(@chat_channel_membership, @chat_channel_membership.user).deliver_later flash[:settings_notice] = "Accepted request of #{@chat_channel_membership.user.username} to join #{channel_name}." membership = ChatChannelMembership.find_by!(chat_channel_id: @chat_channel_membership.chat_channel.id, user: current_user) - redirect_to(edit_chat_channel_membership_path(membership)) && return + respond_to do |format| + format.html { redirect_to(edit_chat_channel_membership_path(membership)) } + format.json { render json: { status: "success", message: "Accepted Request" } } + end && return end else @chat_channel_membership.update(status: "rejected") diff --git a/app/controllers/chat_channels_controller.rb b/app/controllers/chat_channels_controller.rb index 78e7ad65c..bfdb0ca49 100644 --- a/app/controllers/chat_channels_controller.rb +++ b/app/controllers/chat_channels_controller.rb @@ -147,7 +147,7 @@ class ChatChannelsController < ApplicationController def render_unopened_json_response @chat_channels_memberships = if session_current_user_id - ChatChannelMembership.where(user_id: session_current_user_id).includes(:chat_channel). + ChatChannelMembership.where(user_id: session_current_user_id).includes(%i[chat_channel user]). where(has_unopened_messages: true). where(show_global_badge_notification: true). where.not(status: %w[removed_from_channel left_channel]). @@ -177,9 +177,11 @@ class ChatChannelsController < ApplicationController end def render_joining_request_json_response - requested_memberships = current_user.chat_channel_memberships.includes(:chat_channel). - where(chat_channels: { discoverable: true }, role: "mod").pluck(:chat_channel_id).map { |membership_id| ChatChannel.find_by(id: membership_id).requested_memberships } - render json: { joining_requests: requested_memberships.flatten } + requested_memberships_id = current_user.chat_channel_memberships.includes(:chat_channel). + where(chat_channels: { discoverable: true }, role: "mod").pluck(:chat_channel_id).map { |membership_id| ChatChannel.find_by(id: membership_id).requested_memberships }.flatten.map(&:id) + @chat_channels_memberships = ChatChannelMembership.includes(%i[user chat_channel]).where(id: requested_memberships_id) + + render "index.json" end def render_channels_html diff --git a/app/javascript/chat/__tests__/__snapshots__/content.test.jsx.snap b/app/javascript/chat/__tests__/__snapshots__/content.test.jsx.snap index 84bea0c2a..1bdfd86c5 100644 --- a/app/javascript/chat/__tests__/__snapshots__/content.test.jsx.snap +++ b/app/javascript/chat/__tests__/__snapshots__/content.test.jsx.snap @@ -1,10 +1,67 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` as loading-user should render and test snapshot 1`] = ` +exports[` as channel-request should render and test snapshot 1`] = ` +
+ + +
+`; + +exports[` as loading-user should render and test snapshot 1`] = `
-
`; diff --git a/app/javascript/chat/__tests__/__snapshots__/requestManager.test.jsx.snap b/app/javascript/chat/__tests__/__snapshots__/requestManager.test.jsx.snap new file mode 100644 index 000000000..a83a911df --- /dev/null +++ b/app/javascript/chat/__tests__/__snapshots__/requestManager.test.jsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and test snapshot 1`] = ` +
+
+

+ Joining Request +

+

+ Manage request comming to all the channels +

+
+

+ ironman +

+
+

+

+ + +
+
+
+
+
+`; diff --git a/app/javascript/chat/__tests__/content.test.jsx b/app/javascript/chat/__tests__/content.test.jsx index 05b9856d2..ba3821f4d 100644 --- a/app/javascript/chat/__tests__/content.test.jsx +++ b/app/javascript/chat/__tests__/content.test.jsx @@ -3,24 +3,45 @@ import render from 'preact-render-to-json'; import { shallow } from 'preact-render-spy'; import Content from '../content'; -const getContent = () => ( - -); +const data = [ + { + onTriggerContent: false, + resource: { type_of: 'channel-request' }, + activeChannelId: 12345, + pusherKey: 'ASDFGHJKL', + githubToken: '', + }, + { + onTriggerContent: false, + resource: { type_of: 'loading-user' }, + activeChannelId: 1235, + pusherKey: 'ASDFGHJKL', + githubToken: '', + }, +]; + +const getContent = (resource) => ; describe('', () => { describe('as loading-user', () => { it('should render and test snapshot', () => { - const tree = render(getContent()); + const tree = render(getContent(data[0])); expect(tree).toMatchSnapshot(); }); it('should have proper elements, attributes and content', () => { - const context = shallow(getContent()); + const context = shallow(getContent(data[0])); + expect( + context.find('.activechatchannel__activecontent').exists(), + ).toEqual(true); + }); + }); + describe('as channel-request', () => { + it('should render and test snapshot', () => { + const tree = render(getContent(data[1])); + expect(tree).toMatchSnapshot(); + }); + it('should have proper elements, attributes and content', () => { + const context = shallow(getContent(data[1])); expect( context.find('.activechatchannel__activecontent').exists(), ).toEqual(true); diff --git a/app/javascript/chat/__tests__/requestManager.test.jsx b/app/javascript/chat/__tests__/requestManager.test.jsx new file mode 100644 index 000000000..702fba0b2 --- /dev/null +++ b/app/javascript/chat/__tests__/requestManager.test.jsx @@ -0,0 +1,26 @@ +import { h } from 'preact'; +import render from 'preact-render-to-json'; +import { shallow } from 'preact-render-spy'; +import RequestManager from '../requestManager'; + +const data = [ + { + id: 2, + channel_name: 'ironman', + }, +]; + +const getRequestManager = (resource) => ; + +describe('', () => { + it('should render and test snapshot', () => { + const tree = render(getRequestManager(data)); + expect(tree).toMatchSnapshot(); + }); + + it('should have the proper elements, attributes and values', () => { + const context = shallow(getRequestManager(data)); + + expect(context.find('.request_manager_header').exists()).toEqual(true); + }); +}); diff --git a/app/javascript/chat/actions.js b/app/javascript/chat/actions/actions.js similarity index 81% rename from app/javascript/chat/actions.js rename to app/javascript/chat/actions/actions.js index 23938fa9f..36daef35c 100644 --- a/app/javascript/chat/actions.js +++ b/app/javascript/chat/actions/actions.js @@ -1,4 +1,4 @@ -import { fetchSearch } from '../utilities/search'; +import { createDataHash } from '../util'; export function getAllMessages(channelId, messageOffset, successCb, failureCb) { fetch(`/chat_channels/${channelId}?message_offset=${messageOffset}`, { @@ -98,37 +98,22 @@ export function conductModeration( } export function getChannels( - query, - retrievalID, - searchType, - paginationNumber, + searchParams, additionalFilters, successCb, _failureCb, ) { - const dataHash = {}; - if (additionalFilters.filters) { - const [key, value] = additionalFilters.filters.split(':'); - dataHash[key] = value; - } - dataHash.per_page = 30; - dataHash.page = paginationNumber; - dataHash.channel_text = query; - if (searchType === 'discoverable') { - dataHash.user_id = 'all'; - } - const responsePromise = fetchSearch('chat_channels', dataHash); - - return responsePromise.then((response) => { - const channels = response.result; + return createDataHash(additionalFilters, searchParams).then((response) => { if ( - retrievalID === null || - channels.filter((e) => e.chat_channel_id === retrievalID).length === 1 + searchParams.retrievalID === null || + response.result.filter( + (e) => e.chat_channel_id === searchParams.retrievalID, + ).length === 1 ) { - successCb(channels, query); + successCb(response.result, searchParams.query); } else { fetch( - `/chat_channel_memberships/find_by_chat_channel_id?chat_channel_id=${retrievalID}`, + `/chat_channel_memberships/find_by_chat_channel_id?chat_channel_id=${searchParams.retrievalID}`, { Accept: 'application/json', 'Content-Type': 'application/json', @@ -137,8 +122,8 @@ export function getChannels( ) .then((individualResponse) => individualResponse.json()) .then((json) => { - channels.unshift(json); - successCb(channels, query); + response.result.unshift(json); + successCb(response.result, searchParams.query); }); } }); @@ -187,6 +172,17 @@ export function getChannelInvites(successCb, failureCb) { .catch(failureCb); } +export function getJoiningRequest(successCb, failureCb) { + fetch('/chat_channels?state=joining_request', { + 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', @@ -226,23 +222,3 @@ export function deleteMessage(messageId, successCb, failureCb) { .then(successCb) .catch(failureCb); } - -export function sendChannelRequest(id, successCb, failureCb) { - fetch(`/join_chat_channel`, { - method: 'POST', - headers: { - Accept: 'application/json', - 'X-CSRF-Token': window.csrfToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - chat_channel_membership: { - chat_channel_id: id, - }, - }), - credentials: 'same-origin', - }) - .then((response) => response.json()) - .then(successCb) - .catch(failureCb); -} diff --git a/app/javascript/chat/actions/requestActions.js b/app/javascript/chat/actions/requestActions.js new file mode 100644 index 000000000..eb0a0dc5d --- /dev/null +++ b/app/javascript/chat/actions/requestActions.js @@ -0,0 +1,71 @@ +export function rejectJoiningRequest( + channelId, + membershipId, + successCb, + failureCb, +) { + fetch(`/chat_channel_memberships/remove_membership`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + status: 'pending', + chat_channel_id: channelId, + membership_id: membershipId, + }), + credentials: 'same-origin', + }) + .then((response) => response.json()) + .then(successCb) + .catch(failureCb); +} + +export function acceptJoiningRequest( + channelId, + membershipId, + successCb, + failureCb, +) { + fetch(`/chat_channel_memberships/add_membership`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_channel_id: channelId, + membership_id: membershipId, + chat_channel_membership: { + user_action: 'accept', + }, + }), + credentials: 'same-origin', + }) + .then((response) => response.json()) + .then(successCb) + .catch(failureCb); +} + +export function sendChannelRequest(id, successCb, failureCb) { + fetch(`/join_chat_channel`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_channel_membership: { + chat_channel_id: id, + }, + }), + credentials: 'same-origin', + }) + .then((response) => response.json()) + .then(successCb) + .catch(failureCb); +} diff --git a/app/javascript/chat/channels.jsx b/app/javascript/chat/channels.jsx index cec991fbe..5d1de81dd 100644 --- a/app/javascript/chat/channels.jsx +++ b/app/javascript/chat/channels.jsx @@ -11,7 +11,7 @@ const Channels = ({ unopenedChannelIds, handleSwitchChannel, expanded, - filterQuery, + filterQuery = '', channelsLoaded, currentUserId, triggerActiveContent, @@ -32,7 +32,6 @@ const Channels = ({ ); }, ); - const channels = sortedChatChannels.activeChannels.map((channel) => { const isActive = parseInt(activeChannelId, 10) === channel.chat_channel_id; const isUnopened = diff --git a/app/javascript/chat/chat.jsx b/app/javascript/chat/chat.jsx index 35f7a2658..46c82de5a 100644 --- a/app/javascript/chat/chat.jsx +++ b/app/javascript/chat/chat.jsx @@ -11,11 +11,16 @@ import { getUnopenedChannelIds, getContent, getChannelInvites, + getJoiningRequest, sendChannelInviteAction, deleteMessage, editMessage, +} from './actions/actions'; +import { sendChannelRequest, -} from './actions'; + rejectJoiningRequest, + acceptJoiningRequest, +} from './actions/requestActions'; import { hideMessages, scrollToBottom, @@ -46,7 +51,6 @@ export default class Chat extends Component { super(props); const chatChannels = JSON.parse(props.chatChannels); const chatOptions = JSON.parse(props.chatOptions); - this.debouncedChannelFilter = debounceAction( this.triggerChannelFilter.bind(this), ); @@ -75,6 +79,7 @@ export default class Chat extends Component { isMobileDevice: typeof window.orientation !== 'undefined', subscribedPusherChannels: [], inviteChannels: [], + joiningRequests: [], messageOffset: 0, showDeleteModal: false, messageDeleteId: null, @@ -128,14 +133,13 @@ export default class Chat extends Component { channelTypeFilter === 'all' ? {} : { filters: `channel_type:${channelTypeFilter}` }; - getChannels( - '', - activeChannelId, - '', - channelPaginationNum, - filters, - this.loadChannels, - ); + const searchParams = { + query: '', + retrievalID: activeChannelId, + searchType: '', + paginationNumber: channelPaginationNum, + }; + getChannels(searchParams, filters, this.loadChannels); getUnopenedChannelIds(this.markUnopenedChannelIds); } if (!isMobileDevice) { @@ -147,6 +151,7 @@ export default class Chat extends Component { .addEventListener('scroll', this.handleChannelScroll); } getChannelInvites(this.handleChannelInvites, null); + getJoiningRequest(this.handleChannelJoiningRequest, null); } shouldComponentUpdate(nextProps, nextState) { @@ -521,14 +526,13 @@ export default class Chat extends Component { channelTypeFilter === 'all' ? {} : { filters: `channel_type:${channelTypeFilter}` }; - getChannels( - filterQuery, - activeChannelId, - '', - channelPaginationNum, - filters, - this.loadPaginatedChannels, - ); + const searchParams = { + query: filterQuery, + retrievalID: activeChannelId, + searchType: '', + paginationNumber: channelPaginationNum, + }; + getChannels(searchParams, filters, this.loadPaginatedChannels); } }; @@ -536,6 +540,10 @@ export default class Chat extends Component { this.setState({ inviteChannels: response }); }; + handleChannelJoiningRequest = (res) => { + this.setState({ joiningRequests: res }); + }; + handleKeyDown = (e) => { const { showMemberlist, activeContent, activeChannelId } = this.state; const enterPressed = e.keyCode === 13; @@ -823,6 +831,24 @@ export default class Chat extends Component { } }; + handleRequestRejection = (e) => { + rejectJoiningRequest( + e.target.dataset.channelId, + e.target.dataset.membershipId, + this.handleJoiningManagerSuccess(e.target.dataset.membershipId), + null, + ); + }; + + handleRequestApproval = (e) => { + acceptJoiningRequest( + e.target.dataset.channelId, + e.target.dataset.membershipId, + this.handleJoiningManagerSuccess(e.target.dataset.membershipId), + null, + ); + }; + triggerActiveContent = (e) => { if ( // Trying to open in new tab @@ -859,6 +885,13 @@ export default class Chat extends Component { handleJoiningRequest: this.handleJoiningRequest, type_of: 'channel-request', }); + } else if (content === 'sidecar-joining-request-manager') { + this.setActiveContent({ + data: this.state.joiningRequests, + type_of: 'channel-request-manager', + handleRequestRejection: this.handleRequestRejection, + handleRequestApproval: this.handleRequestApproval, + }); } else if (content === 'sidecar_all') { this.setActiveContentState(activeChannelId, { type_of: 'loading-post', @@ -970,17 +1003,17 @@ export default class Chat extends Component { fetchingPaginatedChannels: false, }); const filters = type === 'all' ? {} : { filters: `channel_type:${type}` }; + const searchParams = { + query: filterQuery, + retrievalID: null, + searchType: '', + paginationNumber: 0, + }; if (filterQuery && type !== 'direct') { - getChannels( - filterQuery, - null, - 'discoverable', - 0, - filters, - this.loadChannels, - ); + searchParams.searchType = 'discoverable'; + getChannels(searchParams, filters, this.loadChannels); } else { - getChannels(filterQuery, null, '', 0, filters, this.loadChannels); + getChannels(searchParams, filters, this.loadChannels); } }; @@ -1083,17 +1116,17 @@ export default class Chat extends Component { channelTypeFilter === 'all' ? {} : { filters: `channel_type:${channelTypeFilter}` }; + const searchParams = { + query: e.target.value, + retrievalID: null, + searchType: '', + paginationNumber: 0, + }; if (e.target.value) { - getChannels( - e.target.value, - null, - 'discoverable', - 0, - filters, - this.loadChannels, - ); + searchParams.searchType = 'discoverable'; + getChannels(searchParams, filters, this.loadChannels); } else { - getChannels(e.target.value, null, '', 0, filters, this.loadChannels); + getChannels(searchParams, filters, this.loadChannels); } }; @@ -1120,7 +1153,13 @@ export default class Chat extends Component { document.getElementById('chatchannelsearchbar').focus(); }, 100); } else { - getChannels('', null, '', 0, '', this.loadChannels); + const searchParams = { + query: '', + retrievalID: null, + searchType: '', + paginationNumber: 0, + }; + getChannels(searchParams, this.loadChannels); this.setState({ filterQuery: '' }); } this.setState({ searchShowing: !this.state.searchShowing }); @@ -1133,6 +1172,7 @@ export default class Chat extends Component { const notificationsButton = ''; let notificationsState = ''; let invitesButton = ''; + let joiningRequestButton = ''; if (notificationsPermission === 'granted') { notificationsState = (
@@ -1164,6 +1204,23 @@ export default class Chat extends Component {
); } + if (state.joiningRequests.length > 0) { + joiningRequestButton = ( +
+ +
+ ); + } if (state.expanded) { return (
@@ -1186,6 +1243,7 @@ export default class Chat extends Component { '' )} {invitesButton} + {joiningRequestButton}
+ +
+
+
+ ))} + + + ); +}; + +RequestManager.propTypes = { + resource: PropTypes.shape({ + data: PropTypes.object, + }).isRequired, + handleRequestRejection: PropTypes.func.isRequired, + handleRequestApproval: PropTypes.func.isRequired, +}; +export default RequestManager; diff --git a/app/javascript/chat/util.js b/app/javascript/chat/util.js index 90785f83a..a382145ca 100644 --- a/app/javascript/chat/util.js +++ b/app/javascript/chat/util.js @@ -1,3 +1,5 @@ +import { fetchSearch } from '../utilities/search'; + import 'intersection-observer'; export function getCsrfToken() { @@ -107,3 +109,18 @@ export const channelSorter = (channels, currentUserId, filterQuery) => { .filter((channel) => !ChannelIds[0].includes(channel.chat_channel_id)); return { activeChannels, discoverableChannels }; }; + +export const createDataHash = (additionalFilters, searchParams) => { + const dataHash = {}; + if (additionalFilters.filters) { + const [key, value] = additionalFilters.filters.split(':'); + dataHash[key] = value; + } + dataHash.per_page = 30; + dataHash.page = searchParams.paginationNumber; + dataHash.channel_text = searchParams.query; + if (searchParams.searchType === 'discoverable') { + dataHash.user_id = 'all'; + } + return fetchSearch('chat_channels', dataHash); +}; diff --git a/app/views/chat_channels/index.json.jbuilder b/app/views/chat_channels/index.json.jbuilder index 29a2fe2cf..f750d25f1 100644 --- a/app/views/chat_channels/index.json.jbuilder +++ b/app/views/chat_channels/index.json.jbuilder @@ -16,4 +16,5 @@ json.array!(memberships) do |membership| json.adjusted_slug membership.chat_channel.adjusted_slug(current_user) json.membership_id membership.id + json.member_name membership.user.username end diff --git a/spec/requests/chat_channels_spec.rb b/spec/requests/chat_channels_spec.rb index 2de336622..a0554f705 100644 --- a/spec/requests/chat_channels_spec.rb +++ b/spec/requests/chat_channels_spec.rb @@ -57,8 +57,7 @@ RSpec.describe "ChatChannels", type: :request do membership.chat_channel.update(discoverable: true) sign_in user get "/chat_channels?state=joining_request" - expect(response.body).to include("\"status\":\"joining_request\"") - expect(response.body).to include("joining_requests") + expect(response.body).to include("\"member_name\":\"#{membership.user.username}\"") end end