a tool for shared writing and social publishing
at main 17 kB view raw
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}