Add users mention with search and autocomplete
This commit is contained in:
parent
1ff1c225c5
commit
be7c5a38ba
6 changed files with 656 additions and 12 deletions
|
|
@ -3,6 +3,12 @@
|
||||||
import { uploadImage } from "$lib/blossom";
|
import { uploadImage } from "$lib/blossom";
|
||||||
import { BLOSSOM_URL } from "$lib/config";
|
import { BLOSSOM_URL } from "$lib/config";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
|
import {
|
||||||
|
profileStore,
|
||||||
|
searchLocalProfiles,
|
||||||
|
searchRemoteProfiles,
|
||||||
|
type ProfileEntry,
|
||||||
|
} from "$lib/profiles.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -11,6 +17,7 @@
|
||||||
rows?: number;
|
rows?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minHeightClass?: string;
|
minHeightClass?: string;
|
||||||
|
contextPubkeys?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -20,6 +27,7 @@
|
||||||
rows = 4,
|
rows = 4,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
minHeightClass = "",
|
minHeightClass = "",
|
||||||
|
contextPubkeys = [],
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
||||||
|
|
@ -27,6 +35,70 @@
|
||||||
let uploadError = $state<string | null>(null);
|
let uploadError = $state<string | null>(null);
|
||||||
let previewing = $state(false);
|
let previewing = $state(false);
|
||||||
|
|
||||||
|
// Mention autocomplete state
|
||||||
|
let mentionQuery = $state<string | null>(null);
|
||||||
|
let mentionStart = $state(0);
|
||||||
|
let mentionEnd = $state(0);
|
||||||
|
let mentionRemoteResults = $state<ProfileEntry[]>([]);
|
||||||
|
let mentionIndex = $state(0);
|
||||||
|
let userMovedCursor = $state(false);
|
||||||
|
let anchorAbove = $state(false);
|
||||||
|
let remoteSearching = $state(false);
|
||||||
|
let remoteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let remoteSearchAbort: AbortController | null = null;
|
||||||
|
let listboxEl = $state<HTMLDivElement | null>(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<string>();
|
||||||
|
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() {
|
export function focus() {
|
||||||
textareaEl?.focus();
|
textareaEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +142,155 @@
|
||||||
input.value = "";
|
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<HTMLElement>('[role="option"]');
|
||||||
|
items[idx]?.scrollIntoView({ block: "nearest" });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col {previewing ? 'flex-1 min-h-0' : ''}">
|
<div class="flex flex-col {previewing ? 'flex-1 min-h-0' : ''}">
|
||||||
|
|
@ -85,14 +306,110 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<textarea
|
<div class="relative">
|
||||||
bind:this={textareaEl}
|
<textarea
|
||||||
bind:value
|
bind:this={textareaEl}
|
||||||
{disabled}
|
bind:value
|
||||||
{rows}
|
{disabled}
|
||||||
{placeholder}
|
{rows}
|
||||||
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none {minHeightClass}"
|
{placeholder}
|
||||||
></textarea>
|
oninput={onTextareaInput}
|
||||||
|
onkeydown={onTextareaKeydown}
|
||||||
|
onclick={onTextareaClickOrSelect}
|
||||||
|
onkeyup={onTextareaClickOrSelect}
|
||||||
|
onblur={onTextareaBlur}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls={mentionOpen ? "mention-listbox" : undefined}
|
||||||
|
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none {minHeightClass}"
|
||||||
|
></textarea>
|
||||||
|
{#if mentionOpen}
|
||||||
|
<div
|
||||||
|
id="mention-listbox"
|
||||||
|
bind:this={listboxEl}
|
||||||
|
class="absolute z-30 left-0 right-0 max-h-72 overflow-auto rounded border border-gray-200 bg-white shadow-lg"
|
||||||
|
class:bottom-full={anchorAbove}
|
||||||
|
class:mb-1={anchorAbove}
|
||||||
|
class:top-full={!anchorAbove}
|
||||||
|
class:mt-1={!anchorAbove}
|
||||||
|
>
|
||||||
|
{#if mentionQuery === ""}
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-xs text-gray-400"
|
||||||
|
class:border-b={mergedResults.length > 0}
|
||||||
|
class:border-gray-100={mergedResults.length > 0}
|
||||||
|
>
|
||||||
|
Type to search
|
||||||
|
</div>
|
||||||
|
{:else if remoteSearching && mergedResults.length > 0}
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-xs text-gray-400 flex items-center gap-2 border-b border-gray-100"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 inline-block rounded-full border border-gray-300 border-t-gray-600 animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span>Searching…</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if mergedResults.length === 0 && mentionQuery !== ""}
|
||||||
|
<div class="px-3 py-2 text-xs text-gray-400 flex items-center gap-2">
|
||||||
|
{#if remoteSearching}
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 inline-block rounded-full border border-gray-300 border-t-gray-600 animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span>Searching…</span>
|
||||||
|
{:else}
|
||||||
|
No matches
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if mergedResults.length > 0}
|
||||||
|
<ul role="listbox">
|
||||||
|
{#each mergedResults as entry, i (entry.pubkey)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === safeMentionIndex}
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm"
|
||||||
|
class:bg-gray-100={i === safeMentionIndex}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectMention(entry);
|
||||||
|
}}
|
||||||
|
onmouseenter={() => {
|
||||||
|
userMovedCursor = true;
|
||||||
|
mentionIndex = i;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if entry.picture}
|
||||||
|
<img
|
||||||
|
src={entry.picture}
|
||||||
|
alt=""
|
||||||
|
class="h-6 w-6 rounded-full object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center text-xs text-gray-500 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{profileLabel(entry)[0]?.toUpperCase() ?? "?"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium text-gray-700 truncate">
|
||||||
|
{profileLabel(entry)}
|
||||||
|
</span>
|
||||||
|
{#if profileSubLabel(entry)}
|
||||||
|
<span class="text-xs text-gray-400 truncate">
|
||||||
|
{profileSubLabel(entry)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
|
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
|
||||||
|
|
|
||||||
321
src/lib/profiles.svelte.ts
Normal file
321
src/lib/profiles.svelte.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
import { SimplePool, type Event } from "@nostr/tools";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||||
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
|
|
||||||
|
export type ProfileEntry = {
|
||||||
|
pubkey: string;
|
||||||
|
npub: string;
|
||||||
|
name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
nip05?: string;
|
||||||
|
picture?: string;
|
||||||
|
about?: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAST_SYNC_KEY = "profiles_last_sync";
|
||||||
|
const SEARCH_RELAYS = [
|
||||||
|
"wss://relay.noswhere.com",
|
||||||
|
"wss://search.nos.today",
|
||||||
|
"wss://relay.nostr.band",
|
||||||
|
];
|
||||||
|
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost
|
||||||
|
// never has these — they live on each author's outbox relays — so we query
|
||||||
|
// a small set of well-known aggregators.
|
||||||
|
const PROFILE_RELAYS = [
|
||||||
|
"wss://purplepag.es",
|
||||||
|
"wss://relay.nostr.band",
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://nos.lol",
|
||||||
|
];
|
||||||
|
const FETCH_BATCH_SIZE = 500;
|
||||||
|
|
||||||
|
const profiles = new SvelteMap<string, ProfileEntry>();
|
||||||
|
const groupMembers = new SvelteSet<string>();
|
||||||
|
const userFollows = new SvelteSet<string>();
|
||||||
|
|
||||||
|
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<T>(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<string>): 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<string>;
|
||||||
|
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<ProfileEntry[]> {
|
||||||
|
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<string, ProfileEntry>();
|
||||||
|
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<void> | null = null;
|
||||||
|
|
||||||
|
export function seedProfiles(userPubkey: string | null): Promise<void> {
|
||||||
|
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<string>([...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<Event[]>[] = [];
|
||||||
|
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<string>();
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | 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);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
import { threads as mockThreads } from "$lib/mock";
|
import { threads as mockThreads } from "$lib/mock";
|
||||||
import { auth } from "$lib/auth.svelte";
|
import { auth } from "$lib/auth.svelte";
|
||||||
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
|
||||||
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ async function loadProfile(pubkey: string) {
|
||||||
if (profiles[pubkey]) return;
|
if (profiles[pubkey]) return;
|
||||||
const user = await loadNostrUser(pubkey);
|
const user = await loadNostrUser(pubkey);
|
||||||
profiles[pubkey] = user;
|
profiles[pubkey] = user;
|
||||||
|
ingestNostrUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMockThread(id: string) {
|
function loadMockThread(id: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { SimplePool } from "@nostr/tools";
|
import { SimplePool } from "@nostr/tools";
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
|
||||||
export type ThreadData = {
|
export type ThreadData = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -26,6 +27,7 @@ async function loadProfile(pubkey: string) {
|
||||||
if (profiles[pubkey]) return;
|
if (profiles[pubkey]) return;
|
||||||
const user = await loadNostrUser(pubkey);
|
const user = await loadNostrUser(pubkey);
|
||||||
profiles[pubkey] = user;
|
profiles[pubkey] = user;
|
||||||
|
ingestNostrUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadThreads() {
|
export async function loadThreads() {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||||
import { loadGroup } from "$lib/group.svelte";
|
import { loadGroup } from "$lib/group.svelte";
|
||||||
|
import { seedProfiles } from "$lib/profiles.svelte";
|
||||||
import { MODE } from "$lib/config";
|
import { MODE } from "$lib/config";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
@ -18,9 +19,9 @@
|
||||||
const mode = MODE;
|
const mode = MODE;
|
||||||
const chatEnabled = true;
|
const chatEnabled = true;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
restoreSession();
|
await Promise.all([restoreSession(), loadGroup()]);
|
||||||
loadGroup();
|
seedProfiles(auth.user?.pubkey ?? null);
|
||||||
});
|
});
|
||||||
|
|
||||||
let chatExpanded = $state(false);
|
let chatExpanded = $state(false);
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@
|
||||||
disabled={replying}
|
disabled={replying}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Write a reply..."
|
placeholder="Write a reply..."
|
||||||
|
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
||||||
/>
|
/>
|
||||||
<div class="mt-2 flex justify-end">
|
<div class="mt-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue