cheogram-www
created pr with
cheogram-www/mass-moderate.1
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:mass-moderate | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:mass-moderate [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:mass-moderateadd comment to patch request:
ssh pr.pico.sh comment cheogram-www:mass-moderate "lgtm!"
Patchset
cheogram-www/mass-moderate.1
Allow moderator to mass moderate on ban
Stephen Paul Weber
2026-10-26T13:50:01ZSemantic diff summary
0 added,
9 modified,
0 signature changed,
0 removed
across 3 analyzed files
+1
-1
src/components/Chat.test.tsx
#
| ... | ... | @@ -47,7 +47,7 @@ it("reports an empty typing string when the composer is cleared", async () => { | |
| 47 | 47 | client: mock<Client>(), | |
| 48 | 48 | getChatExtras: () => undefined, | |
| 49 | 49 | setChatExtras: vi.fn(), | |
| 50 | - | innerChatRef: { current: { addMention: vi.fn() } }, | |
| 50 | + | innerChatRef: { current: { addMention: vi.fn(), moderateRecent: vi.fn() } }, | |
| 51 | 51 | isInvite: false, | |
| 52 | 52 | persistence: mock<Persistence & persistence.KeyValueStore>({ | |
| 53 | 53 | get: async () => null, |
+27
-1
src/components/Chat.tsx
#
| ... | ... | @@ -149,6 +149,7 @@ export const ChatInner = () => { | |
| 149 | 149 | setPopupMember: (member: borogove.Member) => void; | |
| 150 | 150 | innerChatRef: MutableRefObject<{ | |
| 151 | 151 | addMention: (member: borogove.Member) => void; | |
| 152 | + | moderateRecent: (member: borogove.Member, reason: string) => void; | |
| 152 | 153 | }>; | |
| 153 | 154 | isInvite: boolean; | |
| 154 | 155 | persistence: borogove.persistence.KeyValueStore & borogove.Persistence; |
| ... | ... | @@ -681,9 +682,34 @@ export const ChatInner = () => { | |
| 681 | 682 | [inputRef], | |
| 682 | 683 | ); | |
| 683 | 684 | ||
| 685 | + | const moderateRecent = useCallback( | |
| 686 | + | async (member: borogove.Member, reason: string) => { | |
| 687 | + | for (const message of messages) { | |
| 688 | + | if (message.senderId === member.id) { | |
| 689 | + | activeChat.moderate(message, reason); | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // Keep paging back through history until a page has no messages from | |
| 694 | + | // this member, so older messages they sent are moderated too. | |
| 695 | + | let before: borogove.ChatMessage | null = messages[0] ?? null; | |
| 696 | + | while (true) { | |
| 697 | + | const page = await activeChat.getMessagesBefore(before); | |
| 698 | + | const fromMember = page.filter((m) => m.senderId === member.id); | |
| 699 | + | for (const message of fromMember) { | |
| 700 | + | activeChat.moderate(message, reason); | |
| 701 | + | } | |
| 702 | + | if (fromMember.length === 0) break; | |
| 703 | + | before = page[0]; | |
| 704 | + | } | |
| 705 | + | }, | |
| 706 | + | [activeChat, messages], | |
| 707 | + | ); | |
| 708 | + | ||
| 684 | 709 | useEffect(() => { | |
| 685 | 710 | innerChatRef.current.addMention = addMention; | |
| 686 | - | }, [innerChatRef, addMention]); | |
| 711 | + | innerChatRef.current.moderateRecent = moderateRecent; | |
| 712 | + | }, [innerChatRef, addMention, moderateRecent]); | |
| 687 | 713 | ||
| 688 | 714 | const toggleDetails = useCallback(() => { | |
| 689 | 715 | if (location.pathname.endsWith("/details")) { |
+61
-4
src/components/Chats.tsx
#
| ... | ... | @@ -1,14 +1,22 @@ | |
| 1 | 1 | import * as borogove from "borogove"; | |
| 2 | 2 | import Fuse from "fuse.js"; | |
| 3 | 3 | import { | |
| 4 | + | Button as MuiButton, | |
| 5 | + | Checkbox, | |
| 4 | 6 | Chip, | |
| 5 | 7 | CircularProgress, | |
| 8 | + | Dialog, | |
| 9 | + | DialogActions, | |
| 10 | + | DialogContent, | |
| 11 | + | DialogContentText, | |
| 12 | + | DialogTitle, | |
| 6 | 13 | Fab, | |
| 14 | + | FormControlLabel, | |
| 7 | 15 | Menu, | |
| 8 | 16 | MenuItem, | |
| 9 | - | Dialog, | |
| 10 | 17 | ListItemIcon, | |
| 11 | 18 | ListSubheader, | |
| 19 | + | TextField, | |
| 12 | 20 | useMediaQuery, | |
| 13 | 21 | } from "@mui/material"; | |
| 14 | 22 | import { QRCanvas, frontalCamera } from "qr/dom.js"; |
| ... | ... | @@ -88,7 +96,8 @@ export const Chats = () => { | |
| 88 | 96 | ||
| 89 | 97 | const innerChatRef = useRef<{ | |
| 90 | 98 | addMention: (member: borogove.Member) => void; | |
| 91 | - | }>({ addMention: () => {} }); | |
| 99 | + | moderateRecent: (member: borogove.Member, reason: string) => void; | |
| 100 | + | }>({ addMention: () => {}, moderateRecent: () => {} }); | |
| 92 | 101 | ||
| 93 | 102 | const [typingMembers, setTypingMembers] = useState< | |
| 94 | 103 | Record<string, borogove.Member[]> |
| ... | ... | @@ -176,6 +185,7 @@ export const Chats = () => { | |
| 176 | 185 | const [addRoleAnchorEl, setAddRoleAnchorEl] = useState<null | HTMLElement>( | |
| 177 | 186 | null, | |
| 178 | 187 | ); | |
| 188 | + | const [banMember, setBanMember] = useState<borogove.Member | null>(null); | |
| 179 | 189 | ||
| 180 | 190 | const [popupMember, setPopupMember] = useState<borogove.Member | undefined>(); | |
| 181 | 191 | const [scanQRCode, setScanQRCode] = useState(false); |
| ... | ... | @@ -228,6 +238,21 @@ export const Chats = () => { | |
| 228 | 238 | return chat; | |
| 229 | 239 | }; | |
| 230 | 240 | ||
| 241 | + | const confirmBan = (event: React.FormEvent<HTMLFormElement>) => { | |
| 242 | + | event.preventDefault(); | |
| 243 | + | if (!banMember || !activeChat) return; | |
| 244 | + | const data = new FormData(event.currentTarget); | |
| 245 | + | const reason = String(data.get("reason") ?? "").trim(); | |
| 246 | + | setBanMember(null); | |
| 247 | + | const role = activeChat | |
| 248 | + | .availableRoles(banMember) | |
| 249 | + | .find((r) => r.id === "outcast"); | |
| 250 | + | if (role) activeChat.addRole(banMember, role); | |
| 251 | + | if (data.get("moderate") !== null) { | |
| 252 | + | innerChatRef.current.moderateRecent(banMember, reason); | |
| 253 | + | } | |
| 254 | + | }; | |
| 255 | + | ||
| 231 | 256 | const conversationInfo = (c: borogove.Chat) => { | |
| 232 | 257 | let info: string | JSX.Element = c.preview(); | |
| 233 | 258 | const extras = getChatExtras(c.chatId); |
| ... | ... | @@ -1124,9 +1149,13 @@ export const Chats = () => { | |
| 1124 | 1149 | <MenuItem | |
| 1125 | 1150 | key={role.id} | |
| 1126 | 1151 | onClick={() => { | |
| 1127 | - | activeChat?.addRole(popupMember, role); | |
| 1128 | - | setPopupMember(undefined); | |
| 1129 | 1152 | setAddRoleAnchorEl(null); | |
| 1153 | + | setPopupMember(undefined); | |
| 1154 | + | if (role.id === "outcast") { | |
| 1155 | + | setBanMember(popupMember); | |
| 1156 | + | return; | |
| 1157 | + | } | |
| 1158 | + | activeChat?.addRole(popupMember, role); | |
| 1130 | 1159 | }} | |
| 1131 | 1160 | > | |
| 1132 | 1161 | {role.title} |
| ... | ... | @@ -1170,6 +1199,34 @@ export const Chats = () => { | |
| 1170 | 1199 | </Dialog> | |
| 1171 | 1200 | )} | |
| 1172 | 1201 | ||
| 1202 | + | {banMember && ( | |
| 1203 | + | <Dialog open onClose={() => setBanMember(null)} id="ban-dialog"> | |
| 1204 | + | <DialogTitle>Ban {banMember.displayName}?</DialogTitle> | |
| 1205 | + | <form onSubmit={confirmBan}> | |
| 1206 | + | <DialogContent> | |
| 1207 | + | <DialogContentText> | |
| 1208 | + | They will be removed from this channel and unable to rejoin. | |
| 1209 | + | </DialogContentText> | |
| 1210 | + | <FormControlLabel | |
| 1211 | + | control={<Checkbox name="moderate" defaultChecked />} | |
| 1212 | + | label="Also moderate their recent messages" | |
| 1213 | + | /> | |
| 1214 | + | <TextField | |
| 1215 | + | name="reason" | |
| 1216 | + | label="Reason" | |
| 1217 | + | defaultValue="Spam" | |
| 1218 | + | fullWidth | |
| 1219 | + | margin="dense" | |
| 1220 | + | /> | |
| 1221 | + | </DialogContent> | |
| 1222 | + | <DialogActions> | |
| 1223 | + | <MuiButton onClick={() => setBanMember(null)}>Cancel</MuiButton> | |
| 1224 | + | <MuiButton type="submit">Ban</MuiButton> | |
| 1225 | + | </DialogActions> | |
| 1226 | + | </form> | |
| 1227 | + | </Dialog> | |
| 1228 | + | )} | |
| 1229 | + | ||
| 1173 | 1230 | <Dialog | |
| 1174 | 1231 | open={scanQRCode} | |
| 1175 | 1232 | onClose={() => setScanQRCode(false)} |