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
This commit is contained in:
Mac Siri 2018-04-16 16:20:12 -04:00 committed by Ben Halpern
parent 610ae3da22
commit 0f35939906
22 changed files with 371 additions and 93 deletions

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -1,7 +1,7 @@
module.exports = {
extends: ['airbnb', 'prettier'],
parserOptions: {
ecmaVersion: 6,
ecmaVersion: 2017,
},
settings: {
react: {

View file

@ -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);
}

View file

@ -0,0 +1,18 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const Alert = ({ showAlert }) => {
const otherClassname = showAlert ? '' : 'chatalert__default--hidden';
return (
<div className={`chatalert__default ${otherClassname}`}>
More new messages below
</div>
);
};
Alert.propTypes = {
showAlert: PropTypes.bool.isRequired,
};
export default Alert;

View file

@ -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 (
<div className="chatchannel">
<div className="chatchannel__messages" id="messagelist">
{ this.renderMessage() }
{this.renderMessage()}
<div id="messagelist__sentinel" />
</div>
<div className="chatchannel__alerts">
<Alert showAlert={this.state.showAlert} />
</div>
<div className="chatchannel__form">
<Compose

View file

@ -6,14 +6,23 @@ import PropTypes from 'prop-types';
*/
const Message = ({
user, message, color,
user, message, color, hidden,
}) => {
const spanStyle = { color };
const linkStyle = { color: 'inherit' };
const messageStyle = { color: hidden ? 'lightgray' : 'inherit' };
return (
<div className="chatmessage">
<span className="chatmessage__username" style={spanStyle}>{ user }</span>
<span className="chatmessage__username" style={spanStyle}>
<a style={linkStyle} href={`/${user}`}>
{user}
</a>
</span>
<span className="chatmessage__divider">: </span>
<span className="chatmessage__message">{ message } </span>
<span className="chatmessage__message" style={messageStyle}>
{hidden ? '<message removed>' : message}
</span>
</div>
);
};
@ -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;

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
class ChatChannelPolicy < ApplicationPolicy
def moderate?
!user_is_banned? && user_is_admin?
end
end

View file

@ -0,0 +1,11 @@
class MessagePolicy < ApplicationPolicy
def create?
!user_is_banned?
end
private
def user_is_banned?
user&.has_role?(:banned)
end
end

View file

@ -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

View file

@ -6,43 +6,45 @@
<% end %>
<% end %>
<% title "DEV LIVE" %>
<link rel="canonical" href="https://dev.to/live"/>
<meta name="description" content="DEV LIVE">
<meta name="keywords" content="software development,engineering,rails,javascript,ruby">
<link rel="canonical" href="https://dev.to/live"/>
<meta name="description" content="DEV LIVE">
<meta name="keywords" content="software development,engineering,rails,javascript,ruby">
<meta property="og:type" content="article" />
<meta property="og:url" content="https://dev.to/live" />
<meta property="og:title" content="DEV LIVE" />
<meta property="og:image" content="https://thepracticaldev.s3.amazonaws.com/i/bqzj1pwho9e0jicqo44s.png" />
<meta property="og:description" content="DEV Live Events" />
<meta property="og:site_name" content="The Practical Dev" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://dev.to/live" />
<meta property="og:title" content="DEV LIVE" />
<meta property="og:image" content="https://thepracticaldev.s3.amazonaws.com/i/bqzj1pwho9e0jicqo44s.png" />
<meta property="og:description" content="DEV Live Events" />
<meta property="og:site_name" content="The Practical Dev" />
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@ThePracticalDev">
<meta name="twitter:title" content="DEV LIVE">
<meta property="og:description" content="DEV Live Events" />
<meta name="twitter:image:src" content="https://thepracticaldev.s3.amazonaws.com/i/bqzj1pwho9e0jicqo44s.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@ThePracticalDev">
<meta name="twitter:title" content="DEV LIVE">
<meta property="og:description" content="DEV Live Events" />
<meta name="twitter:image:src" content="https://thepracticaldev.s3.amazonaws.com/i/bqzj1pwho9e0jicqo44s.png">
<style>
<% cache "live-page-css", expires_in: 1.hour do %>
<%= Rails.application.assets['live.css'].to_s.html_safe %>
<% end %>
<% cache "live-page-css", expires_in: 1.hour do %>
<%= Rails.application.assets['live.css'].to_s.html_safe %>
<% end %>
</style>
<% if current_user&.has_role?(:super_admin) || (Flipflop.live_is_live? && current_user&.workshop_eligible?) %>
<div class="live-container">
<div class="live-video">
<script src="//player.dacast.com/js/player.js" width="1500" height="800" id="<%= ENV["DACAST_STREAM_CODE"]%>" player="jw7" jwurl="juQPHW/th8VUHaO/2KkeVlAdGN9ksJZog2z6SH+TyMU=" class="dacast-video"></script>
<div class="live-container">
<div class="live-component">
<div class="live-video">
<script src="//player.dacast.com/js/player.js" width="1920" height="1080" id="<%= ENV["DACAST_STREAM_CODE"]%>" player="jw7" jwurl="juQPHW/th8VUHaO/2KkeVlAdGN9ksJZog2z6SH+TyMU=" class="dacast-video"></script>
</div>
<div class="live-chat-wrapper">
<% if (Flipflop.sendbird?) %>
<div id="sb_chat" class="live-chat" data-sendbird-app-id="<%= ENV["SENDBIRD_APP_ID"] %>" data-sendbird-livechat-url="<%= ENV["SENDBIRD_LIVECHAT_URL"] %>"></div>
<% else %>
<div id="chat" class="live-chat" data-pusher-key="<%= ENV["PUSHER_KEY"] %>"</div>
<% end %>
</div>
</div>
</div>
<div class="live-chat-wrapper">
<% if (Flipflop.sendbird?) %>
<div id="sb_chat" class="live-chat" data-sendbird-app-id="<%= ENV["SENDBIRD_APP_ID"] %>" data-sendbird-livechat-url="<%= ENV["SENDBIRD_LIVECHAT_URL"] %>"></div>
<% else %>
<div id="chat" class="live-chat" data-pusher-key="<%= ENV["PUSHER_KEY"] %>"</div>
<% end %>
</div>
</div>
<div class="live-under-message">DEV Live is in beta. If you are having issues, you may need to try another browser or contact <a href="mailto:members@dev.to">members@dev.to</a> for support. ❤️</div>
<div class="live-under-message">DEV Live is in beta. If you are having issues, you may need to try another browser or contact <a href="mailto:members@dev.to">members@dev.to</a> for support. ❤️</div>
<% elsif Flipflop.live_starting_soon? %>
<div class="live-upcoming-info">
<h1 style="text-align: center"><span style="display: inline-block">DEV LIVE <%= image_tag "emoji/emoji-one-television.png", style: "width: 55px; vertical-align: text-top;" %></span></h1>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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