Commit 0380cd8c765c
tarball4 files changed+82-1
@@ -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}@@ -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
+ <button311
+ 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}>@@ -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';@@ -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
+});