atagen

malice

public
Star0
Watch0

Commit 0380cd8c765c

tarball

atagen <boss@atagen.co>2026-10-24 09:09 UTC

4 files changed+82-1
web: add a more-actions menu to the message action bar

It gathers reply, edit, copy, clone sticker and delete with the sender actions from the user menu, so moderation is reachable from the message itself. Delete is offered only when the power levels allow the redaction and asks for confirmation, since redactions cannot be undone.
Modified file: web/src/App.svelte+53 additions−1 deletions
@@ -10,7 +10,7 @@
1010 import { clonePackDestinations, editableChoices, shortcodeFor } from './lib/packs';
1111 import type { CommandRequest } from './lib/transport';
1212 import UserCard, { type UserAction } from './lib/UserCard.svelte';
13- import { allowed, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';
13+ import { allowed, canRedact, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';
1414 import { moveBefore, pinnedFirst, sortSpaces } from './lib/rail-order';
1515 import ImageLightbox from './lib/ImageLightbox.svelte';
1616 import { lightbox } from './lib/lightbox';
@@ -1349,6 +1349,57 @@
13491349 menu = { items, x, y };
13501350 }
13511351
1352+ async function openMessageMenu(event: TimelineEvent, x: number, y: number): Promise<void> {
1353+ const roomId = $selectedRoomId;
1354+ const eventId = event.event_id;
1355+ if (!roomId || !eventId) return;
1356+ if (!(roomId in levelsByRoom)) await loadLevels(roomId);
1357+ const joined = selectedRoom?.membership === 'Joined';
1358+ const content = event.content;
1359+ const items: MenuItem[] = [];
1360+ if (joined) {
1361+ items.push({
1362+ label: 'Reply',
1363+ action: () =>
1364+ startReply({
1365+ event_id: eventId,
1366+ details: { kind: 'Ready', sender: event.sender, content, timestamp_ms: event.timestamp_ms }
1367+ })
1368+ });
1369+ }
1370+ if (joined && event.own && event.editable && content.kind === 'Message' && content.message.kind === 'Text') {
1371+ const body = content.message.body;
1372+ items.push({ label: 'Edit', action: () => startEdit(eventId, body) });
1373+ }
1374+ if (content.kind === 'Message' && content.message.body) {
1375+ const body = content.message.body;
1376+ items.push({ label: 'Copy text', action: () => copyText(body) });
1377+ }
1378+ items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${roomId}/${eventId}`) });
1379+ if (content.kind === 'Sticker') {
1380+ const body = content.body;
1381+ items.push({ label: 'Clone sticker…', action: () => openCloneStickerMenu(eventId, body, x, y) });
1382+ }
1383+ const me = $sessionUser;
1384+ if (joined && me && roomLevels && canRedact(roomLevels, me, event.sender.user_id) && content.kind !== 'Redacted') {
1385+ items.push({
1386+ label: 'Delete message…',
1387+ danger: true,
1388+ action: () =>
1389+ (menu = {
1390+ x,
1391+ y,
1392+ items: [
1393+ { label: 'Delete', danger: true, action: () => void runClone({ op: 'redact', room_id: roomId, event_id: eventId, reason: null }, 'Could not delete the message') },
1394+ { label: 'Cancel', action: () => undefined }
1395+ ]
1396+ })
1397+ });
1398+ }
1399+ if (!event.own) items.push(...userMenuItems(event.sender.user_id, x, y));
1400+ menu = { items, x, y };
1401+ }
1402+
13521403 function stopPeeking(): void {
13531404 peeking = null;
13541405 queueReadReceipt();
@@ -1998,6 +2049,7 @@
19982049 self={$sessionUser ?? null}
19992050 onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
20002051 onCloneSticker={openCloneStickerMenu}
2052+ onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
20012053 onReply={selectedRoom.membership === 'Joined' ? startReply : null}
20022054 onJump={jumpTo}
20032055 onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
Modified file: web/src/lib/MessageRow.svelte+12 additions
@@ -33,6 +33,7 @@
3333 export let self: string | null = null;
3434 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
3535 export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
36+ export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
3637
3738 function openUser(ev: MouseEvent, menu: boolean): void {
3839 if (!onUser) return;
@@ -305,6 +306,17 @@
305306 ✎
306307 </button>
307308 {/if}
309+ {#if onMore}
310+ <button
311+ type="button"
312+ title="More"
313+ aria-label={`More actions for message from ${displayName}`}
314+ aria-haspopup="menu"
315+ on:click|stopPropagation={(ev) => onMore?.(event, ev.clientX, ev.clientY)}
316+ >
317+ ⋯
318+ </button>
319+ {/if}
308320 </div>
309321 {#if pickerOpen}
310322 <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
Modified file: web/src/lib/permissions.ts+9 additions
@@ -1,6 +1,7 @@
11 export interface PowerLevels {
22 ban: number;
33 kick: number;
4+ redact: number;
45 events_default: number;
56 state_default: number;
67 users_default: number;
@@ -29,6 +30,7 @@ export function parsePowerLevels(raw: unknown): PowerLevels | null {
2930 return {
3031 ban: num(o, 'ban', 50),
3132 kick: num(o, 'kick', 50),
33+ redact: num(o, 'redact', 50),
3234 events_default: num(o, 'events_default', 0),
3335 state_default: num(o, 'state_default', 50),
3436 users_default: num(o, 'users_default', 0),
@@ -69,6 +71,13 @@ export function allowed(levels: PowerLevels, self: string, target: string): Allo
6971 };
7072 }
7173
74+/** Your own messages need only the redaction event level, anyone else's need `redact` too. */
75+export function canRedact(levels: PowerLevels, self: string, sender: string): boolean {
76+ const mine = levelOf(levels, self);
77+ const send = levels.events.get('m.room.redaction') ?? levels.events_default;
78+ return mine >= send && (self === sender || mine >= levels.redact);
79+}
80+
7281 export function roleName(level: number): string | null {
7382 if (level === Infinity) return 'Creator';
7483 if (level >= 100) return 'Admin';
Modified file: web/test/permissions.test.ts+8 additions
@@ -29,3 +29,11 @@ test('you can only act on people below you', () => {
2929 assert.equal(allowed(levels, '@admin:x', '@creator:x').ban, false);
3030 assert.equal(allowed(levels, '@creator:x', '@admin:x').setLevel, true);
3131 });
32+
33+test('anyone can delete their own messages but others need the redact level', async () => {
34+ const { canRedact } = await import('../src/lib/permissions.ts');
35+ assert.equal(canRedact(levels, '@someone:x', '@someone:x'), true);
36+ assert.equal(canRedact(levels, '@someone:x', '@mod:x'), false);
37+ assert.equal(canRedact(levels, '@mod:x', '@someone:x'), true);
38+ assert.equal(canRedact(levels, '@muted:x', '@muted:x'), false);
39+});