diff --git a/eslint.config.js b/eslint.config.js index 50991ca..a608f06 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,44 +1,44 @@ -import prettier from 'eslint-config-prettier'; -import path from 'node:path'; -import { includeIgnoreFile } from '@eslint/compat'; -import js from '@eslint/js'; -import svelte from 'eslint-plugin-svelte'; -import { defineConfig } from 'eslint/config'; -import globals from 'globals'; -import ts from 'typescript-eslint'; -import svelteConfig from './svelte.config.js'; +import prettier from "eslint-config-prettier"; +import path from "node:path"; +import { includeIgnoreFile } from "@eslint/compat"; +import js from "@eslint/js"; +import svelte from "eslint-plugin-svelte"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import ts from "typescript-eslint"; +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( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ts.configs.recommended, - svelte.configs.recommended, - prettier, - svelte.configs.prettier, - { - languageOptions: { globals: { ...globals.browser, ...globals.node } }, - rules: { - // 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 - "no-undef": 'off' - } - }, - { - files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], - languageOptions: { - parserOptions: { - projectService: true, - extraFileExtensions: ['.svelte'], - parser: ts.parser, - svelteConfig - } - } - }, - { - // Override or add rule settings here, such as: - // 'svelte/button-has-type': 'error' - rules: {} - } + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ts.configs.recommended, + svelte.configs.recommended, + prettier, + svelte.configs.prettier, + { + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + // 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 + "no-undef": "off", + }, + }, + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: [".svelte"], + parser: ts.parser, + svelteConfig, + }, + }, + }, + { + // Override or add rule settings here, such as: + // 'svelte/button-has-type': 'error' + rules: {}, + }, ); diff --git a/package.json b/package.json index 7ae9eea..8327b54 100644 --- a/package.json +++ b/package.json @@ -1,46 +1,46 @@ { - "name": "squalk", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint .", - "format": "prettier --write ." - }, - "devDependencies": { - "@eslint/compat": "^2.0.4", - "@eslint/js": "^10.0.1", - "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.57.0", - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.2", - "@types/node": "^22", - "eslint": "^10.2.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.17.0", - "globals": "^17.4.0", - "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.5.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "svelte": "^5.55.2", - "svelte-check": "^4.4.6", - "tailwindcss": "^4.2.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.1", - "vite": "^8.0.7" - }, - "dependencies": { - "@djot/djot": "^0.3.2", - "@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8", - "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3" - } + "name": "squalk", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@eslint/compat": "^2.0.4", + "@eslint/js": "^10.0.1", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^22", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.17.0", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", + "vite": "^8.0.7" + }, + "dependencies": { + "@djot/djot": "^0.3.2", + "@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8", + "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3" + } } diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..520c421 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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 {}; diff --git a/src/app.html b/src/app.html index 6a2bb58..b6a3073 100644 --- a/src/app.html +++ b/src/app.html @@ -1,12 +1,12 @@ - - - - - %sveltekit.head% - - -
%sveltekit.body%
- + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts index 5377389..bbe78e4 100644 --- a/src/lib/admins.svelte.ts +++ b/src/lib/admins.svelte.ts @@ -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(","); diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 33343c7..fa96e3e 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -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 { diff --git a/src/lib/components/ChatSidebar.svelte b/src/lib/components/ChatSidebar.svelte index a4b3123..728b41f 100644 --- a/src/lib/components/ChatSidebar.svelte +++ b/src/lib/components/ChatSidebar.svelte @@ -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(null); let inputEl = $state(null); let openMenuId = $state(null); + let menuPos = $state<{ top?: number; bottom?: number; right: number } | null>( + null, + ); let replyTarget = $state(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); + }; }); @@ -188,7 +232,7 @@ No messages yet. {:else} -
+
{#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()} {/if} - {author.name} - {formatTime(msg.createdAt)} + {author.name} +
+
+ + {#if openMenuId === msg.id && menuPos} + + {/if} +
+ {formatTime(msg.createdAt)} +
{#if msg.replyToId} @@ -229,43 +323,6 @@

-
-
- - {#if openMenuId === msg.id} -
- -
- {/if} -
-
{/each} diff --git a/src/lib/components/DeleteModal.svelte b/src/lib/components/DeleteModal.svelte new file mode 100644 index 0000000..47cb14f --- /dev/null +++ b/src/lib/components/DeleteModal.svelte @@ -0,0 +1,96 @@ + + + + +{#if deleteState.open && deleteState.target} +
+ + +
+{/if} diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..190970c --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,27 @@ + + +{#if message} +
+
+ {message} + +
+
+{/if} diff --git a/src/lib/draft.svelte.ts b/src/lib/draft.svelte.ts index b4fb1ff..978fab7 100644 --- a/src/lib/draft.svelte.ts +++ b/src/lib/draft.svelte.ts @@ -14,19 +14,37 @@ let publishing = $state(false); let publishError = $state(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((_, 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)), diff --git a/src/lib/join.svelte.ts b/src/lib/join.svelte.ts index 2ea13fb..42b3b59 100644 --- a/src/lib/join.svelte.ts +++ b/src/lib/join.svelte.ts @@ -128,7 +128,10 @@ async function publishJoinRequest(groupId: string, code?: string) { const pool = new SimplePool(); try { const timeout = new Promise((_, 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)), diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index d20e7e8..11d7bb5 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -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 { +export async function relayHintFor( + pubkey: string, +): Promise { try { const TIMEOUT = Symbol(); const result = await Promise.race([ @@ -60,7 +62,9 @@ export async function relayHintFor(pubkey: string): Promise } } -export async function buildPTagHints(pubkeys: Iterable): Promise> { +export async function buildPTagHints( + pubkeys: Iterable, +): Promise> { const hints = new Map(); await Promise.all( [...pubkeys].map(async (pk) => { diff --git a/src/lib/mock.ts b/src/lib/mock.ts index 8cba9d0..58af0ae 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -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", diff --git a/src/lib/moderation.svelte.ts b/src/lib/moderation.svelte.ts new file mode 100644 index 0000000..5465331 --- /dev/null +++ b/src/lib/moderation.svelte.ts @@ -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(null); +let busy = $state(false); +let error = $state(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((_, 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; + } +} diff --git a/src/lib/profiles.svelte.ts b/src/lib/profiles.svelte.ts index 66436ac..ed879a3 100644 --- a/src/lib/profiles.svelte.ts +++ b/src/lib/profiles.svelte.ts @@ -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[] = []; 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]); } diff --git a/src/lib/thread.svelte.ts b/src/lib/thread.svelte.ts index 27fa538..22c25c4 100644 --- a/src/lib/thread.svelte.ts +++ b/src/lib/thread.svelte.ts @@ -32,8 +32,12 @@ let detail = $state(null); let profiles = $state>({}); 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((_, 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(); } diff --git a/src/lib/toast.svelte.ts b/src/lib/toast.svelte.ts new file mode 100644 index 0000000..ef1935d --- /dev/null +++ b/src/lib/toast.svelte.ts @@ -0,0 +1,23 @@ +let message = $state(null); +let timer: ReturnType | 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; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9306755..c82a3c2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@ + + diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index 1ea9738..e6bdc70 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -1,14 +1,19 @@