"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; 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(null); const contentRef = useRef(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 ( { // 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 ( 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 */}
{ 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" />
{sortedSuggestions.length === 0 && noResults && (
No results found
)}
    {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 ( {showHeader && ( <> {index > 0 && (
    )}
    {getHeader(result.type, scope)}
    )} {result.type === "did" ? ( { 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" ? ( { 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" ? ( { 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" ? ( { 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} /> ) : ( { props.onSelect(result); props.onOpenChange(false); }} onMouseDown={(e) => e.preventDefault()} title={result.title} selected={index === suggestionIndex} /> )}
    ); })}
); } const Result = (props: { result: React.ReactNode; subtext?: React.ReactNode; icon?: React.ReactNode; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; }) => { return ( ); }; const ScopeButton = (props: { onClick: () => void; children: React.ReactNode; }) => { return ( { e.preventDefault(); e.stopPropagation(); props.onClick(); }} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }} > {props.children} ); }; 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={ props.onServicesClick ? ( <>
{nameContent}
Search ) : ( nameContent ) } subtext={props.displayName && {`@${props.handle}`}} 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={ <>
{props.pubName}
Posts } onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} /> ); }; const PostResult = (props: { title: string; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; }) => { return ( {props.title}
} 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 (
{props.name}
Search } subtext={props.description && {props.description}} 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 ( ) : undefined } result={ hasActions ? ( <>
{props.name}
{props.subscope && props.onSubscopeClick && ( {props.subscope.label} )} {props.hasEmbed && props.onEmbedClick && ( Embed )} ) : (
{props.name}
) } subtext={ hasSubtext ? (
{hasLabels && (
{props.labels!.flatMap( (label, i) => label.text ? [ ...(i > 0 ? [|] : []), {label.text}, ] : [], )}
)} {hasDescription && ( {props.description} )}
) : 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 ( ); }; 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 { 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 { 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 = []; function useMentionServices(enabled: boolean): Array { 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>([]); const [scope, setScope] = useState({ 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; 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, }; }