diff --git a/app/src/react/components/chat/messages/index.jsx b/app/src/react/components/chat/messages/index.jsx index 31d0525..3c79c9d 100644 --- a/app/src/react/components/chat/messages/index.jsx +++ b/app/src/react/components/chat/messages/index.jsx @@ -19,6 +19,7 @@ export default function Messages() { if (!channel) { return null; } + let messageDistinction = false; let messageOwner = false; return (
@@ -34,10 +35,14 @@ export default function Messages() { const $message = ( ); + messageDistinction = message.distinction; messageOwner = message.owner; return $message; })} diff --git a/app/src/react/components/chat/messages/message/index.jsx b/app/src/react/components/chat/messages/message/index.jsx index e5d2c11..f19d176 100644 --- a/app/src/react/components/chat/messages/message/index.jsx +++ b/app/src/react/components/chat/messages/message/index.jsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import {useSelector} from 'react-redux'; +import {ADMIN, MOD} from '@reddichat/chat/client'; import {usernameSelector} from '@reddichat/user/client'; import Markdown from './markdown'; @@ -18,6 +19,7 @@ export default function Message(props) { const { isShort, message: { + distinction = 3, message, owner, pending, @@ -49,6 +51,14 @@ export default function Message(props) { !isShort && (
{-1 === owner ? '! System !' : username}
+ {!!distinction && ( + + {/* eslint-disable-next-line no-bitwise */} + {!!(distinction & ADMIN) && } + {/* eslint-disable-next-line no-bitwise */} + {!!(distinction & MOD) && } + + )} {$messageTime}
) @@ -56,13 +66,7 @@ export default function Message(props) {
{isShort && $messageTime} - +
); @@ -71,6 +75,7 @@ export default function Message(props) { Message.propTypes = { isShort: PropTypes.bool.isRequired, message: PropTypes.shape({ + distinction: PropTypes.number, message: PropTypes.string, owner: PropTypes.number, pending: PropTypes.bool, diff --git a/app/src/react/components/chat/messages/message/index.scss b/app/src/react/components/chat/messages/message/index.scss index 160dda0..ed812af 100644 --- a/app/src/react/components/chat/messages/message/index.scss +++ b/app/src/react/components/chat/messages/message/index.scss @@ -86,3 +86,20 @@ visibility: visible; } } + +.chat--messageDistinction { + display: inline-block; + left: 0.125em; + font-size: 0.6em; + margin-bottom: 0.25rem; + position: relative; + top: 0.375em; + .admin::after { + color: #c81414; + content: '[a]'; + } + .mod::after { + color: #228822; + content: '[m]'; + } +} diff --git a/app/src/react/components/chat/messages/message/moderation/index.jsx b/app/src/react/components/chat/messages/message/moderation/index.jsx index beed5d3..62e6943 100644 --- a/app/src/react/components/chat/messages/message/moderation/index.jsx +++ b/app/src/react/components/chat/messages/message/moderation/index.jsx @@ -1,34 +1,47 @@ import './index.scss'; import {useSocket} from '@latus/socket/client'; +import {channelIsAnonymous, renderChannel} from '@reddichat/core/client'; +import {idSelector, isAdminSelector, isModOfSelector} from '@reddichat/user/client'; import PropTypes from 'prop-types'; import React from 'react'; +import {useSelector} from 'react-redux'; import Actions from 'components/actions'; +import useChannel from 'hooks/useChannel'; + export default function Moderation(props) { const { - messageIsAdmin, - messageIsModerator, - userIsAdmin, - userIsModerator, + owner, uuid, } = props; + const channel = useChannel(); + const id = useSelector(idSelector); + const isAnonymous = channelIsAnonymous(channel); + const isAdmin = useSelector(isAdminSelector); + const isMod = useSelector((state) => isModOfSelector(state, renderChannel(channel))); const socket = useSocket(); const actions = []; - if (messageIsAdmin) { - actions.push(['👑', 'Admin distinction', () => {}]); + if (isAdmin && (id === owner || 0 === owner)) { + actions.push(['👑', 'Admin distinction', () => { + socket.send(['MessageAdminDistinction', uuid]); + }]); } - if (messageIsModerator) { - actions.push(['🎩', 'Moderator distinction', () => {}]); + if (isMod && id === owner) { + actions.push(['🎩', 'Moderator distinction', () => { + socket.send(['MessageModeratorDistinction', uuid]); + }]); } - if (userIsAdmin) { + if (isAdmin) { actions.push(['🛑', 'Site ban', () => { socket.send(['MessageSiteBan', uuid]); }]); } - if (userIsModerator) { - actions.push(['🚫', 'Channel ban', () => {}]); + if (isMod && !isAnonymous) { + actions.push(['🚫', 'Channel ban', () => { + socket.send(['MessageChannelBan', uuid]); + }]); } if (0 === actions.length) { return null; @@ -41,9 +54,6 @@ export default function Moderation(props) { } Moderation.propTypes = { - messageIsAdmin: PropTypes.bool.isRequired, - messageIsModerator: PropTypes.bool.isRequired, - userIsAdmin: PropTypes.bool.isRequired, - userIsModerator: PropTypes.bool.isRequired, + owner: PropTypes.number.isRequired, uuid: PropTypes.string.isRequired, }; diff --git a/app/src/react/components/chat/messages/submit/distinction/index.jsx b/app/src/react/components/chat/messages/submit/distinction/index.jsx new file mode 100644 index 0000000..b959b59 --- /dev/null +++ b/app/src/react/components/chat/messages/submit/distinction/index.jsx @@ -0,0 +1,62 @@ +import './index.scss'; + +import {channelIsAnonymous, renderChannel} from '@reddichat/core/client'; +import {ADMIN, MOD} from '@reddichat/chat/client'; +import {isAdminSelector, isModOfSelector} from '@reddichat/user/client'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {useSelector} from 'react-redux'; + +import useChannel from 'hooks/useChannel'; + +export default function Distinction(props) { + const { + state: [distinction, setDistinction], + } = props; + const channel = useChannel(); + const isAnonymous = channelIsAnonymous(channel); + const isAdmin = useSelector(isAdminSelector); + const isMod = useSelector((state) => isModOfSelector(state, renderChannel(channel))); + return ( +
+ {isAdmin && ( + + )} + {!isAnonymous && isMod && ( + + )} +
+ ); +} + +Distinction.propTypes = { + state: PropTypes.arrayOf(PropTypes.oneOfType([ + PropTypes.number, + PropTypes.func, + ])).isRequired, +}; diff --git a/app/src/react/components/chat/messages/submit/distinction/index.scss b/app/src/react/components/chat/messages/submit/distinction/index.scss new file mode 100644 index 0000000..b85ed1e --- /dev/null +++ b/app/src/react/components/chat/messages/submit/distinction/index.scss @@ -0,0 +1,19 @@ +.chat--messageSubmitModeration { + display: flex; + flex-flow: column; + margin-bottom: 0.25em; + margin-right: 1em; + justify-content: center; + button { + display: flex; + justify-content: center; + align-items: center; + height: 1.5rem; + width: 1.5rem; + margin: 0.375rem 0; + border-radius: 0.5rem; + &.active { + background-color: rgba(255, 255, 255, 0.4); + } + } +} diff --git a/app/src/react/components/chat/messages/submit/index.jsx b/app/src/react/components/chat/messages/submit/index.jsx index 1ae3136..18af51f 100644 --- a/app/src/react/components/chat/messages/submit/index.jsx +++ b/app/src/react/components/chat/messages/submit/index.jsx @@ -1,7 +1,7 @@ import './index.scss'; import {push} from 'connected-react-router'; -import React, {useRef} from 'react'; +import React, {useRef, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {v4 as uuidv4} from 'uuid'; @@ -20,10 +20,13 @@ import { import useChannel from 'hooks/useChannel'; +import Distinction from './distinction'; + export default function ChatSubmitMessage() { const channel = useChannel(); const dispatch = useDispatch(); const $form = useRef(null); + const [distinction, setDistinction] = useState(0); const redditUsername = useSelector(redditUsernameSelector); const user = useSelector(userSelector); const usernames = useSelector(usernamesSelector); @@ -52,16 +55,17 @@ export default function ChatSubmitMessage() {
{ event.preventDefault(); - if (!text) { + const message = text && text.trim(); + if (!message) { return; } 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, + distinction, message: chunk, owner: channelIsAnonymous(channel) ? 0 : user.id, timestamp: Date.now(), @@ -69,6 +73,7 @@ export default function ChatSubmitMessage() { })); caret += 512; } + setDistinction(0); setText(''); }} ref={$form} @@ -77,7 +82,7 @@ export default function ChatSubmitMessage() { className="chat--messagesTextarea" name="message" type="textarea" - onChange={(event) => setText(event.target.value)} + onChange={(event) => setText(event.target.value || '')} onKeyDown={(event) => { if ('Enter' === event.key && !event.shiftKey) { if ($form.current) { @@ -94,6 +99,7 @@ export default function ChatSubmitMessage() { }} value={text || ''} /> + ); diff --git a/app/src/react/components/chat/messages/submit/index.scss b/app/src/react/components/chat/messages/submit/index.scss index 0ac4209..dbb742b 100644 --- a/app/src/react/components/chat/messages/submit/index.scss +++ b/app/src/react/components/chat/messages/submit/index.scss @@ -1,3 +1,8 @@ .chat--messagesTextarea { background-image: url('~images/transpaper.png'); } + +.chat--messageSubmit form { + display: flex; + position: relative; +} diff --git a/packages/chat/src/client/index.js b/packages/chat/src/client/index.js index 3870cbb..8714828 100644 --- a/packages/chat/src/client/index.js +++ b/packages/chat/src/client/index.js @@ -6,6 +6,7 @@ import MessageSiteBan from '../packets/message-site-ban'; import chat from './state'; +export * from '../distinction'; export * from './state'; export default { diff --git a/packages/chat/src/distinction.js b/packages/chat/src/distinction.js new file mode 100644 index 0000000..b4c6f1b --- /dev/null +++ b/packages/chat/src/distinction.js @@ -0,0 +1,2 @@ +export const ADMIN = 1; +export const MOD = 2; diff --git a/packages/chat/src/index.js b/packages/chat/src/index.js index 0da52c8..8589831 100644 --- a/packages/chat/src/index.js +++ b/packages/chat/src/index.js @@ -23,6 +23,7 @@ import leaveChannel from './leave-channel'; export {ensureCanonical}; export * from './state'; +export * from './distinction'; export default { hooks: { diff --git a/packages/chat/src/packets/message.js b/packages/chat/src/packets/message.js index 431a64b..861d0c6 100644 --- a/packages/chat/src/packets/message.js +++ b/packages/chat/src/packets/message.js @@ -16,6 +16,7 @@ export default () => class Message extends Packet { type: 'string', name: 'string', }, + distinction: 'uint8', message: 'string', owner: 'uint32', timestamp: 'float64', @@ -28,13 +29,13 @@ export default () => class Message extends Packet { duration: 15, }; - static async validate({data: {channel, message}}, socket) { - await this.characterLimiter.consume(socket.id, message.length); + static async validate({data: {channel, message: {length}}}, {id}) { + await this.characterLimiter.consume(id, length); if (!validateChannel(channel)) { - throw new ValidationError({code: 400, reason: 'Malformed channel'}); + throw new ValidationError({code: 400, reason: 'malformed'}); } - if (message.length > 512) { - throw new ValidationError({code: 400, reason: 'Message larger than 512 bytes'}); + if (length > 512) { + throw new ValidationError({code: 413, reason: '> 512 bytes'}); } } diff --git a/packages/chat/src/packets/message.server.js b/packages/chat/src/packets/message.server.js index 41712f7..d820a8c 100644 --- a/packages/chat/src/packets/message.server.js +++ b/packages/chat/src/packets/message.server.js @@ -3,7 +3,9 @@ import {v4 as uuidv4} from 'uuid'; import {ModelMap, Op} from '@latus/db'; import {createLimiter} from '@latus/governor'; import {ValidationError} from '@latus/socket'; -import {channelIsAnonymous, renderChannel, validateChannel} from '@reddichat/core'; +import {channelIsAnonymous, renderChannel} from '@reddichat/core'; + +import {ADMIN, MOD} from '../distinction'; import Message from './message'; @@ -19,7 +21,7 @@ export default (latus) => class MessageServer extends Message(latus) { const {req} = socket; const {pubClient} = req.adapter; const {User} = ModelMap(latus); - const {channel, message} = data; + const {channel, distinction, message} = data; const {name, type} = channel; let destinations = []; if ('u' === type) { @@ -71,6 +73,7 @@ export default (latus) => class MessageServer extends Message(latus) { pubClient .multi() .set(key, JSON.stringify({ + distinction, ip: req.ip, message, owner, @@ -82,24 +85,22 @@ export default (latus) => class MessageServer extends Message(latus) { }); } - static async validate({data: {channel, message}}, socket) { - const {req} = socket; - await this.characterLimiter.consume(socket.id, message.length); - if (!validateChannel(channel)) { - throw new ValidationError({code: 400, reason: 'invalid channel'}); - } - if (message.length > 512) { - throw new ValidationError({code: 400, reason: '> 512 bytes'}); - } + static async validate(packet, socket) { + super.validate(packet, socket); + const {data: {channel, distinction}} = packet; + const {req: {user}} = socket; const {Friendship, User} = ModelMap(latus); const {name, type} = channel; if ('u' === type) { + if (!user) { + throw new ValidationError({code: 403, reason: 'unauthorized'}); + } const other = await User.findOne({where: {redditUsername: name}}); const friendship = await Friendship.findOne({ where: { [Op.or]: [ - {[Op.and]: [{addeeId: req.userId}, {adderId: other.id}]}, - {[Op.and]: [{addeeId: other.id}, {adderId: req.userId}]}, + {[Op.and]: [{addeeId: user.id}, {adderId: other.id}]}, + {[Op.and]: [{addeeId: other.id}, {adderId: user.id}]}, ], }, }); @@ -107,6 +108,17 @@ export default (latus) => class MessageServer extends Message(latus) { throw new ValidationError({code: 403, reason: 'not friends'}); } } + if (distinction && !user) { + throw new ValidationError({code: 403, reason: 'unauthorized'}); + } + // eslint-disable-next-line no-bitwise + if (distinction & ADMIN && !user.isAdmin) { + throw new ValidationError({code: 403, reason: 'unauthorized'}); + } + // eslint-disable-next-line no-bitwise + if (distinction & MOD && !await user.isModOf(channel) && !user.isAdmin) { + throw new ValidationError({code: 403, reason: 'unauthorized'}); + } } }; diff --git a/packages/chat/yarn.lock b/packages/chat/yarn.lock index c8095dc..24e5b58 100644 --- a/packages/chat/yarn.lock +++ b/packages/chat/yarn.lock @@ -5885,11 +5885,6 @@ notepack.io@~2.1.2: resolved "https://npm.i12e.cha0s.io/notepack.io/-/notepack.io-2.1.3.tgz#cc904045c751b1a27b2dcfd838d81d0bf3ced923" integrity sha512-AgSt+cP5XMooho1Ppn8NB3FFaVWefV+qZoZncYTUSch2GAEwlYLcIIbT5YVkMlFeNHnfwOvc4HDlbvrB5BRxXA== -notepack.io@~2.2.0: - version "2.2.0" - resolved "https://npm.i12e.cha0s.io/notepack.io/-/notepack.io-2.2.0.tgz#d7ea71d1cb90094f88c6f3c8d84277c2d0cd101c" - integrity sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw== - npm-run-path@^2.0.0: version "2.0.2" resolved "https://npm.i12e.cha0s.io/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -6948,7 +6943,7 @@ redis-parser@^3.0.0: dependencies: redis-errors "^1.0.0" -redis@^3.0.0, redis@^3.0.2: +redis@^3.0.2: version "3.0.2" resolved "https://npm.i12e.cha0s.io/redis/-/redis-3.0.2.tgz#bd47067b8a4a3e6a2e556e57f71cc82c7360150a" integrity sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ== @@ -7561,11 +7556,6 @@ socket.io-adapter@~1.1.0: resolved "https://npm.i12e.cha0s.io/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9" integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g== -socket.io-adapter@~2.0.0: - version "2.0.3" - resolved "https://npm.i12e.cha0s.io/socket.io-adapter/-/socket.io-adapter-2.0.3.tgz#372b7cde7a535fc4f4f0d5ac7f73952a3062d438" - integrity sha512-2wo4EXgxOGSFueqvHAdnmi5JLZzWqMArjuP4nqC26AtLh5PoCPsaRbRdah2xhcwTAMooZfjYiNVNkkmmSMaxOQ== - socket.io-client@2.3.0: version "2.3.0" resolved "https://npm.i12e.cha0s.io/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4" @@ -7615,17 +7605,6 @@ socket.io-redis@5.3.0: socket.io-adapter "~1.1.0" uid2 "0.0.3" -socket.io-redis@^6.0.1: - version "6.0.1" - resolved "https://npm.i12e.cha0s.io/socket.io-redis/-/socket.io-redis-6.0.1.tgz#0d6c82bd6e0dcbb0d70dcbc57f0c3269e6e53594" - integrity sha512-RvxAhVSsDQJfDUEXUER9MvsE99XZurXkAVORjym1FTReqWlvmPVjyAnrpLlH3RxvPFdFa9sN4kmaTtyzjOtRRA== - dependencies: - debug "~4.1.0" - notepack.io "~2.2.0" - redis "^3.0.0" - socket.io-adapter "~2.0.0" - uid2 "0.0.3" - socket.io@2.3.0: version "2.3.0" resolved "https://npm.i12e.cha0s.io/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb" diff --git a/packages/user/src/client/state/user.js b/packages/user/src/client/state/user.js index 9739103..cdba16a 100644 --- a/packages/user/src/client/state/user.js +++ b/packages/user/src/client/state/user.js @@ -1,6 +1,6 @@ import {LOCATION_CHANGE} from 'connected-react-router'; import merge from 'deepmerge'; -import {addMessage, join, leave} from '@reddichat/chat/client'; +import {addMessage, leave} from '@reddichat/chat/client'; import {renderChannel} from '@reddichat/core/client'; import {localStorage, storage} from '@reddichat/state/client'; import { @@ -65,11 +65,26 @@ export const idSelector = createSelector( ({id}) => id, ); +export const isAdminSelector = createSelector( + [userSelector], + ({isAdmin}) => isAdmin, +); + export const isAnonymousSelector = createSelector( [userSelector], ({isAnonymous}) => isAnonymous, ); +export const modOfSelector = createSelector( + [userSelector], + ({modOf}) => modOf, +); + +export const isModOfSelector = createSelector( + [modOfSelector, (_, name) => name], + (chatNames, name) => -1 !== chatNames.indexOf(name), +); + export const pendingFriendshipSelector = createSelector( friendshipSelector, (friendship) => friendship.filter(({status}) => 'pending' === status), @@ -118,7 +133,9 @@ const slice = createSlice({ favorites: [], friendship: [], id: 0, + isAdmin: false, isAnonymous: true, + modOf: [], recent: [], redditUsername: 'anonymous', unread: {}, diff --git a/packages/user/src/models/block.js b/packages/user/src/models/block.js index 820eecf..67bd82c 100644 --- a/packages/user/src/models/block.js +++ b/packages/user/src/models/block.js @@ -13,10 +13,6 @@ class Block extends Model { this.belongsTo(User); } - static get name() { - return 'Block'; - } - } export default Block; diff --git a/packages/user/src/models/favorite.js b/packages/user/src/models/favorite.js index 5efaba3..80be5f1 100644 --- a/packages/user/src/models/favorite.js +++ b/packages/user/src/models/favorite.js @@ -13,10 +13,6 @@ class Favorite extends Model { this.belongsTo(User); } - static get name() { - return 'Favorite'; - } - } export default Favorite; diff --git a/packages/user/src/models/friendship.js b/packages/user/src/models/friendship.js index c737f90..da97849 100644 --- a/packages/user/src/models/friendship.js +++ b/packages/user/src/models/friendship.js @@ -24,10 +24,6 @@ class Friendship extends Model { }); } - static get name() { - return 'Friendship'; - } - } export default Friendship; diff --git a/packages/user/src/models/user-reddichat.js b/packages/user/src/models/user-reddichat.js index b207c8c..1184410 100644 --- a/packages/user/src/models/user-reddichat.js +++ b/packages/user/src/models/user-reddichat.js @@ -33,4 +33,14 @@ export default (User, latus) => class UserReddichat extends User { return friendship.map(({adderId, addeeId, status}) => ({adderId, addeeId, status})); } + get modOf() { + return this.getModOf().then((chatRooms) => ( + Promise.all(chatRooms.map(async ({name}) => name)) + )); + } + + async isModOf(channel) { + return 'r' === channel.type && -1 !== (await this.modOf).indexOf(channel.name); + } + }; diff --git a/packages/user/src/state/user.js b/packages/user/src/state/user.js index 1bc3677..44b6846 100644 --- a/packages/user/src/state/user.js +++ b/packages/user/src/state/user.js @@ -14,6 +14,8 @@ export default async (req, latus) => { favorites: (await user.favorites()).map(renderChannel), friendship: user ? await user.friendships() : [], id: user.id, + isAdmin: user.isAdmin, + modOf: await user.modOf, redditUsername: user.redditUsername, recent: toHydrate.filter(({type}) => 'r' === type).map(({name}) => name), unread: {},