Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
17 kB · 543 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544"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<EditorView | null>; onSelect: (mention: Mention) => void; coords: { top: number; left: number } | null; placeholder?: string;}) { const [searchQuery, setSearchQuery] = useState(""); const [noResults, setNoResults] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const contentRef = useRef<HTMLDivElement>(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 ( <ScopeHeader scope={scope} handleScopeChange={() => { 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 ( <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={ 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" /> </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);
return ( <Fragment key={result.type === "did" ? result.did : result.uri} > {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} /> ) : 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, }); }} /> ) : ( <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 italic text-xs font-normal min-w-0 truncate 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;}) => { 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.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 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 ScopeHeader = (props: { scope: MentionScope; handleScopeChange: () => void;}) => { if (props.scope.type === "default") return; if (props.scope.type === "publication") 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"> Posts from {props.scope.name} </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 };
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<Array<Mention>>([]); const [scope, setScope] = useState<MentionScope>({ 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, };}