diff --git a/package.json b/package.json
index 340e218..89fab91 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/client/chat--message.jsx b/src/client/chat--message.jsx
index 9e53942..50f702c 100644
--- a/src/client/chat--message.jsx
+++ b/src/client/chat--message.jsx
@@ -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) {
}
-
+
{isShort && $messageTime}
@@ -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,
diff --git a/src/client/chat--message.scss b/src/client/chat--message.scss
index 57dddfc..14a8454 100644
--- a/src/client/chat--message.scss
+++ b/src/client/chat--message.scss
@@ -63,12 +63,6 @@ header {
margin: 0;
}
-.chat--messageMarkdown {
- h1, h2, h3, h4, h5, h6 {
- line-height: 1rem;
- }
-}
-
.chat--messageMarkdown {
display: inline;
}
diff --git a/src/client/chat--messages.jsx b/src/client/chat--messages.jsx
index 2966b11..ea7fc55 100644
--- a/src/client/chat--messages.jsx
+++ b/src/client/chat--messages.jsx
@@ -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 = (
);
@@ -70,34 +51,7 @@ export default function PlayersChatSpace() {
return $message;
})}
-
+
);
}
diff --git a/src/client/chat--submitMessage.jsx b/src/client/chat--submitMessage.jsx
new file mode 100644
index 0000000..48ffeb7
--- /dev/null
+++ b/src/client/chat--submitMessage.jsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/client/chat--submitMessage.scss b/src/client/chat--submitMessage.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/client/hooks/useChannel.js b/src/client/hooks/useChannel.js
new file mode 100644
index 0000000..74709db
--- /dev/null
+++ b/src/client/hooks/useChannel.js
@@ -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) : '';
+}
diff --git a/src/client/hooks/useSocket.js b/src/client/hooks/useSocket.js
index a3d2d10..99e5108 100644
--- a/src/client/hooks/useSocket.js
+++ b/src/client/hooks/useSocket.js
@@ -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
diff --git a/src/client/index.scss b/src/client/index.scss
index 6c01433..6aa64d9 100644
--- a/src/client/index.scss
+++ b/src/client/index.scss
@@ -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;
diff --git a/src/client/store/effects.js b/src/client/store/effects.js
new file mode 100644
index 0000000..1426562
--- /dev/null
+++ b/src/client/store/effects.js
@@ -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;
diff --git a/src/client/store.js b/src/client/store/index.js
similarity index 80%
rename from src/client/store.js
rename to src/client/store/index.js
index b8f4260..3c5d9f2 100644
--- a/src/client/store.js
+++ b/src/client/store/index.js
@@ -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,
},
),
diff --git a/src/common/packets/message.packet.js b/src/common/packets/message.packet.js
index 1d5e82b..34fc904 100644
--- a/src/common/packets/message.packet.js
+++ b/src/common/packets/message.packet.js
@@ -5,7 +5,10 @@ export default class Message extends Packet {
static get schema() {
return {
...super.schema,
- data: {},
+ data: {
+ channel: 'string',
+ message: 'string',
+ },
};
}
diff --git a/src/common/state/chat.js b/src/common/state/chat.js
index 6c62099..8a1ab64 100644
--- a/src/common/state/chat.js
+++ b/src/common/state/chat.js
@@ -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;
diff --git a/src/common/state/user.js b/src/common/state/user.js
index f312655..407845c 100644
--- a/src/common/state/user.js
+++ b/src/common/state/user.js
@@ -34,6 +34,7 @@ const slice = createSlice({
blocked: [],
favorites: [],
friendship: [],
+ id: 0,
isAnonymous: false,
redditUsername: 'anonymous',
...(hydration('user') || {isAnonymous: true}),
diff --git a/src/server/entry.js b/src/server/entry.js
new file mode 100644
index 0000000..4a5ffef
--- /dev/null
+++ b/src/server/entry.js
@@ -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,
+ };
+};
diff --git a/src/server/redis.js b/src/server/redis.js
new file mode 100644
index 0000000..4008425
--- /dev/null
+++ b/src/server/redis.js
@@ -0,0 +1,8 @@
+import redis from 'redis';
+
+const {
+ REDIS_HOST,
+ REDIS_PORT,
+} = process.env;
+
+export default () => redis.createClient(REDIS_PORT, REDIS_HOST);
diff --git a/src/server/session.js b/src/server/session.js
index 157ad23..56ce033 100644
--- a/src/server/session.js
+++ b/src/server/session.js
@@ -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) => {
diff --git a/src/server/sockets.js b/src/server/sockets.js
index 11c2218..c2d0aad 100644
--- a/src/server/sockets.js
+++ b/src/server/sockets.js
@@ -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;
diff --git a/yarn.lock b/yarn.lock
index 4ed5fc6..6d2fab2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"