From 5887a5705e45368f059fef344ebfd1009701ee73 Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 9 Jun 2026 20:00:44 +0100 Subject: [PATCH 01/15] Add PUBLIC_SEARCH config option --- .env.example | 1 + README.md | 1 + src/lib/config.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/.env.example b/.env.example index f618555..570c9e7 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ PUBLIC_MODE=simple # simple | full (single forum vs. many ro PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode) PUBLIC_TITLE= # top-bar title; empty falls back to the group name PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected +PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support) PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question) PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net) # Theme color overrides — quote the value, a leading # is read as a comment otherwise diff --git a/README.md b/README.md index 8d76132..f49aba9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Squalk is configured entirely through environment variables (all prefixed `PUBLI | `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. | | `PUBLIC_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. | | `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). | +| `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. | | `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. | | `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. | | `PUBLIC_ACCENT_COLOR` | no | `#e32a6d` | Override the accent (primary) color. Quote the value (`"#00ff00"`) — an unquoted leading `#` is read as a comment. The hover shade is derived automatically. | diff --git a/src/lib/config.ts b/src/lib/config.ts index 65e8e5b..bdaefeb 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -19,6 +19,8 @@ if (MODE === "simple" && !GROUP_ID) { throw new Error("PUBLIC_GROUP_ID is required in simple mode"); } export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes"; +// Requires a relay with NIP-50 support. +export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes"; export const LABELS = (env.PUBLIC_LABELS ?? "") .split(",") .map((l) => l.trim()) From 043be1d3fd5a786093270659d33376a7879a88d2 Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 9 Jun 2026 21:23:13 +0100 Subject: [PATCH 02/15] Add NIP-50 thread search helper --- src/lib/search.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/lib/search.ts diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..e32aa07 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,71 @@ +import type { Event } from "@nostr/tools/core"; +import type { Filter } from "@nostr/tools/filter"; +import { queryForum } from "$lib/relay"; +import { GROUP_ID, MODE } from "$lib/config"; +import { groupsStore } from "$lib/groups.svelte"; + +export type SearchResult = { + threadId: string; + title: string; + snippet: string; + matchKind: "thread" | "reply"; + createdAt: number; +}; + +function snippetOf(content: string): string { + const flat = content.replace(/\s+/g, " ").trim(); + return flat.length > 140 ? flat.slice(0, 140) + "…" : flat; +} + +// NIP-50 search over the forum relay: thread OPs (kind 11) and replies +// (kind 1111), deduped by thread. Simple mode scopes to the single group; +// full mode spans every visible room. +export async function searchThreads(query: string): Promise { + const filter: Filter = { kinds: [11, 1111], search: query, limit: 30 }; + if (MODE === "simple") filter["#h"] = [GROUP_ID]; + else if (groupsStore.list.length > 0) + filter["#h"] = groupsStore.list.map((g) => g.id); + + const events = await queryForum(filter, { label: "search" }); + + const byThread = new Map(); + const opless: SearchResult[] = []; + for (const e of events) { + if (e.kind === 11) { + const existing = byThread.get(e.id); + // An OP match wins over a reply match on the same thread + if (existing && existing.matchKind === "thread") continue; + byThread.set(e.id, { + threadId: e.id, + title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", + snippet: snippetOf(e.content), + matchKind: "thread", + createdAt: e.created_at, + }); + } else { + const root = e.tags.find((t) => t[0] === "E")?.[1]; + if (!root || byThread.has(root)) continue; + const r: SearchResult = { + threadId: root, + title: "", + snippet: snippetOf(e.content), + matchKind: "reply", + createdAt: e.created_at, + }; + byThread.set(root, r); + opless.push(r); + } + } + + // Backfill titles for reply-only matches; the group relay truncates + // multi-id queries, so fetch each OP on its own + await Promise.all( + opless.map(async (r) => { + const ops = await queryForum({ kinds: [11], ids: [r.threadId] }); + const op: Event | undefined = ops[0]; + r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)"; + }), + ); + + return [...byThread.values()].sort((a, b) => b.createdAt - a.createdAt); +} From a77b8a29c36bae42598a9b72442247dd109f50fc Mon Sep 17 00:00:00 2001 From: dtonon Date: Wed, 10 Jun 2026 12:47:22 +0100 Subject: [PATCH 03/15] Add search box with results dropdown to the homepage --- src/lib/components/SearchBox.svelte | 173 ++++++++++++++++++++++++++++ src/routes/+page.svelte | 9 +- 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/SearchBox.svelte diff --git a/src/lib/components/SearchBox.svelte b/src/lib/components/SearchBox.svelte new file mode 100644 index 0000000..67fcaa0 --- /dev/null +++ b/src/lib/components/SearchBox.svelte @@ -0,0 +1,173 @@ + + +
+
+ = 0 + ? `search-result-${activeIndex}` + : undefined} + aria-label="Search discussions" + autocomplete="off" + oninput={schedule} + onkeydown={onKeydown} + onfocus={onFocus} + onblur={onBlur} + class="w-full rounded-lg bg-neutral-100 py-2.5 pr-11 pl-4 text-neutral-800 placeholder-neutral-400 focus:ring-2 focus:ring-neutral-300 focus:outline-none dark:bg-neutral-800 dark:text-neutral-200 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" + /> + +
+ + {#if open} +
+ {#if searching} +
+ + Searching… +
+ {:else if results.length === 0} +
+ No results +
+ {/if} + {#each results as r, i (r.threadId)} + { + e.preventDefault(); + select(r); + }} + onmouseenter={() => (activeIndex = i)} + > + + {r.title} + {#if r.matchKind === "reply"} + reply + {/if} + + {#if r.snippet} + + {r.snippet} + + {/if} + + {/each} +
+ {/if} +
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6126a9c..bf8ca5b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,11 +1,12 @@ + +
+ -
{#if open} From f0c745998079ff7d145846b981436509ff0f6002 Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 16 Jun 2026 20:08:36 +0100 Subject: [PATCH 08/15] Wrap dark input defaults in @layer base so utilities can override --- src/routes/layout.css | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/routes/layout.css b/src/routes/layout.css index af5a9eb..b885fdd 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -31,18 +31,20 @@ body { display: none; } -/* Form inputs in dark mode — the forms plugin defaults to a white background, - which would punch holes through panels. Override at the element level so we - don't have to add `dark:bg-neutral-800` to every /