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