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