flow: lots of UI, limiting

This commit is contained in:
cha0s 2020-07-24 06:28:13 -05:00
parent 7b3986657c
commit 050899b407
20 changed files with 294 additions and 137 deletions

View File

@ -2,47 +2,42 @@ import './bar.scss';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react'; import React from 'react';
export default function Bar(props) { export default function Bar(props) {
const { const {
active,
branding, branding,
buttons, buttons,
isHorizontal, className,
onActive, onActive,
} = props; } = props;
const [active, setActive] = useState(0);
useEffect(() => {
const clampedActive = Math.max(0, Math.min(buttons.length - 1, active));
if (active !== clampedActive) {
onActive(clampedActive, -1);
}
setActive(clampedActive);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, buttons.length]);
return ( return (
<div <div
className={classnames(['bar', isHorizontal ? 'bar--horizontal' : 'bar--vertical'])} className={classnames(['bar', className])}
> >
<ul className="bar__buttons"> <ul className="bar__buttons">
{branding} {branding}
{ {
buttons.map(({count, icon, label}, i) => ( buttons.map(({
// eslint-disable-next-line react/no-array-index-key count,
<li className="bar__buttonItem" key={i}> icon,
label,
}, i) => (
<li
className="bar__buttonItem"
key={label}
>
<button <button
className={classnames('bar__button', {active: i === active})} className={classnames('bar__button', {active: i === active})}
onClick={() => { onClick={() => onActive(i, active)}
onActive(i, active); title={label}
setActive(i);
}}
type="button" type="button"
> >
<span <span
className="bar__buttonIcon" className="bar__buttonIcon"
role="img" role="img"
aria-label={label} aria-label={label}
title={label}
> >
{icon} {icon}
</span> </span>
@ -57,17 +52,19 @@ export default function Bar(props) {
} }
Bar.defaultProps = { Bar.defaultProps = {
active: 0,
branding: null, branding: null,
isHorizontal: true, className: '',
onActive: () => {}, onActive: () => {},
}; };
Bar.propTypes = { Bar.propTypes = {
active: PropTypes.number,
branding: PropTypes.node, branding: PropTypes.node,
buttons: PropTypes.arrayOf(PropTypes.shape({ buttons: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string, label: PropTypes.string,
icon: PropTypes.string, icon: PropTypes.string,
})).isRequired, })).isRequired,
isHorizontal: PropTypes.bool, className: PropTypes.string,
onActive: PropTypes.func, onActive: PropTypes.func,
}; };

View File

@ -1,13 +1,7 @@
@import '~/client/scss/colors.scss'; @import '~/client/scss/colors.scss';
.bar--horizontal { .bar {
height: 4em; height: 3em;
width: 100%;
}
.bar--vertical {
height: 100%;
width: 4em;
} }
.bar__items { .bar__items {
@ -55,10 +49,10 @@
.bar__button { .bar__button {
background-color: #303030; background-color: #303030;
border: none; border: none;
height: 3em; height: 2em;
position: relative; position: relative;
transition: 0.1s background-color; transition: 0.1s background-color;
width: 3em; width: 2em;
&:hover { &:hover {
background-color: #2a2a2a; background-color: #2a2a2a;
} }
@ -82,7 +76,7 @@
.bar__buttonIcon { .bar__buttonIcon {
display: flow-root; display: flow-root;
font-size: 1.5em; font-size: 1em;
} }
.bar__buttonText { .bar__buttonText {

View File

@ -25,6 +25,7 @@
.channel { .channel {
position: relative; position: relative;
user-select: none;
} }
.channel__link { .channel__link {

View File

@ -1,14 +1,105 @@
import './chat--center.scss'; import './chat--center.scss';
import React from 'react'; import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {
leftActiveIndexSelector,
rightActiveIndexSelector,
setLeftActiveIndex,
setRightActiveIndex,
toggleLeftIsOpen,
toggleRightIsOpen,
} from '~/common/state/app';
import {
channelUsersSelector,
} from '~/common/state/chat';
import {
blockedSelector,
idSelector,
pendingFriendshipSelector,
unreadChannelSelector,
unreadUserSelector,
} from '~/common/state/user';
import useChannel from '~/client/hooks/useChannel';
import Bar from './bar';
import ChatMessages from './chat--messages'; import ChatMessages from './chat--messages';
export default function ChatCenter() { export default function ChatCenter() {
const dispatch = useDispatch();
const leftActiveIndex = useSelector(leftActiveIndexSelector);
const rightActiveIndex = useSelector(rightActiveIndexSelector);
const blockedIds = useSelector(blockedSelector);
const channel = useChannel();
const channelUsers = useSelector((state) => channelUsersSelector(state, channel));
const id = useSelector(idSelector);
const pendingFriendship = useSelector(pendingFriendshipSelector);
const unreadChannel = useSelector(unreadChannelSelector);
const unreadUser = useSelector(unreadUserSelector);
const leftButtons = [
{
count: unreadChannel,
icon: '💬',
label: 'Chat',
},
{
count: unreadUser + pendingFriendship.filter(({addeeId}) => addeeId === id).length,
icon: '😁',
label: 'Friends',
},
];
const rightButtons = []
.concat(channelUsers.length > 0 ? [{icon: '🙃', label: 'Present'}] : [])
.concat(blockedIds.length > 0 ? [{icon: '☢️', label: 'Blocked'}] : []);
return ( return (
<div <div
className="center flexed" className="center flexed"
> >
<div className="center__header">
<Bar
active={leftActiveIndex}
branding={(
<li className="bar__brandItem">
<div>
reddi
<span className="muted">?</span>
</div>
{' '}
<div>
chat
<span className="muted">!</span>
</div>
</li>
)}
buttons={leftButtons}
className="bar--left"
isHorizontal
onActive={(active, i) => {
if (i === active) {
dispatch(toggleLeftIsOpen());
}
dispatch(setLeftActiveIndex(active));
}}
/>
<div className="center__headerChannel">
<span className="tiny">chatting in</span>
<span>/r/reddichat</span>
</div>
<Bar
active={rightActiveIndex}
buttons={rightButtons}
className="bar--right"
isHorizontal
onActive={(active, i) => {
if (i === active) {
dispatch(toggleRightIsOpen());
}
dispatch(setRightActiveIndex(active));
}}
/>
</div>
<ChatMessages /> <ChatMessages />
</div> </div>
); );

View File

@ -9,3 +9,25 @@
width: auto; width: auto;
} }
} }
.center__header {
display: flex;
justify-content: space-between;
width: 100%;
height: 3em;
position: relative;
}
.center__headerChannel {
align-self: center;
display: flex;
flex-direction: column;
font-family: var(--thick-title-font-family);
font-weight: bold;
user-select: none;
.tiny {
font-family: var(--title-font-family);
font-size: 0.7em;
align-self: center;
}
}

View File

@ -1,70 +1,27 @@
import './chat--left.scss'; import './chat--left.scss';
import classnames from 'classnames'; import classnames from 'classnames';
import React, {useState} from 'react'; import React from 'react';
import {useSelector} from 'react-redux'; import {useSelector} from 'react-redux';
import { import {
idSelector, leftActiveIndexSelector,
pendingFriendshipSelector, leftIsOpenSelector,
unreadChannelSelector, } from '~/common/state/app';
unreadUserSelector,
} from '~/common/state/user';
import useBreakpoints from './hooks/useBreakpoints'; import useBreakpoints from './hooks/useBreakpoints';
import Bar from './bar';
import ChatLeftFriends from './chat--leftFriends'; import ChatLeftFriends from './chat--leftFriends';
import ChatLeftRooms from './chat--leftRooms'; import ChatLeftRooms from './chat--leftRooms';
export default function ChatLeft() { export default function ChatLeft() {
const {desktop, tablet} = useBreakpoints(); const active = useSelector(leftActiveIndexSelector);
const [active, setActive] = useState(0); const isOpen = useSelector(leftIsOpenSelector);
const [isOpen, setIsOpen] = useState(true); const {tablet} = useBreakpoints();
const toggleOpen = () => setIsOpen(!isOpen); const showsAsOpen = isOpen || !tablet;
const id = useSelector(idSelector);
const pendingFriendship = useSelector(pendingFriendshipSelector);
const showsAsOpen = isOpen || desktop || !tablet;
const unreadChannel = useSelector(unreadChannelSelector);
const unreadUser = useSelector(unreadUserSelector);
const buttons = [
{
count: unreadChannel,
icon: '💬',
label: 'Chat',
},
{
count: unreadUser + pendingFriendship.filter(({addeeId}) => addeeId === id).length,
icon: '😁',
label: 'Friends',
},
];
return ( return (
<div <div
className={classnames('left', 'flexed', showsAsOpen ? 'open' : 'closed')} className={classnames('left', 'flexed', showsAsOpen ? 'open' : 'closed')}
> >
<Bar
branding={(
<li className="bar__brandItem">
<div>
reddi
<span className="muted">?</span>
</div>
{' '}
<div>
chat
<span className="muted">!</span>
</div>
</li>
)}
buttons={buttons}
isHorizontal={showsAsOpen}
onActive={(active, i) => {
if (i === active || !isOpen) {
toggleOpen();
}
setActive(active);
}}
/>
{0 === active && <ChatLeftRooms />} {0 === active && <ChatLeftRooms />}
{1 === active && <ChatLeftFriends />} {1 === active && <ChatLeftFriends />}
</div> </div>

View File

@ -3,6 +3,7 @@
.left { .left {
background-color: #373737; background-color: #373737;
flex-shrink: 0;
transform: translateX(-100%); transform: translateX(-100%);
transition: 0.2s width; transition: 0.2s width;
@include breakpoint(tablet) { @include breakpoint(tablet) {

View File

@ -47,7 +47,7 @@ header {
} }
.chat--messageOwner { .chat--messageOwner {
color: #d68030aa; color: lighten(#d68030aa, 15%);
font-family: Caladea, 'Times New Roman', Times, serif; font-family: Caladea, 'Times New Roman', Times, serif;
font-weight: bold; font-weight: bold;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;

View File

@ -4,7 +4,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
height: 100%; height: calc(100% - 3em);
} }
.chat--messagesSmoosh { .chat--messagesSmoosh {

View File

@ -1,28 +1,30 @@
import './chat--right.scss'; import './chat--right.scss';
import classnames from 'classnames'; import classnames from 'classnames';
import React, {useState} from 'react'; import React from 'react';
import {useSelector} from 'react-redux'; import {useSelector} from 'react-redux';
import {
rightActiveIndexSelector,
rightIsOpenSelector,
} from '~/common/state/app';
import {channelUsersSelector} from '~/common/state/chat'; import {channelUsersSelector} from '~/common/state/chat';
import {blockedSelector} from '~/common/state/user'; import {blockedSelector} from '~/common/state/user';
import useBreakpoints from '~/client/hooks/useBreakpoints'; import useBreakpoints from './hooks/useBreakpoints';
import useChannel from '~/client/hooks/useChannel'; import useChannel from '~/client/hooks/useChannel';
import Bar from './bar';
import ChatRightBlocked from './chat--rightBlocked'; import ChatRightBlocked from './chat--rightBlocked';
import ChatRightUsers from './chat--rightUsers'; import ChatRightUsers from './chat--rightUsers';
export default function ChatRight() { export default function ChatRight() {
const {desktop, tablet} = useBreakpoints(); const active = useSelector(rightActiveIndexSelector);
const [active, setActive] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const channel = useChannel(); const channel = useChannel();
const blockedIds = useSelector(blockedSelector); const blockedIds = useSelector(blockedSelector);
const channelUsers = useSelector((state) => channelUsersSelector(state, channel)); const channelUsers = useSelector((state) => channelUsersSelector(state, channel));
const toggleOpen = () => setIsOpen(!isOpen); const isOpen = useSelector(rightIsOpenSelector);
const showsAsOpen = isOpen || desktop || !tablet; const {tablet} = useBreakpoints();
const showsAsOpen = isOpen || !tablet;
const buttons = [] const buttons = []
.concat(channelUsers.length > 0 ? [{icon: '🙃', label: 'Present'}] : []) .concat(channelUsers.length > 0 ? [{icon: '🙃', label: 'Present'}] : [])
.concat(blockedIds.length > 0 ? [{icon: '☢️', label: 'Blocked'}] : []); .concat(blockedIds.length > 0 ? [{icon: '☢️', label: 'Blocked'}] : []);
@ -33,16 +35,6 @@ export default function ChatRight() {
<div <div
className={classnames('right', 'flexed', showsAsOpen ? 'open' : 'closed')} className={classnames('right', 'flexed', showsAsOpen ? 'open' : 'closed')}
> >
<Bar
buttons={buttons}
isHorizontal={showsAsOpen}
onActive={(active, i) => {
if (i === active || !isOpen) {
toggleOpen();
}
setActive(active);
}}
/>
{0 === active && channelUsers.length > 0 && <ChatRightUsers />} {0 === active && channelUsers.length > 0 && <ChatRightUsers />}
{1 === active && blockedIds.length > 0 && <ChatRightBlocked ids={blockedIds} />} {1 === active && blockedIds.length > 0 && <ChatRightBlocked ids={blockedIds} />}
</div> </div>

View File

@ -2,6 +2,7 @@
.right { .right {
background-color: #373737; background-color: #373737;
flex-shrink: 0;
right: 0; right: 0;
transform: translateX(100%); transform: translateX(100%);
transition: 0.2s width; transition: 0.2s width;

View File

@ -20,15 +20,19 @@ export default function ChatSubmitMessage() {
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
const message = text.slice(0, 1000).trim(); let caret = 0;
if (message) { const message = text.trim();
let chunk;
// eslint-disable-next-line no-cond-assign
while (chunk = message.substr(caret, 512)) {
dispatch(submitMessage({ dispatch(submitMessage({
channel, channel,
message, message: chunk,
owner: '/r/anonymous' === channel ? 0 : user.id, owner: '/r/anonymous' === channel ? 0 : user.id,
timestamp: Date.now(), timestamp: Date.now(),
uuid: uuidv4(), uuid: uuidv4(),
})); }));
caret += 512;
} }
setText(''); setText('');
}} }}
@ -38,7 +42,6 @@ export default function ChatSubmitMessage() {
className="chat--messagesTextarea" className="chat--messagesTextarea"
name="message" name="message"
type="textarea" type="textarea"
maxLength="1000"
onChange={(event) => { onChange={(event) => {
setText(event.target.value); setText(event.target.value);
}} }}

View File

@ -13,7 +13,7 @@
.flexed { .flexed {
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 4em; width: 0;
overflow: hidden; overflow: hidden;
&:not(.center) { &:not(.center) {
max-width: calc(100% - 2em); max-width: calc(100% - 2em);

View File

@ -1,3 +1,4 @@
import RateLimiterMemory from 'rate-limiter-flexible/lib/RateLimiterMemory';
import {v4 as uuidv4} from 'uuid'; import {v4 as uuidv4} from 'uuid';
import AddFavorite from '../../common/packets/add-favorite.packet'; import AddFavorite from '../../common/packets/add-favorite.packet';
@ -43,6 +44,9 @@ import {
import {socket} from '~/client/hooks/useSocket'; import {socket} from '~/client/hooks/useSocket';
const characterLimiter = new RateLimiterMemory({points: 2048, duration: 2});
const messageLimiter = new RateLimiterMemory({points: 10, duration: 15});
const effects = { const effects = {
[addFriendship]: ({dispatch}, {payload: {addeeId, adderId}}) => { [addFriendship]: ({dispatch}, {payload: {addeeId, adderId}}) => {
dispatch(fetchUsernames([addeeId, adderId])); dispatch(fetchUsernames([addeeId, adderId]));
@ -99,31 +103,41 @@ const effects = {
const {channel} = payload; const {channel} = payload;
socket.send(new Leave(payload), () => dispatch(leave({channel}))); socket.send(new Leave(payload), () => dispatch(leave({channel})));
}, },
[submitMessage]: ({dispatch}, {payload}) => { [submitMessage]: async ({dispatch}, {payload}) => {
dispatch(addMessage(payload)); dispatch(addMessage(payload));
socket.send(new Message(payload), (error, result) => { const reject = (ttr) => {
if (error) { dispatch(rejectMessage(payload.uuid));
switch (error.code) { dispatch(addMessage({
case 429: { ...payload,
dispatch(rejectMessage(payload.uuid)); message: [
dispatch(addMessage({ 'You are sending too much.',
...payload, `Try again in ${ttr} second${1 === ttr ? '' : 's'}.`,
message: [ ].join(' '),
'You are sending too many messages.', owner: -1,
`Try again in ${error.ttr} second${1 === error.ttr ? '' : 's'}.`, uuid: uuidv4(),
].join(' '), }));
owner: -1, };
uuid: uuidv4(), try {
})); await characterLimiter.consume('', payload.message.length);
break; await messageLimiter.consume('');
socket.send(new Message(payload), (error, result) => {
if (error) {
switch (error.code) {
case 429: {
reject(error.ttr);
break;
}
default:
} }
default: return;
} }
return; const [timestamp, current] = result;
} dispatch(confirmMessage({current, previous: payload.uuid, timestamp}));
const [timestamp, current] = result; });
dispatch(confirmMessage({current, previous: payload.uuid, timestamp})); }
}); catch (error) {
reject(Math.round(Math.max(0, error.msBeforeNext) / 1000) || 1);
}
}, },
[submitRemoveFavorite]: ({dispatch}, {payload}) => { [submitRemoveFavorite]: ({dispatch}, {payload}) => {
dispatch(removeFromFavorites(payload)); dispatch(removeFromFavorites(payload));

View File

@ -2,8 +2,10 @@ import merge from 'deepmerge';
import {combineReducers} from 'redux'; import {combineReducers} from 'redux';
import {connectRouter, routerMiddleware} from 'connected-react-router'; import {connectRouter, routerMiddleware} from 'connected-react-router';
import app from '~/common/state/app';
import chat from '~/common/state/chat'; import chat from '~/common/state/chat';
import createHistory from '~/common/state/history'; import createHistory from '~/common/state/history';
import {storageSubscription} from '~/common/state/storage';
import user from '~/common/state/user'; import user from '~/common/state/user';
import usernames from '~/common/state/usernames'; import usernames from '~/common/state/usernames';
import createCommonStore from '~/common/store'; import createCommonStore from '~/common/store';
@ -13,13 +15,14 @@ import {middleware as effectsMiddleware} from './effects';
export default function createStore(options = {}) { export default function createStore(options = {}) {
const {history} = options; const {history} = options;
const reducer = combineReducers({ const reducer = combineReducers({
app,
chat, chat,
history: createHistory(history), history: createHistory(history),
router: connectRouter(history), router: connectRouter(history),
user, user,
usernames, usernames,
}); });
return createCommonStore( const store = createCommonStore(
merge( merge(
options, options,
{ {
@ -31,4 +34,6 @@ export default function createStore(options = {}) {
}, },
), ),
); );
store.subscribe(storageSubscription(store));
return store;
} }

61
src/common/state/app.js Normal file
View File

@ -0,0 +1,61 @@
import {createSlice, createSelector} from '@reduxjs/toolkit';
import storage from './storage';
export const appSelector = (state) => state.app;
export const leftActiveIndexSelector = createSelector(
appSelector,
(app) => app.leftActiveIndex,
);
export const rightActiveIndexSelector = createSelector(
appSelector,
(app) => app.rightActiveIndex,
);
export const leftIsOpenSelector = createSelector(
appSelector,
(app) => app.leftIsOpen,
);
export const rightIsOpenSelector = createSelector(
appSelector,
(app) => app.rightIsOpen,
);
const slice = createSlice({
name: 'app',
initialState: storage(appSelector) || {
leftActiveIndex: 0,
leftIsOpen: true,
rightActiveIndex: 0,
rightIsOpen: true,
},
/* eslint-disable no-param-reassign */
extraReducers: {},
reducers: {
setLeftActiveIndex: (state, {payload: activeItem}) => {
state.leftActiveIndex = activeItem;
},
setRightActiveIndex: (state, {payload: activeItem}) => {
state.rightActiveIndex = activeItem;
},
toggleLeftIsOpen: (state) => {
state.leftIsOpen = !state.leftIsOpen;
},
toggleRightIsOpen: (state) => {
state.rightIsOpen = !state.rightIsOpen;
},
},
/* eslint-enable no-param-reassign */
});
export const {
setLeftActiveIndex,
setRightActiveIndex,
toggleLeftIsOpen,
toggleRightIsOpen,
} = slice.actions;
export default slice.reducer;

View File

@ -0,0 +1,13 @@
import throttle from 'lodash.throttle';
export const storageSubscription = (store) => (
throttle(() => localStorage.setItem('redux-state', JSON.stringify(store.getState())), 1000)
);
export default (selector) => {
const state = localStorage.getItem('redux-state');
if (!state) {
return undefined;
}
return selector(JSON.parse(state));
};

View File

@ -1,7 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import {createReadStream} from 'fs'; import {createReadStream} from 'fs';
import http, {ServerResponse} from 'http'; import http, {ServerResponse} from 'http';
import {join, resolve} from 'path'; import {join} from 'path';
import concat from 'concat-stream-p'; import concat from 'concat-stream-p';
import express from 'express'; import express from 'express';

View File

@ -3,19 +3,23 @@ import {v4 as uuidv4} from 'uuid';
import {parseChannel, validateChannel} from '~/common/channel'; import {parseChannel, validateChannel} from '~/common/channel';
import Message from '~/common/packets/message.packet'; import Message from '~/common/packets/message.packet';
import createLimiter from '~/server/limiter';
import {allModels} from '~/server/models/registrar'; import {allModels} from '~/server/models/registrar';
import ValidationError from './validation-error'; import ValidationError from './validation-error';
const characterLimiter = createLimiter({keyPrefix: 'characterLimiter', points: 2048, duration: 2});
export default { export default {
Packet: Message, Packet: Message,
limiter: {points: 10, duration: 15}, limiter: {points: 10, duration: 15},
validator: async ({data: {channel, message}}) => { validator: async ({data: {channel, message}}, socket) => {
await characterLimiter.consume(socket.id, message.length);
if (!validateChannel(channel)) { if (!validateChannel(channel)) {
throw new ValidationError({code: 400, reason: 'Malformed channel'}); throw new ValidationError({code: 400, reason: 'Malformed channel'});
} }
if (message.length > 1024) { if (message.length > 512) {
throw new ValidationError({code: 400, reason: 'Your message was a bit too long'}); throw new ValidationError({code: 400, reason: 'Message larger than 512 bytes'});
} }
}, },
responder: async ({data}, socket) => { responder: async ({data}, socket) => {

View File

@ -18,7 +18,8 @@ export default {
throw new ValidationError({code: 400, reason: "Wasn't blocking."}); throw new ValidationError({code: 400, reason: "Wasn't blocking."});
} }
}, },
responder: async ({data: blocked}, socket) => { responder: async (packet, socket) => {
const {data: blocked} = packet;
const {req} = socket; const {req} = socket;
const {Block: BlockModel} = allModels(); const {Block: BlockModel} = allModels();
await BlockModel.destroy({ await BlockModel.destroy({