From 9e26e9dc603254027166ad8618de0751c84fa056 Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 23 Jun 2026 22:53:31 +0200 Subject: [PATCH] Allow users to delete their messages No more than 30 minutes old --- src/lib/components/ChatSidebar.svelte | 30 ++++++++++++++++++++----- src/lib/moderation.svelte.ts | 30 ++++++++++++++++++++----- src/routes/thread/[id]/+page.svelte | 32 ++++++++++++++++++++++----- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/lib/components/ChatSidebar.svelte b/src/lib/components/ChatSidebar.svelte index 4bc7d1e..ca773ef 100644 --- a/src/lib/components/ChatSidebar.svelte +++ b/src/lib/components/ChatSidebar.svelte @@ -9,7 +9,10 @@ } from "$lib/chat.svelte"; import { auth, openLogin } from "$lib/auth.svelte"; import { isGroupAdmin } from "$lib/admins.svelte"; - import { requestDelete } from "$lib/moderation.svelte"; + import { + requestDelete, + withinSelfDeleteWindow, + } from "$lib/moderation.svelte"; import { withJoin, membershipOf, @@ -51,6 +54,13 @@ !!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id), ); + const isOwnMessage = (msg: ChatMessageData) => + auth.user?.pubkey === msg.pubkey; + + // Admins moderate anything; authors self-delete their own. + const canDeleteMessage = (msg: ChatMessageData) => + canModerate || isOwnMessage(msg); + // Sending needs membership regardless of flags. Only gate a confirmed guest; // while membership resolves the input stays (withJoin nets a stray send), so a // member never flashes "Join to chat". @@ -72,7 +82,13 @@ function requestDeleteMessage(msg: ChatMessageData) { openMenuId = null; requestDelete( - { eventId: msg.id, groupId: activeGroup.id, label: "message" }, + { + eventId: msg.id, + groupId: activeGroup.id, + label: "message", + self: !canModerate, + eventKind: 9, + }, () => removeChatMessage(msg.id), ); } @@ -383,15 +399,19 @@ class="w-full px-3 py-1.5 text-left hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-800" >Reply - {#if canModerate} + {#if canDeleteMessage(msg)} + {@const tooOld = + !canModerate && + !withinSelfDeleteWindow(msg.createdAt)} Delete{tooOld ? " (too old)" : ""} {/if} diff --git a/src/lib/moderation.svelte.ts b/src/lib/moderation.svelte.ts index 607a1cd..48b73d6 100644 --- a/src/lib/moderation.svelte.ts +++ b/src/lib/moderation.svelte.ts @@ -1,12 +1,24 @@ import { auth } from "$lib/auth.svelte"; import { publishForum } from "$lib/relay"; +// Users may delete their own posts only within this window; afterwards the relay +// also refuses (pyramid caps group self-deletes at 2h, we're stricter on top). +export const SELF_DELETE_WINDOW = 30 * 60; // seconds + +export function withinSelfDeleteWindow(createdAt: number): boolean { + return Math.floor(Date.now() / 1000) - createdAt <= SELF_DELETE_WINDOW; +} + // What's pending deletion, surfaced to the confirmation modal. `label` is the -// noun shown in the dialog copy ("discussion", "reply", "message"). +// noun shown in the dialog copy ("discussion", "reply", "message"). `self` picks +// the mechanism: authors delete via NIP-09 (kind 5), admins moderate via +// NIP-29 (kind 9005). `eventKind` feeds the NIP-09 `k` tag. type DeleteTarget = { eventId: string; groupId: string; label: string; + self?: boolean; + eventKind?: number; }; let target = $state(null); @@ -45,8 +57,9 @@ export function cancelDelete() { error = null; } -// NIP-29 kind:9005 delete-event. The relay enforces the role check and only -// resolves the publish (OK: true) once it has processed the deletion. +// Authors self-delete with NIP-09 (kind 5); admins moderate with NIP-29 +// (kind 9005). The relay enforces the matching rule (author match / role) and +// only resolves the publish (OK: true) once it has processed the deletion. export async function confirmDelete(reason?: string) { if (!target) return; if (!auth.signer) { @@ -56,21 +69,28 @@ export async function confirmDelete(reason?: string) { busy = true; error = null; + const kind = target.self ? 5 : 9005; const tags: string[][] = [ ["h", target.groupId], ["e", target.eventId], ]; + if (target.self && target.eventKind !== undefined) { + tags.push(["k", String(target.eventKind)]); + } try { const signed = await auth.signer.signEvent({ - kind: 9005, + kind, created_at: Math.floor(Date.now() / 1000), tags, content: reason?.trim() ?? "", }); const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error("Relay did not respond in time")), 8000), + setTimeout( + () => reject(new Error("Relay did not respond in time")), + 8000, + ), ); await Promise.race([Promise.all(publishForum(signed)), timeout]); diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index b1ae9e5..fe9b37a 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -12,7 +12,10 @@ } from "$lib/thread.svelte"; import { auth, openLogin } from "$lib/auth.svelte"; import { isGroupAdmin } from "$lib/admins.svelte"; - import { requestDelete } from "$lib/moderation.svelte"; + import { + requestDelete, + withinSelfDeleteWindow, + } from "$lib/moderation.svelte"; import { showToast } from "$lib/toast.svelte"; import { withJoin, @@ -142,6 +145,12 @@ !!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId), ); + const isOwnPost = (p: PostData) => auth.user?.pubkey === p.pubkey; + + // Whose delete a post can offer: admins moderate anything; authors self-delete + // their own (the button still shows when too old, just disabled). + const canDeletePost = (p: PostData) => canModerate || isOwnPost(p); + let openMenuId = $state(null); function toggleMenu(id: string, e: MouseEvent) { @@ -150,13 +159,21 @@ } // Deleting the OP removes the whole thread, so leave the page; a reply just - // disappears in place. + // disappears in place. Admins moderate (kind 9005); authors self-delete + // (kind 5) only inside the time window. function requestDeletePost(p: PostData, isOp: boolean) { if (!detail) return; const groupId = detail.groupId; + const self = !canModerate; openMenuId = null; requestDelete( - { eventId: p.id, groupId, label: isOp ? "discussion" : "reply" }, + { + eventId: p.id, + groupId, + label: isOp ? "discussion" : "reply", + self, + eventKind: isOp ? 11 : 1111, + }, () => { if (isOp) { showToast("Discussion deleted"); @@ -326,7 +343,9 @@ >
- {#if canModerate} + {#if canDeletePost(p)} + {@const tooOld = + !canModerate && !withinSelfDeleteWindow(p.createdAt)}
Delete{tooOld ? " (too old)" : ""}
{/if}