Update chat app (#326)

* Fix spacing

* Update webpacker gem to 3.5

* Add custom preact-compact config

* Add babel-loader to resolve a weird issue

* Add react-textarea-autocomplete

* Move 2 dependencies into devDependencies

* Add babel-eslint & eslint-plugin-babel

* Refactor chat

* Add fixed width to chat's channels

* Add timestamp for chat

* Fix long word issue

* Add showTimestamp options to chat

* Add profile image to chat

* Make profile image in chat circle

* Improve message send logic

* Add proper user session handling for chat

* Choose profile user image source for chat

* Adjust user's text color

* Fix lint
This commit is contained in:
Mac Siri 2018-05-21 13:19:56 -04:00 committed by Ben Halpern
parent e21544be52
commit 7cb65649e4
19 changed files with 899 additions and 758 deletions

View file

@ -11,12 +11,12 @@
"preact"
],
"env": {
"test": {
"plugins": [
"transform-es2015-modules-commonjs",
["transform-react-jsx", { "pragma":"h" }],
"transform-class-properties"
]
"test": {
"plugins": [
"transform-es2015-modules-commonjs",
["transform-react-jsx", { "pragma":"h" }],
"transform-class-properties"
]
}
},
"plugins": [

View file

@ -189,7 +189,7 @@ GEM
after_commit_action (~> 1.0)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.3)
crass (1.0.4)
csv_shaper (1.3.0)
activesupport (>= 3.0.0)
dalli (2.7.6)
@ -244,7 +244,7 @@ GEM
environs (1.1.0)
equalizer (0.0.11)
errbase (0.0.3)
erubi (1.7.0)
erubi (1.7.1)
eventmachine (1.2.5)
excon (0.60.0)
execjs (2.7.0)
@ -539,7 +539,7 @@ GEM
logging (2.2.2)
little-plugger (~> 1.1)
multi_json (~> 1.10)
loofah (2.2.0)
loofah (2.2.2)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
lumberjack (1.0.12)
@ -622,14 +622,14 @@ GEM
multi_json (~> 1.0)
pusher-signature (~> 0.1.8)
pusher-signature (0.1.8)
rack (2.0.4)
rack (2.0.5)
rack-host-redirect (1.3.0)
rack
rack-protection (2.0.1)
rack
rack-proxy (0.6.3)
rack-proxy (0.6.4)
rack
rack-test (0.8.2)
rack-test (1.0.0)
rack (>= 1.0, < 3)
rack-timeout (0.4.2)
rack_session_access (0.1.1)
@ -655,8 +655,8 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2)
rails-observers (0.1.5)
activemodel (>= 4.0)
rails_12factor (0.0.3)
@ -671,7 +671,7 @@ GEM
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
rainbow (3.0.0)
rake (12.3.0)
rake (12.3.1)
rb-fsevent (0.10.2)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
@ -881,7 +881,7 @@ GEM
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
webpacker (3.2.2)
webpacker (3.5.3)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)

View file

@ -7,7 +7,7 @@
}
.chat__channels {
width: 276px;
width: 160px;
height: inherit;
}
@ -33,15 +33,11 @@
.activechatchannel__messages {
font-size: 15px;
padding: 10px 0px;
display: flex;
flex-direction: column;
flex-grow: 1;
height: 300px;
overflow-y: scroll;
overflow-x: hidden;
text-align: left;
overflow-wrap: break-word;
word-wrap: break-word;
overscroll-behavior-y: contain;
}
@ -75,8 +71,9 @@
// Chatmessage
.chatmessage {
display: inline-block;
padding: 3px 10px;
display: flex;
// justify-content: space-between;
&:hover {
background: #e3e3e3;
@ -89,6 +86,7 @@
flex-direction: column;
justify-content: space-between;
height: inherit;
width: inherit;
}
.chatchanneltab {
@ -140,23 +138,43 @@
border-top: 1px solid #c9c9c9
}
.chatmessage__username {
.chatmessage__body {
flex-grow: 1;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.chatmessage__timestamp {
font-size: 11px;
color: lightgray;
min-width: 95px;
}
.chatmessagebody__profileimage {
height: 18px;
width: 18px;
margin-right: 3px;
border-radius: 20px;
vertical-align: bottom;
}
.chatmessagebody__username {
font-weight: 600;
}
.chatmessage__username--link {
.chatmessagebody__username--link {
color: inherit;
}
.chatmessage__divider {
.chatmessagebody__divider {
}
.chatmessage__message {
.chatmessagebody__message {
}
.chatmessage__currentuser {
.chatmessagebody__currentuser {
background: black;
color: white;
padding: 1px 5px;

View file

@ -40,17 +40,13 @@ class MessagesController < ApplicationController
user_id: new_message.user.id,
chat_channel_id: new_message.chat_channel.id,
username: new_message.user.username,
profile_image_url: ProfileImage.new(new_message.user).get(90),
message: new_message.message_html,
timestamp: new_message.timestamp,
color: darker_user_color(new_message),
timestamp: new_message.created_at,
color: new_message.preferred_user_color,
}.to_json
end
def darker_user_color(new_message)
HexComparer.new([new_message.user.bg_color_hex || "#000000",
new_message.user.text_color_hex || "#000000"]).brightness(0.9)
end
def message_params
params.require(:message).permit(:message_markdown, :user_id, :chat_channel_id)
end

View file

@ -1,4 +1,5 @@
module.exports = {
parser: 'babel-eslint',
extends: ['airbnb', 'prettier'],
parserOptions: {
ecmaVersion: 2017,
@ -12,7 +13,7 @@ module.exports = {
jest: true,
browser: true,
},
plugins: ['import'],
plugins: ['import', 'babel'],
rules: {
'import/no-extraneous-dependencies': [
'error',

View file

@ -1,27 +1,26 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { conductModeration, getAllMessages, sendMessage } from './actions';
import { hideMessages, scrollToBottom, setupObserver } from './util';
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';
class Chat extends Component {
export default class Chat extends Component {
static propTypes = {
pusherKey: PropTypes.number.isRequired,
chatChannels: PropTypes.array.isRequired,
chatOptions: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
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.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.handleSwitchChannel = this.handleSwitchChannel.bind(this);
const chatChannels = JSON.parse(this.props.chatChannels);
const chatOptions = JSON.parse(this.props.chatOptions);
this.state = {
@ -34,6 +33,7 @@ class Chat extends Component {
chatChannels,
activeChannelId: chatChannels[0].id,
showChannelsList: chatOptions.showChannelsList,
showTimestamp: chatOptions.showTimestamp,
};
}
@ -50,7 +50,7 @@ class Chat extends Component {
}
}
setupChannel(channelId) {
setupChannel = channelId => {
getAllMessages(channelId, this.receiveAllMessages);
setupPusher(this.props.pusherKey, {
channelId,
@ -58,9 +58,9 @@ class Chat extends Component {
channelCleared: this.clearChannel,
redactUserMessages: this.redactUserMessages,
});
}
};
observerCallback(entries) {
observerCallback = entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.setState({ scrolled: false, showAlert: false });
@ -68,15 +68,15 @@ class Chat extends Component {
this.setState({ scrolled: true });
}
});
}
};
receiveAllMessages(res) {
receiveAllMessages = res => {
const { chatChannelId, messages } = res;
const newMessages = { ...this.state.messages, [chatChannelId]: messages };
this.setState({ messages: newMessages });
}
};
receiveNewMessage(message) {
receiveNewMessage = message => {
const receivedChatChannelId = message.chat_channel_id;
const newMessages = this.state.messages[receivedChatChannelId].slice();
newMessages.push(message);
@ -94,31 +94,38 @@ class Chat extends Component {
[receivedChatChannelId]: newMessages,
},
});
}
};
redactUserMessages(res) {
redactUserMessages = res => {
// 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(res) {
clearChannel = res => {
const newMessages = { ...this.state.messages, [res.chat_channel_id]: [] };
this.setState({ messages: newMessages });
}
};
handleKeyDown(e) {
if (e.keyCode === 13) {
e.preventDefault();
if (e.target.value.length > 0) {
handleKeyDown = e => {
const enterPressed = e.keyCode === 13;
const targetValue = e.target.value;
const messageIsEmpty = targetValue.length === 0;
const shiftPressed = e.shiftKey;
if (enterPressed) {
if(messageIsEmpty) {
e.preventDefault();
} else if (!messageIsEmpty && !shiftPressed) {
e.preventDefault();
this.handleMessageSubmit(e.target.value);
e.target.value = '';
}
}
}
};
handleMessageSubmit(message) {
handleMessageSubmit = message => {
// should check if user has the priviledge
if (message[0] === '/') {
conductModeration(
@ -135,50 +142,51 @@ class Chat extends Component {
this.handleFailure,
);
}
}
};
handleSwitchChannel(e) {
handleSwitchChannel = e => {
e.preventDefault();
this.setState({
activeChannelId: e.target.dataset.channelId,
scrolled: false,
showAlert: false,
});
}
};
handleSubmitOnClick(e) {
handleSubmitOnClick = e => {
e.preventDefault();
const message = document.getElementById('messageform').value;
if (message.length > 0) {
this.handleMessageSubmit(message);
document.getElementById('messageform').value = '';
}
}
};
handleSuccess(response) {
handleSuccess = response => {
if (response.status === 'error') {
this.receiveNewMessage(response.message);
}
}
};
handleFailure(err) {
handleFailure = err => {
console.error(err);
}
};
renderMessage() {
const { activeChannelId, messages } = this.state;
renderMessage = () => {
const { activeChannelId, messages, showTimestamp } = this.state;
return messages[activeChannelId].map(message => (
<Message
user={message.username}
profileImageUrl={message.profile_image_url}
message={message.message}
timeStamp={message.timestamp}
timestamp={showTimestamp ? message.timestamp : null}
color={message.color}
type={message.type}
/>
));
}
};
renderChatChannels() {
renderChatChannels = () => {
if (this.state.showChannelsList) {
return (
<div className="chat__channels">
@ -191,27 +199,25 @@ class Chat extends Component {
);
}
return '';
}
};
renderActiveChatChannel() {
return (
<div className="activechatchannel">
<div className="activechatchannel__messages" id="messagelist">
{this.renderMessage()}
<div id="messagelist__sentinel" />
</div>
<div className="activechatchannel__alerts">
<Alert showAlert={this.state.showAlert} />
</div>
<div className="activechatchannel__form">
<Compose
handleKeyDown={this.handleKeyDown}
handleSubmitOnClick={this.handleSubmitOnClick}
/>
</div>
renderActiveChatChannel = () => (
<div className="activechatchannel">
<div className="activechatchannel__messages" id="messagelist">
{this.renderMessage()}
<div id="messagelist__sentinel" />
</div>
);
}
<div className="activechatchannel__alerts">
<Alert showAlert={this.state.showAlert} />
</div>
<div className="activechatchannel__form">
<Compose
handleKeyDown={this.handleKeyDown}
handleSubmitOnClick={this.handleSubmitOnClick}
/>
</div>
</div>
);
render() {
return (
@ -222,11 +228,3 @@ class Chat extends Component {
);
}
}
Chat.propTypes = {
pusherKey: PropTypes.number.isRequired,
chatChannels: PropTypes.array.isRequired,
chatOptions: PropTypes.object.isRequired,
};
export default Chat;

View file

@ -1,20 +1,29 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { adjustTimestamp } from './util';
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, type }) => {
const Message = ({
user,
message,
color,
type,
timestamp,
profileImageUrl,
}) => {
const spanStyle = { color };
if (type === 'error') {
return <ErrorMessage message={message} />;
} else if (type === 'hidden') {
return <Hiddenmessage user={user} color={color} />;
return (
<Hiddenmessage
user={user}
color={color}
profileImageUrl={profileImageUrl}
/>
);
}
const re = new RegExp(`@${window.currentUser.username}`);
@ -23,31 +32,47 @@ const Message = ({ user, message, color, type }) => {
if (match) {
messageArea = (
<span className="chatmessage__message">
<span className="chatmessagebody__message">
{message.substr(0, match.index)}
<span className="chatmessage__currentuser">
<span className="chatmessagebody__currentuser">
{`@${window.currentUser.username}`}
</span>
{message.substr(match.index + match[0].length)}
</span>
);
} else {
messageArea = <span className="chatmessage__message">{message}</span>;
messageArea = <span className="chatmessagebody__message">{message}</span>;
}
return (
<div className="chatmessage">
<span className="chatmessage__username" style={spanStyle}>
<a
className="chatmessage__username--link"
href={`/${user}`}
target="_blank"
>
{user}
<div className="chatmessage__body">
<a href={`/${user}`} target="_blank">
<img
className="chatmessagebody__profileimage"
src={profileImageUrl}
alt={`${user} profile`}
/>
</a>
</span>
<span className="chatmessage__divider">: </span>
{messageArea}
<span className="chatmessagebody__username" style={spanStyle}>
<a
className="chatmessagebody__username--link"
href={`/${user}`}
target="_blank"
>
{user}
</a>
</span>
<span className="chatmessagebody__divider">: </span>
{messageArea}
</div>
{timestamp ? (
<span className="chatmessage__timestamp">
{`${adjustTimestamp(timestamp)}`}
</span>
) : (
<span />
)}
</div>
);
};
@ -57,12 +82,14 @@ Message.propTypes = {
color: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
type: PropTypes.string,
// hidden: PropTypes.bool,
// error: PropTypes.bool,
timestamp: PropTypes.string,
profileImageUrl: PropTypes.string,
};
Message.defaultProps = {
type: 'normalMessage',
timestamp: null,
profileImageUrl: '',
};
export default Message;

View file

@ -5,9 +5,9 @@ const ErrorMessage = ({ message }) => {
const errorStyle = { color: 'darksalmon', 'font-size': '13px' };
return (
<div className="chatmessage">
<span className="chatmessage__message" style={errorStyle}>
<span className="chatmessage__body" style={errorStyle}>
{'Sorry '}
<span className="chatmessage__currentuser">
<span className="chatmessagebody__currentuser">
{`@${window.currentUser.username}`}
</span>
{` ${message}`}

View file

@ -1,23 +1,35 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const HiddenMessage = ({ user, color }) => {
const HiddenMessage = ({ user, color, profileImageUrl }) => {
const spanStyle = { color };
return (
<div className="chatmessage">
<span className="chatmessage__username" style={spanStyle}>
<a
className="chatmessage__username--link"
href={`/${user}`}
target="_blank"
>
{user}
<div className="chatmessage__body">
<a href={`/${user}`} target="_blank">
<img
className="chatmessagebody__profileimage"
src={profileImageUrl}
alt={`${user} profile`}
/>
</a>
</span>
<span className="chatmessage__divider">: </span>
<span className="chatmessage__message" style={{ color: 'lightgray' }}>
{'<message removed>'}
</span>
<span className="chatmessagebody__username" style={spanStyle}>
<a
className="chatmessagebody__username--link"
href={`/${user}`}
target="_blank"
>
{user}
</a>
</span>
<span className="chatmessagebody__divider">: </span>
<span
className="chatmessagebody__message"
style={{ color: 'lightgray' }}
>
{'<message removed>'}
</span>
</div>
</div>
);
};
@ -25,6 +37,7 @@ const HiddenMessage = ({ user, color }) => {
HiddenMessage.propTypes = {
user: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
profileImageUrl: PropTypes.string.isRequired,
};
export default HiddenMessage;

View file

@ -54,3 +54,15 @@ export function hideMessages(messages, userId) {
);
return cleanedMessages;
}
export function adjustTimestamp(timestamp) {
let time = new Date(timestamp);
const options = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
};
time = new Intl.DateTimeFormat('en-US', options).format(time);
return time;
}

View file

@ -8,8 +8,9 @@ class Message < ApplicationRecord
before_validation :evaluate_markdown
before_validation :evaluate_channel_permission
def timestamp
created_at.strftime("%H:%M:%S")
def preferred_user_color
color_options = [user.bg_color_hex || "#000000", user.text_color_hex || "#000000"]
HexComparer.new(color_options).brightness(0.9)
end
private

View file

@ -1,10 +1,10 @@
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.profile_image_url ProfileImage.new(message.user).get(90)
json.message message.message_markdown
json.timestamp message.timestamp
json.color message.user.bg_color_hex
json.timestamp message.created_at
json.color message.preferred_user_color
end
json.key_format! camelize: :lower

View file

@ -1,11 +1,12 @@
<% 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>
<% if user_signed_in? %>
<%= javascript_pack_tag 'chat', defer: true %>
<style>
.inner-content {
height: calc(100vh - 115px);
margin: 100px auto;
@ -13,11 +14,14 @@
max-width: 1240px;
}
.live-chat {
height: calc(100vh - 115px);
}
</style>
.live-chat {
height: calc(100vh - 115px);
}
</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>
<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, showTimestamp: true}.to_json %>">
</div>
<% else %>
<%= render "devise/registrations/registration_form" %>
<% end %>

View file

@ -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"] %>" data-chat-channels="<%= @chat_channels %>" data-chat-options="<%= {showChannelsList:false}.to_json %>">
<div id="chat" class="live-chat" data-pusher-key="<%= ENV["PUSHER_KEY"] %>" data-chat-channels="<%= @chat_channels %>" data-chat-options="<%= {showChannelsList:false, showTimestamp: false}.to_json %>">
<% end %>
</div>
</div>

9
config/webpack/custom.js Normal file
View file

@ -0,0 +1,9 @@
module.exports = {
resolve: {
alias: {
react: 'preact-compat',
'react-dom': 'preact-compat',
'create-react-class': 'preact-compat/lib/create-react-class',
},
},
};

View file

@ -1,3 +1,9 @@
const { environment } = require('@rails/webpacker')
const { environment } = require('@rails/webpacker');
const customConfig = require('./custom');
module.exports = environment
environment.config.set('resolve.extensions', ['.foo', '.bar']);
environment.config.set('output.filename', '[name].js');
environment.config.merge(customConfig);
environment.config.delete('output.chunkFilename');
module.exports = environment;

View file

@ -11,7 +11,6 @@
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180516184437) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

View file

@ -47,13 +47,17 @@
"@storybook/addons": "^3.3.15",
"@storybook/react": "^3.3.15",
"babel-core": "^6.26.0",
"babel-eslint": "8",
"babel-jest": "^22.1.0",
"babel-loader": "^7.1.4",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-env": "^1.6.1",
"babel-preset-preact": "^1.1.0",
"eslint": "^4.9.0",
"eslint-config-airbnb": "^16.1.0",
"eslint-config-airbnb-base": "^12.0.0",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-babel": "^5.1.0",
"eslint-plugin-ignore-erb": "^0.1.1",
"eslint-plugin-import": "^2.10.0",
"eslint-plugin-jsx-a11y": "^6.0.2",
@ -66,11 +70,11 @@
"preact-render-spy": "^1.2.1",
"preact-render-to-json": "^3.6.6",
"prettier": "^1.11.1",
"webpack-dev-server": "^2.9.1"
"webpack-dev-server": "^2.11.1"
},
"dependencies": {
"@rails/webpacker": "^3.0.1",
"babel-plugin-transform-react-jsx": "^6.24.1",
"@rails/webpacker": "^3.5.3",
"@webscopeio/react-textarea-autocomplete": "^2.2.0",
"preact": "^8.2.5",
"prop-types": "^15.6.0",
"pusher-js": "^4.2.2",

1237
yarn.lock

File diff suppressed because it is too large Load diff