Allow admins to delete posts

This commit is contained in:
dtonon 2026-05-26 15:49:38 +01:00
parent 3bc2031866
commit 2f6ea767d1
21 changed files with 683 additions and 208 deletions

View file

@ -4,9 +4,12 @@
chatStore,
getChatMessage,
sendChatMessage,
removeChatMessage,
type ChatMessageData,
} from "$lib/chat.svelte";
import { auth, openLogin } from "$lib/auth.svelte";
import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.svelte";
import { withJoin } from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata";
@ -25,6 +28,9 @@
let listEl = $state<HTMLDivElement | null>(null);
let inputEl = $state<MentionAutocomplete | null>(null);
let openMenuId = $state<string | null>(null);
let menuPos = $state<{ top?: number; bottom?: number; right: number } | null>(
null,
);
let replyTarget = $state<ChatMessageData | null>(null);
let inputValue = $state("");
let sending = $state(false);
@ -33,6 +39,36 @@
const messages = $derived(chatStore.messages);
const profiles = $derived(chatStore.profiles);
const canModerate = $derived(
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id),
);
function requestDeleteMessage(msg: ChatMessageData) {
openMenuId = null;
requestDelete(
{ eventId: msg.id, groupId: activeGroup.id, label: "message" },
() => removeChatMessage(msg.id),
);
}
// The message list clips overflow, so an absolute menu gets cut off by the top
// bar or the input. Anchor it with fixed coords from the button instead,
// opening upward unless there isn't room above.
function toggleMenu(id: string, e: MouseEvent) {
e.stopPropagation();
if (openMenuId === id) {
openMenuId = null;
return;
}
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const right = window.innerWidth - r.right;
menuPos =
r.top > 96
? { bottom: window.innerHeight - r.top + 4, right }
: { top: r.bottom + 4, right };
openMenuId = id;
}
// Distinct authors of loaded messages — power the @ autocomplete context.
const contextPubkeys = $derived([...new Set(messages.map((m) => m.pubkey))]);
@ -111,6 +147,7 @@
function onListScroll() {
if (!listEl) return;
if (openMenuId) openMenuId = null;
const distance =
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
userScrolledUp = distance > 100;
@ -139,8 +176,15 @@
function closeMenu() {
openMenuId = null;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") openMenuId = null;
}
document.addEventListener("click", closeMenu);
return () => document.removeEventListener("click", closeMenu);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("click", closeMenu);
document.removeEventListener("keydown", onKey);
};
});
</script>
@ -188,7 +232,7 @@
No messages yet.
</div>
{:else}
<div class="mt-auto space-y-4 pb-4">
<div class="mt-auto space-y-5 pb-4">
{#each messages as msg (msg.id)}
{@const author = resolveAuthor(msg.pubkey)}
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
@ -208,10 +252,60 @@
{author.name[0].toUpperCase()}
</span>
{/if}
<span class="font-medium text-neutral-600">{author.name}</span>
<span class="ml-auto text-xs text-neutral-400"
>{formatTime(msg.createdAt)}</span
>
<span class="font-medium text-neutral-500">{author.name}</span>
<div class="ml-auto flex items-center gap-1">
<div class="relative">
<button
onclick={(e) => toggleMenu(msg.id, e)}
class="flex items-center justify-center rounded p-0.5 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
aria-label="Message actions"
aria-haspopup="menu"
aria-expanded={openMenuId === msg.id}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
</button>
{#if openMenuId === msg.id && menuPos}
<div
role="menu"
style={`${menuPos.top !== undefined ? `top:${menuPos.top}px` : `bottom:${menuPos.bottom}px`};right:${menuPos.right}px`}
class="fixed z-50 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
>
<button
role="menuitem"
onclick={(e) => {
e.stopPropagation();
startReply(msg);
}}
class="w-full px-3 py-1.5 text-left hover:bg-neutral-50"
>Reply</button
>
{#if canModerate}
<button
role="menuitem"
onclick={(e) => {
e.stopPropagation();
requestDeleteMessage(msg);
}}
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
>Delete</button
>
{/if}
</div>
{/if}
</div>
<span class="text-xs text-neutral-400"
>{formatTime(msg.createdAt)}</span
>
</div>
</div>
<div class="mt-0.5">
{#if msg.replyToId}
@ -229,43 +323,6 @@
<p class="leading-5 text-neutral-700">
<ChatContent content={msg.content} {profiles} />
</p>
<div class="mt-0.5 flex items-center gap-2">
<div class="relative ml-auto">
<button
onclick={(e) => {
e.stopPropagation();
openMenuId = openMenuId === msg.id ? null : msg.id;
}}
class="flex items-center justify-center rounded p-0.5 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
aria-label="Message actions"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
</button>
{#if openMenuId === msg.id}
<div
class="absolute right-0 bottom-6 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
>
<button
onclick={(e) => {
e.stopPropagation();
startReply(msg);
}}
class="w-full px-3 py-1.5 text-left hover:bg-neutral-50"
>Reply</button
>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { tick } from "svelte";
import {
deleteState,
cancelDelete,
confirmDelete,
} from "$lib/moderation.svelte";
let reason = $state("");
let reasonEl = $state<HTMLTextAreaElement | null>(null);
$effect(() => {
if (deleteState.open) {
tick().then(() => reasonEl?.focus());
} else {
reason = "";
}
});
function onKeydown(e: KeyboardEvent) {
if (!deleteState.open) return;
if (e.key === "Escape") cancelDelete();
}
async function onConfirm() {
await confirmDelete(reason);
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if deleteState.open && deleteState.target}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
aria-label="Close"
class="absolute inset-0 bg-black/40"
onclick={cancelDelete}
></button>
<div
class="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="delete-title"
>
<h2 id="delete-title" class="mb-2 text-lg font-semibold text-neutral-900">
Delete {deleteState.target.label}?
</h2>
<p class="mb-4 text-sm text-neutral-600">
This asks the relay to remove the {deleteState.target.label} for everyone.
It can't be undone.
</p>
{#if deleteState.error}
<div
class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
role="alert"
>
{deleteState.error}
</div>
{/if}
<label for="delete-reason" class="mb-1 block text-sm text-neutral-600"
>Reason (optional)</label
>
<textarea
id="delete-reason"
bind:this={reasonEl}
bind:value={reason}
disabled={deleteState.busy}
rows="2"
placeholder="Recorded with the deletion"
class="mb-4 w-full resize-none rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:ring-brand focus:outline-none disabled:opacity-50"
></textarea>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={cancelDelete}
disabled={deleteState.busy}
class="rounded px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onclick={onConfirm}
disabled={deleteState.busy}
class="rounded bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{deleteState.busy ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { toastStore, dismissToast } from "$lib/toast.svelte";
const message = $derived(toastStore.message);
</script>
{#if message}
<div
class="fixed bottom-6 left-1/2 z-60 -translate-x-1/2 px-4"
role="status"
aria-live="polite"
>
<div
class="flex items-center gap-3 rounded-lg bg-neutral-900 px-4 py-2.5 text-sm text-white shadow-xl"
>
<span>{message}</span>
<button
type="button"
onclick={dismissToast}
aria-label="Dismiss"
class="text-lg leading-none text-neutral-400 hover:text-white"
>
×
</button>
</div>
</div>
{/if}