Allow users to delete their messages
No more than 30 minutes old
This commit is contained in:
parent
2daaec15fa
commit
9e26e9dc60
3 changed files with 76 additions and 16 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue