From 0f35939906f736ded293ad75b862b0d4363b2137 Mon Sep 17 00:00:00 2001 From: Mac Siri Date: Mon, 16 Apr 2018 16:20:12 -0400 Subject: [PATCH] Update chat app's features (#204) * Update chat.scss --skip-ci * Fix height issue * Add highlighting * Implement bannged user handling for chat * Add scroll obsever * Limit initial messages load count & max messages * Add new message alert when scrolled * Add link to usernames in chat * Implement channel clearing feature * Update parserOptions * Implement banning feature for chat app WIP * Add policy for ChatChannelsController * Fix arugment error * Update user_not_authorized to handle JSON request * Update specs for updated user_not_authorized * Update font-weight for windows os --- app/assets/stylesheets/chat.scss | 35 +++++++++- app/assets/stylesheets/live.scss | 62 +++++++++-------- app/controllers/application_controller.rb | 15 ++++- app/controllers/chat_channels_controller.rb | 41 ++++++++++- app/controllers/messages_controller.rb | 2 + app/javascript/.eslintrc.js | 2 +- app/javascript/chat/actions.js | 34 ++++++++-- app/javascript/chat/alert.jsx | 18 +++++ app/javascript/chat/chat.jsx | 75 ++++++++++++++++++--- app/javascript/chat/message.jsx | 20 +++++- app/javascript/chat/pusher.js | 6 +- app/javascript/chat/util.js | 22 +++++- app/models/chat_channel.rb | 7 ++ app/policies/application_policy.rb | 4 ++ app/policies/chat_channel_policy.rb | 5 ++ app/policies/message_policy.rb | 11 +++ app/views/chat_channels/show.json.jbuilder | 3 +- app/views/pages/live.html.erb | 60 +++++++++-------- config/routes.rb | 1 + spec/requests/chat_channels_spec.rb | 37 +++++++++- spec/requests/editor_spec.rb | 2 +- spec/requests/image_uploads_spec.rb | 2 +- 22 files changed, 371 insertions(+), 93 deletions(-) create mode 100644 app/javascript/chat/alert.jsx create mode 100644 app/policies/chat_channel_policy.rb create mode 100644 app/policies/message_policy.rb diff --git a/app/assets/stylesheets/chat.scss b/app/assets/stylesheets/chat.scss index 980617800..45103a983 100644 --- a/app/assets/stylesheets/chat.scss +++ b/app/assets/stylesheets/chat.scss @@ -1,6 +1,7 @@ // High level class .chatchannel { height: inherit; + max-height: inherit; display: flex; flex-direction: column; justify-content: space-between; @@ -9,7 +10,7 @@ .chatchannel__messages { font-size: 15px; - padding: 10px; + padding: 10px 0px; display: flex; flex-direction: column; flex-grow: 1; @@ -17,6 +18,29 @@ overflow-y: scroll; overflow-x: hidden; text-align: left; + overflow-wrap: break-word; + word-wrap: break-word; +} + +.chatchannel__alerts { + position: relative; +} + +.chatalert__default { + align-items: center; + background-color: rgba(0, 0, 0, 0.5); + color: white; + display: flex; + height: 25px; + justify-content: center; + position: absolute; + top: -25px; + width: 100%; + font-size: 13px; +} + +.chatalert__default--hidden { + display: none; } .chatchannel__form { @@ -29,11 +53,16 @@ // Chatmessage .chatmessage { display: inline-block; - margin-bottom: 3px; + padding: 3px 10px; + + &:hover { + background: #e3e3e3; + } + } .chatmessage__username { - font-weight: 500; + font-weight: 600; } .chatmessage__divider { diff --git a/app/assets/stylesheets/live.scss b/app/assets/stylesheets/live.scss index 4893182d7..52402a15c 100644 --- a/app/assets/stylesheets/live.scss +++ b/app/assets/stylesheets/live.scss @@ -1,4 +1,6 @@ .live-container{ + display: flex; + flex-direction: column; margin:auto; max-width:1636px; min-height:96vh; @@ -7,36 +9,38 @@ margin-top:78px; width: 98%; } - .live-video{ - width: 100%; - height:62.5vw; - text-align:center; - max-height:920px; - float:left; - @media screen and ( min-width:760px ){ - width: calc(100% - 300px); - height: calc(100vh - 132px); - } - @media screen and ( min-width:1200px ){ - width: calc(100% - 400px); - } - } - .live-chat-wrapper{ - text-align:center; - float:left; - width: 100%; - .live-chat{ - height: calc(100vh - 62.5vw - 42px); - } - @media screen and ( min-width: 760px ){ - width:300px; - .live-chat{ - height: calc(100vh - 134px); - max-height:920px; + .live-component { + .live-video{ + width: 100%; + height:62.5vw; + text-align:center; + max-height:920px; + float:left; + @media screen and ( min-width:760px ){ + width: calc(100% - 300px); + height: calc(100vh - 132px); + } + @media screen and ( min-width:1200px ){ + width: calc(100% - 400px); } } - @media screen and ( min-width: 1200px ){ - width:400px; + .live-chat-wrapper{ + text-align:center; + float:left; + width: 100%; + .live-chat{ + height: calc(100vh - 62.5vw - 42px); + } + @media screen and ( min-width: 760px ){ + width:300px; + .live-chat{ + height: calc(100vh - 134px); + max-height:920px; + } + } + @media screen and ( min-width: 1200px ){ + width:400px; + } } } } @@ -54,4 +58,4 @@ max-width:96%; width: 700px; font-size:1.2em; -} \ No newline at end of file +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6b60dccaa..82ea72acb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -39,7 +39,10 @@ class ApplicationController < ActionController::Base def authenticate_user! unless current_user - redirect_to "/enter" + respond_to do |format| + format.html { redirect_to "/enter" } + format.json { render json: { error: "Please sign in" }, status: 401 } + end end end @@ -99,7 +102,13 @@ class ApplicationController < ActionController::Base private def user_not_authorized - flash[:alert] = "You are not authorized to perform this action." - redirect_to(request.referrer || root_path) + respond_to do |format| + format.html do + flash[:alert] = "You are not authorized to perform this action." + redirect_to(request.referrer || root_path) + end + + format.json { render json: { error: "Not authorized" }, status: 401 } + end end end diff --git a/app/controllers/chat_channels_controller.rb b/app/controllers/chat_channels_controller.rb index 7b7ed9e14..f141ab877 100644 --- a/app/controllers/chat_channels_controller.rb +++ b/app/controllers/chat_channels_controller.rb @@ -1,11 +1,50 @@ class ChatChannelsController < ApplicationController + before_action :authenticate_user!, only: [:moderate] + def show @chat_channel = ChatChannel.includes(:messages).find_by(id: params[:id]) if @chat_channel @chat_channel else - render json: ["The chat channel you are looking for is either invalid or does not exist"], + message = "The chat channel you are looking for is either invalid or does not exist" + render json: { error: message }, status: 401 end end + + def moderate + @chat_channel = ChatChannel.find(params[:id]) + authorize @chat_channel + command = chat_channel_params[:command].split + case command[0] + when "/ban" + banned_user = User.find_by_username(command[1]) + if banned_user + 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 + else + render json: { error: "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 + else + render json: { error: "username not found" }, status: 400 + end + when "/clearchannel" + @chat_channel.clear_channel + else + render json: { error: "invalid command" }, status: 400 + end + end + + private + + def chat_channel_params + params.require(:chat_channel).permit(:command) + end end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 0e76f0f4a..128241614 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -3,6 +3,7 @@ class MessagesController < ApplicationController def create @message = Message.new(message_params) + authorize @message success = false if @message.save @@ -29,6 +30,7 @@ class MessagesController < ApplicationController def create_pusher_payload(new_message) { + user_id: new_message.user.id, username: new_message.user.username, message: new_message.message_markdown, timestamp: new_message.timestamp, diff --git a/app/javascript/.eslintrc.js b/app/javascript/.eslintrc.js index f86a8f55a..64993d1a1 100644 --- a/app/javascript/.eslintrc.js +++ b/app/javascript/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { extends: ['airbnb', 'prettier'], parserOptions: { - ecmaVersion: 6, + ecmaVersion: 2017, }, settings: { react: { diff --git a/app/javascript/chat/actions.js b/app/javascript/chat/actions.js index b838b961c..6510c0520 100644 --- a/app/javascript/chat/actions.js +++ b/app/javascript/chat/actions.js @@ -2,7 +2,8 @@ export function getAllMessages(successCb, failureCb) { fetch('/chat_channels/1', { Accept: 'application/json', 'Content-Type': 'application/json', - }).then(response => response.json()) + }) + .then(response => response.json()) .then(successCb) .catch(failureCb); } @@ -16,12 +17,35 @@ export function sendMessage(message, successCb, failureCb) { 'Content-Type': 'application/json', }, body: JSON.stringify({ - message_html: message, - user_id: window.currentUser.id, - chat_channel_id: '1', + message: { + message_html: message, + user_id: window.currentUser.id, + chat_channel_id: '1', + }, }), credentials: 'same-origin', - }).then(response => response.json()) + }) + .then(response => response.json()) + .then(successCb) + .catch(failureCb); +} + +export function conductModeration(message, successCb, failureCb) { + fetch('/chat_channels/1/moderate', { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_channel: { + command: message, + }, + }), + credentials: 'same-origin', + }) + .then(response => response.json()) .then(successCb) .catch(failureCb); } diff --git a/app/javascript/chat/alert.jsx b/app/javascript/chat/alert.jsx new file mode 100644 index 000000000..52ec65ec8 --- /dev/null +++ b/app/javascript/chat/alert.jsx @@ -0,0 +1,18 @@ +import { h } from 'preact'; +import PropTypes from 'prop-types'; + +const Alert = ({ showAlert }) => { + const otherClassname = showAlert ? '' : 'chatalert__default--hidden'; + + return ( +
+ More new messages below +
+ ); +}; + +Alert.propTypes = { + showAlert: PropTypes.bool.isRequired, +}; + +export default Alert; diff --git a/app/javascript/chat/chat.jsx b/app/javascript/chat/chat.jsx index 85ba7dad4..4f383673b 100644 --- a/app/javascript/chat/chat.jsx +++ b/app/javascript/chat/chat.jsx @@ -1,7 +1,8 @@ import { h, Component } from 'preact'; import PropTypes from 'prop-types'; -import { getAllMessages, sendMessage } from './actions'; -import { scrollToBottom } from './util'; +import { conductModeration, getAllMessages, sendMessage } from './actions'; +import { hideMessages, scrollToBottom, setupObserver } from './util'; +import Alert from './alert'; import Compose from './compose'; import Message from './message'; import setupPusher from './pusher'; @@ -9,24 +10,47 @@ import setupPusher from './pusher'; class Chat extends Component { constructor(props) { super(props); - this.receiveAllMessages = this.receiveAllMessages.bind(this); - this.receiveNewMessage = this.receiveNewMessage.bind(this); + this.handleFailure = this.handleFailure.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleMessageSubmit = this.handleMessageSubmit.bind(this); this.handleSubmitOnClick = this.handleSubmitOnClick.bind(this); - this.handleFailure = this.handleFailure.bind(this); + this.handleSuccess = this.handleSuccess.bind(this); + this.observerCallback = this.observerCallback.bind(this); + this.receiveAllMessages = this.receiveAllMessages.bind(this); + this.receiveNewMessage = this.receiveNewMessage.bind(this); + this.clearChannel = this.clearChannel.bind(this); + this.redactUserMessages = this.redactUserMessages.bind(this); this.state = { messages: [], + scrolled: false, + showAlert: false, }; } componentDidMount() { getAllMessages(this.receiveAllMessages); - setupPusher(this.props.pusherKey, this.receiveNewMessage); + setupPusher(this.props.pusherKey, { + messageCreated: this.receiveNewMessage, + channelCleared: this.clearChannel, + redactUserMessages: this.redactUserMessages, + }); + setupObserver(this.observerCallback); } componentDidUpdate() { - scrollToBottom(); + if (!this.state.scrolled) { + scrollToBottom(); + } + } + + observerCallback(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.setState({ scrolled: false, showAlert: false }); + } else { + this.setState({ scrolled: true }); + } + }); } receiveAllMessages(res) { @@ -36,9 +60,24 @@ class Chat extends Component { receiveNewMessage(message) { const newMessages = this.state.messages.slice(); newMessages.push(message); + if (newMessages.length > 150) { + newMessages.shift(); + } + this.setState({ + messages: newMessages, + showAlert: this.state.scrolled, + }); + } + + redactUserMessages(res) { + const newMessages = hideMessages(this.state.messages.slice(), res.userId); this.setState({ messages: newMessages }); } + clearChannel() { + this.setState({ messages: [] }); + } + handleKeyDown(e) { if (e.keyCode === 13) { e.preventDefault(); @@ -48,16 +87,27 @@ class Chat extends Component { } handleMessageSubmit(message) { - sendMessage(message, null, this.handleFailure); + // should check if user has the priviledge + if (message[0] === '/') { + conductModeration(message, this.handleSuccess, this.handleFailure); + } else { + sendMessage(message, this.handleSuccess, this.handleFailure); + } } handleSubmitOnClick(e) { e.preventDefault(); const message = document.getElementById('messageform').value; - sendMessage(message, null, this.handleFailure); + this.handleMessageSubmit(message); document.getElementById('messageform').value = ''; } + handleSuccess(response) { + if (Object.prototype.hasOwnProperty.call(response, 'error')) { + console.log(response.error); + } + } + handleFailure(err) { console.error(err); } @@ -69,6 +119,7 @@ class Chat extends Component { message={message.message} timeStamp={message.timestamp} color={message.color} + hidden={message.hidden} /> )); } @@ -77,7 +128,11 @@ class Chat extends Component { return (
- { this.renderMessage() } + {this.renderMessage()} +
+
+
+
{ const spanStyle = { color }; + const linkStyle = { color: 'inherit' }; + const messageStyle = { color: hidden ? 'lightgray' : 'inherit' }; + return (
- { user } + + + {user} + + : - { message } + + {hidden ? '' : message} +
); }; @@ -22,6 +31,11 @@ Message.propTypes = { user: PropTypes.string.isRequired, color: PropTypes.string.isRequired, message: PropTypes.string.isRequired, + hidden: PropTypes.bool, +}; + +Message.defaultProps = { + hidden: false, }; export default Message; diff --git a/app/javascript/chat/pusher.js b/app/javascript/chat/pusher.js index a2ee96d62..1a1285566 100644 --- a/app/javascript/chat/pusher.js +++ b/app/javascript/chat/pusher.js @@ -1,11 +1,13 @@ import Pusher from 'pusher-js'; -export default function setupPusher(key, callback) { +export default function setupPusher(key, callbackObjects) { const pusher = new Pusher(key, { cluster: 'us2', encrypted: true, }); const channel = pusher.subscribe('1'); - channel.bind('message-created', callback); + channel.bind('message-created', callbackObjects.messageCreated); + channel.bind('channel-cleared', callbackObjects.channelCleared); + channel.bind('user-banned', callbackObjects.redactUserMessages); } diff --git a/app/javascript/chat/util.js b/app/javascript/chat/util.js index 20f2f7004..6ef4809ea 100644 --- a/app/javascript/chat/util.js +++ b/app/javascript/chat/util.js @@ -5,12 +5,14 @@ export function getUserDataAndCsrfToken() { let userData = null; const dataUserAttribute = document.body.getAttribute('data-user'); const meta = document.querySelector("meta[name='csrf-token']"); - if (dataUserAttribute && + if ( + dataUserAttribute && dataUserAttribute !== 'undefined' && dataUserAttribute !== undefined && meta && meta.content !== 'undefined' && - meta.content !== undefined) { + meta.content !== undefined + ) { userData = JSON.parse(dataUserAttribute); } i += 1; @@ -30,3 +32,19 @@ export function scrollToBottom() { const element = document.getElementById('messagelist'); element.scrollTop = element.scrollHeight; } + +export function setupObserver(callback) { + const sentinel = document.querySelector('#messagelist__sentinel'); + const somethingObserver = new IntersectionObserver(callback); + somethingObserver.observe(sentinel); +} + +export function hideMessages(messages, userId) { + const newMessages = messages.map((message) => { + if (message.user_id === userId) { + return Object.assign({ hidden: true }, message); + } + return message; + }); + return newMessages; +} diff --git a/app/models/chat_channel.rb b/app/models/chat_channel.rb index 1b983dbce..7b7fa5a3b 100644 --- a/app/models/chat_channel.rb +++ b/app/models/chat_channel.rb @@ -2,4 +2,11 @@ class ChatChannel < ApplicationRecord has_many :messages validates :channel_type, presence: true, inclusion: { in: %w(open) } + + def clear_channel + messages.each(&:destroy!) + Pusher.trigger(id, "channel-cleared", [].to_json) + rescue Pusher::Error => e + logger.info "PUSHER ERROR: #{e.message}" + end end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 9a77a8a73..12357d3c9 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -56,4 +56,8 @@ class ApplicationPolicy def user_is_admin? user.has_any_role?(:super_admin, :admin) end + + def user_is_banned? + user.has_role?(:banned) + end end diff --git a/app/policies/chat_channel_policy.rb b/app/policies/chat_channel_policy.rb new file mode 100644 index 000000000..888487313 --- /dev/null +++ b/app/policies/chat_channel_policy.rb @@ -0,0 +1,5 @@ +class ChatChannelPolicy < ApplicationPolicy + def moderate? + !user_is_banned? && user_is_admin? + end +end diff --git a/app/policies/message_policy.rb b/app/policies/message_policy.rb new file mode 100644 index 000000000..8594b9879 --- /dev/null +++ b/app/policies/message_policy.rb @@ -0,0 +1,11 @@ +class MessagePolicy < ApplicationPolicy + def create? + !user_is_banned? + end + + private + + def user_is_banned? + user&.has_role?(:banned) + end +end diff --git a/app/views/chat_channels/show.json.jbuilder b/app/views/chat_channels/show.json.jbuilder index 280e49541..fa3155479 100644 --- a/app/views/chat_channels/show.json.jbuilder +++ b/app/views/chat_channels/show.json.jbuilder @@ -1,4 +1,5 @@ -json.messages @chat_channel.messages do |message| +json.messages @chat_channel.messages.order("created_at DESC").limit(50).reverse do |message| + json.user_id message.user.id json.username message.user.username json.message message.message_markdown json.timestamp message.timestamp diff --git a/app/views/pages/live.html.erb b/app/views/pages/live.html.erb index 2fac69b61..653407a7a 100644 --- a/app/views/pages/live.html.erb +++ b/app/views/pages/live.html.erb @@ -6,43 +6,45 @@ <% end %> <% end %> <% title "DEV LIVE" %> - - - + + + - - - - - - + + + + + + - - - - - + + + + + <% if current_user&.has_role?(:super_admin) || (Flipflop.live_is_live? && current_user&.workshop_eligible?) %> -
-
- +
+
+
+ +
+
+ <% if (Flipflop.sendbird?) %> +
" data-sendbird-livechat-url="<%= ENV["SENDBIRD_LIVECHAT_URL"] %>">
+ <% else %> +
"
+ <% end %> +
+
-
- <% if (Flipflop.sendbird?) %> -
" data-sendbird-livechat-url="<%= ENV["SENDBIRD_LIVECHAT_URL"] %>">
- <% else %> -
"
- <% end %> -
-
-
DEV Live is in beta. If you are having issues, you may need to try another browser or contact members@dev.to for support. ❤️
+
DEV Live is in beta. If you are having issues, you may need to try another browser or contact members@dev.to for support. ❤️
<% elsif Flipflop.live_starting_soon? %>

DEV LIVE <%= image_tag "emoji/emoji-one-television.png", style: "width: 55px; vertical-align: text-top;" %>

diff --git a/config/routes.rb b/config/routes.rb index 800c9d5c3..9f9a63681 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -92,6 +92,7 @@ Rails.application.routes.draw do get "/notifications/:username" => "notifications#index" patch "/onboarding_update" => "users#onboarding_update" get "email_subscriptions/unsubscribe" + post "chat_channels/:id/moderate" => "chat_channels#moderate" # resources :users ### Subscription vanity url diff --git a/spec/requests/chat_channels_spec.rb b/spec/requests/chat_channels_spec.rb index fe9b52b16..ac211c338 100644 --- a/spec/requests/chat_channels_spec.rb +++ b/spec/requests/chat_channels_spec.rb @@ -1,10 +1,12 @@ require "rails_helper" RSpec.describe "ChatChannels", type: :request do + let(:user) { create(:user) } + let(:test_subject) { create(:user) } + let(:chat_channel) { create(:chat_channel) } + describe "GET /chat_channels/:id" do context "when request is valid" do - let(:chat_channel) { create(:chat_channel) } - before do get "/chat_channels/#{chat_channel.id}", headers: { HTTP_ACCEPT: "application/json" } end @@ -30,4 +32,35 @@ RSpec.describe "ChatChannels", type: :request do end end end + + describe "POST /chat_channels/:id/moderate" do + it "returns 401 unless user is logged in" do + post "/chat_channels/#{chat_channel.id}/moderate", + params: { chat_channel: { command: "/ban huh" } }, + headers: { HTTP_ACCEPT: "application/json" } + expect(response.status).to eq(401) + end + + it "returns 401 if user is logged in but not authorized" do + sign_in user + post "/chat_channels/#{chat_channel.id}/moderate", + params: { chat_channel: { command: "/ban huh" } }, + headers: { HTTP_ACCEPT: "application/json" } + expect(response.status).to eq(401) + end + + context "when user is logged-in and authorized" do + before do + user.add_role :super_admin + sign_in user + allow(Pusher).to receive(:trigger).and_return(true) + end + + it "enforces chat_channel_params" do + post "/chat_channels/#{chat_channel.id}/moderate", + params: { chat_channel: { command: "/ban #{test_subject.username}" } } + expect(response.status).to eq(200) + end + end + end end diff --git a/spec/requests/editor_spec.rb b/spec/requests/editor_spec.rb index 872f85458..9a1619e6e 100644 --- a/spec/requests/editor_spec.rb +++ b/spec/requests/editor_spec.rb @@ -43,7 +43,7 @@ RSpec.describe "Editor", type: :request do context "when not logged-in" do it "redirects to /enter" do post "/articles/preview", headers: headers - expect(response).to redirect_to("/enter") + expect(response).to have_http_status(401) end end diff --git a/spec/requests/image_uploads_spec.rb b/spec/requests/image_uploads_spec.rb index 790602b58..f8504ea1c 100644 --- a/spec/requests/image_uploads_spec.rb +++ b/spec/requests/image_uploads_spec.rb @@ -8,7 +8,7 @@ RSpec.describe "ImageUploads", type: :request do context "when not logged-in" do it "redirects to /enter" do post "/image_uploads", headers: headers - expect(response).to redirect_to("/enter") + expect(response).to have_http_status(401) end end