flow: lots, join/leave

This commit is contained in:
cha0s 2020-07-18 00:57:11 -05:00
parent d62f5fbfa3
commit 3ce283ff4c
15 changed files with 213 additions and 100 deletions

View File

@ -18,7 +18,7 @@ const App = () => (
/> />
<Route <Route
component={Chat} component={Chat}
path="/chat/:type/:name" path="/chat"
/> />
</Switch> </Switch>
</div> </div>

View File

@ -2,7 +2,8 @@ import './channel.scss';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, {useState} from 'react'; import React from 'react';
import {Link} from 'react-router-dom';
export default function Channel(props) { export default function Channel(props) {
const { const {
@ -11,22 +12,17 @@ export default function Channel(props) {
name, name,
prefix, prefix,
} = props; } = props;
const [isActioning, setIsActioning] = useState(false);
return ( return (
<div <div
className={classnames('channel', {actioning: isActioning})} className={classnames('channel')}
> >
<a <Link
className="channel__link" className="channel__link"
href={href} to={href}
onContextMenu={(event) => {
setIsActioning(!isActioning);
event.preventDefault();
}}
> >
<span className="muted">{prefix}</span> <span className="muted">{prefix}</span>
{name} {name}
</a> </Link>
<div <div
className="channel__actions" className="channel__actions"
> >

View File

@ -1,3 +1,5 @@
@import '~/client/scss/colors.scss';
.channel { .channel {
position: relative; position: relative;
} }

View File

@ -31,6 +31,9 @@ export default function ChatMessages() {
current?.scrollTo(0, !current ? 0 : current.scrollHeight); current?.scrollTo(0, !current ? 0 : current.scrollHeight);
} }
}, [current, heightWatch, messageCount, isAtTheBottom]); }, [current, heightWatch, messageCount, isAtTheBottom]);
if (!channel) {
return null;
}
let messageOwner = false; let messageOwner = false;
return ( return (
<div className="chat--messages"> <div className="chat--messages">

View File

@ -1,27 +1,36 @@
import './chat.scss'; import './chat.scss';
import React, {useEffect, useRef} from 'react'; import React, {useEffect, useRef} from 'react';
import {useSelector} from 'react-redux'; import {useSelector, useDispatch} from 'react-redux';
import {useHistory, useParams} from 'react-router-dom'; import {useHistory} from 'react-router-dom';
import {channelSelector, submitJoin} from '~/common/state/chat';
import {userSelector} from '~/common/state/user'; import {userSelector} from '~/common/state/user';
import {validateChannel} from '~/common/channel';
import useBreakpoints from '~/client/hooks/useBreakpoints';
import useChannel from '~/client/hooks/useChannel';
import useBreakpoints from './hooks/useBreakpoints';
import ChatCenter from './chat--center'; import ChatCenter from './chat--center';
import ChatLeft from './chat--left'; import ChatLeft from './chat--left';
import ChatRight from './chat--right'; import ChatRight from './chat--right';
export default function Chat() { export default function Chat() {
const dispatch = useDispatch();
const history = useHistory(); const history = useHistory();
const {type, name} = useParams();
const ref = useRef(null); const ref = useRef(null);
const {tablet} = useBreakpoints(); const {tablet} = useBreakpoints();
const user = useSelector(userSelector); const user = useSelector(userSelector);
const allowedUser = !user.isAnonymous || ('r' === type && 'anonymous' === name); const channel = useChannel();
const hasChannel = !!useSelector((state) => channelSelector(state, channel));
const allowedUser = !user.isAnonymous || (channel === '/r/anonymous');
useEffect(() => { useEffect(() => {
if (!allowedUser) { if (!allowedUser) {
history.goBack(); history.goBack();
} }
if (!hasChannel && validateChannel(channel)) {
dispatch(submitJoin({channel, id: user.id}));
}
}); });
if (!allowedUser) { if (!allowedUser) {
return null; return null;

View File

@ -1,8 +1,19 @@
import './home.scss'; import './home.scss';
import React from 'react'; import React, {useEffect} from 'react';
import {useSelector} from 'react-redux';
import {useHistory} from 'react-router-dom';
import {userSelector} from '~/common/state/user';
export default function Home() { export default function Home() {
const history = useHistory();
const user = useSelector(userSelector);
useEffect(() => {
if (user.id) {
history.replace('/chat');
}
});
return ( return (
<div className="home"> <div className="home">
<div className="home__inner"> <div className="home__inner">

View File

@ -1,9 +1,31 @@
import Join from '~/common/packets/join.packet';
import Leave from '~/common/packets/leave.packet';
import Message from '~/common/packets/message.packet'; import Message from '~/common/packets/message.packet';
import {addMessage, confirmMessage, submitMessage} from '~/common/state/chat'; import {
addMessage,
confirmMessage,
join,
leave,
submitJoin,
submitLeave,
submitMessage,
} from '~/common/state/chat';
import {socket} from '~/client/hooks/useSocket'; import {socket} from '~/client/hooks/useSocket';
const effects = { const effects = {
[submitJoin]: ({dispatch}, {payload}) => {
const {channel} = payload;
socket.send(new Join(payload), ({messages, users}) => {
dispatch(join({channel, messages, users}));
});
},
[submitLeave]: ({dispatch}, {payload}) => {
const {channel} = payload;
socket.send(new Leave(payload), () => {
dispatch(leave({channel}));
});
},
[submitMessage]: ({dispatch}, {payload}) => { [submitMessage]: ({dispatch}, {payload}) => {
dispatch(addMessage(payload)); dispatch(addMessage(payload));
socket.send(new Message(payload), ([timestamp, current]) => { socket.send(new Message(payload), ([timestamp, current]) => {

View File

@ -8,3 +8,28 @@ export const parseChannel = (url) => {
}; };
export const joinChannel = ({name, type}) => `/${type}/${name}`; export const joinChannel = ({name, type}) => `/${type}/${name}`;
const countryExceptions = ['de', 'es', 'it'];
export const validateSubreddit = (name) => {
if (-1 !== countryExceptions.indexOf(name)) {
return true;
}
return !!name.match(/^[A-Za-z0-9][A-Za-z0-9_]{2,20}$/i);
};
export const validateUsername = (name) => name.match(/^[\w-]{3,20}/);
export const validateChannel = (channel) => {
if (!channel) {
return false;
}
const parts = parseChannel(`/chat${channel}`);
if (!parts) {
return false;
}
const {name, type} = parts;
if (-1 === ['r', 'u'].indexOf(type)) {
return false;
}
return ('r' === type ? validateSubreddit : validateUsername)(name);
};

View File

@ -17,7 +17,7 @@ export const messagesSelector = (state) => state.chat.messages;
export const channelMessagesSelector = createSelector( export const channelMessagesSelector = createSelector(
[channelSelector, messagesSelector], [channelSelector, messagesSelector],
(channel, messages) => channel.messages.map((uuid) => messages[uuid]), (channel, messages) => (!channel ? [] : channel.messages.map((uuid) => messages[uuid])),
); );
const slice = createSlice({ const slice = createSlice({
@ -61,8 +61,14 @@ const slice = createSlice({
focus: ({unread}, {payload: {channel}}) => { focus: ({unread}, {payload: {channel}}) => {
unread[channel] = 0; unread[channel] = 0;
}, },
join: ({channels}, {payload: {channel, messages, users}}) => { join: ({channels, messages}, {payload: {channel, messages: channelMessages, users}}) => {
channels[channel] = {messages, users}; channelMessages.forEach((message) => {
messages[message.uuid] = message;
});
channels[channel] = {
messages: channelMessages.map((message) => message.uuid),
users,
};
}, },
joined: ({channels}, {payload: {channel, id}}) => { joined: ({channels}, {payload: {channel, id}}) => {
channels[channel].users.push(id); channels[channel].users.push(id);
@ -83,6 +89,8 @@ const slice = createSlice({
removeRecent: ({recent}, {payload: {channel}}) => { removeRecent: ({recent}, {payload: {channel}}) => {
recent.splice(recent.indexOf(channel), 1); recent.splice(recent.indexOf(channel), 1);
}, },
submitJoin: () => {},
submitLeave: () => {},
submitMessage: () => {}, submitMessage: () => {},
}, },
}); });
@ -99,6 +107,8 @@ export const {
left, left,
removeMessage, removeMessage,
removeRecent, removeRecent,
submitJoin,
submitLeave,
submitMessage, submitMessage,
} = slice.actions; } = slice.actions;

View File

@ -8,18 +8,22 @@ import createRedisClient, {keys} from './redis';
const redisClient = createRedisClient(); const redisClient = createRedisClient();
const mget = promisify(redisClient.mget.bind(redisClient)); const mget = promisify(redisClient.mget.bind(redisClient));
export const channelUserCounts = async (channel) => { export const channelUserCounts = async (req, channel) => {
const socketKeys = await keys(redisClient, `${channel}:users:*`); const clients = promisify(req.adapter.clients.bind(req.adapter));
const socketKeys = await clients([channel]);
const customRequest = promisify(req.adapter.customRequest.bind(req.adapter));
const replies = await customRequest({type: 'socketUsers', payload: socketKeys});
const socketUsers = replies.reduce((r, m) => ({...r, ...m}), {});
return 0 === socketKeys.length return 0 === socketKeys.length
? [] ? []
: (await mget(...socketKeys)).reduce((r, k) => ({...r, [k]: 1 + (r[k] || 0)}), {}); : Object.values(socketUsers).reduce((r, uid) => ({...r, [uid]: 1 + (r[uid] || 0)}), {});
}; };
export const channelUsers = async (channel) => ( export const channelUsers = async (req, channel) => (
Object.keys(await channelUserCounts(channel)).map((id) => parseInt(id, 10)) Object.keys(await channelUserCounts(req, channel)).map((id) => parseInt(id, 10))
); );
const channelState = async (req, channel) => { export const channelState = async (req, channel) => {
const messageKeys = await keys(redisClient, `${channel}:messages:*`); const messageKeys = await keys(redisClient, `${channel}:messages:*`);
const messages = 0 === messageKeys.length const messages = 0 === messageKeys.length
? [] ? []
@ -30,7 +34,7 @@ const channelState = async (req, channel) => {
})) }))
.sort((l, r) => l.timestamp - r.timestamp); .sort((l, r) => l.timestamp - r.timestamp);
const userId = req.user ? req.user.id : 0; const userId = req.user ? req.user.id : 0;
const users = await channelUsers(channel); const users = await channelUsers(req, channel);
return { return {
messages, messages,
users: -1 !== users.indexOf(userId) ? users : users.concat([userId]), users: -1 !== users.indexOf(userId) ? users : users.concat([userId]),
@ -43,17 +47,18 @@ export const userState = async (req) => {
? { ? {
favorites: [], favorites: [],
friends: await user.friends(), friends: await user.friends(),
id: user.id,
redditUsername: user.redditUsername, redditUsername: user.redditUsername,
} }
: null; : null;
}; };
export const channelsToHydrate = (req) => ( export const channelsToHydrate = async (req) => (
(req.channel ? [req.channel] : []).concat(req.user ? req.user.favorites : []) (req.channel ? [req.channel] : []).concat(req.user ? await req.user.favorites() : [])
); );
export const chatState = async (req) => { export const chatState = async (req) => {
const toHydrate = channelsToHydrate(req); const toHydrate = await channelsToHydrate(req);
if (0 === toHydrate.length) { if (0 === toHydrate.length) {
return null; return null;
} }

View File

@ -8,9 +8,6 @@ import express from 'express';
import httpProxy from 'http-proxy'; import httpProxy from 'http-proxy';
import {invokeHookFlat} from 'scwp'; import {invokeHookFlat} from 'scwp';
import {parseChannel} from '~/common/channel';
import userRoutes from './routes/user';
import passport from './passport'; import passport from './passport';
import session from './session'; import session from './session';
@ -33,41 +30,39 @@ export async function createHttpServer() {
app.use(session()); app.use(session());
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); app.use(passport.session());
app.use((req, res, next) => {
req.channel = parseChannel(req.url);
next();
});
userRoutes(app);
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
httpServer.app = app;
httpServer.createFallthrough = async () => {
if ('production' !== process.env.NODE_ENV) {
const proxy = httpProxy.createProxyServer({
secure: false,
target: 'http://127.0.0.1:31345',
});
proxy.on('proxyRes', async (proxyRes, req, res) => {
const buffer = await proxyRes.pipe(concat());
if ('text/html; charset=UTF-8' === proxyRes.headers['content-type']) {
res.end(await hydration(req, buffer));
}
else {
res.end(buffer);
}
});
proxy.on('error', (err, req, res) => {
if (res instanceof ServerResponse) {
res.status(502).end('Bad Gateway (WDS)');
}
});
app.get('*', (req, res) => proxy.web(req, res, {selfHandleResponse: true}));
httpServer.on('close', () => proxy.close());
}
else {
app.use(express.static(join(__dirname, '..', 'client')));
const stream = createReadStream(join(__dirname, '..', 'client', 'index.html'));
const buffer = await stream.pipe(concat());
app.get('*', async (req, res) => res.end(await hydration(req, buffer)));
}
};
httpServer.listen(31344, '0.0.0.0'); httpServer.listen(31344, '0.0.0.0');
if ('production' !== process.env.NODE_ENV) {
const proxy = httpProxy.createProxyServer({
secure: false,
target: 'http://127.0.0.1:31345',
});
proxy.on('proxyRes', async (proxyRes, req, res) => {
const buffer = await proxyRes.pipe(concat());
if ('text/html; charset=UTF-8' === proxyRes.headers['content-type']) {
res.end(await hydration(req, buffer));
}
else {
res.end(buffer);
}
});
proxy.on('error', (err, req, res) => {
if (res instanceof ServerResponse) {
res.status(502).end('Bad Gateway (WDS)');
}
});
app.get('*', (req, res) => proxy.web(req, res, {selfHandleResponse: true}));
httpServer.on('close', () => proxy.close());
}
else {
app.use(express.static(join(__dirname, '..', 'client')));
const stream = createReadStream(join(__dirname, '..', 'client', 'index.html'));
const buffer = await stream.pipe(concat());
app.get('*', async (req, res) => res.end(await hydration(req, buffer)));
}
return httpServer; return httpServer;
} }

View File

@ -1,5 +1,8 @@
import {registerHooks} from 'scwp'; import {registerHooks} from 'scwp';
import {parseChannel} from '~/common/channel';
import userRoutes from './routes/user';
import {createDatabaseConnection, destroyDatabaseConnection} from './db'; import {createDatabaseConnection, destroyDatabaseConnection} from './db';
import {createHttpServer, destroyHttpServer} from './http'; import {createHttpServer, destroyHttpServer} from './http';
import {createReplServer, destroyReplServer} from './repl'; import {createReplServer, destroyReplServer} from './repl';
@ -41,6 +44,15 @@ async function restartListening() {
httpServer = await createHttpServer(); httpServer = await createHttpServer();
replServer = await createReplServer(); replServer = await createReplServer();
socketServer = await createSocketServer(httpServer); socketServer = await createSocketServer(httpServer);
const {app} = httpServer;
app.use((req, res, next) => {
req.adapter = socketServer.io.of('/').adapter;
req.channel = parseChannel(req.url);
req.userId = req.user ? req.user.id : 0;
next();
});
userRoutes(app);
await httpServer.createFallthrough();
// Accounting bullshit // Accounting bullshit
trackConnections(); trackConnections();
} }

View File

@ -34,6 +34,10 @@ class User extends BaseModel {
}); });
} }
async favorites() {
return [];
}
async friends() { async friends() {
const {Friendship} = allModels(); const {Friendship} = allModels();
const friendships = await Friendship.findAll({ const friendships = await Friendship.findAll({

View File

@ -6,10 +6,7 @@ import passport from 'passport';
export default function userRoutes(app) { export default function userRoutes(app) {
app.get('/auth/reddit', (req, res, next) => { app.get('/auth/reddit', (req, res, next) => {
req.session.state = randomBytes(32).toString('hex'); req.session.state = randomBytes(32).toString('hex');
passport.authenticate('reddit', { passport.authenticate('reddit', {state: req.session.state})(req, res, next);
state: req.session.state,
duration: 'permanent',
})(req, res, next);
}); });
app.get('/auth/reddit/callback', (req, res, next) => { app.get('/auth/reddit/callback', (req, res, next) => {
if (req.query.state === req.session.state) { if (req.query.state === req.session.state) {

View File

@ -4,7 +4,7 @@ import {promisify} from 'util';
import redisAdapter from 'socket.io-redis'; import redisAdapter from 'socket.io-redis';
import {v4 as uuidv4} from 'uuid'; import {v4 as uuidv4} from 'uuid';
import {SocketServer} from '@avocado/net/server/socket'; import {ServerSocket, SocketServer} from '@avocado/net/server/socket';
import socketSession from 'express-socket.io-session'; import socketSession from 'express-socket.io-session';
import {joinChannel, parseChannel} from '~/common/channel'; import {joinChannel, parseChannel} from '~/common/channel';
@ -12,7 +12,12 @@ import Join from '~/common/packets/join.packet';
import Leave from '~/common/packets/leave.packet'; import Leave from '~/common/packets/leave.packet';
import Message from '~/common/packets/message.packet'; import Message from '~/common/packets/message.packet';
import {channelsToHydrate, channelUserCounts, channelUsers} from '~/server/entry'; import {
channelsToHydrate,
channelState,
channelUserCounts,
channelUsers,
} from '~/server/entry';
import passport from './passport'; import passport from './passport';
import createRedisClient, {keys} from './redis'; import createRedisClient, {keys} from './redis';
@ -21,8 +26,6 @@ import session from './session';
const pubClient = createRedisClient(); const pubClient = createRedisClient();
const subClient = createRedisClient(); const subClient = createRedisClient();
const adapter = redisAdapter({pubClient, subClient}); const adapter = redisAdapter({pubClient, subClient});
const del = promisify(pubClient.del.bind(pubClient));
const set = promisify(pubClient.set.bind(pubClient));
export function createSocketServer(httpServer) { export function createSocketServer(httpServer) {
const socketServer = new SocketServer(httpServer, { const socketServer = new SocketServer(httpServer, {
@ -37,31 +40,49 @@ export function createSocketServer(httpServer) {
}); });
socketServer.io.use((socket, next) => { socketServer.io.use((socket, next) => {
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
socket.handshake.adapter = socketServer.io.of('/').adapter;
socket.handshake.channel = parseChannel(socket.handshake.query.referrer); socket.handshake.channel = parseChannel(socket.handshake.query.referrer);
socket.handshake.userId = socket.handshake.user ? socket.handshake.user.id : 0; socket.handshake.userId = socket.handshake.user ? socket.handshake.user.id : 0;
/* eslint-enable no-param-reassign */ /* eslint-enable no-param-reassign */
next(); next();
}); });
socketServer.io.use(async (socket, next) => { const userJoin = async (channel, socket) => {
const {userId} = socket.handshake; const {userId} = socket.handshake;
const join = promisify(socket.join.bind(socket)); const users = await channelUsers(socket.handshake, channel);
if (-1 === users.indexOf(userId)) {
ServerSocket.send(socket.to(channel), new Join({channel, id: userId}));
}
await promisify(socket.join.bind(socket))(channel);
};
const userLeave = async (channel, socket) => {
const {userId} = socket.req;
await promisify(socket.leave.bind(socket))(channel);
const userCounts = await channelUserCounts(socket.req, channel);
if (!userCounts[userId]) {
socket.to(channel, new Leave({channel, id: userId}));
}
};
socketServer.io.use(async (socket, next) => {
await Promise.all( await Promise.all(
channelsToHydrate(socket.handshake) (await channelsToHydrate(socket.handshake))
.map((channel) => joinChannel(channel)) .map((channel) => joinChannel(channel))
.map(async (channel) => { .map((channel) => userJoin(channel, socket)),
await join(channel);
const users = await channelUsers(channel);
if (-1 === users.indexOf(userId)) {
socketServer.send(new Join({channel, id: userId}), channel);
}
await set(`${channel}:users:${socket.id}`, userId);
}),
); );
next(); next();
}); });
socketServer.on('connect', (socket) => { socketServer.on('connect', (socket) => {
const {req} = socket; const {req} = socket;
socket.on('packet', (packet, fn) => { socket.on('packet', async (packet, fn) => {
if (packet instanceof Join) {
const {channel} = packet.data;
await userJoin(channel, socket.socket);
fn(await channelState(req, channel));
}
if (packet instanceof Leave) {
const {channel} = packet.data;
await userLeave(channel, socket);
fn();
}
if (packet instanceof Message) { if (packet instanceof Message) {
const {channel, message} = packet.data; const {channel, message} = packet.data;
const owner = req.user ? req.user.id : 0; const owner = req.user ? req.user.id : 0;
@ -85,21 +106,22 @@ export function createSocketServer(httpServer) {
.exec(() => fn([timestamp, uuid])); .exec(() => fn([timestamp, uuid]));
} }
}); });
socket.on('disconnect', async () => { socket.on('disconnecting', async () => {
const socketKeys = await keys(pubClient, `*:users:${socket.id}`); Object.keys(socket.socket.rooms).forEach((room) => {
const {userId} = req; if (parseChannel(`/chat${room}`)) {
if (socketKeys.length > 0) { userLeave(room, socket);
const channels = socketKeys.map((key) => key.split(':')[0]); }
await Promise.all(channels.map(async (channel) => { });
const userCounts = await channelUserCounts(channel);
if (1 === userCounts[userId]) {
socketServer.send(new Leave({channel, id: userId}), channel);
}
}));
await del(socketKeys);
}
}); });
}); });
socketServer.io.of('/').adapter.customHook = (req, fn) => {
if ('socketUsers' === req.type) {
const sids = req.payload;
const {connected} = socketServer.io.of('/');
const here = sids.filter((sid) => !!connected[sid]);
fn(here.reduce((r, sid) => ({...r, [sid]: connected[sid].handshake.userId}), {}));
}
};
return socketServer; return socketServer;
} }