Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
34 kB · 1042 lines
TSX
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043"use client";import { Agent } from "@atproto/api";import { useState, useEffect, useMemo, Fragment, useRef, useCallback,} from "react";import useSWR from "swr";import * as Popover from "@radix-ui/react-popover";import { EditorView } from "prosemirror-view";import { callRPC } from "app/api/rpc/client";import type * as SearchService from "lexicons/api/types/parts/page/mention/search";import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";import { GoBackSmall } from "components/Icons/GoBackSmall";import { SearchTiny } from "components/Icons/SearchTiny";import { CloseTiny } from "./Icons/CloseTiny";import { GoToArrow } from "./Icons/GoToArrow";import { GoBackTiny } from "./Icons/GoBackTiny";
export function MentionAutocomplete(props: { open: boolean; onOpenChange: (open: boolean) => void; view: React.RefObject<EditorView | null>; onSelect: (mention: Mention) => void; onEmbed?: (mention: Mention & { type: "service_result" }) => void; coords: { top: number; left: number } | null; placeholder?: string;}) { const [searchQuery, setSearchQuery] = useState(""); const inputRef = useRef<HTMLInputElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope, searchComplete, hasDidScopableServices, } = useMentionSuggestions(searchQuery, props.open);
const noResults = searchComplete && searchQuery !== "" && suggestions.length === 0;
const sortedSuggestions = useMemo(() => { const order: Mention["type"][] = [ "did", "publication", "post", "service", "service_result", ]; return [...suggestions].sort( (a, b) => order.indexOf(a.type) - order.indexOf(b.type), ); }, [suggestions]);
// Clear search when scope changes const handleScopeChange = useCallback( (newScope: MentionScope) => { setSearchQuery(""); setSuggestionIndex(0); setScope(newScope); }, [setScope, setSuggestionIndex], );
// Focus input when opened useEffect(() => { if (props.open && inputRef.current) { // Small delay to ensure the popover is mounted setTimeout(() => inputRef.current?.focus(), 0); } }, [props.open]);
// Reset state when closed useEffect(() => { if (!props.open) { setSearchQuery(""); setScope({ type: "default" }); setSuggestionIndex(0); } }, [props.open, setScope, setSuggestionIndex]);
// Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); props.onOpenChange(false); props.view.current?.focus(); return; }
if (e.key === "Backspace" && searchQuery === "") { // Backspace at the start of input closes autocomplete and refocuses editor e.preventDefault(); props.onOpenChange(false); props.view.current?.focus(); return; }
// Reverse arrow key direction when popover is rendered above const isReversed = contentRef.current?.dataset.side === "top"; const upKey = isReversed ? "ArrowDown" : "ArrowUp"; const downKey = isReversed ? "ArrowUp" : "ArrowDown";
if (e.key === upKey) { e.preventDefault(); if (suggestionIndex > 0) { setSuggestionIndex((i) => i - 1); } } else if (e.key === downKey) { e.preventDefault(); if (suggestionIndex < sortedSuggestions.length - 1) { setSuggestionIndex((i) => i + 1); } } else if (e.key === "Tab") { const selectedSuggestion = sortedSuggestions[suggestionIndex]; if (selectedSuggestion?.type === "publication") { e.preventDefault(); handleScopeChange({ type: "publication", uri: selectedSuggestion.uri, name: selectedSuggestion.name, }); } else if (selectedSuggestion?.type === "did" && hasDidScopableServices) { // Tab on a DID result → show services that can be scoped to this DID e.preventDefault(); handleScopeChange({ type: "did_services", did: selectedSuggestion.did, name: selectedSuggestion.displayName || selectedSuggestion.handle, }); } else if ( selectedSuggestion?.type === "service" && scope.type === "did_services" ) { // Tab on a service in the DID services scope → search that service scoped to the DID e.preventDefault(); handleScopeChange({ type: "service", serviceUri: selectedSuggestion.serviceUri, name: selectedSuggestion.name, scope: scope.did, serviceName: selectedSuggestion.name, scopedDid: scope.did, scopedDidName: scope.name, }); } else if (selectedSuggestion?.type === "service") { e.preventDefault(); handleScopeChange(serviceScopeFromMention(selectedSuggestion)); } else if ( selectedSuggestion?.type === "service_result" && selectedSuggestion.subscope && scope.type === "service" ) { e.preventDefault(); handleScopeChange({ type: "service", serviceUri: scope.serviceUri, name: selectedSuggestion.name, scope: selectedSuggestion.subscope.scope, serviceName: scope.serviceName ?? scope.name, scopedDid: scope.scopedDid, scopedDidName: scope.scopedDidName, }); } } else if (e.key === "Enter") { e.preventDefault(); const selectedSuggestion = sortedSuggestions[suggestionIndex]; if ( selectedSuggestion?.type === "service" && scope.type === "did_services" ) { handleScopeChange({ type: "service", serviceUri: selectedSuggestion.serviceUri, name: selectedSuggestion.name, scope: scope.did, serviceName: selectedSuggestion.name, scopedDid: scope.did, scopedDidName: scope.name, }); } else if (selectedSuggestion?.type === "service") { handleScopeChange(serviceScopeFromMention(selectedSuggestion)); } else if ( (e.ctrlKey || e.metaKey) && selectedSuggestion?.type === "service_result" && selectedSuggestion.embed && props.onEmbed ) { props.onEmbed(selectedSuggestion); props.onOpenChange(false); } else if (selectedSuggestion) { props.onSelect(selectedSuggestion); props.onOpenChange(false); } } else if ( e.key === " " && searchQuery === "" && scope.type === "default" ) { // Space immediately after opening closes the autocomplete e.preventDefault(); props.onOpenChange(false); // Insert a space after the @ in the editor if (props.view.current) { const view = props.view.current; const tr = view.state.tr.insertText(" "); view.dispatch(tr); view.focus(); } } };
if (!props.open || !props.coords) return null;
const getHeader = (type: Mention["type"], scope?: MentionScope) => { // When in a built-in scope, show a back header if ( scope?.type === "identities" || scope?.type === "publications" || scope?.type === "publication" || scope?.type === "service" || scope?.type === "did_services" ) { return ( <ScopeHeader scope={scope} handleScopeChange={() => { // When in a service entered from did_services, go back to did_services if (scope.type === "service" && scope.scopedDid) { handleScopeChange({ type: "did_services", did: scope.scopedDid, name: scope.scopedDidName ?? scope.scopedDid, }); // When in a subscoped service, go back to the parent service scope } else if (scope.type === "service" && scope.scope) { handleScopeChange({ type: "service", serviceUri: scope.serviceUri, name: scope.serviceName ?? scope.name, }); } else { handleScopeChange({ type: "default" }); } }} /> ); } switch (type) { case "did": return "People"; case "publication": return "Publications"; case "post": return "Posts"; case "service": return "Services"; case "service_result": return null; } };
return ( <Popover.Root open> <Popover.Anchor style={{ top: props.coords.top - 24, left: props.coords.left, height: 24, position: "absolute", }} /> <Popover.Portal> <Popover.Content ref={contentRef} align="start" sideOffset={4} collisionPadding={32} onOpenAutoFocus={(e) => e.preventDefault()} className={`dropdownMenu group/mention-menu z-20 bg-bg-page flex data-[side=top]:flex-col-reverse flex-col p-1 gap-1 text-primary border border-border rounded-md shadow-md sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) max-h-(--radix-popover-content-available-height) overflow-hidden`} > {/* Dropdown Header - sticky */} <div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0"> <div className="flex items-center gap-1 flex-1 min-w-0 text-primary"> <div className="text-tertiary"> <SearchTiny className="w-4 h-4 shrink-0" /> </div> <input ref={inputRef} size={100} type="text" value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); setSuggestionIndex(0); }} onKeyDown={handleKeyDown} autoFocus placeholder={scopePlaceholder(scope, props.placeholder)} className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" /> </div> </div> <div className="overflow-y-auto flex-1 min-h-0"> {sortedSuggestions.length === 0 && noResults && ( <div className="text-sm text-tertiary italic px-3 py-1 text-center"> No results found </div> )} <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> {sortedSuggestions.map((result, index) => { const prevResult = sortedSuggestions[index - 1]; const showHeader = index === 0 || (prevResult && prevResult.type !== result.type);
const key = result.type === "did" ? result.did : result.type === "service" ? result.serviceUri : result.uri;
return ( <Fragment key={key}> {showHeader && ( <> {index > 0 && ( <hr className="border-border-light mx-1 my-1" /> )} <div className="text-xs text-tertiary font-bold pt-1 px-2"> {getHeader(result.type, scope)} </div> </> )} {result.type === "did" ? ( <DidResult onClick={() => { props.onSelect(result); props.onOpenChange(false); }} onMouseDown={(e) => e.preventDefault()} displayName={result.displayName} handle={result.handle} avatar={result.avatar} selected={index === suggestionIndex} onServicesClick={ hasDidScopableServices ? () => { handleScopeChange({ type: "did_services", did: result.did, name: result.displayName || result.handle, }); } : undefined } /> ) : result.type === "publication" ? ( <PublicationResult onClick={() => { props.onSelect(result); props.onOpenChange(false); }} onMouseDown={(e) => e.preventDefault()} pubName={result.name} uri={result.uri} selected={index === suggestionIndex} onPostsClick={() => { handleScopeChange({ type: "publication", uri: result.uri, name: result.name, }); }} /> ) : result.type === "service" ? ( <ServiceEntry onClick={() => { if (scope.type === "did_services") { handleScopeChange({ type: "service", serviceUri: result.serviceUri, name: result.name, scope: scope.did, serviceName: result.name, scopedDid: scope.did, scopedDidName: scope.name, }); } else { handleScopeChange(serviceScopeFromMention(result)); } }} onMouseDown={(e) => e.preventDefault()} name={result.name} description={result.description} selected={index === suggestionIndex} /> ) : result.type === "service_result" ? ( <ServiceSearchResult onClick={() => { props.onSelect(result); props.onOpenChange(false); }} onMouseDown={(e) => e.preventDefault()} name={result.name} description={result.description} labels={result.labels} icon={result.icon} hasEmbed={!!result.embed} onEmbedClick={ result.embed && props.onEmbed ? () => { props.onEmbed!(result); props.onOpenChange(false); } : undefined } subscope={result.subscope} onSubscopeClick={ result.subscope && scope.type === "service" ? () => { handleScopeChange({ type: "service", serviceUri: scope.serviceUri, name: result.name, scope: result.subscope!.scope, serviceName: scope.serviceName ?? scope.name, }); } : undefined } selected={index === suggestionIndex} /> ) : ( <PostResult onClick={() => { props.onSelect(result); props.onOpenChange(false); }} onMouseDown={(e) => e.preventDefault()} title={result.title} selected={index === suggestionIndex} /> )} </Fragment> ); })} </ul> </div> </Popover.Content> </Popover.Portal> </Popover.Root> );}
const Result = (props: { result: React.ReactNode; subtext?: React.ReactNode; icon?: React.ReactNode; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean;}) => { return ( <button className={` menuItem w-full flex-row! gap-2! text-secondary leading-snug text-sm ${props.subtext ? "py-1!" : "py-2!"} ${props.selected ? "bg-[var(--accent-light)]!" : ""}`} onClick={() => { props.onClick(); }} onMouseDown={(e) => props.onMouseDown(e)} > {props.icon} <div className="flex flex-col min-w-0 flex-1"> <div className={`flex gap-2 items-center w-full truncate justify-between`} > {props.result} </div> {props.subtext && ( <div className="text-tertiary text-xs font-normal min-w-0 pb-[1px]"> {props.subtext} </div> )} </div> </button> );};
const ScopeButton = (props: { onClick: () => void; children: React.ReactNode;}) => { return ( <span className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer" onClick={(e) => { e.preventDefault(); e.stopPropagation(); props.onClick(); }} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }} > {props.children} <ArrowRightTiny className="scale-80" /> </span> );};
const DidResult = (props: { displayName?: string; handle: string; avatar?: string; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; onServicesClick?: () => void;}) => { const nameContent = props.displayName ? props.displayName : props.handle; return ( <Result icon={ props.avatar ? ( <img src={props.avatar} alt="" className="w-5 h-5 rounded-full shrink-0" /> ) : ( <div className="w-5 h-5 rounded-full bg-border shrink-0" /> ) } result={ props.onServicesClick ? ( <> <div className="truncate w-full grow min-w-0">{nameContent}</div> <ScopeButton onClick={props.onServicesClick}>Search</ScopeButton> </> ) : ( nameContent ) } subtext={props.displayName && <span className="truncate italic">{`@${props.handle}`}</span>} onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} /> );};
const PublicationResult = (props: { pubName: string; uri: string; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; onPostsClick: () => void;}) => { return ( <Result icon={ <img src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`} alt="" className="w-5 h-5 rounded-full shrink-0" /> } result={ <> <div className="truncate w-full grow min-w-0">{props.pubName}</div> <ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton> </> } onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} /> );};
const PostResult = (props: { title: string; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean;}) => { return ( <Result result={<div className="truncate w-full">{props.title}</div>} onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} /> );};
const ServiceEntry = (props: { name: string; description?: string; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean;}) => { return ( <Result result={ <> <div className="truncate w-full grow min-w-0">{props.name}</div> <ScopeButton onClick={props.onClick}>Search</ScopeButton> </> } subtext={props.description && <span className="truncate italic">{props.description}</span>} onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} /> );};
const ServiceSearchResult = (props: { name: string; description?: string; labels?: SearchService.MentionLabel[]; icon?: string; hasEmbed?: boolean; onEmbedClick?: () => void; subscope?: SearchService.SubscopeInfo; onSubscopeClick?: () => void; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean;}) => { const hasActions = (props.hasEmbed && props.onEmbedClick) || (props.subscope && props.onSubscopeClick); const hasLabels = props.labels && props.labels.length > 0; const hasDescription = !!props.description; const hasSubtext = hasLabels || hasDescription; return ( <Result icon={ props.icon ? ( <img src={props.icon} alt="" className="w-5 h-5 rounded-full shrink-0" /> ) : undefined } result={ hasActions ? ( <> <div className={`grow min-w-0 ${hasSubtext ? "line-clamp-2 whitespace-normal" : "truncate w-full"}`}>{props.name}</div> {props.subscope && props.onSubscopeClick && ( <ScopeButton onClick={props.onSubscopeClick}> {props.subscope.label} </ScopeButton> )} {props.hasEmbed && props.onEmbedClick && ( <ScopeButton onClick={props.onEmbedClick}>Embed</ScopeButton> )} </> ) : ( <div className={`min-w-0 ${hasSubtext ? "line-clamp-2 whitespace-normal" : "truncate w-full"}`}>{props.name}</div> ) } subtext={ hasSubtext ? ( <div className="flex flex-col gap-0.5"> {hasLabels && ( <div className="flex flex-wrap gap-1 items-center text-tertiary text-xs leading-normal"> {props.labels!.flatMap( (label, i) => label.text ? [ ...(i > 0 ? [<span key={`sep-${i}`} className="text-border-light">|</span>] : []), <span key={i}>{label.text}</span>, ] : [], )} </div> )} {hasDescription && ( <span className="text-secondary text-xs truncate"> {props.description} </span> )} </div> ) : undefined } onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} /> );};
const ScopeHeader = (props: { scope: MentionScope; handleScopeChange: () => void;}) => { if (props.scope.type === "default") return;
const label = props.scope.type === "identities" ? "People" : props.scope.type === "publications" ? "Publications" : props.scope.type === "publication" ? `Posts from ${props.scope.name}` : props.scope.type === "did_services" ? `Services for ${props.scope.name}` : `Results from ${props.scope.name}`;
return ( <button className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs" onClick={() => props.handleScopeChange()} onMouseDown={(e) => e.preventDefault()} > <GoBackTiny className="shrink-0 " /> <div className="grow w-full truncate text-left">{label}</div> </button> );};
export type Mention = | { type: "did"; handle: string; did: string; displayName?: string; avatar?: string; } | { type: "publication"; uri: string; name: string; url: string } | { type: "post"; uri: string; title: string; url: string } | { type: "service"; serviceUri: string; name: string; description?: string; canBeScopedToDid?: boolean; } | { type: "service_result"; uri: string; name: string; description?: string; labels?: SearchService.MentionLabel[]; href?: string; icon?: string; embed?: SearchService.EmbedInfo; subscope?: SearchService.SubscopeInfo; };
export type MentionScope = | { type: "default" } | { type: "identities" } | { type: "publications" } | { type: "publication"; uri: string; name: string } | { type: "service"; serviceUri: string; name: string; scope?: string; serviceName?: string; scopedDid?: string; scopedDidName?: string; } | { type: "did_services"; did: string; name: string };
function scopePlaceholder(scope: MentionScope, fallback?: string): string { switch (scope.type) { case "identities": return "Search people..."; case "publications": return "Search publications..."; case "publication": return "Search posts..."; case "service": return `Search ${scope.name}...`; case "did_services": return `Search ${scope.name} with...`; default: return fallback ?? "Search people & publications..."; }}
function serviceScopeFromMention( service: Mention & { type: "service" },): MentionScope { if (service.serviceUri === BUILTIN_IDENTITIES.serviceUri) return { type: "identities" }; if (service.serviceUri === BUILTIN_PUBLICATIONS.serviceUri) return { type: "publications" }; return { type: "service", serviceUri: service.serviceUri, name: service.name, };}
const BUILTIN_IDENTITIES: Mention & { type: "service" } = { type: "service", serviceUri: "builtin:identities", name: "Identities", description: "Search people on Bluesky",};const BUILTIN_PUBLICATIONS: Mention & { type: "service" } = { type: "service", serviceUri: "builtin:publications", name: "Publications", description: "Search publications on Leaflet",};
const bskyAgent = new Agent("https://public.api.bsky.app");
async function searchIdentities( query: string, limit: number,): Promise<Mention[]> { const result = await bskyAgent.searchActorsTypeahead({ q: query, limit }); return result.data.actors.map((actor) => ({ type: "did" as const, handle: actor.handle, did: actor.did, displayName: actor.displayName, avatar: actor.avatar, }));}
async function searchPublications( query: string, limit: number,): Promise<Mention[]> { const publications = await callRPC(`search_publication_names`, { query, limit, }); return publications.result.publications.map((p) => ({ type: "publication" as const, uri: p.uri, name: p.name, url: p.url, }));}
const EMPTY_SERVICES: Array<Mention> = [];function useMentionServices(enabled: boolean): Array<Mention> { const { data } = useSWR( enabled ? "mention_services" : null, async () => { try { const result = await callRPC(`get_user_mention_services`, {}); return result.result.services .map( (s: { uri: string; name: string; description?: string; canBeScopedToDid?: boolean; }) => ({ type: "service" as const, serviceUri: s.uri, name: s.name, description: s.description, canBeScopedToDid: s.canBeScopedToDid, }), ) .sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); } catch { return EMPTY_SERVICES; } }, { revalidateOnFocus: false, revalidateOnReconnect: false }, ); return data || EMPTY_SERVICES;}
function useMentionSuggestions(query: string | null, open: boolean) { const [suggestionIndex, setSuggestionIndex] = useState(0); const [suggestions, setSuggestions] = useState<Array<Mention>>([]); const [scope, setScope] = useState<MentionScope>({ type: "default" }); const [searchComplete, setSearchComplete] = useState(false); const externalServices = useMentionServices(open); const allServices = useMemo( () => externalServices.length > 0 ? [BUILTIN_IDENTITIES, BUILTIN_PUBLICATIONS, ...externalServices] : EMPTY_SERVICES, [externalServices], ); const hasServices = allServices.length > 0; const didScopableServices = useMemo( () => externalServices.filter( (s) => s.type === "service" && s.canBeScopedToDid, ), [externalServices], ); const hasDidScopableServices = didScopableServices.length > 0;
// Clear suggestions immediately when scope changes const setScopeAndClear = useCallback((newScope: MentionScope) => { setSuggestions([]); setScope(newScope); }, []);
useEffect(() => { let stale = false; setSearchComplete(false); // DID services scope: show DID-scopable services filtered locally if (scope.type === "did_services") { const filtered = didScopableServices.filter((s) => s.type === "service" ? s.name.toLowerCase().includes((query || "").toLowerCase()) : true, ); setSuggestions(filtered); setSearchComplete(true); return; }
// Default scope with services: show local filter instantly, debounce network fallback if (hasServices && scope.type === "default") { const filtered = allServices.filter((s) => s.type === "service" ? s.name.toLowerCase().includes((query || "").toLowerCase()) : true, ); setSuggestions(filtered); setSearchComplete(true);
// If local filter found matches, no need for network search if (!query || filtered.length > 0) return; setSearchComplete(false); }
const handler = setTimeout(async () => { if (stale || !open) return;
let results: Array<Mention>;
if (scope.type === "identities") { results = await searchIdentities(query || "", 10); } else if (scope.type === "publications") { results = await searchPublications(query || "", 10); } else if (scope.type === "publication") { // Search within a specific publication's documents const documents = await callRPC(`search_publication_documents`, { publication_uri: scope.uri, query: query || "", limit: 10, }); results = (documents?.result?.documents ?? []).map((d) => ({ type: "post" as const, uri: d.uri, title: d.title, url: d.url, })); } else if (scope.type === "service") { const res = await callRPC(`proxy_mention_search`, { service_uri: scope.serviceUri, search: query || "", ...(scope.scope ? { scope: scope.scope } : {}), }); const items: SearchService.Result[] = res?.result?.results ?? []; results = items.map((r) => ({ type: "service_result" as const, uri: r.uri, name: r.name, description: r.description, labels: r.labels, href: r.href, icon: r.icon, embed: r.embed, subscope: r.subscope, })); } else if (hasServices) { // Default scope with services: local filter showed no matches, fall back to identity search results = await searchIdentities(query || "", 10); } else { // Default scope, no services: search people & publications together const [identities, publications] = await Promise.all([ searchIdentities(query || "", 8), searchPublications(query || "", 8), ]); results = [...identities, ...publications]; }
if (!stale) { setSuggestions(results); setSearchComplete(true); } }, 300);
return () => { stale = true; clearTimeout(handler); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, scope, open, hasServices, allServices, didScopableServices]);
useEffect(() => { if (suggestionIndex > suggestions.length - 1) { setSuggestionIndex(Math.max(0, suggestions.length - 1)); } }, [suggestionIndex, suggestions.length]);
return { suggestions, suggestionIndex, setSuggestionIndex, scope, setScope: setScopeAndClear, searchComplete, hasDidScopableServices, };}