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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue