feat: literal actual chat

This commit is contained in:
cha0s 2020-07-16 18:00:27 -05:00
parent 2000f99fb3
commit ad915391b7
19 changed files with 271 additions and 118 deletions

View File

@ -56,7 +56,8 @@
"scwp": "1.x",
"sequelize": "^6.2.4",
"socket.io-redis": "^5.3.0",
"source-map-support": "^0.5.11"
"source-map-support": "^0.5.11",
"uuid": "8.2.0"
},
"devDependencies": {
"@neutrinojs/airbnb": "^9.1.0",

View File

@ -6,7 +6,7 @@ import React from 'react';
import ReactMarkdown from 'react-markdown';
export default function PlayersChatMessageSpace(props) {
const {message: {owner, text, timestamp}, isShort} = props;
const {message: {owner, message, timestamp}, isShort} = props;
const ownerMap = {
1: 'cha0s',
2: 'BabeHasHiccups',
@ -38,7 +38,7 @@ export default function PlayersChatMessageSpace(props) {
}
<div className="chat--messageText">
<div className="chat--messageMarkdown">
<ReactMarkdown source={text} />
<ReactMarkdown source={message} />
</div>
{isShort && $messageTime}
</div>
@ -49,7 +49,7 @@ export default function PlayersChatMessageSpace(props) {
PlayersChatMessageSpace.propTypes = {
isShort: PropTypes.bool.isRequired,
message: PropTypes.shape({
text: PropTypes.string,
message: PropTypes.string,
timestamp: PropTypes.number,
owner: PropTypes.string,
}).isRequired,

View File

@ -63,12 +63,6 @@ header {
margin: 0;
}
.chat--messageMarkdown {
h1, h2, h3, h4, h5, h6 {
line-height: 1rem;
}
}
.chat--messageMarkdown {
display: inline;
}

View File

@ -1,38 +1,18 @@
import './chat--messages.scss';
import React, {useLayoutEffect, useRef, useState} from 'react';
import React, {useLayoutEffect, useRef} from 'react';
import {useSelector} from 'react-redux';
import {channelMessagesSelector} from '~/common/state/chat';
import useChannel from '~/client/hooks/useChannel';
import ChatMessage from './chat--message';
import ChatSubmitMessage from './chat--submitMessage';
export default function PlayersChatSpace() {
const [$form, $messages] = [useRef(null), useRef(null)];
const [text, setText] = useState('');
const messages = [
{
key: 1,
owner: 1,
text: 'Hi!',
timestamp: Date.now(),
},
{
key: 2,
owner: 2,
text: 'Yo.',
timestamp: Date.now(),
},
{
key: 3,
owner: 2,
text: 'How have you been?',
timestamp: Date.now(),
},
{
key: 4,
owner: 1,
text: 'Not too bad.',
timestamp: Date.now(),
},
];
export default function ChatMessages() {
const channel = useChannel();
const $messages = useRef(null);
const {current} = $messages;
const isAtTheBottom = !current
? true
@ -44,6 +24,7 @@ export default function PlayersChatSpace() {
), current.offsetHeight)
);
const heightWatch = current && current.scrollHeight;
const messages = useSelector((state) => channelMessagesSelector(state, channel));
const messageCount = messages && messages.length;
useLayoutEffect(() => {
if (isAtTheBottom) {
@ -61,8 +42,8 @@ export default function PlayersChatSpace() {
{messages && messages.map((message) => {
const $message = (
<ChatMessage
key={message.key}
isShort={messageOwner === message.owner}
key={message.uuid}
isShort={0 === message.owner || messageOwner === message.owner}
message={message}
/>
);
@ -70,34 +51,7 @@ export default function PlayersChatSpace() {
return $message;
})}
</div>
<form
onSubmit={(event) => {
event.preventDefault();
const trimmed = text.slice(0, 1000).trim();
if (trimmed) {
// socket.send(new ActionPacket(chat({owner: playerId, text: trimmed}, {game: id})));
}
setText('');
}}
ref={$form}
>
<textarea
className="chat--messagesTextarea"
name="message"
type="textarea"
maxLength="1000"
onChange={(event) => {
setText(event.target.value);
}}
onKeyDown={(event) => {
if ('Enter' === event.key && !event.shiftKey) {
$form.current?.dispatchEvent(new Event('submit', {cancelable: true}));
event.preventDefault();
}
}}
value={text}
/>
</form>
<ChatSubmitMessage />
</div>
);
}

View File

@ -0,0 +1,56 @@
import './chat--messages.scss';
import React, {useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {v4 as uuidv4} from 'uuid';
import {addMessage} from '~/common/state/chat';
import {userSelector} from '~/common/state/user';
import useChannel from '~/client/hooks/useChannel';
export default function ChatSubmitMessage() {
const channel = useChannel();
const dispatch = useDispatch();
const $form = useRef(null);
const user = useSelector(userSelector);
const [text, setText] = useState('');
return (
<div className="chat--messageSubmit">
<form
onSubmit={(event) => {
event.preventDefault();
const message = text.slice(0, 1000).trim();
if (message) {
dispatch(addMessage({
channel,
message,
owner: user.id,
timestamp: Date.now(),
uuid: uuidv4(),
}));
}
setText('');
}}
ref={$form}
>
<textarea
className="chat--messagesTextarea"
name="message"
type="textarea"
maxLength="1000"
onChange={(event) => {
setText(event.target.value);
}}
onKeyDown={(event) => {
if ('Enter' === event.key && !event.shiftKey) {
$form.current?.dispatchEvent(new Event('submit', {cancelable: true}));
event.preventDefault();
}
}}
value={text}
/>
</form>
</div>
);
}

View File

View File

@ -0,0 +1,6 @@
import {useLocation} from 'react-router-dom';
export default function useChannel() {
const {pathname} = useLocation();
return pathname.match(/^\/chat\//) ? pathname.substr('/chat'.length) : '';
}

View File

@ -2,9 +2,8 @@ import {useEffect} from 'react';
import {SocketClient} from '@avocado/net/client/socket';
const frontendOrigin = window.location.href;
const isSecure = 'https' === frontendOrigin.substr(0, 5);
export const socket = new SocketClient(frontendOrigin, {secure: isSecure});
const isSecure = 'https:' === window.location.protocol;
export const socket = new SocketClient(window.location.host, {secure: isSecure});
export default function useSocket(fn) {
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -39,10 +39,34 @@ q:before, q:after {
content: '';
content: none;
}
em {
font-style: italic;
}
strong {
font-weight: bold;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.6em;
}
h3 {
font-size: 1.4em;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1.1em;
}
h6 {
font-size: 1.05em;
}
* {
box-sizing: border-box;

22
src/client/store/effects.js vendored Normal file
View File

@ -0,0 +1,22 @@
import Message from '~/common/packets/message.packet';
import {addMessage, confirmMessage} from '~/common/state/chat';
import {socket} from '~/client/hooks/useSocket';
const effects = {
[addMessage]: ({dispatch}, {payload}) => {
socket.send(new Message(payload), ([timestamp, current]) => {
dispatch(confirmMessage({current, previous: payload.uuid, timestamp}));
});
},
};
export const middleware = (store) => (next) => (action) => {
const result = next(action);
if (effects[action.type]) {
setTimeout(() => effects[action.type](store, action), 0);
}
return result;
};
export default effects;

View File

@ -5,6 +5,8 @@ import chat from '~/common/state/chat';
import user from '~/common/state/user';
import createCommonStore from '~/common/store';
import {middleware as effectsMiddleware} from './effects';
const reducer = combineReducers({
chat,
user,
@ -15,7 +17,7 @@ export default function createStore(options = {}) {
merge(
options,
{
middleware: [],
middleware: [effectsMiddleware],
reducer,
},
),

View File

@ -5,7 +5,10 @@ export default class Message extends Packet {
static get schema() {
return {
...super.schema,
data: {},
data: {
channel: 'string',
message: 'string',
},
};
}

View File

@ -6,7 +6,19 @@ import {
import hydration from './hydration';
export const userSelector = (state) => state.user;
export const channelsSelector = (state) => state.chat.channels;
export const channelSelector = createSelector(
[channelsSelector, (_, channel) => channel],
(channels, channel) => channels[channel],
);
export const messagesSelector = (state) => state.chat.messages;
export const channelMessagesSelector = createSelector(
[channelSelector, messagesSelector],
(channel, messages) => channel.messages.map((uuid) => messages[uuid]),
);
const slice = createSlice({
name: 'chat',
@ -20,35 +32,48 @@ const slice = createSlice({
...(hydration('chat') || {}),
},
reducers: {
addMessage: (state, {payload: {channel, message}}) => {
state.messages.push(message);
state.channels[channel].messages.push(message.uuid);
if (state.focus !== channel) {
state.unread[channel] += 1;
addMessage: ({
channels,
focus,
messages,
unread,
}, {payload}) => {
const {channel, uuid} = payload;
messages[uuid] = payload;
channels[channel].messages.push(uuid);
if (focus !== channel) {
unread[channel] += 1;
}
},
addRecent: (state, {payload: {channel}}) => {
state.recent.push(channel);
addRecent: ({recent}, {payload: {channel}}) => {
recent.push(channel);
},
editMessage: (state, {payload: {uuid, content}}) => {
state.messages[uuid] = content;
confirmMessage: ({channels, messages}, {payload: {previous, current, timestamp}}) => {
messages[current] = {...messages[previous], timestamp, uuid: current};
delete messages[previous];
const {[messages[current].channel]: channel} = channels;
const index = channel.messages.findIndex((uuid) => uuid === previous);
channel.messages[index] = current;
},
focus: (state, {payload: {channel}}) => {
state.unread[channel] = 0;
editMessage: ({messages}, {payload: {uuid, message}}) => {
messages[uuid].message = message;
},
join: (state, {payload: {channel, messages, users}}) => {
state.channels[channel] = {messages, users};
state.unread[channel] = 0;
focus: ({unread}, {payload: {channel}}) => {
unread[channel] = 0;
},
joined: (state, {payload: {channel, id}}) => {
state.channels[channel].users.push(id);
join: ({channels, unread}, {payload: {channel, messages, users}}) => {
channels[channel] = {messages, users};
unread[channel] = 0;
},
leave: (state, {payload: {channel}}) => {
delete state.channels[channel];
delete state.unread[channel];
joined: ({channels}, {payload: {channel, id}}) => {
channels[channel].users.push(id);
},
left: (state, {payload: {channel, id}}) => {
const {users} = state.channels[channel];
leave: ({channels, unread}, {payload: {channel}}) => {
delete channels[channel];
delete unread[channel];
},
left: ({channels}, {payload: {channel, id}}) => {
const {users} = channels[channel];
users.splice(users.indexOf(id), 1);
},
removeMessage: (state, {payload: {channel, uuid}}) => {
@ -56,8 +81,7 @@ const slice = createSlice({
const {messages} = state.channels[channel];
messages.splice(messages.indexOf(uuid), 1);
},
removeRecent: (state, {payload: {channel}}) => {
const {recent} = state;
removeRecent: ({recent}, {payload: {channel}}) => {
recent.splice(recent.indexOf(channel), 1);
},
},
@ -65,9 +89,14 @@ const slice = createSlice({
export const {
addMessage,
addRecent,
confirmMessage,
editMessage,
focus,
join,
joined,
leave,
left,
removeMessage,
removeRecent,
} = slice.actions;

View File

@ -34,6 +34,7 @@ const slice = createSlice({
blocked: [],
favorites: [],
friendship: [],
id: 0,
isAnonymous: false,
redditUsername: 'anonymous',
...(hydration('user') || {isAnonymous: true}),

37
src/server/entry.js Normal file
View File

@ -0,0 +1,37 @@
import createRedisClient from './redis';
const redisClient = createRedisClient();
// eslint-disable-next-line import/prefer-default-export
export const enterChannel = async (channel) => {
const messages = await new Promise((resolve, reject) => {
redisClient.scan(
0,
'COUNT', 50,
'MATCH', `${channel}:*`,
(error, [, keys]) => (
error
? reject(error)
: resolve(
0 === keys.length
? []
: new Promise((resolve, reject) => {
redisClient.mget(keys, (error, replies) => (
error
? reject(error)
: resolve(replies
.map((reply, i) => ({
...JSON.parse(reply),
uuid: keys[i].split(':')[1],
}))
.sort((l, r) => l.timestamp - r.timestamp))
));
}),
)
),
);
});
return {
messages,
};
};

8
src/server/redis.js Normal file
View File

@ -0,0 +1,8 @@
import redis from 'redis';
const {
REDIS_HOST,
REDIS_PORT,
} = process.env;
export default () => redis.createClient(REDIS_PORT, REDIS_HOST);

View File

@ -1,14 +1,10 @@
import session from 'express-session';
import redis from 'redis';
import {registerHooks} from 'scwp';
const {
REDIS_HOST,
REDIS_PORT,
} = process.env;
const redisClient = redis.createClient(REDIS_PORT, REDIS_HOST);
import {enterChannel} from './entry';
import createRedisClient from './redis';
const redisClient = createRedisClient();
// eslint-disable-next-line import/newline-after-import
const RedisStore = require('connect-redis')(session);
@ -54,11 +50,14 @@ registerHooks({
messages: {},
recent: [],
};
const entries = await Promise.all(
favorites.map((favorite) => enterChannel(channelName(favorite))),
);
for (let i = 0; i < favorites.length; i++) {
const channel = channelName(favorites[i]);
const messages = [];
const {messages} = entries[i];
chat.channels[channel] = {
messages,
messages: messages.map((message) => message.uuid),
users: [],
};
messages.forEach((message) => {

View File

@ -1,20 +1,22 @@
/* eslint-disable import/no-extraneous-dependencies */
import redisAdapter from 'socket.io-redis';
import {v4 as uuidv4} from 'uuid';
import {SocketServer} from '@avocado/net/server/socket';
import socketSession from 'express-socket.io-session';
import Message from '~/common/packets/message.packet';
import passport from './passport';
import createRedisClient from './redis';
import session from './session';
const {
REDIS_HOST,
REDIS_PORT,
} = process.env;
const pubClient = createRedisClient();
const subClient = createRedisClient();
export function createSocketServer(httpServer) {
const socketServer = new SocketServer(httpServer, {
adapter: redisAdapter({host: REDIS_HOST, port: REDIS_PORT}),
adapter: redisAdapter({pubClient, subClient}),
});
socketServer.io.use(socketSession(session()));
socketServer.io.use((socket, next) => {
@ -26,6 +28,22 @@ export function createSocketServer(httpServer) {
socketServer.on('connect', (socket) => {
const {req} = socket;
socket.on('packet', (packet, fn) => {
if (packet instanceof Message) {
const owner = req.user ? req.user.id : 0;
const timestamp = Date.now();
const uuid = uuidv4();
const {channel, message} = packet.data;
const key = `${channel}:${uuid}`;
pubClient
.multi()
.set(key, JSON.stringify({
message,
owner,
timestamp,
}))
.expire(key, 600)
.exec(() => fn([timestamp, uuid]));
}
});
});
return socketServer;

View File

@ -9656,16 +9656,16 @@ utils-merge@1.0.1, utils-merge@1.x.x:
resolved "https://npm.i12e.cha0s.io/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@8.2.0, uuid@^8.1.0:
version "8.2.0"
resolved "https://npm.i12e.cha0s.io/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e"
integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==
uuid@^3.3.2, uuid@^3.4.0:
version "3.4.0"
resolved "https://npm.i12e.cha0s.io/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.1.0:
version "8.2.0"
resolved "https://npm.i12e.cha0s.io/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e"
integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==
v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1:
version "2.1.1"
resolved "https://npm.i12e.cha0s.io/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"