"use client"; import { Agent } from "@atproto/api"; import { useState, useEffect, Fragment, useRef, useCallback } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import * as Popover from "@radix-ui/react-popover"; import { EditorView } from "prosemirror-view"; import { callRPC } from "app/api/rpc/client"; 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; coords: { top: number; left: number } | null; placeholder?: string; }) { const [searchQuery, setSearchQuery] = useState(""); const [noResults, setNoResults] = useState(false); const inputRef = useRef(null); const contentRef = useRef(null); const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = useMentionSuggestions(searchQuery); // 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); setNoResults(false); } }, [props.open, setScope, setSuggestionIndex]); // Handle timeout for showing "No results found" useEffect(() => { if (searchQuery && suggestions.length === 0) { setNoResults(false); const timer = setTimeout(() => { setNoResults(true); }, 2000); return () => clearTimeout(timer); } else { setNoResults(false); } }, [searchQuery, suggestions.length]); // 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 < suggestions.length - 1) { setSuggestionIndex((i) => i + 1); } } else if (e.key === "Tab") { const selectedSuggestion = suggestions[suggestionIndex]; if (selectedSuggestion?.type === "publication") { e.preventDefault(); handleScopeChange({ type: "publication", uri: selectedSuggestion.uri, name: selectedSuggestion.name, }); } } else if (e.key === "Enter") { e.preventDefault(); const selectedSuggestion = suggestions[suggestionIndex]; 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) => { switch (type) { case "did": return "People"; case "publication": return "Publications"; case "post": if (scope) { return ( { handleScopeChange({ type: "default" }); }} /> ); } else return "Posts"; } }; const sortedSuggestions = [...suggestions].sort((a, b) => { const order: Mention["type"][] = ["did", "publication", "post"]; return order.indexOf(a.type) - order.indexOf(b.type); }); 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={ scope.type === "publication" ? "Search posts..." : props.placeholder ?? "Search people & publications..." } 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); 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} /> ) : 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, }); }} /> ) : ( { 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; }) => { return ( ) : (
) } result={props.displayName ? props.displayName : props.handle} 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 ScopeHeader = (props: { scope: MentionScope; handleScopeChange: () => void; }) => { if (props.scope.type === "default") return; if (props.scope.type === "publication") 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 }; export type MentionScope = | { type: "default" } | { type: "publication"; uri: string; name: string }; function useMentionSuggestions(query: string | null) { const [suggestionIndex, setSuggestionIndex] = useState(0); const [suggestions, setSuggestions] = useState>([]); const [scope, setScope] = useState({ type: "default" }); // Clear suggestions immediately when scope changes const setScopeAndClear = useCallback((newScope: MentionScope) => { setSuggestions([]); setScope(newScope); }, []); useDebouncedEffect( async () => { if (!query && scope.type === "default") { setSuggestions([]); return; } if (scope.type === "publication") { // Search within the publication's documents const documents = await callRPC(`search_publication_documents`, { publication_uri: scope.uri, query: query || "", limit: 10, }); setSuggestions( documents.result.documents.map((d) => ({ type: "post" as const, uri: d.uri, title: d.title, url: d.url, })), ); } else { // Default scope: search people and publications const agent = new Agent("https://public.api.bsky.app"); const [result, publications] = await Promise.all([ agent.searchActorsTypeahead({ q: query || "", limit: 8, }), callRPC(`search_publication_names`, { query: query || "", limit: 8 }), ]); setSuggestions([ ...result.data.actors.map((actor) => ({ type: "did" as const, handle: actor.handle, did: actor.did, displayName: actor.displayName, avatar: actor.avatar, })), ...publications.result.publications.map((p) => ({ type: "publication" as const, uri: p.uri, name: p.name, url: p.url, })), ]); } }, 300, [query, scope], ); useEffect(() => { if (suggestionIndex > suggestions.length - 1) { setSuggestionIndex(Math.max(0, suggestions.length - 1)); } }, [suggestionIndex, suggestions.length]); return { suggestions, suggestionIndex, setSuggestionIndex, scope, setScope: setScopeAndClear, }; }