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
This commit is contained in:
parent
b2c7450c6f
commit
b4292ade33
25 changed files with 503 additions and 98 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
34
app/javascript/chat/channels.jsx
Normal file
34
app/javascript/chat/channels.jsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
className={`chatchanneltab ${otherClassname}`}
|
||||
onClick={handleSwitchChannel}
|
||||
data-channel-id={channel.id}
|
||||
>
|
||||
{channel.channel_name}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="chatchannels">
|
||||
<div className="chatchannels__channelslist">{channels}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Channels.propTypes = {
|
||||
activeChannelId: PropTypes.number.isRequired,
|
||||
chatChannels: PropTypes.array.isRequired,
|
||||
handleSwitchChannel: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Channels;
|
||||
|
|
@ -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 => (
|
||||
<Message
|
||||
user={message.username}
|
||||
message={message.message}
|
||||
timeStamp={message.timestamp}
|
||||
color={message.color}
|
||||
hidden={message.hidden}
|
||||
type={message.type}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
renderChatChannels() {
|
||||
if (this.state.showChannelsList) {
|
||||
return (
|
||||
<div className="chat__channels">
|
||||
<Channels
|
||||
activeChannelId={this.state.activeChannelId}
|
||||
chatChannels={this.state.chatChannels}
|
||||
handleSwitchChannel={this.handleSwitchChannel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
renderActiveChatChannel() {
|
||||
return (
|
||||
<div className="chatchannel">
|
||||
<div className="chatchannel__messages" id="messagelist">
|
||||
<div className="activechatchannel">
|
||||
<div className="activechatchannel__messages" id="messagelist">
|
||||
{this.renderMessage()}
|
||||
<div id="messagelist__sentinel" />
|
||||
</div>
|
||||
<div className="chatchannel__alerts">
|
||||
<div className="activechatchannel__alerts">
|
||||
<Alert showAlert={this.state.showAlert} />
|
||||
</div>
|
||||
<div className="chatchannel__form">
|
||||
<div className="activechatchannel__form">
|
||||
<Compose
|
||||
handleKeyDown={this.handleKeyDown}
|
||||
handleSubmitOnClick={this.handleSubmitOnClick}
|
||||
|
|
@ -143,10 +212,21 @@ class Chat extends Component {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="chat">
|
||||
{this.renderChatChannels()}
|
||||
<div className="chat__activechat">{this.renderActiveChatChannel()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Chat.propTypes = {
|
||||
pusherKey: PropTypes.number.isRequired,
|
||||
chatChannels: PropTypes.array.isRequired,
|
||||
chatOptions: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
|
|
|
|||
|
|
@ -1,28 +1,53 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import ErrorMessage from './messages/errorMessage';
|
||||
import Hiddenmessage from './messages/hiddenMessage';
|
||||
|
||||
/*
|
||||
* The prop also contain timeStamp, which is currently not in used
|
||||
*
|
||||
*/
|
||||
|
||||
const Message = ({
|
||||
user, message, color, hidden,
|
||||
}) => {
|
||||
const Message = ({ user, message, color, type }) => {
|
||||
const spanStyle = { color };
|
||||
const linkStyle = { color: 'inherit' };
|
||||
const messageStyle = { color: hidden ? 'lightgray' : 'inherit' };
|
||||
|
||||
if (type === 'error') {
|
||||
return <ErrorMessage message={message} />;
|
||||
} else if (type === 'hidden') {
|
||||
return <Hiddenmessage user={user} color={color} />;
|
||||
}
|
||||
|
||||
const re = new RegExp(`@${window.currentUser.username}`);
|
||||
const match = re.exec(message);
|
||||
let messageArea;
|
||||
|
||||
if (match) {
|
||||
messageArea = (
|
||||
<span className="chatmessage__message">
|
||||
{message.substr(0, match.index)}
|
||||
<span className="chatmessage__currentuser">
|
||||
{`@${window.currentUser.username}`}
|
||||
</span>
|
||||
{message.substr(match.index + match[0].length)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
messageArea = <span className="chatmessage__message">{message}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chatmessage">
|
||||
<span className="chatmessage__username" style={spanStyle}>
|
||||
<a style={linkStyle} href={`/${user}`}>
|
||||
<a
|
||||
className="chatmessage__username--link"
|
||||
href={`/${user}`}
|
||||
target="_blank"
|
||||
>
|
||||
{user}
|
||||
</a>
|
||||
</span>
|
||||
<span className="chatmessage__divider">: </span>
|
||||
<span className="chatmessage__message" style={messageStyle}>
|
||||
{hidden ? '<message removed>' : message}
|
||||
</span>
|
||||
{messageArea}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -31,11 +56,13 @@ Message.propTypes = {
|
|||
user: PropTypes.string.isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
// hidden: PropTypes.bool,
|
||||
// error: PropTypes.bool,
|
||||
};
|
||||
|
||||
Message.defaultProps = {
|
||||
hidden: false,
|
||||
type: 'normalMessage',
|
||||
};
|
||||
|
||||
export default Message;
|
||||
|
|
|
|||
23
app/javascript/chat/messages/errorMessage.jsx
Normal file
23
app/javascript/chat/messages/errorMessage.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ErrorMessage = ({ message }) => {
|
||||
const errorStyle = { color: 'darksalmon', 'font-size': '13px' };
|
||||
return (
|
||||
<div className="chatmessage">
|
||||
<span className="chatmessage__message" style={errorStyle}>
|
||||
{'Sorry '}
|
||||
<span className="chatmessage__currentuser">
|
||||
{`@${window.currentUser.username}`}
|
||||
</span>
|
||||
{` ${message}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ErrorMessage.propTypes = {
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
||||
30
app/javascript/chat/messages/hiddenMessage.jsx
Normal file
30
app/javascript/chat/messages/hiddenMessage.jsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const HiddenMessage = ({ user, color }) => {
|
||||
const spanStyle = { color };
|
||||
return (
|
||||
<div className="chatmessage">
|
||||
<span className="chatmessage__username" style={spanStyle}>
|
||||
<a
|
||||
className="chatmessage__username--link"
|
||||
href={`/${user}`}
|
||||
target="_blank"
|
||||
>
|
||||
{user}
|
||||
</a>
|
||||
</span>
|
||||
<span className="chatmessage__divider">: </span>
|
||||
<span className="chatmessage__message" style={{ color: 'lightgray' }}>
|
||||
{'<message removed>'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
HiddenMessage.propTypes = {
|
||||
user: PropTypes.string.isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default HiddenMessage;
|
||||
|
|
@ -6,7 +6,7 @@ export default function setupPusher(key, callbackObjects) {
|
|||
encrypted: true,
|
||||
});
|
||||
|
||||
const channel = pusher.subscribe('1');
|
||||
const channel = pusher.subscribe(callbackObjects.channelId.toString());
|
||||
channel.bind('message-created', callbackObjects.messageCreated);
|
||||
channel.bind('channel-cleared', callbackObjects.channelCleared);
|
||||
channel.bind('user-banned', callbackObjects.redactUserMessages);
|
||||
|
|
|
|||
|
|
@ -40,11 +40,17 @@ export function setupObserver(callback) {
|
|||
}
|
||||
|
||||
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;
|
||||
const cleanedMessages = Object.keys(messages).reduce(
|
||||
(accumulator, channelId) => {
|
||||
const newMessages = messages[channelId].map(message => {
|
||||
if (message.user_id === userId) {
|
||||
return Object.assign({ type: 'hidden' }, message);
|
||||
}
|
||||
return message;
|
||||
});
|
||||
return { ...accumulator, [channelId]: newMessages };
|
||||
},
|
||||
{},
|
||||
);
|
||||
return cleanedMessages;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,35 @@ import { h, render } from 'preact';
|
|||
import { getUserDataAndCsrfToken } from '../chat/util';
|
||||
import Chat from '../chat/chat';
|
||||
|
||||
HTMLDocument.prototype.ready = new Promise((resolve) => {
|
||||
if (document.readyState !== 'loading') { return resolve(); }
|
||||
HTMLDocument.prototype.ready = new Promise(resolve => {
|
||||
if (document.readyState !== 'loading') {
|
||||
return resolve();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => resolve());
|
||||
return null;
|
||||
});
|
||||
|
||||
document.ready
|
||||
.then(getUserDataAndCsrfToken()
|
||||
.then((currentUser) => {
|
||||
if (document.getElementById('chat')) {
|
||||
const { pusherKey } = document.getElementById('chat').dataset;
|
||||
window.currentUser = currentUser;
|
||||
window.csrfToken = document.querySelector("meta[name='csrf-token']").content;
|
||||
render(
|
||||
<Chat pusherKey={pusherKey} />,
|
||||
document.getElementById('chat'),
|
||||
);
|
||||
}
|
||||
}));
|
||||
document.ready.then(
|
||||
getUserDataAndCsrfToken().then(currentUser => {
|
||||
if (document.getElementById('chat')) {
|
||||
const { chatChannels, pusherKey, chatOptions } = document.getElementById(
|
||||
'chat',
|
||||
).dataset;
|
||||
window.currentUser = currentUser;
|
||||
window.csrfToken = document.querySelector(
|
||||
"meta[name='csrf-token']",
|
||||
).content;
|
||||
const root = render(
|
||||
<Chat
|
||||
pusherKey={pusherKey}
|
||||
chatChannels={chatChannels}
|
||||
chatOptions={chatOptions}
|
||||
/>,
|
||||
document.getElementById('chat'),
|
||||
);
|
||||
window.InstantClick.on('change', () => {
|
||||
render('', document.body, root);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
class ChatChannel < ApplicationRecord
|
||||
has_many :messages
|
||||
|
||||
validates :channel_type, presence: true, inclusion: { in: %w(open) }
|
||||
validates :channel_type, presence: true, inclusion: { in: %w(open invite_only) }
|
||||
|
||||
def clear_channel
|
||||
messages.each(&:destroy!)
|
||||
Pusher.trigger(id, "channel-cleared", [].to_json)
|
||||
Pusher.trigger(id, "channel-cleared", { chat_channel_id: id }.to_json)
|
||||
true
|
||||
rescue Pusher::Error => e
|
||||
logger.info "PUSHER ERROR: #{e.message}"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ class Message < ApplicationRecord
|
|||
belongs_to :user
|
||||
belongs_to :chat_channel
|
||||
|
||||
validates :message_html, presence: true, length: { maximum: 600 }
|
||||
validates :message_markdown, presence: true
|
||||
validates :message_html, presence: true
|
||||
validates :message_markdown, presence: true, length: { maximum: 600 }
|
||||
|
||||
before_validation :evaluate_markdown
|
||||
before_validation :evaluate_channel_permission
|
||||
|
||||
def timestamp
|
||||
created_at.strftime("%H:%M:%S")
|
||||
|
|
@ -14,6 +15,16 @@ class Message < ApplicationRecord
|
|||
private
|
||||
|
||||
def evaluate_markdown
|
||||
self.message_markdown = message_html
|
||||
self.message_html = message_markdown
|
||||
end
|
||||
|
||||
def evaluate_channel_permission
|
||||
channel_type = ChatChannel.find(chat_channel_id).channel_type
|
||||
return if channel_type == "open"
|
||||
if user&.has_role?(:chatroom_beta_tester)
|
||||
# this is fine
|
||||
else
|
||||
errors.add(:base, "You are not a participant of this chat channel.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class Role < ApplicationRecord
|
|||
level_1_member
|
||||
workshop_pass
|
||||
video_permission
|
||||
chatroom_beta_tester
|
||||
),
|
||||
}
|
||||
scopify
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
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
|
||||
|
|
@ -5,3 +6,7 @@ json.messages @chat_channel.messages.order("created_at DESC").limit(50).reverse
|
|||
json.timestamp message.timestamp
|
||||
json.color message.user.bg_color_hex
|
||||
end
|
||||
|
||||
json.key_format! camelize: :lower
|
||||
|
||||
json.chat_channel_id @chat_channel.id
|
||||
|
|
|
|||
24
app/views/pages/chat.html.erb
Normal file
24
app/views/pages/chat.html.erb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<% if user_signed_in? %>
|
||||
<%= javascript_pack_tag 'chat', defer: true %>
|
||||
<% end %>
|
||||
<% title "DEV Chat" %>
|
||||
<link rel="canonical" href="https://dev.to/chat"/>
|
||||
<meta name="description" content="DEV Chat">
|
||||
|
||||
<style>
|
||||
.inner-content {
|
||||
height: 500px;
|
||||
margin: 0px 50px;
|
||||
margin-top: 150px;
|
||||
margin-bottom: 150px;
|
||||
max-width: 1250px;
|
||||
}
|
||||
|
||||
.live-chat {
|
||||
height: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="inner-content">
|
||||
<div id="chat" class="live-chat" data-pusher-key="<%= ENV["PUSHER_KEY"] %>" data-chat-channels="<%= @chat_channels %>" data-chat-options="<%= {showChannelsList:true}.to_json %>">
|
||||
</div>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
<% 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>
|
||||
<div id="chat" class="live-chat" data-pusher-key="<%= ENV["PUSHER_KEY"] %>" data-chat-channels="<%= @chat_channels %>" data-chat-options="<%= {showChannelsList:false}.to_json %>">
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ Rails.application.routes.draw do
|
|||
get "/infiniteloop" => "pages#infinite_loop"
|
||||
get "/faq" => "pages#faq"
|
||||
get "/live" => "pages#live"
|
||||
get "/chat" => "pages#chat"
|
||||
get "/swagnets" => "pages#swagnets"
|
||||
get "/welcome" => "pages#welcome"
|
||||
get "/💸", to: redirect("t/hiring")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddChannelNameToChatChannels < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :chat_channels, :channel_name, :string
|
||||
end
|
||||
end
|
||||
|
|
@ -161,6 +161,7 @@ ActiveRecord::Schema.define(version: 20180508200948) do
|
|||
end
|
||||
|
||||
create_table "chat_channels", force: :cascade do |t|
|
||||
t.string "channel_name"
|
||||
t.string "channel_type", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
FactoryBot.define do
|
||||
factory :message do
|
||||
message_markdown { Faker::Lorem.sentence }
|
||||
chat_channel
|
||||
|
||||
trait :ignore_after_callback do
|
||||
after(:build) do |message|
|
||||
message.define_singleton_method(:evaluate_channel_permission) {}
|
||||
message.define_singleton_method(:evaluate_markdown) {}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Message, type: :model do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to belong_to(:chat_channel) }
|
||||
it { is_expected.to validate_presence_of(:message_html) }
|
||||
it { is_expected.to validate_presence_of(:message_markdown) }
|
||||
describe "validations" do
|
||||
subject { build(:message, :ignore_after_callback)}
|
||||
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to belong_to(:chat_channel) }
|
||||
it { is_expected.to validate_presence_of(:message_html) }
|
||||
it { is_expected.to validate_presence_of(:message_markdown) }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ RSpec.describe "Messages", type: :request do
|
|||
describe "POST /messages" do
|
||||
let(:new_message) do
|
||||
{
|
||||
message_html: "hi",
|
||||
message_markdown: "hi",
|
||||
user_id: user.id,
|
||||
chat_channel_id: chat_channel.id,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue