Add toggleable profanity filter to text channels (disabled by default).

This commit is contained in:
ADAMJR 2022-12-16 22:37:07 +00:00
parent 85144561c4
commit e467aabddf
13 changed files with 133 additions and 41 deletions

View File

@ -10,6 +10,7 @@
"dependencies": {
"@acrd/ion": "github:acrdapp/ion",
"@acrd/types": "file:../frontend/src/types",
"bad-words": "^3.0.4",
"body-parser": "^1.19.0",
"chai-things": "^0.2.0",
"colors": "^1.4.0",
@ -44,6 +45,7 @@
"winston": "^3.3.3"
},
"devDependencies": {
"@types/bad-words": "^3.0.1",
"@types/chai": "^4.2.14",
"@types/chai-as-promised": "^7.1.3",
"@types/chai-spies": "^1.0.3",
@ -1454,6 +1456,12 @@
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==",
"dev": true
},
"node_modules/@types/bad-words": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/bad-words/-/bad-words-3.0.1.tgz",
"integrity": "sha512-7la3ZDJG1tlRqySO+pnXycZpacaMEw/iLEm8kc4l+I+jN8KjBfoQVwO6jm98xzXVLrxV8vDrB5TaMoop8sKclQ==",
"dev": true
},
"node_modules/@types/body-parser": {
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
@ -2155,6 +2163,22 @@
"node": ">= 10.0.0"
}
},
"node_modules/bad-words": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/bad-words/-/bad-words-3.0.4.tgz",
"integrity": "sha512-v/Q9uRPH4+yzDVLL4vR1+S9KoFgOEUl5s4axd6NIAq8SV2mradgi4E8lma/Y0cw1ltVdvyegCQQKffCPRCp8fg==",
"dependencies": {
"badwords-list": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/badwords-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-1.0.0.tgz",
"integrity": "sha512-oWhaSG67e+HQj3OGHQt2ucP+vAPm1wTbdp2aDHeuh4xlGXBdWwzZ//pfu6swf5gZ8iX0b7JgmSo8BhgybbqszA=="
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",

View File

@ -15,6 +15,7 @@
"dependencies": {
"@acrd/ion": "github:acrdapp/ion",
"@acrd/types": "file:../frontend/src/types",
"bad-words": "^3.0.4",
"body-parser": "^1.19.0",
"chai-things": "^0.2.0",
"colors": "^1.4.0",
@ -49,6 +50,7 @@
"winston": "^3.3.3"
},
"devDependencies": {
"@types/bad-words": "^3.0.1",
"@types/chai": "^4.2.14",
"@types/chai-as-promised": "^7.1.3",
"@types/chai-spies": "^1.0.3",

View File

@ -13,7 +13,7 @@ export default class Messages extends DBWrapper<string, MessageDocument> {
}
public async create(authorId: string, channelId: string, { attachmentURLs, content }: Partial<Entity.Message>) {
// TODO: TESTME
// TODO: TESTME
if (!content && !attachmentURLs?.length)
throw new TypeError('Empty messages are not valid');

View File

@ -14,6 +14,7 @@ export interface VoiceChannelDocument extends Document, ChannelTypes.Voice {
id: string;
createdAt: never;
memberIds: string[];
filterProfanity: never;
}
export type ChannelDocument = TextChannelDocument | VoiceChannelDocument;
@ -36,6 +37,7 @@ export const Channel = model<ChannelDocument>('channel', new Schema({
maxlength: [32, 'Name too long'],
validate: [validators.textChannelName, 'Invalid name'],
},
filterProfanity: { type: Boolean },
firstMessageId: {
type: String,
validate: [validators.optionalSnowflake, 'Invalid Snowflake ID'],

View File

@ -7,7 +7,7 @@ import { Entity, WS } from '@acrd/types';
export default class implements WSEvent<'CHANNEL_UPDATE'> {
public on = 'CHANNEL_UPDATE' as const;
public async invoke(ws: WebSocket, client: Socket, { position, name, summary, overrides, channelId }: WS.Params.ChannelUpdate) {
public async invoke(ws: WebSocket, client: Socket, { position, name, summary, filterProfanity, overrides, channelId }: WS.Params.ChannelUpdate) {
const channel = await deps.channels.get(channelId);
await deps.wsGuard.validateCan(client, channel.guildId, 'MANAGE_CHANNELS');
@ -20,6 +20,9 @@ export default class implements WSEvent<'CHANNEL_UPDATE'> {
await this.raiseHigherChannels(position, channel);
}
if (filterProfanity != undefined)
partialChannel.filterProfanity = filterProfanity;
Object.assign(channel, partialChannel);
await channel.save();

View File

@ -4,6 +4,7 @@ import { WSEvent, } from './ws-event';
import { Channel } from '../../data/models/channel';
import striptags from 'striptags';
import { WS } from '@acrd/types';
import ProfanityFilter from 'bad-words';
export default class implements WSEvent<'MESSAGE_CREATE'> {
on = 'MESSAGE_CREATE' as const;
@ -11,17 +12,20 @@ export default class implements WSEvent<'MESSAGE_CREATE'> {
public async invoke(ws: WebSocket, client: Socket, { attachmentURLs, channelId, content, embed }: WS.Params.MessageCreate) {
const authorId = ws.sessions.userId(client);
const [_, message, author] = await Promise.all([
const [_, channel, author] = await Promise.all([
deps.wsGuard.validateCanInChannel(client, channelId, 'SEND_MESSAGES'),
deps.messages.create(authorId, channelId, {
attachmentURLs,
content: (content) ? striptags(content) : '',
embed,
}),
deps.channels.getText(channelId),
deps.users.getSelf(authorId),
]);
await Channel.updateOne({ _id: channelId }, { lastMessageId: message.id });
var message = await deps.messages.create(authorId, channelId, {
attachmentURLs,
content: this.filterContent(content, channel.filterProfanity),
embed,
});
channel.lastMessageId = message.id;
await channel.save();
author.lastReadMessageIds ??= {};
author.lastReadMessageIds[channelId] = message.id;
@ -33,4 +37,13 @@ export default class implements WSEvent<'MESSAGE_CREATE'> {
send: { message },
}];
}
private filterContent(content: string | undefined, filterProfanity: boolean) {
const badWords = new ProfanityFilter({ placeHolder: '?' });
if (content && filterProfanity)
return striptags(badWords.clean(content));
else if (content)
return striptags(content);
return '';
}
}

View File

@ -22,7 +22,7 @@ const MessageHeader: FunctionComponent<MessageHeaderProps> = ({ author, message,
<div>
<ContextMenuTrigger id={author.id}>
<span
style={{ color: highestRole.color }}
style={{ color: highestRole?.color }}
className="text-base heading hover:underline cursor-pointer mr-2">{author.username}</span>
</ContextMenuTrigger>
<span className="text-xs">

View File

@ -2,13 +2,56 @@ input {
border: 1px solid var(--bg-secondary-alt);
caret-color: var(--heading);
}
input:hover {
border: 1px solid var(--bg-tertiary);
}
input:focus {
border: 1px solid var(--link);
}
input:disabled {
color: var(--muted);
cursor: not-allowed;
}
/* native toggle */
input[type=checkbox] {
transform: scale(.75);
margin-left: -10px;
}
input[type="checkbox"] {
position: relative;
width: 80px;
height: 40px;
-webkit-appearance: none;
appearance: none;
background: var(--danger);
outline: none;
border-radius: 2rem;
cursor: pointer;
box-shadow: inset 0 0 5px rgb(0 0 0 / 50%);
}
input[type="checkbox"]::before {
content: "";
width: 40px;
height: 40px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 0;
left: 0;
transition: 0.5s;
}
input[type="checkbox"]:checked::before {
transform: translateX(100%);
background: #fff;
}
input[type="checkbox"]:checked {
background: var(--success);
}

View File

@ -9,17 +9,17 @@ import ReactTooltip from 'react-tooltip';
export interface InputProps {
name: string;
register?: UseFormRegister<FieldValues>;
options?: any;
autoFocus?: boolean;
options?: any;
label?: string;
type?: string;
className?: string;
disabled?: boolean;
tooltip?: string;
register?: UseFormRegister<FieldValues>;
setFocusedInputId?: (val: any) => any;
}
const Input: React.FunctionComponent<InputProps & React.AllHTMLAttributes<HTMLInputElement>> = (props) => {
const { label, name, register, options, type, autoFocus, className, disabled, tooltip } = props;
const id = name + 'Input';
@ -59,5 +59,5 @@ const Input: React.FunctionComponent<InputProps & React.AllHTMLAttributes<HTMLIn
</div>
);
}
export default Input;

View File

@ -6,11 +6,10 @@ import Input from '../../inputs/input';
import NormalButton from '../../utils/buttons/normal-button';
import Category from '../../utils/category';
import SaveChanges from '../../utils/save-changes';
const ChannelSettingsOverview: React.FunctionComponent = () => {
const dispatch = useDispatch();
const channel = useSelector((s: Store.AppState) => s.ui.activeChannel)!;
const guild = useSelector((s: Store.AppState) => s.ui.activeGuild)!;
const { register, handleSubmit, setValue } = useForm();
const onSave = (e) => {
@ -21,7 +20,7 @@ const ChannelSettingsOverview: React.FunctionComponent = () => {
const confirmation = window.confirm('Are you sure you want to delete this guild?');
confirmation && dispatch(deleteChannel(channel.id));
}
return (
<form
onChange={() => dispatch(openSaveChanges(true))}
@ -29,7 +28,7 @@ const ChannelSettingsOverview: React.FunctionComponent = () => {
<header>
<h1 className="text-xl font-bold inline">Channel Overview</h1>
</header>
<section className="w-1/3">
<Input
label="Name"
@ -43,6 +42,14 @@ const ChannelSettingsOverview: React.FunctionComponent = () => {
register={register}
options={{ value: channel.summary }}
className="pt-5" />
<Input
tooltip='Mask explicit words in messages.'
label="Filter Profanity"
name="filterProfanity"
type="checkbox"
register={register}
options={{ value: channel.filterProfanity }}
className="pt-5" />
</section>
<Category
@ -60,8 +67,8 @@ const ChannelSettingsOverview: React.FunctionComponent = () => {
setValue={setValue}
onSave={onSave}
obj={channel} />
</form>
</form>
);
}
export default ChannelSettingsOverview;

View File

@ -61,28 +61,28 @@ const UserSettingsOverview: React.FunctionComponent = () => {
</form>
<Category
className="py-2 mt-5"
title="Advanced Settings" />
className="py-2 mt-5"
title="Advanced Settings" />
<section>
<div className="w-1/2 pb-5">
<label htmlFor="devMode">Dev Mode</label>
<Toggle
onChange={(e) => e.stopPropagation()}
onClick={() => dispatch(toggleDevMode())}
checked={devMode}
className="float-right"
id="devMode" />
</div>
<div className="w-1/2 pb-5">
<label htmlFor="devMode">Dev Mode</label>
<Toggle
onChange={(e) => e.stopPropagation()}
onClick={() => dispatch(toggleDevMode())}
checked={devMode}
className="float-right"
id="devMode" />
</div>
<NormalButton
id="deleteUserButton"
role="button"
onClick={handleSubmit(onDelete)}
className="bg-danger">Delete</NormalButton>
<NormalButton
id="deleteUserButton"
role="button"
onClick={handleSubmit(onDelete)}
className="bg-danger">Delete</NormalButton>
</section>
</div>
);
}
export default UserSettingsOverview;

View File

@ -10,11 +10,10 @@ export interface SaveChangesProps {
onSave: (e) => any;
onOpen?: () => any;
onReset?: (e) => any;
/** @deprecated */
setValue?: UseFormSetValue<FieldValues>;
obj: object;
}
const SaveChanges: React.FunctionComponent<SaveChangesProps> = (props) => {
const { closeSnackbar, enqueueSnackbar } = useSnackbar();
const dispatch = useDispatch();
@ -63,5 +62,5 @@ const SaveChanges: React.FunctionComponent<SaveChangesProps> = (props) => {
return null;
}
export default SaveChanges;

View File

@ -13,7 +13,6 @@ const slice = createSlice({
stoppedEditingMessage: (state) => {
delete state.editingMessageId;
},
// only 1 invite is created -> to save data, and stop spam
focusedResource: (state, { payload }) => {
state.activeResource = payload;
},