diff --git a/src/lib/components/MessageEditor.svelte b/src/lib/components/MessageEditor.svelte index feb743e..e24fc10 100644 --- a/src/lib/components/MessageEditor.svelte +++ b/src/lib/components/MessageEditor.svelte @@ -3,6 +3,12 @@ import { uploadImage } from "$lib/blossom"; import { BLOSSOM_URL } from "$lib/config"; import PostContent from "$lib/components/PostContent.svelte"; + import { + profileStore, + searchLocalProfiles, + searchRemoteProfiles, + type ProfileEntry, + } from "$lib/profiles.svelte"; type Props = { value: string; @@ -11,6 +17,7 @@ rows?: number; placeholder?: string; minHeightClass?: string; + contextPubkeys?: string[]; }; let { @@ -20,6 +27,7 @@ rows = 4, placeholder = "", minHeightClass = "", + contextPubkeys = [], }: Props = $props(); let textareaEl = $state(null); @@ -27,6 +35,70 @@ let uploadError = $state(null); let previewing = $state(false); + // Mention autocomplete state + let mentionQuery = $state(null); + let mentionStart = $state(0); + let mentionEnd = $state(0); + let mentionRemoteResults = $state([]); + let mentionIndex = $state(0); + let userMovedCursor = $state(false); + let anchorAbove = $state(false); + let remoteSearching = $state(false); + let remoteSearchTimer: ReturnType | null = null; + let remoteSearchAbort: AbortController | null = null; + let listboxEl = $state(null); + + const contextSet = $derived(new Set(contextPubkeys)); + + const mentionLocalResults = $derived.by(() => { + if (mentionQuery === null) return []; + if (mentionQuery === "") { + // Bare "@": only thread participants we know about, no global cache spill. + const out: ProfileEntry[] = []; + for (const pk of contextSet) { + const p = profileStore.profiles.get(pk); + if (p) out.push(p); + } + return out + .sort((a, b) => + (a.name ?? a.displayName ?? "").localeCompare( + b.name ?? b.displayName ?? "", + ), + ) + .slice(0, 8); + } + return searchLocalProfiles(mentionQuery, { + contextPubkeys: contextSet, + limit: 8, + }); + }); + + // Most-relevant first internally, reversed for display so the best match + // sits at the bottom (closest to the textarea when the dropdown opens above). + const mergedResults = $derived.by(() => { + const seen = new Set(); + const out: ProfileEntry[] = []; + for (const p of mentionLocalResults) { + if (seen.has(p.pubkey)) continue; + seen.add(p.pubkey); + out.push(p); + } + for (const p of mentionRemoteResults) { + if (seen.has(p.pubkey)) continue; + seen.add(p.pubkey); + out.push(p); + } + return out.slice(0, 8).reverse(); + }); + + const mentionOpen = $derived(mentionQuery !== null); + + const safeMentionIndex = $derived.by(() => { + if (mergedResults.length === 0) return 0; + if (!userMovedCursor) return mergedResults.length - 1; + return Math.min(mentionIndex, mergedResults.length - 1); + }); + export function focus() { textareaEl?.focus(); } @@ -70,6 +142,155 @@ input.value = ""; } } + + function detectMentionContext(text: string, cursor: number) { + const before = text.slice(0, cursor); + const m = before.match(/(?:^|\s)@([^\s@]*)$/); + if (!m) return null; + const query = m[1]; + return { + query, + start: cursor - query.length - 1, + end: cursor, + }; + } + + function scheduleRemoteSearch(query: string, localCount: number) { + if (remoteSearchTimer) clearTimeout(remoteSearchTimer); + remoteSearchAbort?.abort(); + if (!query || query.length < 1 || localCount >= 8) { + mentionRemoteResults = []; + remoteSearching = false; + return; + } + remoteSearching = true; + remoteSearchTimer = setTimeout(async () => { + const abort = new AbortController(); + remoteSearchAbort = abort; + const captured = query; + try { + const results = await searchRemoteProfiles(captured, abort.signal); + if (abort.signal.aborted) return; + if (mentionQuery !== captured) return; + mentionRemoteResults = results; + } finally { + if (mentionQuery === captured) remoteSearching = false; + } + }, 200); + } + + function closeMention() { + mentionQuery = null; + mentionRemoteResults = []; + mentionIndex = 0; + userMovedCursor = false; + remoteSearching = false; + if (remoteSearchTimer) clearTimeout(remoteSearchTimer); + remoteSearchAbort?.abort(); + } + + function updateMentionFromTextarea() { + const ta = textareaEl; + if (!ta) { + closeMention(); + return; + } + const ctx = detectMentionContext(ta.value, ta.selectionStart); + if (!ctx) { + closeMention(); + return; + } + const wasOpen = mentionQuery !== null; + if (ctx.query !== mentionQuery) { + mentionIndex = 0; + userMovedCursor = false; + mentionRemoteResults = []; + } + mentionQuery = ctx.query; + mentionStart = ctx.start; + mentionEnd = ctx.end; + scheduleRemoteSearch(ctx.query, mentionLocalResults.length); + if (!wasOpen) updateAnchor(); + } + + function updateAnchor() { + const ta = textareaEl; + if (!ta) return; + const rect = ta.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + anchorAbove = spaceBelow < 280 && spaceAbove > spaceBelow; + } + + async function selectMention(entry: ProfileEntry) { + const ta = textareaEl; + if (!ta || mentionQuery === null) return; + const before = value.slice(0, mentionStart); + const after = value.slice(mentionEnd); + const insertion = `nostr:${entry.npub}`; + const trailing = after.startsWith(" ") ? "" : " "; + value = before + insertion + trailing + after; + closeMention(); + await tick(); + const pos = (before + insertion + trailing).length; + ta.focus(); + ta.setSelectionRange(pos, pos); + } + + function profileLabel(p: ProfileEntry): string { + return p.name || p.displayName || p.nip05 || p.npub.slice(0, 12) + "…"; + } + + function profileSubLabel(p: ProfileEntry): string | null { + const main = profileLabel(p); + if (p.nip05 && p.nip05 !== main) return p.nip05; + if (p.displayName && p.displayName !== main) return p.displayName; + return null; + } + + function onTextareaInput() { + updateMentionFromTextarea(); + } + + function onTextareaKeydown(e: KeyboardEvent) { + if (mentionQuery === null) return; + if (e.key === "Escape") { + e.preventDefault(); + closeMention(); + return; + } + if (mergedResults.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + userMovedCursor = true; + mentionIndex = (safeMentionIndex + 1) % mergedResults.length; + } else if (e.key === "ArrowUp") { + e.preventDefault(); + userMovedCursor = true; + mentionIndex = + (safeMentionIndex - 1 + mergedResults.length) % mergedResults.length; + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + const entry = mergedResults[safeMentionIndex]; + if (entry) selectMention(entry); + } + } + + function onTextareaClickOrSelect() { + updateMentionFromTextarea(); + } + + function onTextareaBlur() { + setTimeout(() => closeMention(), 120); + } + + // Keep the highlighted (most-relevant) item visible as results change. + $effect(() => { + if (!listboxEl) return; + const idx = safeMentionIndex; + const items = listboxEl.querySelectorAll('[role="option"]'); + items[idx]?.scrollIntoView({ block: "nearest" }); + });
@@ -85,14 +306,110 @@ {/if}
{:else} - +
+ + {#if mentionOpen} +
+ {#if mentionQuery === ""} +
0} + class:border-gray-100={mergedResults.length > 0} + > + Type to search +
+ {:else if remoteSearching && mergedResults.length > 0} +
+ + Searching… +
+ {/if} + {#if mergedResults.length === 0 && mentionQuery !== ""} +
+ {#if remoteSearching} + + Searching… + {:else} + No matches + {/if} +
+ {:else if mergedResults.length > 0} +
    + {#each mergedResults as entry, i (entry.pubkey)} +
  • { + e.preventDefault(); + selectMention(entry); + }} + onmouseenter={() => { + userMovedCursor = true; + mentionIndex = i; + }} + > + {#if entry.picture} + + {:else} + + {/if} + + {profileLabel(entry)} + + {#if profileSubLabel(entry)} + + {profileSubLabel(entry)} + + {/if} +
  • + {/each} +
+ {/if} +
+ {/if} +
{/if}
(); +const groupMembers = new SvelteSet(); +const userFollows = new SvelteSet(); + +export const profileStore = { + get profiles() { + return profiles; + }, + get groupMembers() { + return groupMembers; + }, + get userFollows() { + return userFollows; + }, +}; + +function parseProfileEvent(evt: Event): ProfileEntry | null { + if (evt.kind !== 0) return null; + let md: { + name?: string; + display_name?: string; + nip05?: string; + picture?: string; + about?: string; + } = {}; + try { + md = JSON.parse(evt.content); + } catch { + // Ignore malformed metadata + } + return { + pubkey: evt.pubkey, + npub: nip19.npubEncode(evt.pubkey), + name: md.name, + displayName: md.display_name, + nip05: md.nip05, + picture: md.picture, + about: md.about, + fetchedAt: evt.created_at, + }; +} + +function upsertProfile(entry: ProfileEntry) { + const existing = profiles.get(entry.pubkey); + if (existing && existing.fetchedAt >= entry.fetchedAt) return; + profiles.set(entry.pubkey, entry); +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +function rankFor(pubkey: string, contextPubkeys?: Set): number { + if (contextPubkeys?.has(pubkey)) return 100; + if (groupMembers.has(pubkey)) return 50; + if (userFollows.has(pubkey)) return 10; + return 1; +} + +function profileMatchesQuery(p: ProfileEntry, q: string): boolean { + if (!q) return true; + const ql = q.toLowerCase(); + if (p.name?.toLowerCase().includes(ql)) return true; + if (p.displayName?.toLowerCase().includes(ql)) return true; + if (p.nip05?.toLowerCase().includes(ql)) return true; + return false; +} + +export type SearchOptions = { + contextPubkeys?: Set; + limit?: number; +}; + +export function searchLocalProfiles( + query: string, + opts?: SearchOptions, +): ProfileEntry[] { + const limit = opts?.limit ?? 8; + const q = query.trim(); + const matches: { entry: ProfileEntry; rank: number; prefix: number }[] = []; + for (const p of profiles.values()) { + if (!profileMatchesQuery(p, q)) continue; + const ql = q.toLowerCase(); + const prefix = + p.name?.toLowerCase().startsWith(ql) || + p.displayName?.toLowerCase().startsWith(ql) || + p.nip05?.toLowerCase().startsWith(ql) + ? 1 + : 0; + 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; + if (a.rank !== b.rank) return b.rank - a.rank; + const an = a.entry.name ?? a.entry.displayName ?? ""; + const bn = b.entry.name ?? b.entry.displayName ?? ""; + return an.localeCompare(bn); + }); + return matches.slice(0, limit).map((m) => m.entry); +} + +export async function searchRemoteProfiles( + query: string, + signal?: AbortSignal, +): Promise { + const q = query.trim(); + if (!q) return []; + const pool = new SimplePool(); + try { + const events = await pool.querySync( + SEARCH_RELAYS, + { kinds: [0], search: q, limit: 20 }, + { maxWait: 4000 }, + ); + if (signal?.aborted) return []; + const byPubkey = new Map(); + for (const evt of events) { + const entry = parseProfileEvent(evt); + if (!entry) continue; + const existing = byPubkey.get(entry.pubkey); + if (!existing || existing.fetchedAt < entry.fetchedAt) { + byPubkey.set(entry.pubkey, entry); + } + upsertProfile(entry); + } + return [...byPubkey.values()]; + } catch (e) { + console.error("[profiles] remote search failed", e); + return []; + } finally { + pool.close(SEARCH_RELAYS); + } +} + +let seedPromise: Promise | null = null; + +export function seedProfiles(userPubkey: string | null): Promise { + if (seedPromise) return seedPromise; + seedPromise = doSeedProfiles(userPubkey).catch((e) => { + console.error("[profiles] seed failed", e); + }); + return seedPromise; +} + +async function doSeedProfiles(userPubkey: string | null) { + const pool = new SimplePool(); + try { + // Group members live on the forum relay (NIP-29). + const groupEvents = await pool.querySync([RELAY_URL], { + kinds: [39002], + "#d": [GROUP_ID], + limit: 1, + }); + for (const evt of groupEvents) { + for (const t of evt.tags) { + if (t[0] === "p" && t[1]) groupMembers.add(t[1]); + } + } + + // Follow lists live on each user's outbox relays — query indexers. + if (userPubkey) { + const followEvents = await pool.querySync(PROFILE_RELAYS, { + kinds: [3], + authors: [userPubkey], + limit: 1, + }); + let latest: Event | null = null; + for (const evt of followEvents) { + if (!latest || evt.created_at > latest.created_at) latest = evt; + } + if (latest) { + for (const t of latest.tags) { + if (t[0] === "p" && t[1]) userFollows.add(t[1]); + } + } + } + + const seedSet = new Set([...groupMembers, ...userFollows]); + console.log( + `[profiles] seed: ${groupMembers.size} group members, ${userFollows.size} follows`, + ); + if (seedSet.size === 0) return; + + const known: string[] = []; + const unknown: string[] = []; + for (const pk of seedSet) { + if (profiles.has(pk)) known.push(pk); + else unknown.push(pk); + } + + const lastSync = parseInt( + localStorage.getItem(LAST_SYNC_KEY) ?? "0", + 10, + ); + + const fetches: Promise[] = []; + for (const batch of chunk(known, FETCH_BATCH_SIZE)) { + fetches.push( + pool.querySync(PROFILE_RELAYS, { + kinds: [0], + authors: batch, + since: lastSync, + }), + ); + } + for (const batch of chunk(unknown, FETCH_BATCH_SIZE)) { + fetches.push( + pool.querySync(PROFILE_RELAYS, { kinds: [0], authors: batch }), + ); + } + + const results = await Promise.all(fetches); + let count = 0; + for (const events of results) { + for (const evt of events) { + const entry = parseProfileEvent(evt); + if (entry) { + upsertProfile(entry); + count++; + } + } + } + console.log(`[profiles] seeded ${count} kind:0 events into cache`); + + localStorage.setItem( + LAST_SYNC_KEY, + String(Math.floor(Date.now() / 1000)), + ); + } finally { + pool.close([RELAY_URL, ...PROFILE_RELAYS]); + } +} + +const pendingFetch = new Set(); +let flushTimer: ReturnType | null = null; + +export function ensureProfile(pubkey: string) { + if (profiles.has(pubkey)) return; + pendingFetch.add(pubkey); + if (flushTimer) clearTimeout(flushTimer); + flushTimer = setTimeout(flushPendingFetch, 50); +} + +async function flushPendingFetch() { + flushTimer = null; + if (pendingFetch.size === 0) return; + const batch = Array.from(pendingFetch); + pendingFetch.clear(); + const pool = new SimplePool(); + try { + const events = await pool.querySync(PROFILE_RELAYS, { + kinds: [0], + authors: batch, + }); + for (const evt of events) { + const entry = parseProfileEvent(evt); + if (entry) upsertProfile(entry); + } + } catch (e) { + console.error("[profiles] lazy fetch failed", e); + } finally { + pool.close(PROFILE_RELAYS); + } +} + +export function ingestProfileEvent(evt: Event) { + const entry = parseProfileEvent(evt); + if (entry) upsertProfile(entry); +} + +export function ingestNostrUser(user: NostrUser) { + if (!user.pubkey) return; + const md = user.metadata ?? {}; + const entry: ProfileEntry = { + pubkey: user.pubkey, + npub: user.npub, + name: md.name, + displayName: md.display_name, + nip05: md.nip05, + picture: md.picture ?? user.image, + about: md.about, + fetchedAt: user.lastUpdated || 0, + }; + upsertProfile(entry); +} diff --git a/src/lib/thread.svelte.ts b/src/lib/thread.svelte.ts index dcf2924..13468d5 100644 --- a/src/lib/thread.svelte.ts +++ b/src/lib/thread.svelte.ts @@ -3,6 +3,7 @@ import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; import { RELAY_URL, GROUP_ID } from "$lib/config"; import { threads as mockThreads } from "$lib/mock"; import { auth } from "$lib/auth.svelte"; +import { ingestNostrUser } from "$lib/profiles.svelte"; const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id); @@ -33,6 +34,7 @@ async function loadProfile(pubkey: string) { if (profiles[pubkey]) return; const user = await loadNostrUser(pubkey); profiles[pubkey] = user; + ingestNostrUser(user); } function loadMockThread(id: string) { diff --git a/src/lib/threads.svelte.ts b/src/lib/threads.svelte.ts index 2e2db38..1a89f24 100644 --- a/src/lib/threads.svelte.ts +++ b/src/lib/threads.svelte.ts @@ -1,6 +1,7 @@ import { SimplePool } from "@nostr/tools"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; import { RELAY_URL, GROUP_ID } from "$lib/config"; +import { ingestNostrUser } from "$lib/profiles.svelte"; export type ThreadData = { id: string; @@ -26,6 +27,7 @@ async function loadProfile(pubkey: string) { if (profiles[pubkey]) return; const user = await loadNostrUser(pubkey); profiles[pubkey] = user; + ingestNostrUser(user); } export async function loadThreads() { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 34b01ef..9a621c7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,8 +9,9 @@ import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte"; import { page } from "$app/state"; import { onMount } from "svelte"; - import { restoreSession } from "$lib/auth.svelte"; + import { auth, restoreSession } from "$lib/auth.svelte"; import { loadGroup } from "$lib/group.svelte"; + import { seedProfiles } from "$lib/profiles.svelte"; import { MODE } from "$lib/config"; let { children } = $props(); @@ -18,9 +19,9 @@ const mode = MODE; const chatEnabled = true; - onMount(() => { - restoreSession(); - loadGroup(); + onMount(async () => { + await Promise.all([restoreSession(), loadGroup()]); + seedProfiles(auth.user?.pubkey ?? null); }); let chatExpanded = $state(false); diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index a6e87fa..59cd175 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -198,6 +198,7 @@ disabled={replying} rows={4} placeholder="Write a reply..." + contextPubkeys={allPosts.map((p) => p.pubkey)} />