Files
eranos/src/hooks/useSearchProfiles.ts
T
Chad Curtis c28797be97 Add follow-prioritized search autocomplete and @mention support
Search autocomplete now prioritizes followed profiles via client-side
matching merged ahead of relay results. A following badge (UserRoundCheck)
appears on avatars in both the sidebar dropdown and search page accounts
tab. The compose box supports @mentions with an inline autocomplete
dropdown that inserts nostr:npub1 URIs and adds p tags on publish.
2026-02-21 16:58:40 -06:00

119 lines
3.7 KiB
TypeScript

import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useFollowedProfiles } from '@/hooks/useFollowedProfiles';
export interface SearchProfile {
pubkey: string;
metadata: NostrMetadata;
event: NostrEvent;
}
/** Check if a profile matches a search query by name, display_name, nip05, or about. */
function profileMatchesQuery(metadata: NostrMetadata, query: string): boolean {
const q = query.toLowerCase();
const fields = [
metadata.name,
metadata.display_name,
metadata.nip05,
];
return fields.some((field) => field?.toLowerCase().includes(q));
}
/** Search for profiles by username/nip05 using NIP-50 search on relay.ditto.pub. */
export function useSearchProfiles(query: string) {
const { nostr } = useNostr();
const { profiles: followedProfiles, pubkeys: followedPubkeys } = useFollowedProfiles();
const relayResults = useQuery<SearchProfile[]>({
queryKey: ['search-profiles', query],
queryFn: async ({ signal }) => {
if (!query.trim()) return [];
// NIP-50 profile search (uses pool, reuses existing connections)
const events = await nostr.query(
[{ kinds: [0], search: query.trim(), limit: 10 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const profiles: SearchProfile[] = [];
for (const event of events) {
try {
const metadata = n.json().pipe(n.metadata()).parse(event.content);
profiles.push({ pubkey: event.pubkey, metadata, event });
} catch {
// Skip invalid metadata
}
}
// Deduplicate by pubkey (keep latest event)
const seen = new Map<string, SearchProfile>();
for (const profile of profiles) {
const existing = seen.get(profile.pubkey);
if (!existing || profile.event.created_at > existing.event.created_at) {
seen.set(profile.pubkey, profile);
}
}
return Array.from(seen.values());
},
enabled: query.trim().length >= 1,
staleTime: 30 * 1000,
placeholderData: (prev) => prev,
});
// Merge followed profiles (client-side matched) ahead of relay results
const data = useMemo(() => {
const trimmed = query.trim();
if (!trimmed) return relayResults.data;
// Client-side match against followed profiles
const matchedFollows = followedProfiles.filter((p) =>
profileMatchesQuery(p.metadata, trimmed),
);
const relayProfiles = relayResults.data ?? [];
// Build merged list: matched follows first, then relay results (no dupes)
const seen = new Set<string>();
const merged: SearchProfile[] = [];
// Add matched follows first
for (const profile of matchedFollows) {
if (!seen.has(profile.pubkey)) {
seen.add(profile.pubkey);
merged.push(profile);
}
}
// Add relay results, but for followed users move them to follow section
// (they're already there from above, so just skip dupes)
// For non-followed relay results, sort followed ones first too
const remainingFollowed: SearchProfile[] = [];
const remainingOther: SearchProfile[] = [];
for (const profile of relayProfiles) {
if (seen.has(profile.pubkey)) continue;
seen.add(profile.pubkey);
if (followedPubkeys.has(profile.pubkey)) {
remainingFollowed.push(profile);
} else {
remainingOther.push(profile);
}
}
merged.push(...remainingFollowed, ...remainingOther);
return merged;
}, [query, followedProfiles, followedPubkeys, relayResults.data]);
return {
...relayResults,
data,
followedPubkeys,
};
}