feat: distinction!

This commit is contained in:
cha0s 2020-12-13 10:14:23 -06:00
parent b73687a9c5
commit c341642473
20 changed files with 222 additions and 80 deletions

View File

@ -19,6 +19,7 @@ export default function Messages() {
if (!channel) {
return null;
}
let messageDistinction = false;
let messageOwner = false;
return (
<div className="chat--messages">
@ -34,10 +35,14 @@ export default function Messages() {
const $message = (
<Message
key={message.uuid}
isShort={0 === message.owner || messageOwner === message.owner}
isShort={
(0 === message.owner || messageOwner === message.owner)
&& messageDistinction === message.distinction
}
message={message}
/>
);
messageDistinction = message.distinction;
messageOwner = message.owner;
return $message;
})}

View File

@ -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 && (
<header>
<div className="chat--messageOwner">{-1 === owner ? '! System !' : username}</div>
{!!distinction && (
<span className="chat--messageDistinction">
{/* eslint-disable-next-line no-bitwise */}
{!!(distinction & ADMIN) && <span className="admin" />}
{/* eslint-disable-next-line no-bitwise */}
{!!(distinction & MOD) && <span className="mod" />}
</span>
)}
{$messageTime}
</header>
)
@ -56,13 +66,7 @@ export default function Message(props) {
<div className="chat--messageText">
<Markdown message={message} />
{isShort && $messageTime}
<Moderation
messageIsAdmin
messageIsModerator
userIsAdmin
userIsModerator
uuid={uuid}
/>
<Moderation owner={owner} uuid={uuid} />
</div>
</div>
);
@ -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,

View File

@ -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]';
}
}

View File

@ -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,
};

View File

@ -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 (
<div className="chat--messageSubmitModeration">
{isAdmin && (
<button
aria-label="Distinguish as administrator"
// eslint-disable-next-line no-bitwise
className={classnames('admin', {active: distinction & ADMIN})}
title="Distinguish as administrator"
type="button"
onClick={() => {
// eslint-disable-next-line no-bitwise
setDistinction(distinction & ADMIN ? distinction & ~ADMIN : distinction | ADMIN);
}}
>
<span>👑</span>
</button>
)}
{!isAnonymous && isMod && (
<button
aria-label="Distinguish as moderator"
// eslint-disable-next-line no-bitwise
className={classnames('mod', {active: distinction & MOD})}
title="Distinguish as moderator"
type="button"
onClick={() => {
// eslint-disable-next-line no-bitwise
setDistinction(distinction & MOD ? distinction & ~MOD : distinction | MOD);
}}
>
<span>🎩</span>
</button>
)}
</div>
);
}
Distinction.propTypes = {
state: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.number,
PropTypes.func,
])).isRequired,
};

View File

@ -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);
}
}
}

View File

@ -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() {
<form
onSubmit={(event) => {
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 || ''}
/>
<Distinction state={[distinction, setDistinction]} />
</form>
</div>
);

View File

@ -1,3 +1,8 @@
.chat--messagesTextarea {
background-image: url('~images/transpaper.png');
}
.chat--messageSubmit form {
display: flex;
position: relative;
}

View File

@ -6,6 +6,7 @@ import MessageSiteBan from '../packets/message-site-ban';
import chat from './state';
export * from '../distinction';
export * from './state';
export default {

View File

@ -0,0 +1,2 @@
export const ADMIN = 1;
export const MOD = 2;

View File

@ -23,6 +23,7 @@ import leaveChannel from './leave-channel';
export {ensureCanonical};
export * from './state';
export * from './distinction';
export default {
hooks: {

View File

@ -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'});
}
}

View File

@ -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'});
}
}
};

View File

@ -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"

View File

@ -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: {},

View File

@ -13,10 +13,6 @@ class Block extends Model {
this.belongsTo(User);
}
static get name() {
return 'Block';
}
}
export default Block;

View File

@ -13,10 +13,6 @@ class Favorite extends Model {
this.belongsTo(User);
}
static get name() {
return 'Favorite';
}
}
export default Favorite;

View File

@ -24,10 +24,6 @@ class Friendship extends Model {
});
}
static get name() {
return 'Friendship';
}
}
export default Friendship;

View File

@ -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);
}
};

View File

@ -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: {},