From 050899b407cfcbf0f201f45962ef36e2eb5cc5c6 Mon Sep 17 00:00:00 2001 From: cha0s Date: Fri, 24 Jul 2020 06:28:13 -0500 Subject: [PATCH] flow: lots of UI, limiting --- src/client/bar.jsx | 41 +++++++------- src/client/bar.scss | 16 ++---- src/client/channel.scss | 1 + src/client/chat--center.jsx | 91 ++++++++++++++++++++++++++++++ src/client/chat--center.scss | 22 ++++++++ src/client/chat--left.jsx | 59 +++---------------- src/client/chat--left.scss | 1 + src/client/chat--message.scss | 2 +- src/client/chat--messages.scss | 2 +- src/client/chat--right.jsx | 28 ++++----- src/client/chat--right.scss | 1 + src/client/chat--submitMessage.jsx | 11 ++-- src/client/chat.scss | 2 +- src/client/store/effects.js | 58 +++++++++++-------- src/client/store/index.js | 7 ++- src/common/state/app.js | 61 ++++++++++++++++++++ src/common/state/storage.js | 13 +++++ src/server/http.js | 2 +- src/server/packet/message.js | 10 +++- src/server/packet/unblock.js | 3 +- 20 files changed, 294 insertions(+), 137 deletions(-) create mode 100644 src/common/state/app.js create mode 100644 src/common/state/storage.js diff --git a/src/client/bar.jsx b/src/client/bar.jsx index dfc755d..987b471 100644 --- a/src/client/bar.jsx +++ b/src/client/bar.jsx @@ -2,47 +2,42 @@ import './bar.scss'; import classnames from 'classnames'; import PropTypes from 'prop-types'; -import React, {useEffect, useState} from 'react'; +import React from 'react'; export default function Bar(props) { const { + active, branding, buttons, - isHorizontal, + className, onActive, } = 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 (
); diff --git a/src/client/chat--center.scss b/src/client/chat--center.scss index 4b29f82..6ab88b2 100644 --- a/src/client/chat--center.scss +++ b/src/client/chat--center.scss @@ -9,3 +9,25 @@ 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; + } +} diff --git a/src/client/chat--left.jsx b/src/client/chat--left.jsx index 41fafd6..c8bf751 100644 --- a/src/client/chat--left.jsx +++ b/src/client/chat--left.jsx @@ -1,70 +1,27 @@ import './chat--left.scss'; import classnames from 'classnames'; -import React, {useState} from 'react'; +import React from 'react'; import {useSelector} from 'react-redux'; import { - idSelector, - pendingFriendshipSelector, - unreadChannelSelector, - unreadUserSelector, -} from '~/common/state/user'; + leftActiveIndexSelector, + leftIsOpenSelector, +} from '~/common/state/app'; import useBreakpoints from './hooks/useBreakpoints'; -import Bar from './bar'; import ChatLeftFriends from './chat--leftFriends'; import ChatLeftRooms from './chat--leftRooms'; export default function ChatLeft() { - const {desktop, tablet} = useBreakpoints(); - const [active, setActive] = useState(0); - const [isOpen, setIsOpen] = useState(true); - const toggleOpen = () => setIsOpen(!isOpen); - 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', - }, - ]; + const active = useSelector(leftActiveIndexSelector); + const isOpen = useSelector(leftIsOpenSelector); + const {tablet} = useBreakpoints(); + const showsAsOpen = isOpen || !tablet; return (
- -
- reddi - ? -
- {' '} -
- chat - ! -
- - )} - buttons={buttons} - isHorizontal={showsAsOpen} - onActive={(active, i) => { - if (i === active || !isOpen) { - toggleOpen(); - } - setActive(active); - }} - /> {0 === active && } {1 === active && }
diff --git a/src/client/chat--left.scss b/src/client/chat--left.scss index 517b5a3..233ac53 100644 --- a/src/client/chat--left.scss +++ b/src/client/chat--left.scss @@ -3,6 +3,7 @@ .left { background-color: #373737; + flex-shrink: 0; transform: translateX(-100%); transition: 0.2s width; @include breakpoint(tablet) { diff --git a/src/client/chat--message.scss b/src/client/chat--message.scss index 3c3f58d..0c7e68a 100644 --- a/src/client/chat--message.scss +++ b/src/client/chat--message.scss @@ -47,7 +47,7 @@ header { } .chat--messageOwner { - color: #d68030aa; + color: lighten(#d68030aa, 15%); font-family: Caladea, 'Times New Roman', Times, serif; font-weight: bold; margin-bottom: 0.25rem; diff --git a/src/client/chat--messages.scss b/src/client/chat--messages.scss index 98a7b9e..ffdcbcd 100644 --- a/src/client/chat--messages.scss +++ b/src/client/chat--messages.scss @@ -4,7 +4,7 @@ display: flex; flex-direction: column; overflow: hidden; - height: 100%; + height: calc(100% - 3em); } .chat--messagesSmoosh { diff --git a/src/client/chat--right.jsx b/src/client/chat--right.jsx index de0dd22..4e56fe0 100644 --- a/src/client/chat--right.jsx +++ b/src/client/chat--right.jsx @@ -1,28 +1,30 @@ import './chat--right.scss'; import classnames from 'classnames'; -import React, {useState} from 'react'; +import React from 'react'; import {useSelector} from 'react-redux'; +import { + rightActiveIndexSelector, + rightIsOpenSelector, +} from '~/common/state/app'; import {channelUsersSelector} from '~/common/state/chat'; import {blockedSelector} from '~/common/state/user'; -import useBreakpoints from '~/client/hooks/useBreakpoints'; +import useBreakpoints from './hooks/useBreakpoints'; import useChannel from '~/client/hooks/useChannel'; -import Bar from './bar'; import ChatRightBlocked from './chat--rightBlocked'; import ChatRightUsers from './chat--rightUsers'; export default function ChatRight() { - const {desktop, tablet} = useBreakpoints(); - const [active, setActive] = useState(0); - const [isOpen, setIsOpen] = useState(false); + const active = useSelector(rightActiveIndexSelector); const channel = useChannel(); const blockedIds = useSelector(blockedSelector); const channelUsers = useSelector((state) => channelUsersSelector(state, channel)); - const toggleOpen = () => setIsOpen(!isOpen); - const showsAsOpen = isOpen || desktop || !tablet; + const isOpen = useSelector(rightIsOpenSelector); + const {tablet} = useBreakpoints(); + const showsAsOpen = isOpen || !tablet; const buttons = [] .concat(channelUsers.length > 0 ? [{icon: '🙃', label: 'Present'}] : []) .concat(blockedIds.length > 0 ? [{icon: '☢️', label: 'Blocked'}] : []); @@ -33,16 +35,6 @@ export default function ChatRight() {
- { - if (i === active || !isOpen) { - toggleOpen(); - } - setActive(active); - }} - /> {0 === active && channelUsers.length > 0 && } {1 === active && blockedIds.length > 0 && }
diff --git a/src/client/chat--right.scss b/src/client/chat--right.scss index bc2198d..3db1893 100644 --- a/src/client/chat--right.scss +++ b/src/client/chat--right.scss @@ -2,6 +2,7 @@ .right { background-color: #373737; + flex-shrink: 0; right: 0; transform: translateX(100%); transition: 0.2s width; diff --git a/src/client/chat--submitMessage.jsx b/src/client/chat--submitMessage.jsx index 98aba06..019fe53 100644 --- a/src/client/chat--submitMessage.jsx +++ b/src/client/chat--submitMessage.jsx @@ -20,15 +20,19 @@ export default function ChatSubmitMessage() {
{ event.preventDefault(); - const message = text.slice(0, 1000).trim(); - if (message) { + let caret = 0; + const message = text.trim(); + let chunk; + // eslint-disable-next-line no-cond-assign + while (chunk = message.substr(caret, 512)) { dispatch(submitMessage({ channel, - message, + message: chunk, owner: '/r/anonymous' === channel ? 0 : user.id, timestamp: Date.now(), uuid: uuidv4(), })); + caret += 512; } setText(''); }} @@ -38,7 +42,6 @@ export default function ChatSubmitMessage() { className="chat--messagesTextarea" name="message" type="textarea" - maxLength="1000" onChange={(event) => { setText(event.target.value); }} diff --git a/src/client/chat.scss b/src/client/chat.scss index c9e003a..79addcf 100644 --- a/src/client/chat.scss +++ b/src/client/chat.scss @@ -13,7 +13,7 @@ .flexed { position: absolute; height: 100%; - width: 4em; + width: 0; overflow: hidden; &:not(.center) { max-width: calc(100% - 2em); diff --git a/src/client/store/effects.js b/src/client/store/effects.js index fa96999..5e96f83 100644 --- a/src/client/store/effects.js +++ b/src/client/store/effects.js @@ -1,3 +1,4 @@ +import RateLimiterMemory from 'rate-limiter-flexible/lib/RateLimiterMemory'; import {v4 as uuidv4} from 'uuid'; import AddFavorite from '../../common/packets/add-favorite.packet'; @@ -43,6 +44,9 @@ import { import {socket} from '~/client/hooks/useSocket'; +const characterLimiter = new RateLimiterMemory({points: 2048, duration: 2}); +const messageLimiter = new RateLimiterMemory({points: 10, duration: 15}); + const effects = { [addFriendship]: ({dispatch}, {payload: {addeeId, adderId}}) => { dispatch(fetchUsernames([addeeId, adderId])); @@ -99,31 +103,41 @@ const effects = { const {channel} = payload; socket.send(new Leave(payload), () => dispatch(leave({channel}))); }, - [submitMessage]: ({dispatch}, {payload}) => { + [submitMessage]: async ({dispatch}, {payload}) => { dispatch(addMessage(payload)); - socket.send(new Message(payload), (error, result) => { - if (error) { - switch (error.code) { - case 429: { - dispatch(rejectMessage(payload.uuid)); - dispatch(addMessage({ - ...payload, - message: [ - 'You are sending too many messages.', - `Try again in ${error.ttr} second${1 === error.ttr ? '' : 's'}.`, - ].join(' '), - owner: -1, - uuid: uuidv4(), - })); - break; + const reject = (ttr) => { + dispatch(rejectMessage(payload.uuid)); + dispatch(addMessage({ + ...payload, + message: [ + 'You are sending too much.', + `Try again in ${ttr} second${1 === ttr ? '' : 's'}.`, + ].join(' '), + owner: -1, + uuid: uuidv4(), + })); + }; + try { + await characterLimiter.consume('', payload.message.length); + 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}) => { dispatch(removeFromFavorites(payload)); diff --git a/src/client/store/index.js b/src/client/store/index.js index ab9a6df..635616f 100644 --- a/src/client/store/index.js +++ b/src/client/store/index.js @@ -2,8 +2,10 @@ import merge from 'deepmerge'; import {combineReducers} from 'redux'; import {connectRouter, routerMiddleware} from 'connected-react-router'; +import app from '~/common/state/app'; import chat from '~/common/state/chat'; import createHistory from '~/common/state/history'; +import {storageSubscription} from '~/common/state/storage'; import user from '~/common/state/user'; import usernames from '~/common/state/usernames'; import createCommonStore from '~/common/store'; @@ -13,13 +15,14 @@ import {middleware as effectsMiddleware} from './effects'; export default function createStore(options = {}) { const {history} = options; const reducer = combineReducers({ + app, chat, history: createHistory(history), router: connectRouter(history), user, usernames, }); - return createCommonStore( + const store = createCommonStore( merge( options, { @@ -31,4 +34,6 @@ export default function createStore(options = {}) { }, ), ); + store.subscribe(storageSubscription(store)); + return store; } diff --git a/src/common/state/app.js b/src/common/state/app.js new file mode 100644 index 0000000..203e837 --- /dev/null +++ b/src/common/state/app.js @@ -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; diff --git a/src/common/state/storage.js b/src/common/state/storage.js new file mode 100644 index 0000000..c375d03 --- /dev/null +++ b/src/common/state/storage.js @@ -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)); +}; diff --git a/src/server/http.js b/src/server/http.js index ceed1d0..5c16741 100644 --- a/src/server/http.js +++ b/src/server/http.js @@ -1,7 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import {createReadStream} from 'fs'; import http, {ServerResponse} from 'http'; -import {join, resolve} from 'path'; +import {join} from 'path'; import concat from 'concat-stream-p'; import express from 'express'; diff --git a/src/server/packet/message.js b/src/server/packet/message.js index d28407f..d29ba36 100644 --- a/src/server/packet/message.js +++ b/src/server/packet/message.js @@ -3,19 +3,23 @@ import {v4 as uuidv4} from 'uuid'; import {parseChannel, validateChannel} from '~/common/channel'; import Message from '~/common/packets/message.packet'; +import createLimiter from '~/server/limiter'; import {allModels} from '~/server/models/registrar'; import ValidationError from './validation-error'; +const characterLimiter = createLimiter({keyPrefix: 'characterLimiter', points: 2048, duration: 2}); + export default { Packet: Message, 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)) { throw new ValidationError({code: 400, reason: 'Malformed channel'}); } - if (message.length > 1024) { - throw new ValidationError({code: 400, reason: 'Your message was a bit too long'}); + if (message.length > 512) { + throw new ValidationError({code: 400, reason: 'Message larger than 512 bytes'}); } }, responder: async ({data}, socket) => { diff --git a/src/server/packet/unblock.js b/src/server/packet/unblock.js index 42a5596..40b2ab3 100644 --- a/src/server/packet/unblock.js +++ b/src/server/packet/unblock.js @@ -18,7 +18,8 @@ export default { 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 {Block: BlockModel} = allModels(); await BlockModel.destroy({