From b5b2f97d356ba7d38447d3086459384638ddbd2b Mon Sep 17 00:00:00 2001 From: dtonon Date: Thu, 9 Apr 2026 16:28:01 +0100 Subject: [PATCH] Allow to post new discussions --- .env.example | 2 + src/lib/blossom.ts | 112 ++++++ src/lib/components/LeftSidebar.svelte | 12 + src/lib/components/NewDiscussionModal.svelte | 391 +++++++++++++++++++ src/lib/config.ts | 7 + src/lib/draft.svelte.ts | 125 ++++++ src/routes/+layout.svelte | 2 + src/routes/+page.svelte | 11 + src/routes/room/[slug]/+page.svelte | 11 + 9 files changed, 673 insertions(+) create mode 100644 src/lib/blossom.ts create mode 100644 src/lib/components/NewDiscussionModal.svelte create mode 100644 src/lib/draft.svelte.ts diff --git a/.env.example b/.env.example index 94541e9..95a0d92 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,5 @@ PUBLIC_RELAY_URL=ws://localhost:3334 PUBLIC_GROUP_ID=mygrouprandomid PUBLIC_MODE=simple # simple | full PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure +PUBLIC_LABELS= # comma-separated labels (e.g., bug,feature,question) +PUBLIC_BLOSSOM_URL= # Blossom server URL (e.g., https://blossom.primal.net) diff --git a/src/lib/blossom.ts b/src/lib/blossom.ts new file mode 100644 index 0000000..bc6f682 --- /dev/null +++ b/src/lib/blossom.ts @@ -0,0 +1,112 @@ +import { auth } from "$lib/auth.svelte"; +import { BLOSSOM_URL } from "$lib/config"; + +async function sha256Hex(data: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +// base64url-encode a UTF-8 string, the format expected by Blossom auth headers. +function b64url(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +export type BlobDescriptor = { + url: string; + sha256: string; + size: number; + type?: string; +}; + +export async function uploadImage(file: File): Promise { + if (!BLOSSOM_URL) throw new Error("Blossom server not configured"); + if (!auth.signer) throw new Error("Not logged in"); + + console.log("[blossom] hashing", file.name, file.size, file.type); + const buf = await file.arrayBuffer(); + const x = await sha256Hex(buf); + console.log("[blossom] sha256:", x); + + console.log("[blossom] signing auth event…"); + const signPromise = auth.signer.signEvent({ + kind: 24242, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["t", "upload"], + ["x", x], + ["expiration", String(Math.floor(Date.now() / 1000) + 300)], + ], + content: `Upload ${file.name}`, + }); + // Some extensions silently swallow signEvent if a popup is blocked or a + // previous prompt is still pending. Time out so the UI can recover. + const signTimeout = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + "Signer didn't respond — check your Nostr extension for a pending approval popup", + ), + ), + 60000, + ), + ); + const event = await Promise.race([signPromise, signTimeout]); + console.log("[blossom] auth event signed:", event.id); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + + console.log("[blossom] PUT", `${BLOSSOM_URL}/upload`); + let res: Response; + try { + res = await fetch(`${BLOSSOM_URL}/upload`, { + method: "PUT", + headers: { + Authorization: "Nostr " + b64url(JSON.stringify(event)), + "Content-Type": file.type || "application/octet-stream", + }, + body: buf, + signal: controller.signal, + }); + } catch (e) { + const err = e as Error; + console.error("[blossom] fetch failed:", err); + if (err.name === "AbortError") { + throw new Error(`Upload timed out — is ${BLOSSOM_URL} reachable?`); + } + // A bare TypeError from fetch means the request never completed: server + // unreachable, mixed content, or CORS preflight rejected. Browsers don't + // surface CORS rejections as a distinct error, so we hint at it. + if (err.name === "TypeError") { + throw new Error( + `Cannot reach ${BLOSSOM_URL}. Check that the server is running and that CORS allows PUT /upload from this origin.`, + ); + } + throw new Error(`Upload request failed: ${err.message}`); + } finally { + clearTimeout(timer); + } + + console.log("[blossom] response", res.status); + if (!res.ok) { + const reason = + res.headers.get("X-Reason") || + (await res.text().catch(() => "")) || + res.statusText; + throw new Error(`Upload failed (${res.status}): ${reason}`); + } + + try { + const blob = (await res.json()) as BlobDescriptor; + console.log("[blossom] uploaded:", blob.url); + return blob; + } catch { + throw new Error("Upload succeeded but response was not valid JSON"); + } +} diff --git a/src/lib/components/LeftSidebar.svelte b/src/lib/components/LeftSidebar.svelte index 591455f..4f19706 100644 --- a/src/lib/components/LeftSidebar.svelte +++ b/src/lib/components/LeftSidebar.svelte @@ -2,6 +2,7 @@ import { rooms } from "$lib/mock"; import { auth, openLogin, logout } from "$lib/auth.svelte"; import { groupStore } from "$lib/group.svelte"; + import { draftState, resumeDraft } from "$lib/draft.svelte"; type Props = { mode: "simple" | "full"; @@ -68,6 +69,16 @@ +
+ {#if draftState.iconized} + + {/if} {#if auth.user} {/if} +
diff --git a/src/lib/components/NewDiscussionModal.svelte b/src/lib/components/NewDiscussionModal.svelte new file mode 100644 index 0000000..73b3993 --- /dev/null +++ b/src/lib/components/NewDiscussionModal.svelte @@ -0,0 +1,391 @@ + + + + +{#if draftState.modalOpen} +
+ + +
+{/if} diff --git a/src/lib/config.ts b/src/lib/config.ts index d3a6825..fb37a76 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,6 +3,8 @@ import { PUBLIC_GROUP_ID, PUBLIC_MODE, PUBLIC_JOINCODE, + PUBLIC_LABELS, + PUBLIC_BLOSSOM_URL, } from "$env/static/public"; export const RELAY_URL = PUBLIC_RELAY_URL; @@ -10,3 +12,8 @@ export const GROUP_ID = PUBLIC_GROUP_ID; export const MODE: "simple" | "full" = PUBLIC_MODE === "full" ? "full" : "simple"; export const JOINCODE_REQUIRED = PUBLIC_JOINCODE === "yes"; +export const LABELS = (PUBLIC_LABELS ?? "") + .split(",") + .map((l) => l.trim()) + .filter(Boolean); +export const BLOSSOM_URL = (PUBLIC_BLOSSOM_URL ?? "").replace(/\/$/, ""); diff --git a/src/lib/draft.svelte.ts b/src/lib/draft.svelte.ts new file mode 100644 index 0000000..2edb4e2 --- /dev/null +++ b/src/lib/draft.svelte.ts @@ -0,0 +1,125 @@ +import { SimplePool } from "@nostr/tools"; +import { auth } from "$lib/auth.svelte"; +import { withJoin } from "$lib/join.svelte"; +import { GROUP_ID, RELAY_URL } from "$lib/config"; + +let modalOpen = $state(false); +let iconized = $state(false); +let title = $state(""); +let labels = $state([]); +let content = $state(""); +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 hasDraft() { + return title.trim().length > 0 || + content.trim().length > 0 || + labels.length > 0; + }, +}; + +export function openDraft() { + modalOpen = true; + iconized = false; + publishError = null; +} + +export function iconizeDraft() { + modalOpen = false; + iconized = true; +} + +export function resumeDraft() { + modalOpen = true; + iconized = false; +} + +export function discardDraft() { + title = ""; + labels = []; + content = ""; + publishError = null; + modalOpen = false; + iconized = false; +} + +export function addLabel(l: string) { + const v = l.trim(); + if (!v) return; + if (labels.includes(v)) return; + labels = [...labels, v]; +} + +export function removeLabel(l: string) { + labels = labels.filter((x) => x !== l); +} + +export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }> { + if (!auth.signer) { + publishError = "Not logged in"; + return { ok: false }; + } + const t = title.trim(); + const c = content.trim(); + if (!t || !c) { + publishError = "Title and content are required"; + return { ok: false }; + } + + publishing = true; + publishError = null; + let threadId: string | undefined; + + try { + const success = await withJoin(async () => { + const tags: string[][] = [ + ["h", GROUP_ID], + ["title", t], + ]; + for (const l of labels) tags.push(["t", l]); + + const event = await auth.signer!.signEvent({ + kind: 11, + created_at: Math.floor(Date.now() / 1000), + tags, + content: c, + }); + + 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], event)), + timeout, + ]); + } finally { + pool.destroy(); + } + + threadId = event.id; + }); + + if (success && threadId) { + discardDraft(); + return { ok: true, threadId }; + } + return { ok: false }; + } catch (e) { + publishError = e instanceof Error ? e.message : "Failed to publish"; + return { ok: false }; + } finally { + publishing = false; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 043c043..34b01ef 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import ChatSidebar from "$lib/components/ChatSidebar.svelte"; import LoginModal from "$lib/components/LoginModal.svelte"; import JoinModal from "$lib/components/JoinModal.svelte"; + import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte"; import { page } from "$app/state"; import { onMount } from "svelte"; import { restoreSession } from "$lib/auth.svelte"; @@ -50,3 +51,4 @@ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 66f3411..67c4c4f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,9 +10,19 @@ type Author, } from "$lib/components/ThreadItem.svelte"; import type { NostrUser } from "@nostr/gadgets/metadata"; + import { auth, openLogin } from "$lib/auth.svelte"; + import { openDraft } from "$lib/draft.svelte"; onMount(loadThreads); + function onNewTopic() { + if (!auth.user) { + openLogin(); + return; + } + openDraft(); + } + function relativeTime(ts: number): string { const diff = Math.floor(Date.now() / 1000) - ts; if (diff < 3600) return `${Math.floor(diff / 60)}m`; @@ -61,6 +71,7 @@

Discussions