Allow users to delete their messages

No more than 30 minutes old
This commit is contained in:
dtonon 2026-06-23 22:53:31 +02:00
parent 2daaec15fa
commit 9e26e9dc60
3 changed files with 76 additions and 16 deletions

View file

@ -9,7 +9,10 @@
} from "$lib/chat.svelte"; } from "$lib/chat.svelte";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { isGroupAdmin } from "$lib/admins.svelte"; import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.svelte"; import {
requestDelete,
withinSelfDeleteWindow,
} from "$lib/moderation.svelte";
import { import {
withJoin, withJoin,
membershipOf, membershipOf,
@ -51,6 +54,13 @@
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id), !!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; // Sending needs membership regardless of flags. Only gate a confirmed guest;
// while membership resolves the input stays (withJoin nets a stray send), so a // while membership resolves the input stays (withJoin nets a stray send), so a
// member never flashes "Join to chat". // member never flashes "Join to chat".
@ -72,7 +82,13 @@
function requestDeleteMessage(msg: ChatMessageData) { function requestDeleteMessage(msg: ChatMessageData) {
openMenuId = null; openMenuId = null;
requestDelete( requestDelete(
{ eventId: msg.id, groupId: activeGroup.id, label: "message" }, {
eventId: msg.id,
groupId: activeGroup.id,
label: "message",
self: !canModerate,
eventKind: 9,
},
() => removeChatMessage(msg.id), () => 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" class="w-full px-3 py-1.5 text-left hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-800"
>Reply</button >Reply</button
> >
{#if canModerate} {#if canDeleteMessage(msg)}
{@const tooOld =
!canModerate &&
!withinSelfDeleteWindow(msg.createdAt)}
<button <button
role="menuitem" role="menuitem"
disabled={tooOld}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
requestDeleteMessage(msg); requestDeleteMessage(msg);
}} }}
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 dark:hover:bg-neutral-800" class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:hover:bg-transparent dark:hover:bg-neutral-800 dark:disabled:text-neutral-600"
>Delete</button >Delete{tooOld ? " (too old)" : ""}</button
> >
{/if} {/if}
</div> </div>

View file

@ -1,12 +1,24 @@
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { publishForum } from "$lib/relay"; 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 // 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 = { type DeleteTarget = {
eventId: string; eventId: string;
groupId: string; groupId: string;
label: string; label: string;
self?: boolean;
eventKind?: number;
}; };
let target = $state<DeleteTarget | null>(null); let target = $state<DeleteTarget | null>(null);
@ -45,8 +57,9 @@ export function cancelDelete() {
error = null; error = null;
} }
// NIP-29 kind:9005 delete-event. The relay enforces the role check and only // Authors self-delete with NIP-09 (kind 5); admins moderate with NIP-29
// resolves the publish (OK: true) once it has processed the deletion. // (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) { export async function confirmDelete(reason?: string) {
if (!target) return; if (!target) return;
if (!auth.signer) { if (!auth.signer) {
@ -56,21 +69,28 @@ export async function confirmDelete(reason?: string) {
busy = true; busy = true;
error = null; error = null;
const kind = target.self ? 5 : 9005;
const tags: string[][] = [ const tags: string[][] = [
["h", target.groupId], ["h", target.groupId],
["e", target.eventId], ["e", target.eventId],
]; ];
if (target.self && target.eventKind !== undefined) {
tags.push(["k", String(target.eventKind)]);
}
try { try {
const signed = await auth.signer.signEvent({ const signed = await auth.signer.signEvent({
kind: 9005, kind,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
content: reason?.trim() ?? "", content: reason?.trim() ?? "",
}); });
const timeout = new Promise<never>((_, reject) => const timeout = new Promise<never>((_, 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]); await Promise.race([Promise.all(publishForum(signed)), timeout]);

View file

@ -12,7 +12,10 @@
} from "$lib/thread.svelte"; } from "$lib/thread.svelte";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { isGroupAdmin } from "$lib/admins.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 { showToast } from "$lib/toast.svelte";
import { import {
withJoin, withJoin,
@ -142,6 +145,12 @@
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId), !!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<string | null>(null); let openMenuId = $state<string | null>(null);
function toggleMenu(id: string, e: MouseEvent) { function toggleMenu(id: string, e: MouseEvent) {
@ -150,13 +159,21 @@
} }
// Deleting the OP removes the whole thread, so leave the page; a reply just // 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) { function requestDeletePost(p: PostData, isOp: boolean) {
if (!detail) return; if (!detail) return;
const groupId = detail.groupId; const groupId = detail.groupId;
const self = !canModerate;
openMenuId = null; openMenuId = null;
requestDelete( requestDelete(
{ eventId: p.id, groupId, label: isOp ? "discussion" : "reply" }, {
eventId: p.id,
groupId,
label: isOp ? "discussion" : "reply",
self,
eventKind: isOp ? 11 : 1111,
},
() => { () => {
if (isOp) { if (isOp) {
showToast("Discussion deleted"); showToast("Discussion deleted");
@ -326,7 +343,9 @@
> >
</div> </div>
<div class="ml-4 flex flex-shrink-0 items-center gap-1"> <div class="ml-4 flex flex-shrink-0 items-center gap-1">
{#if canModerate} {#if canDeletePost(p)}
{@const tooOld =
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
<div class="relative"> <div class="relative">
<button <button
onclick={(e) => toggleMenu(p.id, e)} onclick={(e) => toggleMenu(p.id, e)}
@ -353,12 +372,13 @@
> >
<button <button
role="menuitem" role="menuitem"
disabled={tooOld}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
requestDeletePost(p, index === 0); requestDeletePost(p, index === 0);
}} }}
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50" class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:hover:bg-transparent dark:disabled:text-neutral-600"
>Delete</button >Delete{tooOld ? " (too old)" : ""}</button
> >
</div> </div>
{/if} {/if}