a tool for shared writing and social publishing
at feature/at-mentions 540 lines 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}) { 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}