a tool for shared writing and social publishing
1"use client";
2import { Agent } from "@atproto/api";
3import { useState, useEffect, Fragment, useRef, useCallback } from "react";
4import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
5import * as Popover from "@radix-ui/react-popover";
6import { EditorView } from "prosemirror-view";
7import { callRPC } from "app/api/rpc/client";
8import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
9import { GoBackSmall } from "components/Icons/GoBackSmall";
10import { SearchTiny } from "components/Icons/SearchTiny";
11import { CloseTiny } from "./Icons/CloseTiny";
12import { GoToArrow } from "./Icons/GoToArrow";
13import { GoBackTiny } from "./Icons/GoBackTiny";
14
15export function MentionAutocomplete(props: {
16 open: boolean;
17 onOpenChange: (open: boolean) => void;
18 view: React.RefObject<EditorView | null>;
19 onSelect: (mention: Mention) => void;
20 coords: { top: number; left: number } | null;
21 placeholder?: string;
22}) {
23 const [searchQuery, setSearchQuery] = useState("");
24 const [noResults, setNoResults] = useState(false);
25 const inputRef = useRef<HTMLInputElement>(null);
26 const contentRef = useRef<HTMLDivElement>(null);
27
28 const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } =
29 useMentionSuggestions(searchQuery);
30
31 // Clear search when scope changes
32 const handleScopeChange = useCallback(
33 (newScope: MentionScope) => {
34 setSearchQuery("");
35 setSuggestionIndex(0);
36 setScope(newScope);
37 },
38 [setScope, setSuggestionIndex],
39 );
40
41 // Focus input when opened
42 useEffect(() => {
43 if (props.open && inputRef.current) {
44 // Small delay to ensure the popover is mounted
45 setTimeout(() => inputRef.current?.focus(), 0);
46 }
47 }, [props.open]);
48
49 // Reset state when closed
50 useEffect(() => {
51 if (!props.open) {
52 setSearchQuery("");
53 setScope({ type: "default" });
54 setSuggestionIndex(0);
55 setNoResults(false);
56 }
57 }, [props.open, setScope, setSuggestionIndex]);
58
59 // Handle timeout for showing "No results found"
60 useEffect(() => {
61 if (searchQuery && suggestions.length === 0) {
62 setNoResults(false);
63 const timer = setTimeout(() => {
64 setNoResults(true);
65 }, 2000);
66 return () => clearTimeout(timer);
67 } else {
68 setNoResults(false);
69 }
70 }, [searchQuery, suggestions.length]);
71
72 // Handle keyboard navigation
73 const handleKeyDown = (e: React.KeyboardEvent) => {
74 if (e.key === "Escape") {
75 e.preventDefault();
76 props.onOpenChange(false);
77 props.view.current?.focus();
78 return;
79 }
80
81 if (e.key === "Backspace" && searchQuery === "") {
82 // Backspace at the start of input closes autocomplete and refocuses editor
83 e.preventDefault();
84 props.onOpenChange(false);
85 props.view.current?.focus();
86 return;
87 }
88
89 // Reverse arrow key direction when popover is rendered above
90 const isReversed = contentRef.current?.dataset.side === "top";
91 const upKey = isReversed ? "ArrowDown" : "ArrowUp";
92 const downKey = isReversed ? "ArrowUp" : "ArrowDown";
93
94 if (e.key === upKey) {
95 e.preventDefault();
96 if (suggestionIndex > 0) {
97 setSuggestionIndex((i) => i - 1);
98 }
99 } else if (e.key === downKey) {
100 e.preventDefault();
101 if (suggestionIndex < suggestions.length - 1) {
102 setSuggestionIndex((i) => i + 1);
103 }
104 } else if (e.key === "Tab") {
105 const selectedSuggestion = suggestions[suggestionIndex];
106 if (selectedSuggestion?.type === "publication") {
107 e.preventDefault();
108 handleScopeChange({
109 type: "publication",
110 uri: selectedSuggestion.uri,
111 name: selectedSuggestion.name,
112 });
113 }
114 } else if (e.key === "Enter") {
115 e.preventDefault();
116 const selectedSuggestion = suggestions[suggestionIndex];
117 if (selectedSuggestion) {
118 props.onSelect(selectedSuggestion);
119 props.onOpenChange(false);
120 }
121 } else if (
122 e.key === " " &&
123 searchQuery === "" &&
124 scope.type === "default"
125 ) {
126 // Space immediately after opening closes the autocomplete
127 e.preventDefault();
128 props.onOpenChange(false);
129 // Insert a space after the @ in the editor
130 if (props.view.current) {
131 const view = props.view.current;
132 const tr = view.state.tr.insertText(" ");
133 view.dispatch(tr);
134 view.focus();
135 }
136 }
137 };
138
139 if (!props.open || !props.coords) return null;
140
141 const getHeader = (type: Mention["type"], scope?: MentionScope) => {
142 switch (type) {
143 case "did":
144 return "People";
145 case "publication":
146 return "Publications";
147 case "post":
148 if (scope) {
149 return (
150 <ScopeHeader
151 scope={scope}
152 handleScopeChange={() => {
153 handleScopeChange({ type: "default" });
154 }}
155 />
156 );
157 } else return "Posts";
158 }
159 };
160
161 const sortedSuggestions = [...suggestions].sort((a, b) => {
162 const order: Mention["type"][] = ["did", "publication", "post"];
163 return order.indexOf(a.type) - order.indexOf(b.type);
164 });
165
166 return (
167 <Popover.Root open>
168 <Popover.Anchor
169 style={{
170 top: props.coords.top - 24,
171 left: props.coords.left,
172 height: 24,
173 position: "absolute",
174 }}
175 />
176 <Popover.Portal>
177 <Popover.Content
178 ref={contentRef}
179 align="start"
180 sideOffset={4}
181 collisionPadding={32}
182 onOpenAutoFocus={(e) => e.preventDefault()}
183 className={`dropdownMenu group/mention-menu z-20 bg-bg-page
184 flex data-[side=top]:flex-col-reverse flex-col
185 p-1 gap-1 text-primary
186 border border-border rounded-md shadow-md
187 sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width)
188 max-h-(--radix-popover-content-available-height)
189 overflow-hidden`}
190 >
191 {/* Dropdown Header - sticky */}
192 <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">
193 <div className="flex items-center gap-1 flex-1 min-w-0 text-primary">
194 <div className="text-tertiary">
195 <SearchTiny className="w-4 h-4 shrink-0" />
196 </div>
197 <input
198 ref={inputRef}
199 size={100}
200 type="text"
201 value={searchQuery}
202 onChange={(e) => {
203 setSearchQuery(e.target.value);
204 setSuggestionIndex(0);
205 }}
206 onKeyDown={handleKeyDown}
207 autoFocus
208 placeholder={
209 scope.type === "publication"
210 ? "Search posts..."
211 : props.placeholder ?? "Search people & publications..."
212 }
213 className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary"
214 />
215 </div>
216 </div>
217 <div className="overflow-y-auto flex-1 min-h-0">
218 {sortedSuggestions.length === 0 && noResults && (
219 <div className="text-sm text-tertiary italic px-3 py-1 text-center">
220 No results found
221 </div>
222 )}
223 <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse">
224 {sortedSuggestions.map((result, index) => {
225 const prevResult = sortedSuggestions[index - 1];
226 const showHeader =
227 index === 0 ||
228 (prevResult && prevResult.type !== result.type);
229
230 return (
231 <Fragment
232 key={result.type === "did" ? result.did : result.uri}
233 >
234 {showHeader && (
235 <>
236 {index > 0 && (
237 <hr className="border-border-light mx-1 my-1" />
238 )}
239 <div className="text-xs text-tertiary font-bold pt-1 px-2">
240 {getHeader(result.type, scope)}
241 </div>
242 </>
243 )}
244 {result.type === "did" ? (
245 <DidResult
246 onClick={() => {
247 props.onSelect(result);
248 props.onOpenChange(false);
249 }}
250 onMouseDown={(e) => e.preventDefault()}
251 displayName={result.displayName}
252 handle={result.handle}
253 avatar={result.avatar}
254 selected={index === suggestionIndex}
255 />
256 ) : result.type === "publication" ? (
257 <PublicationResult
258 onClick={() => {
259 props.onSelect(result);
260 props.onOpenChange(false);
261 }}
262 onMouseDown={(e) => e.preventDefault()}
263 pubName={result.name}
264 uri={result.uri}
265 selected={index === suggestionIndex}
266 onPostsClick={() => {
267 handleScopeChange({
268 type: "publication",
269 uri: result.uri,
270 name: result.name,
271 });
272 }}
273 />
274 ) : (
275 <PostResult
276 onClick={() => {
277 props.onSelect(result);
278 props.onOpenChange(false);
279 }}
280 onMouseDown={(e) => e.preventDefault()}
281 title={result.title}
282 selected={index === suggestionIndex}
283 />
284 )}
285 </Fragment>
286 );
287 })}
288 </ul>
289 </div>
290 </Popover.Content>
291 </Popover.Portal>
292 </Popover.Root>
293 );
294}
295
296const Result = (props: {
297 result: React.ReactNode;
298 subtext?: React.ReactNode;
299 icon?: React.ReactNode;
300 onClick: () => void;
301 onMouseDown: (e: React.MouseEvent) => void;
302 selected?: boolean;
303}) => {
304 return (
305 <button
306 className={`
307 menuItem w-full flex-row! gap-2!
308 text-secondary leading-snug text-sm
309 ${props.subtext ? "py-1!" : "py-2!"}
310 ${props.selected ? "bg-[var(--accent-light)]!" : ""}`}
311 onClick={() => {
312 props.onClick();
313 }}
314 onMouseDown={(e) => props.onMouseDown(e)}
315 >
316 {props.icon}
317 <div className="flex flex-col min-w-0 flex-1">
318 <div
319 className={`flex gap-2 items-center w-full truncate justify-between`}
320 >
321 {props.result}
322 </div>
323 {props.subtext && (
324 <div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-[1px]">
325 {props.subtext}
326 </div>
327 )}
328 </div>
329 </button>
330 );
331};
332
333const ScopeButton = (props: {
334 onClick: () => void;
335 children: React.ReactNode;
336}) => {
337 return (
338 <span
339 className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer"
340 onClick={(e) => {
341 e.preventDefault();
342 e.stopPropagation();
343 props.onClick();
344 }}
345 onMouseDown={(e) => {
346 e.preventDefault();
347 e.stopPropagation();
348 }}
349 >
350 {props.children} <ArrowRightTiny className="scale-80" />
351 </span>
352 );
353};
354
355const DidResult = (props: {
356 displayName?: string;
357 handle: string;
358 avatar?: string;
359 onClick: () => void;
360 onMouseDown: (e: React.MouseEvent) => void;
361 selected?: boolean;
362}) => {
363 return (
364 <Result
365 icon={
366 props.avatar ? (
367 <img
368 src={props.avatar}
369 alt=""
370 className="w-5 h-5 rounded-full shrink-0"
371 />
372 ) : (
373 <div className="w-5 h-5 rounded-full bg-border shrink-0" />
374 )
375 }
376 result={props.displayName ? props.displayName : props.handle}
377 subtext={props.displayName && `@${props.handle}`}
378 onClick={props.onClick}
379 onMouseDown={props.onMouseDown}
380 selected={props.selected}
381 />
382 );
383};
384
385const PublicationResult = (props: {
386 pubName: string;
387 uri: string;
388 onClick: () => void;
389 onMouseDown: (e: React.MouseEvent) => void;
390 selected?: boolean;
391 onPostsClick: () => void;
392}) => {
393 return (
394 <Result
395 icon={
396 <img
397 src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`}
398 alt=""
399 className="w-5 h-5 rounded-full shrink-0"
400 />
401 }
402 result={
403 <>
404 <div className="truncate w-full grow min-w-0">{props.pubName}</div>
405 <ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton>
406 </>
407 }
408 onClick={props.onClick}
409 onMouseDown={props.onMouseDown}
410 selected={props.selected}
411 />
412 );
413};
414
415const PostResult = (props: {
416 title: string;
417 onClick: () => void;
418 onMouseDown: (e: React.MouseEvent) => void;
419 selected?: boolean;
420}) => {
421 return (
422 <Result
423 result={<div className="truncate w-full">{props.title}</div>}
424 onClick={props.onClick}
425 onMouseDown={props.onMouseDown}
426 selected={props.selected}
427 />
428 );
429};
430
431const ScopeHeader = (props: {
432 scope: MentionScope;
433 handleScopeChange: () => void;
434}) => {
435 if (props.scope.type === "default") return;
436 if (props.scope.type === "publication")
437 return (
438 <button
439 className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs"
440 onClick={() => props.handleScopeChange()}
441 onMouseDown={(e) => e.preventDefault()}
442 >
443 <GoBackTiny className="shrink-0 " />
444
445 <div className="grow w-full truncate text-left">
446 Posts from {props.scope.name}
447 </div>
448 </button>
449 );
450};
451
452export type Mention =
453 | {
454 type: "did";
455 handle: string;
456 did: string;
457 displayName?: string;
458 avatar?: string;
459 }
460 | { type: "publication"; uri: string; name: string; url: string }
461 | { type: "post"; uri: string; title: string; url: string };
462
463export type MentionScope =
464 | { type: "default" }
465 | { type: "publication"; uri: string; name: string };
466function useMentionSuggestions(query: string | null) {
467 const [suggestionIndex, setSuggestionIndex] = useState(0);
468 const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
469 const [scope, setScope] = useState<MentionScope>({ type: "default" });
470
471 // Clear suggestions immediately when scope changes
472 const setScopeAndClear = useCallback((newScope: MentionScope) => {
473 setSuggestions([]);
474 setScope(newScope);
475 }, []);
476
477 useDebouncedEffect(
478 async () => {
479 if (!query && scope.type === "default") {
480 setSuggestions([]);
481 return;
482 }
483
484 if (scope.type === "publication") {
485 // Search within the publication's documents
486 const documents = await callRPC(`search_publication_documents`, {
487 publication_uri: scope.uri,
488 query: query || "",
489 limit: 10,
490 });
491 setSuggestions(
492 documents.result.documents.map((d) => ({
493 type: "post" as const,
494 uri: d.uri,
495 title: d.title,
496 url: d.url,
497 })),
498 );
499 } else {
500 // Default scope: search people and publications
501 const agent = new Agent("https://public.api.bsky.app");
502 const [result, publications] = await Promise.all([
503 agent.searchActorsTypeahead({
504 q: query || "",
505 limit: 8,
506 }),
507 callRPC(`search_publication_names`, { query: query || "", limit: 8 }),
508 ]);
509 setSuggestions([
510 ...result.data.actors.map((actor) => ({
511 type: "did" as const,
512 handle: actor.handle,
513 did: actor.did,
514 displayName: actor.displayName,
515 avatar: actor.avatar,
516 })),
517 ...publications.result.publications.map((p) => ({
518 type: "publication" as const,
519 uri: p.uri,
520 name: p.name,
521 url: p.url,
522 })),
523 ]);
524 }
525 },
526 300,
527 [query, scope],
528 );
529
530 useEffect(() => {
531 if (suggestionIndex > suggestions.length - 1) {
532 setSuggestionIndex(Math.max(0, suggestions.length - 1));
533 }
534 }, [suggestionIndex, suggestions.length]);
535
536 return {
537 suggestions,
538 suggestionIndex,
539 setSuggestionIndex,
540 scope,
541 setScope: setScopeAndClear,
542 };
543}