Allow admins to delete posts
This commit is contained in:
parent
3bc2031866
commit
2f6ea767d1
21 changed files with 683 additions and 208 deletions
|
|
@ -1,14 +1,14 @@
|
||||||
import prettier from 'eslint-config-prettier';
|
import prettier from "eslint-config-prettier";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import { includeIgnoreFile } from '@eslint/compat';
|
import { includeIgnoreFile } from "@eslint/compat";
|
||||||
import js from '@eslint/js';
|
import js from "@eslint/js";
|
||||||
import svelte from 'eslint-plugin-svelte';
|
import svelte from "eslint-plugin-svelte";
|
||||||
import { defineConfig } from 'eslint/config';
|
import { defineConfig } from "eslint/config";
|
||||||
import globals from 'globals';
|
import globals from "globals";
|
||||||
import ts from 'typescript-eslint';
|
import ts from "typescript-eslint";
|
||||||
import svelteConfig from './svelte.config.js';
|
import svelteConfig from "./svelte.config.js";
|
||||||
|
|
||||||
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
|
|
@ -22,23 +22,23 @@ export default defineConfig(
|
||||||
rules: {
|
rules: {
|
||||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
"no-undef": 'off'
|
"no-undef": "off",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
projectService: true,
|
||||||
extraFileExtensions: ['.svelte'],
|
extraFileExtensions: [".svelte"],
|
||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig
|
svelteConfig,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Override or add rule settings here, such as:
|
// Override or add rule settings here, such as:
|
||||||
// 'svelte/button-has-type': 'error'
|
// 'svelte/button-has-type': 'error'
|
||||||
rules: {}
|
rules: {},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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[]) {
|
export async function loadRoomAdmins(roomIds: string[]) {
|
||||||
if (roomIds.length === 0) return;
|
if (roomIds.length === 0) return;
|
||||||
const key = [...roomIds].sort().join(",");
|
const key = [...roomIds].sort().join(",");
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ export function getChatMessage(id: string): ChatMessageData | undefined {
|
||||||
return messages.find((m) => m.id === id);
|
return messages.find((m) => m.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeChatMessage(id: string) {
|
||||||
|
messages = messages.filter((m) => m.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProfile(pubkey: string) {
|
async function loadProfile(pubkey: string) {
|
||||||
if (profiles[pubkey]) return;
|
if (profiles[pubkey]) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@
|
||||||
chatStore,
|
chatStore,
|
||||||
getChatMessage,
|
getChatMessage,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
|
removeChatMessage,
|
||||||
type ChatMessageData,
|
type ChatMessageData,
|
||||||
} 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 { requestDelete } from "$lib/moderation.svelte";
|
||||||
import { withJoin } from "$lib/join.svelte";
|
import { withJoin } from "$lib/join.svelte";
|
||||||
import { activeGroup } from "$lib/active.svelte";
|
import { activeGroup } from "$lib/active.svelte";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
@ -25,6 +28,9 @@
|
||||||
let listEl = $state<HTMLDivElement | null>(null);
|
let listEl = $state<HTMLDivElement | null>(null);
|
||||||
let inputEl = $state<MentionAutocomplete | null>(null);
|
let inputEl = $state<MentionAutocomplete | null>(null);
|
||||||
let openMenuId = $state<string | 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 replyTarget = $state<ChatMessageData | null>(null);
|
||||||
let inputValue = $state("");
|
let inputValue = $state("");
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
|
|
@ -33,6 +39,36 @@
|
||||||
const messages = $derived(chatStore.messages);
|
const messages = $derived(chatStore.messages);
|
||||||
const profiles = $derived(chatStore.profiles);
|
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.
|
// Distinct authors of loaded messages — power the @ autocomplete context.
|
||||||
const contextPubkeys = $derived([...new Set(messages.map((m) => m.pubkey))]);
|
const contextPubkeys = $derived([...new Set(messages.map((m) => m.pubkey))]);
|
||||||
|
|
||||||
|
|
@ -111,6 +147,7 @@
|
||||||
|
|
||||||
function onListScroll() {
|
function onListScroll() {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
if (openMenuId) openMenuId = null;
|
||||||
const distance =
|
const distance =
|
||||||
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
|
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
|
||||||
userScrolledUp = distance > 100;
|
userScrolledUp = distance > 100;
|
||||||
|
|
@ -139,8 +176,15 @@
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
openMenuId = null;
|
openMenuId = null;
|
||||||
}
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") openMenuId = null;
|
||||||
|
}
|
||||||
document.addEventListener("click", closeMenu);
|
document.addEventListener("click", closeMenu);
|
||||||
return () => document.removeEventListener("click", closeMenu);
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", closeMenu);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -188,7 +232,7 @@
|
||||||
No messages yet.
|
No messages yet.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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)}
|
{#each messages as msg (msg.id)}
|
||||||
{@const author = resolveAuthor(msg.pubkey)}
|
{@const author = resolveAuthor(msg.pubkey)}
|
||||||
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
|
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
|
||||||
|
|
@ -208,11 +252,61 @@
|
||||||
{author.name[0].toUpperCase()}
|
{author.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-medium text-neutral-600">{author.name}</span>
|
<span class="font-medium text-neutral-500">{author.name}</span>
|
||||||
<span class="ml-auto text-xs text-neutral-400"
|
<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
|
>{formatTime(msg.createdAt)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-0.5">
|
<div class="mt-0.5">
|
||||||
{#if msg.replyToId}
|
{#if msg.replyToId}
|
||||||
<div
|
<div
|
||||||
|
|
@ -229,43 +323,6 @@
|
||||||
<p class="leading-5 text-neutral-700">
|
<p class="leading-5 text-neutral-700">
|
||||||
<ChatContent content={msg.content} {profiles} />
|
<ChatContent content={msg.content} {profiles} />
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
96
src/lib/components/DeleteModal.svelte
Normal file
96
src/lib/components/DeleteModal.svelte
Normal 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}
|
||||||
27
src/lib/components/Toast.svelte
Normal file
27
src/lib/components/Toast.svelte
Normal 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}
|
||||||
|
|
@ -14,19 +14,37 @@ let publishing = $state(false);
|
||||||
let publishError = $state<string | null>(null);
|
let publishError = $state<string | null>(null);
|
||||||
|
|
||||||
export const draftState = {
|
export const draftState = {
|
||||||
get modalOpen() { return modalOpen; },
|
get modalOpen() {
|
||||||
get iconized() { return iconized; },
|
return modalOpen;
|
||||||
get title() { return title; },
|
},
|
||||||
set title(v: string) { title = v; },
|
get iconized() {
|
||||||
get labels() { return labels; },
|
return iconized;
|
||||||
get content() { return content; },
|
},
|
||||||
set content(v: string) { content = v; },
|
get title() {
|
||||||
get publishing() { return publishing; },
|
return title;
|
||||||
get publishError() { return publishError; },
|
},
|
||||||
|
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() {
|
get hasDraft() {
|
||||||
return title.trim().length > 0 ||
|
return (
|
||||||
content.trim().length > 0 ||
|
title.trim().length > 0 || content.trim().length > 0 || labels.length > 0
|
||||||
labels.length > 0;
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -71,7 +89,10 @@ export function removeLabel(l: string) {
|
||||||
labels = labels.filter((x) => x !== l);
|
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) {
|
if (!auth.signer) {
|
||||||
publishError = "Not logged in";
|
publishError = "Not logged in";
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
|
|
@ -93,7 +114,9 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
|
||||||
let threadId: string | undefined;
|
let threadId: string | undefined;
|
||||||
|
|
||||||
const ownPubkey = auth.user?.pubkey;
|
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);
|
const hints = await buildPTagHints(mentionPubkeys);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,7 +141,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
try {
|
try {
|
||||||
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([
|
await Promise.race([
|
||||||
Promise.all(pool.publish([RELAY_URL], event)),
|
Promise.all(pool.publish([RELAY_URL], event)),
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,10 @@ async function publishJoinRequest(groupId: string, code?: string) {
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
try {
|
try {
|
||||||
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([
|
await Promise.race([
|
||||||
Promise.all(pool.publish([RELAY_URL], event)),
|
Promise.all(pool.publish([RELAY_URL], event)),
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,11 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
|
||||||
try {
|
try {
|
||||||
const decoded = nip19.decode(m[1]);
|
const decoded = nip19.decode(m[1]);
|
||||||
if (decoded.type === "note") {
|
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") {
|
} else if (decoded.type === "nevent") {
|
||||||
const { id, relays, author } = decoded.data;
|
const { id, relays, author } = decoded.data;
|
||||||
if (!seen.has(id))
|
if (!seen.has(id)) seen.set(id, { id, relay: relays?.[0], author });
|
||||||
seen.set(id, { id, relay: relays?.[0], author });
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid bech32, skip
|
// Invalid bech32, skip
|
||||||
|
|
@ -44,7 +44,9 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
|
||||||
return [...seen.values()];
|
return [...seen.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function relayHintFor(pubkey: string): Promise<string | undefined> {
|
export async function relayHintFor(
|
||||||
|
pubkey: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const TIMEOUT = Symbol();
|
const TIMEOUT = Symbol();
|
||||||
const result = await Promise.race([
|
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>();
|
const hints = new Map<string, string>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...pubkeys].map(async (pk) => {
|
[...pubkeys].map(async (pk) => {
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,10 @@ export const chatMessages: ChatMessage[] = [
|
||||||
content:
|
content:
|
||||||
"Proin vitae ex iaculis, luctus elit in, fermentum turpis. Pellentesque sagittis congue quam.",
|
"Proin vitae ex iaculis, luctus elit in, fermentum turpis. Pellentesque sagittis congue quam.",
|
||||||
createdAt: "2025-01-15T13:05:00Z",
|
createdAt: "2025-01-15T13:05:00Z",
|
||||||
reactions: [{ emoji: "❤️", count: 2 }, { emoji: "🎉", count: 1 }],
|
reactions: [
|
||||||
|
{ emoji: "❤️", count: 2 },
|
||||||
|
{ emoji: "🎉", count: 1 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "c4",
|
id: "c4",
|
||||||
|
|
|
||||||
97
src/lib/moderation.svelte.ts
Normal file
97
src/lib/moderation.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,7 +129,11 @@ export function searchLocalProfiles(
|
||||||
p.nip05?.toLowerCase().startsWith(ql)
|
p.nip05?.toLowerCase().startsWith(ql)
|
||||||
? 1
|
? 1
|
||||||
: 0;
|
: 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) => {
|
matches.sort((a, b) => {
|
||||||
if (a.prefix !== b.prefix) return b.prefix - a.prefix;
|
if (a.prefix !== b.prefix) return b.prefix - a.prefix;
|
||||||
|
|
@ -230,10 +234,7 @@ async function doSeedProfiles(userPubkey: string | null) {
|
||||||
else unknown.push(pk);
|
else unknown.push(pk);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSync = parseInt(
|
const lastSync = parseInt(localStorage.getItem(LAST_SYNC_KEY) ?? "0", 10);
|
||||||
localStorage.getItem(LAST_SYNC_KEY) ?? "0",
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetches: Promise<Event[]>[] = [];
|
const fetches: Promise<Event[]>[] = [];
|
||||||
for (const batch of chunk(known, FETCH_BATCH_SIZE)) {
|
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`);
|
console.log(`[profiles] seeded ${count} kind:0 events into cache`);
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(LAST_SYNC_KEY, String(Math.floor(Date.now() / 1000)));
|
||||||
LAST_SYNC_KEY,
|
|
||||||
String(Math.floor(Date.now() / 1000)),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
pool.close([RELAY_URL, ...PROFILE_RELAYS]);
|
pool.close([RELAY_URL, ...PROFILE_RELAYS]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,12 @@ let detail = $state<ThreadDetail | null>(null);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
|
|
||||||
export const threadDetailStore = {
|
export const threadDetailStore = {
|
||||||
get detail() { return detail; },
|
get detail() {
|
||||||
get profiles() { return profiles; },
|
return detail;
|
||||||
|
},
|
||||||
|
get profiles() {
|
||||||
|
return profiles;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadProfile(pubkey: string) {
|
async function loadProfile(pubkey: string) {
|
||||||
|
|
@ -52,15 +56,29 @@ function loadMockThread(id: string) {
|
||||||
title: t.title,
|
title: t.title,
|
||||||
labels: t.tags.map((tag) => tag.label),
|
labels: t.tags.map((tag) => tag.label),
|
||||||
groupId: GROUP_ID,
|
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) => ({
|
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) {
|
for (const a of allAuthors) {
|
||||||
profiles[a.pubkey] = {
|
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 },
|
metadata: { name: a.name, picture: a.picture },
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
} as NostrUser;
|
} as NostrUser;
|
||||||
|
|
@ -71,7 +89,11 @@ export async function loadThread(id: string) {
|
||||||
detail = null;
|
detail = null;
|
||||||
profiles = {};
|
profiles = {};
|
||||||
|
|
||||||
if (!isNostrId(id)) { await Promise.resolve(); loadMockThread(id); return; }
|
if (!isNostrId(id)) {
|
||||||
|
await Promise.resolve();
|
||||||
|
loadMockThread(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
try {
|
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) {
|
export async function sendReply(content: string, ownPubkey: string) {
|
||||||
if (!detail) throw new Error("No thread loaded");
|
if (!detail) throw new Error("No thread loaded");
|
||||||
if (!auth.signer) throw new Error("Not logged in");
|
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();
|
const pool = new SimplePool();
|
||||||
try {
|
try {
|
||||||
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(pool.publish([RELAY_URL], signed)), timeout]);
|
await Promise.race([
|
||||||
|
Promise.all(pool.publish([RELAY_URL], signed)),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
pool.destroy();
|
pool.destroy();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
src/lib/toast.svelte.ts
Normal file
23
src/lib/toast.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
import LoginModal from "$lib/components/LoginModal.svelte";
|
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||||
import JoinModal from "$lib/components/JoinModal.svelte";
|
import JoinModal from "$lib/components/JoinModal.svelte";
|
||||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.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 { page } from "$app/state";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||||
|
|
@ -214,3 +216,5 @@
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
<JoinModal />
|
<JoinModal />
|
||||||
<NewDiscussionModal />
|
<NewDiscussionModal />
|
||||||
|
<DeleteModal />
|
||||||
|
<Toast />
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import {
|
import {
|
||||||
threadDetailStore,
|
threadDetailStore,
|
||||||
loadThread,
|
loadThread,
|
||||||
sendReply,
|
sendReply,
|
||||||
|
removeReply,
|
||||||
type PostData,
|
type PostData,
|
||||||
} 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 { requestDelete } from "$lib/moderation.svelte";
|
||||||
|
import { showToast } from "$lib/toast.svelte";
|
||||||
import { withJoin } from "$lib/join.svelte";
|
import { withJoin } from "$lib/join.svelte";
|
||||||
import { setActiveGroup } from "$lib/active.svelte";
|
import { setActiveGroup } from "$lib/active.svelte";
|
||||||
import { RELAY_URL, MODE } from "$lib/config";
|
import { RELAY_URL, MODE } from "$lib/config";
|
||||||
|
|
@ -106,6 +111,50 @@
|
||||||
const detail = $derived(threadDetailStore.detail);
|
const detail = $derived(threadDetailStore.detail);
|
||||||
const profiles = $derived(threadDetailStore.profiles);
|
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 allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
||||||
const postEls = $derived([opEl, ...replyEls]);
|
const postEls = $derived([opEl, ...replyEls]);
|
||||||
const threadEventAuthors = $derived(
|
const threadEventAuthors = $derived(
|
||||||
|
|
@ -244,13 +293,52 @@
|
||||||
<div class="flex-shrink-0 md:hidden">
|
<div class="flex-shrink-0 md:hidden">
|
||||||
{@render avatar(author)}
|
{@render avatar(author)}
|
||||||
</div>
|
</div>
|
||||||
<span class="truncate font-medium text-neutral-400"
|
<span class="truncate font-medium text-neutral-500"
|
||||||
>{author.name}</span
|
>{author.name}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-neutral-400 ml-4 flex-shrink-0"
|
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
||||||
>{formatDate(p.createdAt)}</span
|
{#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>
|
||||||
<div data-quote-post-index={index} id="post-{p.id}" class="scroll-mt-32">
|
<div data-quote-post-index={index} id="post-{p.id}" class="scroll-mt-32">
|
||||||
<PostContent content={p.content} {profiles} {threadEventAuthors} />
|
<PostContent content={p.content} {profiles} {threadEventAuthors} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue