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

14
src/app.d.ts vendored
View file

@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -29,6 +29,18 @@ export const adminPubkeys = {
},
};
// Per-group admin check. NIP-29 roles are scoped to a group, so a delete
// button must gate on the post's own group, not the cross-room union above.
export function isGroupAdmin(
pubkey: string | undefined,
groupId: string | undefined,
): boolean {
if (!pubkey || !groupId) return false;
return MODE === "full"
? (byRoom[groupId]?.includes(pubkey) ?? false)
: (groupStore.data?.admins?.includes(pubkey) ?? false);
}
export async function loadRoomAdmins(roomIds: string[]) {
if (roomIds.length === 0) return;
const key = [...roomIds].sort().join(",");

View file

@ -34,6 +34,10 @@ export function getChatMessage(id: string): ChatMessageData | undefined {
return messages.find((m) => m.id === id);
}
export function removeChatMessage(id: string) {
messages = messages.filter((m) => m.id !== id);
}
async function loadProfile(pubkey: string) {
if (profiles[pubkey]) return;
try {

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}

View file

@ -14,19 +14,37 @@ let publishing = $state(false);
let publishError = $state<string | null>(null);
export const draftState = {
get modalOpen() { return modalOpen; },
get iconized() { return iconized; },
get title() { return title; },
set title(v: string) { title = v; },
get labels() { return labels; },
get content() { return content; },
set content(v: string) { content = v; },
get publishing() { return publishing; },
get publishError() { return publishError; },
get modalOpen() {
return modalOpen;
},
get iconized() {
return iconized;
},
get title() {
return title;
},
set title(v: string) {
title = v;
},
get labels() {
return labels;
},
get content() {
return content;
},
set content(v: string) {
content = v;
},
get publishing() {
return publishing;
},
get publishError() {
return publishError;
},
get hasDraft() {
return title.trim().length > 0 ||
content.trim().length > 0 ||
labels.length > 0;
return (
title.trim().length > 0 || content.trim().length > 0 || labels.length > 0
);
},
};
@ -71,7 +89,10 @@ export function removeLabel(l: string) {
labels = labels.filter((x) => x !== l);
}
export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }> {
export async function publishDraft(): Promise<{
ok: boolean;
threadId?: string;
}> {
if (!auth.signer) {
publishError = "Not logged in";
return { ok: false };
@ -93,7 +114,9 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
let threadId: string | undefined;
const ownPubkey = auth.user?.pubkey;
const mentionPubkeys = extractMentionPubkeys(c).filter((pk) => pk !== ownPubkey);
const mentionPubkeys = extractMentionPubkeys(c).filter(
(pk) => pk !== ownPubkey,
);
const hints = await buildPTagHints(mentionPubkeys);
try {
@ -118,7 +141,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
const pool = new SimplePool();
try {
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(pool.publish([RELAY_URL], event)),

View file

@ -128,7 +128,10 @@ async function publishJoinRequest(groupId: string, code?: string) {
const pool = new SimplePool();
try {
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(pool.publish([RELAY_URL], event)),

View file

@ -31,11 +31,11 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
try {
const decoded = nip19.decode(m[1]);
if (decoded.type === "note") {
if (!seen.has(decoded.data)) seen.set(decoded.data, { id: decoded.data });
if (!seen.has(decoded.data))
seen.set(decoded.data, { id: decoded.data });
} else if (decoded.type === "nevent") {
const { id, relays, author } = decoded.data;
if (!seen.has(id))
seen.set(id, { id, relay: relays?.[0], author });
if (!seen.has(id)) seen.set(id, { id, relay: relays?.[0], author });
}
} catch {
// Invalid bech32, skip
@ -44,7 +44,9 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
return [...seen.values()];
}
export async function relayHintFor(pubkey: string): Promise<string | undefined> {
export async function relayHintFor(
pubkey: string,
): Promise<string | undefined> {
try {
const TIMEOUT = Symbol();
const result = await Promise.race([
@ -60,7 +62,9 @@ export async function relayHintFor(pubkey: string): Promise<string | undefined>
}
}
export async function buildPTagHints(pubkeys: Iterable<string>): Promise<Map<string, string>> {
export async function buildPTagHints(
pubkeys: Iterable<string>,
): Promise<Map<string, string>> {
const hints = new Map<string, string>();
await Promise.all(
[...pubkeys].map(async (pk) => {

View file

@ -340,7 +340,10 @@ export const chatMessages: ChatMessage[] = [
content:
"Proin vitae ex iaculis, luctus elit in, fermentum turpis. Pellentesque sagittis congue quam.",
createdAt: "2025-01-15T13:05:00Z",
reactions: [{ emoji: "❤️", count: 2 }, { emoji: "🎉", count: 1 }],
reactions: [
{ emoji: "❤️", count: 2 },
{ emoji: "🎉", count: 1 },
],
},
{
id: "c4",

View file

@ -0,0 +1,97 @@
import { SimplePool } from "@nostr/tools";
import { RELAY_URL } from "$lib/config";
import { auth } from "$lib/auth.svelte";
// What's pending deletion, surfaced to the confirmation modal. `label` is the
// noun shown in the dialog copy ("discussion", "reply", "message").
type DeleteTarget = {
eventId: string;
groupId: string;
label: string;
};
let target = $state<DeleteTarget | null>(null);
let busy = $state(false);
let error = $state<string | null>(null);
// Kept out of reactive state on purpose: a closure stored in $state would be
// proxied, and the caller decides how to visually hide the event on success.
let onDeleted: (() => void) | null = null;
export const deleteState = {
get target() {
return target;
},
get open() {
return target !== null;
},
get busy() {
return busy;
},
get error() {
return error;
},
};
export function requestDelete(t: DeleteTarget, handler: () => void) {
target = t;
onDeleted = handler;
error = null;
}
export function cancelDelete() {
if (busy) return;
target = null;
onDeleted = null;
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.
export async function confirmDelete(reason?: string) {
if (!target) return;
if (!auth.signer) {
error = "Not logged in";
return;
}
busy = true;
error = null;
const tags: string[][] = [
["h", target.groupId],
["e", target.eventId],
];
try {
const signed = await auth.signer.signEvent({
kind: 9005,
created_at: Math.floor(Date.now() / 1000),
tags,
content: reason?.trim() ?? "",
});
const pool = new SimplePool();
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Relay did not respond in time")),
8000,
),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], signed)),
timeout,
]);
} finally {
pool.destroy();
}
onDeleted?.();
target = null;
onDeleted = null;
} catch (e) {
error = e instanceof Error ? e.message : "Failed to delete";
} finally {
busy = false;
}
}

View file

@ -129,7 +129,11 @@ export function searchLocalProfiles(
p.nip05?.toLowerCase().startsWith(ql)
? 1
: 0;
matches.push({ entry: p, rank: rankFor(p.pubkey, opts?.contextPubkeys), prefix });
matches.push({
entry: p,
rank: rankFor(p.pubkey, opts?.contextPubkeys),
prefix,
});
}
matches.sort((a, b) => {
if (a.prefix !== b.prefix) return b.prefix - a.prefix;
@ -230,10 +234,7 @@ async function doSeedProfiles(userPubkey: string | null) {
else unknown.push(pk);
}
const lastSync = parseInt(
localStorage.getItem(LAST_SYNC_KEY) ?? "0",
10,
);
const lastSync = parseInt(localStorage.getItem(LAST_SYNC_KEY) ?? "0", 10);
const fetches: Promise<Event[]>[] = [];
for (const batch of chunk(known, FETCH_BATCH_SIZE)) {
@ -264,10 +265,7 @@ async function doSeedProfiles(userPubkey: string | null) {
}
console.log(`[profiles] seeded ${count} kind:0 events into cache`);
localStorage.setItem(
LAST_SYNC_KEY,
String(Math.floor(Date.now() / 1000)),
);
localStorage.setItem(LAST_SYNC_KEY, String(Math.floor(Date.now() / 1000)));
} finally {
pool.close([RELAY_URL, ...PROFILE_RELAYS]);
}

View file

@ -32,8 +32,12 @@ let detail = $state<ThreadDetail | null>(null);
let profiles = $state<Record<string, NostrUser>>({});
export const threadDetailStore = {
get detail() { return detail; },
get profiles() { return profiles; },
get detail() {
return detail;
},
get profiles() {
return profiles;
},
};
async function loadProfile(pubkey: string) {
@ -52,15 +56,29 @@ function loadMockThread(id: string) {
title: t.title,
labels: t.tags.map((tag) => tag.label),
groupId: GROUP_ID,
op: { id: t.op.id, pubkey: t.op.author.pubkey, createdAt: toUnix(t.op.createdAt), content: t.op.content },
op: {
id: t.op.id,
pubkey: t.op.author.pubkey,
createdAt: toUnix(t.op.createdAt),
content: t.op.content,
},
replies: (t.op.replies ?? []).map((r) => ({
id: r.id, pubkey: r.author.pubkey, createdAt: toUnix(r.createdAt), content: r.content,
id: r.id,
pubkey: r.author.pubkey,
createdAt: toUnix(r.createdAt),
content: r.content,
})),
};
const allAuthors = [t.op.author, ...(t.op.replies ?? []).map((r) => r.author)];
const allAuthors = [
t.op.author,
...(t.op.replies ?? []).map((r) => r.author),
];
for (const a of allAuthors) {
profiles[a.pubkey] = {
pubkey: a.pubkey, npub: a.pubkey, shortName: a.name, image: a.picture,
pubkey: a.pubkey,
npub: a.pubkey,
shortName: a.name,
image: a.picture,
metadata: { name: a.name, picture: a.picture },
lastUpdated: 0,
} as NostrUser;
@ -71,7 +89,11 @@ export async function loadThread(id: string) {
detail = null;
profiles = {};
if (!isNostrId(id)) { await Promise.resolve(); loadMockThread(id); return; }
if (!isNostrId(id)) {
await Promise.resolve();
loadMockThread(id);
return;
}
const pool = new SimplePool();
try {
@ -110,6 +132,11 @@ export async function loadThread(id: string) {
}
}
export function removeReply(id: string) {
if (!detail) return;
detail = { ...detail, replies: detail.replies.filter((r) => r.id !== id) };
}
export async function sendReply(content: string, ownPubkey: string) {
if (!detail) throw new Error("No thread loaded");
if (!auth.signer) throw new Error("Not logged in");
@ -167,9 +194,15 @@ export async function sendReply(content: string, ownPubkey: string) {
const pool = new SimplePool();
try {
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(pool.publish([RELAY_URL], signed)), timeout]);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], signed)),
timeout,
]);
} finally {
pool.destroy();
}

23
src/lib/toast.svelte.ts Normal file
View file

@ -0,0 +1,23 @@
let message = $state<string | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
export const toastStore = {
get message() {
return message;
},
};
export function showToast(msg: string, duration = 3500) {
message = msg;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
message = null;
timer = null;
}, duration);
}
export function dismissToast() {
if (timer) clearTimeout(timer);
timer = null;
message = null;
}

View file

@ -9,6 +9,8 @@
import LoginModal from "$lib/components/LoginModal.svelte";
import JoinModal from "$lib/components/JoinModal.svelte";
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
import DeleteModal from "$lib/components/DeleteModal.svelte";
import Toast from "$lib/components/Toast.svelte";
import { page } from "$app/state";
import { onMount } from "svelte";
import { auth, restoreSession } from "$lib/auth.svelte";
@ -214,3 +216,5 @@
<LoginModal />
<JoinModal />
<NewDiscussionModal />
<DeleteModal />
<Toast />

View file

@ -1,14 +1,19 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import * as nip19 from "@nostr/tools/nip19";
import {
threadDetailStore,
loadThread,
sendReply,
removeReply,
type PostData,
} from "$lib/thread.svelte";
import { auth, openLogin } from "$lib/auth.svelte";
import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.svelte";
import { showToast } from "$lib/toast.svelte";
import { withJoin } from "$lib/join.svelte";
import { setActiveGroup } from "$lib/active.svelte";
import { RELAY_URL, MODE } from "$lib/config";
@ -106,6 +111,50 @@
const detail = $derived(threadDetailStore.detail);
const profiles = $derived(threadDetailStore.profiles);
const canModerate = $derived(
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
);
let openMenuId = $state<string | null>(null);
function toggleMenu(id: string, e: MouseEvent) {
e.stopPropagation();
openMenuId = openMenuId === id ? null : id;
}
// Deleting the OP removes the whole thread, so leave the page; a reply just
// disappears in place.
function requestDeletePost(p: PostData, isOp: boolean) {
if (!detail) return;
const groupId = detail.groupId;
openMenuId = null;
requestDelete(
{ eventId: p.id, groupId, label: isOp ? "discussion" : "reply" },
() => {
if (isOp) {
showToast("Discussion deleted");
goto(MODE === "full" ? `/room/${groupId}` : "/");
} else {
removeReply(p.id);
}
},
);
}
$effect(() => {
if (!openMenuId) return;
const close = () => (openMenuId = null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") openMenuId = null;
};
document.addEventListener("click", close);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("click", close);
document.removeEventListener("keydown", onKey);
};
});
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
const postEls = $derived([opEl, ...replyEls]);
const threadEventAuthors = $derived(
@ -244,13 +293,52 @@
<div class="flex-shrink-0 md:hidden">
{@render avatar(author)}
</div>
<span class="truncate font-medium text-neutral-400"
<span class="truncate font-medium text-neutral-500"
>{author.name}</span
>
</div>
<span class="text-sm text-neutral-400 ml-4 flex-shrink-0"
>{formatDate(p.createdAt)}</span
>
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
{#if canModerate}
<div class="relative">
<button
onclick={(e) => toggleMenu(p.id, e)}
class="flex items-center justify-center rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
aria-label="Post actions"
aria-haspopup="menu"
aria-expanded={openMenuId === p.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 === p.id}
<div
role="menu"
class="absolute right-0 top-7 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
>
<button
role="menuitem"
onclick={(e) => {
e.stopPropagation();
requestDeletePost(p, index === 0);
}}
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
>Delete</button
>
</div>
{/if}
</div>
{/if}
<span class="text-sm text-neutral-400">{formatDate(p.createdAt)}</span
>
</div>
</div>
<div data-quote-post-index={index} id="post-{p.id}" class="scroll-mt-32">
<PostContent content={p.content} {profiles} {threadEventAuthors} />