From b4292ade330adf7a199552ab0e28abad98e0be28 Mon Sep 17 00:00:00 2001 From: Mac Siri Date: Mon, 14 May 2018 12:50:32 -0400 Subject: [PATCH] Implement chatroom (#308) * Fix multiple chat loading bug * Open profile links in chat via new tab * Add unmount handling for chat * Add overscroll property to chat * Add channel_name to ChatChannel model * Create route and page for chatroom WIP * Expose ChatChannel id to live page * Implement multichannel support WIP * Implement chatroom page WIP * Add css for chatroom * Update chat's moderation * Refactor chat's message type * Swap the use of :message_markdown & :message_html * Add channel permissions WIP * Fix failing specs * Adjust json reponses * Add empty message prevention * Update participant error message * Change Workshop channel to General --- app/assets/stylesheets/chat.scss | 98 ++++++++++++- app/controllers/chat_channels_controller.rb | 11 +- app/controllers/messages_controller.rb | 35 ++++- app/controllers/pages_controller.rb | 10 +- app/javascript/chat/actions.js | 19 ++- app/javascript/chat/channels.jsx | 34 +++++ app/javascript/chat/chat.jsx | 138 ++++++++++++++---- app/javascript/chat/message.jsx | 49 +++++-- app/javascript/chat/messages/errorMessage.jsx | 23 +++ .../chat/messages/hiddenMessage.jsx | 30 ++++ app/javascript/chat/pusher.js | 2 +- app/javascript/chat/util.js | 20 ++- app/javascript/packs/chat.jsx | 44 ++++-- app/models/chat_channel.rb | 5 +- app/models/message.rb | 17 ++- app/models/role.rb | 1 + app/views/chat_channels/show.json.jbuilder | 5 + app/views/pages/chat.html.erb | 24 +++ app/views/pages/live.html.erb | 2 +- config/routes.rb | 1 + ...70132_add_channel_name_to_chat_channels.rb | 5 + db/schema.rb | 1 + spec/factories/messages.rb | 13 ++ spec/models/message_spec.rb | 12 +- spec/requests/messages_spec.rb | 2 +- 25 files changed, 503 insertions(+), 98 deletions(-) create mode 100644 app/javascript/chat/channels.jsx create mode 100644 app/javascript/chat/messages/errorMessage.jsx create mode 100644 app/javascript/chat/messages/hiddenMessage.jsx create mode 100644 app/views/pages/chat.html.erb create mode 100644 db/migrate/20180508170132_add_channel_name_to_chat_channels.rb diff --git a/app/assets/stylesheets/chat.scss b/app/assets/stylesheets/chat.scss index 45103a983..495c17dde 100644 --- a/app/assets/stylesheets/chat.scss +++ b/app/assets/stylesheets/chat.scss @@ -1,5 +1,27 @@ // High level class -.chatchannel { +.chat { + display: flex; + height: inherit; +} + +.chat__channels { + width: 125px; + border: 1px solid #c9c9c9; + border-right: none; + background: #ededed; + height: inherit; +} + +.chat__channels--hidden { + display: none; +} + +.chat__activechat { + flex-grow: 1; + height: inherit; +} + +.activechatchannel { height: inherit; max-height: inherit; display: flex; @@ -8,7 +30,7 @@ border: 1px solid #c9c9c9; } -.chatchannel__messages { +.activechatchannel__messages { font-size: 15px; padding: 10px 0px; display: flex; @@ -20,9 +42,10 @@ text-align: left; overflow-wrap: break-word; word-wrap: break-word; + overscroll-behavior-y: contain; } -.chatchannel__alerts { +.activechatchannel__alerts { position: relative; } @@ -43,7 +66,7 @@ display: none; } -.chatchannel__form { +.activechatchannel__form { border-top: 1px solid #c9c9c9; background: #ededed; width: 100%; @@ -61,10 +84,67 @@ } +.chatchannels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: inherit; +} + +.chatchanneltab { + height: 30px; + width: 100%; + margin-bottom: 5px; + border: none; + border-left: 8px solid transparent; + background: inherit; + text-align: start; + text-transform: uppercase; + font-size: 14px; + font-weight: 500; +} + +.chatchanneltab--active { + background: white; + border-left: 8px solid black; +} + +.chatchanneltab--inactive { + &:hover { + background: #e3e3e3; + } +} + +@keyframes example { + // animation-name: example; + // animation-duration: 4s; + // animation-iteration-count: infinite; + from { + border-left: 8px solid transparent; + } + to { + border-left: 8px solid #ffb6c1; + } +} + +.chatchannels__channelslist { + padding: 10px 0; + flex-grow: 1; +} + +.chatchannels__misc { + height: 70px; + border-top: 1px solid #c9c9c9 +} + .chatmessage__username { font-weight: 600; } +.chatmessage__username--link { + color: inherit; +} + .chatmessage__divider { } @@ -73,6 +153,13 @@ } +.chatmessage__currentuser { + background: black; + color: white; + padding: 1px 5px; + border-radius: 2px; +} + // Messagecomposer .messagecomposer { display: flex; @@ -87,7 +174,7 @@ margin: 10px; padding: 6px; resize: none; - width: 80%; + flex-grow: 1; } .messagecomposer__submit { @@ -96,4 +183,5 @@ border-radius: 3px; background: #66e2d5; font-weight: 500px; + width: 50px; } diff --git a/app/controllers/chat_channels_controller.rb b/app/controllers/chat_channels_controller.rb index f141ab877..5ad220d9e 100644 --- a/app/controllers/chat_channels_controller.rb +++ b/app/controllers/chat_channels_controller.rb @@ -23,22 +23,23 @@ class ChatChannelsController < ApplicationController banned_user.add_role :banned banned_user.messages.each(&:destroy!) Pusher.trigger(@chat_channel.id, "user-banned", { userId: banned_user.id }.to_json) - render json: { success: "banned!" }, status: 200 + render json: { status: "success", message: "banned!" }, status: 200 else - render json: { error: "username not found" }, status: 400 + render json: { status: "error", message: "username not found" }, status: 400 end when "/unban" banned_user = User.find_by_username(command[1]) if banned_user banned_user.remove_role :banned - render json: { success: "unbanned!" }, status: 200 + render json: { status: "success", message: "unbanned!" }, status: 200 else - render json: { error: "username not found" }, status: 400 + render json: { status: "error", message: "username not found" }, status: 400 end when "/clearchannel" @chat_channel.clear_channel + render json: { status: "success", message: "cleared!" }, status: 200 else - render json: { error: "invalid command" }, status: 400 + render json: { status: "error", message: "invalid command" }, status: 400 end end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 128241614..6df1cfd01 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -16,13 +16,20 @@ class MessagesController < ApplicationController end if success - render json: ["Message created"], status: 201 + render json: { status: "success", message: "Message created" }, status: 201 else - result = "Message created but could not trigger Pusher" - render json: [result, @message.to_json], status: 201 + error_message = "Message created but could not trigger Pusher" + render json: { status: "error", message: error_message }, status: 201 end else - render json: e.message, status: 401 + render json: { + status: "error", + message: { + chat_channel_id: @message.chat_channel_id, + message: @message.errors.full_messages, + type: "error", + }, + }, status: 401 end end @@ -31,14 +38,30 @@ class MessagesController < ApplicationController def create_pusher_payload(new_message) { user_id: new_message.user.id, + chat_channel_id: new_message.chat_channel.id, username: new_message.user.username, - message: new_message.message_markdown, + message: new_message.message_html, timestamp: new_message.timestamp, color: new_message.user.bg_color_hex, }.to_json end def message_params - params.require(:message).permit(:message_html, :user_id, :chat_channel_id) + params.require(:message).permit(:message_markdown, :user_id, :chat_channel_id) + end + + def user_not_authorized + respond_to do |format| + format.json do + render json: { + status: "error", + message: { + chat_channel_id: message_params[:chat_channel_id], + message: "You can not do that because you are banned", + type: "error", + }, + }, status: 401 + end + end end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 2d10b2875..b4e1d7ebf 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -38,7 +38,15 @@ class PagesController < ApplicationController end end - def live; end + def live + @chat_channels = [ChatChannel.find_by_channel_name("Workshop")].to_json + end + + def chat + workshop = ChatChannel.find_by_channel_name("General") + meta = ChatChannel.find_by_channel_name("Meta") + @chat_channels = [workshop, meta].to_json + end private # helpers diff --git a/app/javascript/chat/actions.js b/app/javascript/chat/actions.js index 6510c0520..a401540cf 100644 --- a/app/javascript/chat/actions.js +++ b/app/javascript/chat/actions.js @@ -1,5 +1,5 @@ -export function getAllMessages(successCb, failureCb) { - fetch('/chat_channels/1', { +export function getAllMessages(channelId, successCb, failureCb) { + fetch(`/chat_channels/${channelId}`, { Accept: 'application/json', 'Content-Type': 'application/json', }) @@ -8,7 +8,7 @@ export function getAllMessages(successCb, failureCb) { .catch(failureCb); } -export function sendMessage(message, successCb, failureCb) { +export function sendMessage(activeChannelId, message, successCb, failureCb) { fetch('/messages', { method: 'POST', headers: { @@ -18,9 +18,9 @@ export function sendMessage(message, successCb, failureCb) { }, body: JSON.stringify({ message: { - message_html: message, + message_markdown: message, user_id: window.currentUser.id, - chat_channel_id: '1', + chat_channel_id: activeChannelId, }, }), credentials: 'same-origin', @@ -30,8 +30,13 @@ export function sendMessage(message, successCb, failureCb) { .catch(failureCb); } -export function conductModeration(message, successCb, failureCb) { - fetch('/chat_channels/1/moderate', { +export function conductModeration( + activeChannelId, + message, + successCb, + failureCb, +) { + fetch(`/chat_channels/${activeChannelId}/moderate`, { method: 'POST', headers: { Accept: 'application/json', diff --git a/app/javascript/chat/channels.jsx b/app/javascript/chat/channels.jsx new file mode 100644 index 000000000..49c36adcd --- /dev/null +++ b/app/javascript/chat/channels.jsx @@ -0,0 +1,34 @@ +import { h } from 'preact'; +import PropTypes from 'prop-types'; + +const Channels = ({ activeChannelId, chatChannels, handleSwitchChannel }) => { + const channels = chatChannels.map(channel => { + const otherClassname = + parseInt(activeChannelId, 10) === channel.id + ? 'chatchanneltab--active' + : 'chatchanneltab--inactive'; + return ( + + ); + }); + + return ( +
+
{channels}
+
+ ); +}; + +Channels.propTypes = { + activeChannelId: PropTypes.number.isRequired, + chatChannels: PropTypes.array.isRequired, + handleSwitchChannel: PropTypes.func.isRequired, +}; + +export default Channels; diff --git a/app/javascript/chat/chat.jsx b/app/javascript/chat/chat.jsx index 4f383673b..b522d54b5 100644 --- a/app/javascript/chat/chat.jsx +++ b/app/javascript/chat/chat.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { conductModeration, getAllMessages, sendMessage } from './actions'; import { hideMessages, scrollToBottom, setupObserver } from './util'; import Alert from './alert'; +import Channels from './channels'; import Compose from './compose'; import Message from './message'; import setupPusher from './pusher'; @@ -20,19 +21,25 @@ class Chat extends Component { this.receiveNewMessage = this.receiveNewMessage.bind(this); this.clearChannel = this.clearChannel.bind(this); this.redactUserMessages = this.redactUserMessages.bind(this); + this.handleSwitchChannel = this.handleSwitchChannel.bind(this); + const chatChannels = JSON.parse(this.props.chatChannels); + const chatOptions = JSON.parse(this.props.chatOptions); this.state = { - messages: [], + messages: chatChannels.reduce( + (accumulator, target) => ({ ...accumulator, [target.id]: [] }), + {}, + ), scrolled: false, showAlert: false, + chatChannels, + activeChannelId: chatChannels[0].id, + showChannelsList: chatOptions.showChannelsList, }; } componentDidMount() { - getAllMessages(this.receiveAllMessages); - setupPusher(this.props.pusherKey, { - messageCreated: this.receiveNewMessage, - channelCleared: this.clearChannel, - redactUserMessages: this.redactUserMessages, + this.state.chatChannels.forEach(channel => { + this.setupChannel(channel.id); }); setupObserver(this.observerCallback); } @@ -43,8 +50,18 @@ class Chat extends Component { } } + setupChannel(channelId) { + getAllMessages(channelId, this.receiveAllMessages); + setupPusher(this.props.pusherKey, { + channelId, + messageCreated: this.receiveNewMessage, + channelCleared: this.clearChannel, + redactUserMessages: this.redactUserMessages, + }); + } + observerCallback(entries) { - entries.forEach((entry) => { + entries.forEach(entry => { if (entry.isIntersecting) { this.setState({ scrolled: false, showAlert: false }); } else { @@ -54,57 +71,93 @@ class Chat extends Component { } receiveAllMessages(res) { - this.setState({ messages: res.messages }); + const { chatChannelId, messages } = res; + const newMessages = { ...this.state.messages, [chatChannelId]: messages }; + this.setState({ messages: newMessages }); } receiveNewMessage(message) { - const newMessages = this.state.messages.slice(); + const receivedChatChannelId = message.chat_channel_id; + const newMessages = this.state.messages[receivedChatChannelId].slice(); newMessages.push(message); if (newMessages.length > 150) { newMessages.shift(); } + const newShowAlert = + this.state.activeChannelId === receivedChatChannelId + ? { showAlert: this.state.scrolled } + : {}; this.setState({ - messages: newMessages, - showAlert: this.state.scrolled, + ...newShowAlert, + messages: { + ...this.state.messages, + [receivedChatChannelId]: newMessages, + }, }); } redactUserMessages(res) { - const newMessages = hideMessages(this.state.messages.slice(), res.userId); + // This is shallow clone. This might cause a problem + const clonedMessages = Object.assign({}, this.state.messages); + const newMessages = hideMessages(clonedMessages, res.userId); this.setState({ messages: newMessages }); } - clearChannel() { - this.setState({ messages: [] }); + clearChannel(res) { + const newMessages = { ...this.state.messages, [res.chat_channel_id]: [] }; + this.setState({ messages: newMessages }); } handleKeyDown(e) { if (e.keyCode === 13) { e.preventDefault(); - this.handleMessageSubmit(e.target.value); - e.target.value = ''; + if (e.target.value.length > 0) { + this.handleMessageSubmit(e.target.value); + e.target.value = ''; + } } } handleMessageSubmit(message) { // should check if user has the priviledge if (message[0] === '/') { - conductModeration(message, this.handleSuccess, this.handleFailure); + conductModeration( + this.state.activeChannelId, + message, + this.handleSuccess, + this.handleFailure, + ); } else { - sendMessage(message, this.handleSuccess, this.handleFailure); + sendMessage( + this.state.activeChannelId, + message, + this.handleSuccess, + this.handleFailure, + ); } } + handleSwitchChannel(e) { + e.preventDefault(); + this.setState({ + activeChannelId: e.target.dataset.channelId, + scrolled: false, + showAlert: false, + }); + } + handleSubmitOnClick(e) { e.preventDefault(); const message = document.getElementById('messageform').value; - this.handleMessageSubmit(message); - document.getElementById('messageform').value = ''; + if (message.length > 0) { + this.handleMessageSubmit(message); + document.getElementById('messageform').value = ''; + } } handleSuccess(response) { - if (Object.prototype.hasOwnProperty.call(response, 'error')) { - console.log(response.error); + if (response.status === 'error') { + this.receiveNewMessage(response.message); } } @@ -113,28 +166,44 @@ class Chat extends Component { } renderMessage() { - return this.state.messages.map(message => ( + const { activeChannelId, messages } = this.state; + return messages[activeChannelId].map(message => (