a tool for shared writing and social publishing

refactor autocomplete and implement post mentions

+492 -309
+62 -57
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 127 127 return tr; 128 128 }); 129 129 } 130 - export type MentionState = { 131 - active: boolean; 132 - range: { from: number; to: number } | null; 133 - selectedMention: Mention | null; 134 - }; 135 130 export function BlueskyPostEditorProsemirror(props: { 136 131 editorStateRef: React.RefObject<EditorState | null>; 137 132 initialContent?: string; ··· 140 135 const mountRef = useRef<HTMLDivElement | null>(null); 141 136 const viewRef = useRef<EditorView | null>(null); 142 137 const [editorState, setEditorState] = useState<EditorState | null>(null); 143 - const [mentionState, setMentionState] = useState<MentionState>({ 144 - active: false, 145 - range: null, 146 - selectedMention: null, 147 - }); 138 + const [mentionOpen, setMentionOpen] = useState(false); 139 + const [mentionCoords, setMentionCoords] = useState<{ 140 + top: number; 141 + left: number; 142 + } | null>(null); 143 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 144 + 145 + const openMentionAutocomplete = useCallback(() => { 146 + if (!viewRef.current) return; 147 + const view = viewRef.current; 148 + const pos = view.state.selection.from; 149 + setMentionInsertPos(pos); 150 + const coords = view.coordsAtPos(pos - 1); 151 + setMentionCoords({ 152 + top: coords.bottom + window.scrollY, 153 + left: coords.left + window.scrollX, 154 + }); 155 + setMentionOpen(true); 156 + }, []); 148 157 149 158 const handleMentionSelect = useCallback( 150 - (mention: Mention, range: { from: number; to: number }) => { 159 + (mention: Mention) => { 151 160 if (mention.type !== "did") return; 152 - if (!viewRef.current) return; 161 + if (!viewRef.current || mentionInsertPos === null) return; 153 162 const view = viewRef.current; 154 - const { from, to } = range; 163 + const from = mentionInsertPos - 1; 164 + const to = mentionInsertPos; 155 165 const tr = view.state.tr; 156 166 157 - // Delete the query text (keep the @) 158 - tr.delete(from + 1, to); 167 + // Delete the @ symbol 168 + tr.delete(from, to); 159 169 160 - // Insert the mention text after the @ 161 - const mentionText = mention.handle; 162 - tr.insertText(mentionText, from + 1); 170 + // Insert @handle 171 + const mentionText = "@" + mention.handle; 172 + tr.insertText(mentionText, from); 163 173 164 - // Apply mention mark to @ and handle 174 + // Apply mention mark 165 175 tr.addMark( 166 176 from, 167 - from + 1 + mentionText.length, 177 + from + mentionText.length, 168 178 bskyPostSchema.marks.mention.create({ did: mention.did }), 169 179 ); 170 180 171 181 // Add a space after the mention 172 - tr.insertText(" ", from + 1 + mentionText.length); 182 + tr.insertText(" ", from + mentionText.length); 173 183 174 184 view.dispatch(tr); 175 185 view.focus(); 176 186 }, 177 - [], 187 + [mentionInsertPos], 178 188 ); 179 189 180 - const mentionStateRef = useRef(mentionState); 181 - mentionStateRef.current = mentionState; 190 + const handleMentionOpenChange = useCallback((open: boolean) => { 191 + setMentionOpen(open); 192 + if (!open) { 193 + setMentionCoords(null); 194 + setMentionInsertPos(null); 195 + } 196 + }, []); 182 197 183 198 useLayoutEffect(() => { 184 199 if (!mountRef.current) return; 185 200 201 + // Input rule to trigger mention autocomplete when @ is typed 202 + const mentionInputRule = new InputRule( 203 + /(?:^|\s)@$/, 204 + (state, match, start, end) => { 205 + setTimeout(() => openMentionAutocomplete(), 0); 206 + return null; 207 + }, 208 + ); 209 + 186 210 const initialState = EditorState.create({ 187 211 schema: bskyPostSchema, 188 212 doc: props.initialContent ··· 195 219 }) 196 220 : undefined, 197 221 plugins: [ 198 - inputRules({ rules: [createHashtagInputRule()] }), 222 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 199 223 keymap({ 200 224 "Mod-z": undo, 201 225 "Mod-y": redo, 202 226 "Shift-Mod-z": redo, 203 - Enter: (state, dispatch) => { 204 - // Check if mention autocomplete is active 205 - const currentMentionState = mentionStateRef.current; 206 - if ( 207 - currentMentionState.active && 208 - currentMentionState.selectedMention && 209 - currentMentionState.range 210 - ) { 211 - handleMentionSelect( 212 - currentMentionState.selectedMention, 213 - currentMentionState.range, 214 - ); 215 - return true; 216 - } 217 - // Otherwise let the default Enter behavior happen (new paragraph) 218 - return false; 219 - }, 220 227 }), 221 228 keymap(baseKeymap), 222 229 autolink({ ··· 251 258 view.destroy(); 252 259 viewRef.current = null; 253 260 }; 254 - }, [handleMentionSelect]); 261 + }, [openMentionAutocomplete]); 255 262 256 263 return ( 257 264 <div className="relative w-full h-full group"> 258 - {editorState && ( 259 - <MentionAutocomplete 260 - editorState={editorState} 261 - view={viewRef} 262 - onSelect={handleMentionSelect} 263 - onMentionStateChange={(active, range, selectedMention) => { 264 - setMentionState({ active, range, selectedMention }); 265 - }} 266 - /> 267 - )} 265 + <MentionAutocomplete 266 + open={mentionOpen} 267 + onOpenChange={handleMentionOpenChange} 268 + view={viewRef} 269 + onSelect={handleMentionSelect} 270 + coords={mentionCoords} 271 + /> 268 272 {editorState?.doc.textContent.length === 0 && ( 269 273 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 270 274 Write a post to share your writing! ··· 388 392 ); 389 393 tr.insertText(" ", from + 1 + mention.handle.length); 390 394 } 391 - if (mention.type === "publication") { 392 - tr.insertText(mention.name, from + 1); 395 + if (mention.type === "publication" || mention.type === "post") { 396 + let name = mention.type == "post" ? mention.title : mention.name; 397 + tr.insertText(name, from + 1); 393 398 tr.addMark( 394 399 from, 395 - from + 1 + mention.name.length, 400 + from + 1 + name.length, 396 401 schema.marks.atMention.create({ atURI: mention.uri }), 397 402 ); 398 - tr.insertText(" ", from + 1 + mention.name.length); 403 + tr.insertText(" ", from + 1 + name.length); 399 404 } 400 405 401 406 // Insert the mention text after the @
+2
app/api/rpc/[command]/route.ts
··· 12 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 13 import { get_publication_data } from "./get_publication_data"; 14 14 import { search_publication_names } from "./search_publication_names"; 15 + import { search_publication_documents } from "./search_publication_documents"; 15 16 16 17 let supabase = createClient<Database>( 17 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 37 38 get_leaflet_data, 38 39 get_publication_data, 39 40 search_publication_names, 41 + search_publication_documents, 40 42 ]; 41 43 export async function POST( 42 44 req: Request,
+41
app/api/rpc/[command]/search_publication_documents.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + 5 + export type SearchPublicationDocumentsReturnType = Awaited< 6 + ReturnType<(typeof search_publication_documents)["handler"]> 7 + >; 8 + 9 + export const search_publication_documents = makeRoute({ 10 + route: "search_publication_documents", 11 + input: z.object({ 12 + publication_uri: z.string(), 13 + query: z.string(), 14 + limit: z.number().optional().default(10), 15 + }), 16 + handler: async ( 17 + { publication_uri, query, limit }, 18 + { supabase }: Pick<Env, "supabase">, 19 + ) => { 20 + // Get documents in the publication, filtering by title using JSON operator 21 + const { data: documents, error } = await supabase 22 + .from("documents_in_publications") 23 + .select("document, documents!inner(uri, data)") 24 + .eq("publication", publication_uri) 25 + .ilike("documents.data->>title", `%${query}%`) 26 + .limit(limit); 27 + 28 + if (error) { 29 + throw new Error( 30 + `Failed to search publication documents: ${error.message}`, 31 + ); 32 + } 33 + 34 + const result = documents.map((d) => ({ 35 + uri: d.documents.uri, 36 + title: (d.documents.data as { title?: string })?.title || "Untitled", 37 + })); 38 + 39 + return { result: { documents: result } }; 40 + }, 41 + });
+9 -4
app/api/rpc/[command]/search_publication_names.ts
··· 16 16 { query, limit }, 17 17 { supabase }: Pick<Env, "supabase">, 18 18 ) => { 19 - // Search publications by name (case-insensitive partial match) 19 + // Search publications by name in record (case-insensitive partial match) 20 20 const { data: publications, error } = await supabase 21 21 .from("publications") 22 - .select("uri, name, identity_did, record") 23 - .ilike("name", `%${query}%`) 22 + .select("uri, record") 23 + .ilike("record->>name", `%${query}%`) 24 24 .limit(limit); 25 25 26 26 if (error) { 27 27 throw new Error(`Failed to search publications: ${error.message}`); 28 28 } 29 29 30 - return { result: { publications } }; 30 + const result = publications.map((p) => ({ 31 + uri: p.uri, 32 + name: (p.record as { name?: string })?.name || "Untitled", 33 + })); 34 + 35 + return { result: { publications: result } }; 31 36 }, 32 37 });
+1 -1
components/Blocks/BlockCommandBar.tsx
··· 197 197 198 198 return ( 199 199 <button 200 - className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]"}`} 200 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 201 201 onMouseOver={() => { 202 202 props.setHighlighted(props.name); 203 203 }}
+76 -21
components/Blocks/TextBlock/index.tsx
··· 188 188 let editorState = useEditorStates( 189 189 (s) => s.editorStates[props.entityID], 190 190 )?.editor; 191 - let { viewRef, handleMentionSelect, setMentionState, mentionStateRef } = 192 - useMentionState(props.entityID); 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 193 199 194 200 let { mountRef, actionTimeout } = useMountProsemirror({ 195 201 props, 196 - mentionStateRef, 202 + openMentionAutocomplete, 197 203 }); 198 204 199 205 return ( ··· 230 236 } 231 237 }} 232 238 onFocus={() => { 239 + handleMentionOpenChange(false); 233 240 setTimeout(() => { 234 241 useUIState.getState().setSelectedBlock(props); 235 242 useUIState.setState(() => ({ ··· 255 262 ${props.className}`} 256 263 ref={mountRef} 257 264 /> 258 - {editorState && focused && ( 265 + {focused && ( 259 266 <MentionAutocomplete 260 - editorState={editorState} 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 261 269 view={viewRef} 262 270 onSelect={handleMentionSelect} 263 - onMentionStateChange={(active, range, selectedMention) => { 264 - setMentionState({ active, range, selectedMention }); 265 - }} 271 + coords={mentionCoords} 266 272 /> 267 273 )} 268 274 {editorState?.doc.textContent.length === 0 && ··· 459 465 let view = useEditorStates((s) => s.editorStates[entityID])?.view; 460 466 let viewRef = useRef(view || null); 461 467 viewRef.current = view || null; 462 - const [mentionState, setMentionState] = useState<{ 463 - active: boolean; 464 - range: { from: number; to: number } | null; 465 - selectedMention: Mention | null; 466 - }>({ active: false, range: null, selectedMention: null }); 467 - const mentionStateRef = useRef(mentionState); 468 - mentionStateRef.current = mentionState; 468 + 469 + const [mentionOpen, setMentionOpen] = useState(false); 470 + const [mentionCoords, setMentionCoords] = useState<{ 471 + top: number; 472 + left: number; 473 + } | null>(null); 474 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 475 + 476 + // Close autocomplete when this block is no longer focused 477 + const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); 478 + useEffect(() => { 479 + if (!isFocused) { 480 + setMentionOpen(false); 481 + setMentionCoords(null); 482 + setMentionInsertPos(null); 483 + } 484 + }, [isFocused]); 485 + 486 + const openMentionAutocomplete = useCallback(() => { 487 + const view = useEditorStates.getState().editorStates[entityID]?.view; 488 + if (!view) return; 489 + 490 + // Get the position right after the @ we just inserted 491 + const pos = view.state.selection.from; 492 + setMentionInsertPos(pos); 493 + 494 + // Get coordinates for the popup 495 + const coords = view.coordsAtPos(pos - 1); // Position of the @ 496 + setMentionCoords({ 497 + top: coords.bottom + window.scrollY, 498 + left: coords.left + window.scrollX, 499 + }); 500 + setMentionOpen(true); 501 + }, [entityID]); 469 502 470 503 const handleMentionSelect = useCallback( 471 - (mention: Mention, range: { from: number; to: number }) => { 472 - let view = useEditorStates.getState().editorStates[entityID]?.view; 473 - if (!view) return; 474 - addMentionToEditor(mention, range, view); 504 + (mention: Mention) => { 505 + const view = useEditorStates.getState().editorStates[entityID]?.view; 506 + if (!view || mentionInsertPos === null) return; 507 + 508 + // The @ is at mentionInsertPos - 1, we need to replace it with the mention 509 + const from = mentionInsertPos - 1; 510 + const to = mentionInsertPos; 511 + 512 + addMentionToEditor(mention, { from, to }, view); 513 + view.focus(); 475 514 }, 476 - [], 515 + [entityID, mentionInsertPos], 477 516 ); 478 - return { mentionStateRef, handleMentionSelect, viewRef, setMentionState }; 517 + 518 + const handleMentionOpenChange = useCallback((open: boolean) => { 519 + setMentionOpen(open); 520 + if (!open) { 521 + setMentionCoords(null); 522 + setMentionInsertPos(null); 523 + } 524 + }, []); 525 + 526 + return { 527 + viewRef, 528 + mentionOpen, 529 + mentionCoords, 530 + openMentionAutocomplete, 531 + handleMentionSelect, 532 + handleMentionOpenChange, 533 + }; 479 534 };
+9
components/Blocks/TextBlock/inputRules.ts
··· 14 14 export const inputrules = ( 15 15 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 16 16 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 17 + openMentionAutocomplete?: () => void, 17 18 ) => 18 19 inputRules({ 19 20 //Strikethrough ··· 180 181 data: { type: "number", value: headingLevel }, 181 182 }); 182 183 return tr; 184 + }), 185 + 186 + // Mention - @ at start of line or after space 187 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 188 + if (!openMentionAutocomplete) return null; 189 + // Schedule opening the autocomplete after the transaction is applied 190 + setTimeout(() => openMentionAutocomplete(), 0); 191 + return null; // Let the @ be inserted normally 183 192 }), 184 193 ], 185 194 });
+1 -20
components/Blocks/TextBlock/keymap.ts
··· 24 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 26 import { UndoManager } from "src/undoManager"; 27 - import { 28 - addMentionToEditor, 29 - MentionState, 30 - } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 - 32 27 type PropsRef = RefObject< 33 28 BlockProps & { 34 29 entity_set: { set: string }; ··· 39 34 propsRef: PropsRef, 40 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 41 36 um: UndoManager, 42 - mentionStateRef: RefObject<MentionState>, 37 + openMentionAutocomplete: () => void, 43 38 ) => 44 39 ({ 45 40 "Meta-b": toggleMark(schema.marks.strong), ··· 143 138 "Shift-Backspace": backspace(propsRef, repRef), 144 139 Enter: (state, dispatch, view) => { 145 140 return um.withUndoGroup(() => { 146 - const currentMentionState = mentionStateRef.current; 147 - if ( 148 - currentMentionState.active && 149 - currentMentionState.selectedMention && 150 - currentMentionState.range 151 - ) { 152 - if (view) 153 - addMentionToEditor( 154 - currentMentionState.selectedMention, 155 - currentMentionState.range, 156 - view, 157 - ); 158 - return true; 159 - } 160 141 return enter(propsRef, repRef)(state, dispatch, view); 161 142 }); 162 143 },
+4 -5
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 23 import { useHandlePaste } from "./useHandlePaste"; 24 24 import { BlockProps } from "../Block"; 25 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 - import { MentionState } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 27 26 28 27 export function useMountProsemirror({ 29 28 props, 30 - mentionStateRef, 29 + openMentionAutocomplete, 31 30 }: { 32 31 props: BlockProps; 33 - mentionStateRef: React.RefObject<MentionState>; 32 + openMentionAutocomplete: () => void; 34 33 }) { 35 34 let { entityID, parent } = props; 36 35 let rep = useReplicache(); ··· 55 54 propsRef, 56 55 repRef, 57 56 rep.undoManager, 58 - mentionStateRef, 57 + openMentionAutocomplete, 59 58 ); 60 59 const editor = EditorState.create({ 61 60 schema: schema, 62 61 plugins: [ 63 62 ySyncPlugin(value), 64 63 keymap(km), 65 - inputrules(propsRef, repRef), 64 + inputrules(propsRef, repRef, openMentionAutocomplete), 66 65 keymap(baseKeymap), 67 66 highlightSelectionPlugin, 68 67 autolink({
+287 -201
components/Mention.tsx
··· 1 1 "use client"; 2 2 import { Agent } from "@atproto/api"; 3 - import { useState, useEffect, Fragment } from "react"; 3 + import { useState, useEffect, Fragment, useRef, useCallback } from "react"; 4 4 import { createPortal } from "react-dom"; 5 5 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 6 import * as Popover from "@radix-ui/react-popover"; 7 - import { EditorState } from "prosemirror-state"; 8 7 import { EditorView } from "prosemirror-view"; 9 8 import { callRPC } from "app/api/rpc/client"; 10 9 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 11 - import { onMouseDown } from "src/utils/iosInputMouseDown"; 10 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 + import { SearchTiny } from "components/Icons/SearchTiny"; 12 12 13 13 export function MentionAutocomplete(props: { 14 - editorState: EditorState; 14 + open: boolean; 15 + onOpenChange: (open: boolean) => void; 15 16 view: React.RefObject<EditorView | null>; 16 - onSelect: (mention: Mention, range: { from: number; to: number }) => void; 17 - onMentionStateChange: ( 18 - active: boolean, 19 - range: { from: number; to: number } | null, 20 - selectedMention: Mention | null, 21 - ) => void; 17 + onSelect: (mention: Mention) => void; 18 + coords: { top: number; left: number } | null; 22 19 }) { 23 - const [mentionQuery, setMentionQuery] = useState<string | null>(null); 24 - const [mentionRange, setMentionRange] = useState<{ 25 - from: number; 26 - to: number; 27 - } | null>(null); 28 - const [mentionCoords, setMentionCoords] = useState<{ 29 - top: number; 30 - left: number; 31 - } | null>(null); 32 - 33 - const { suggestionIndex, setSuggestionIndex, suggestions } = 34 - useMentionSuggestions(mentionQuery); 35 - 36 - // Check for mention pattern whenever editor state changes 37 - useEffect(() => { 38 - const { $from } = props.editorState.selection; 39 - const textBefore = $from.parent.textBetween( 40 - Math.max(0, $from.parentOffset - 50), 41 - $from.parentOffset, 42 - null, 43 - "\ufffc", 44 - ); 45 - 46 - // Look for @ followed by word characters before cursor 47 - const match = textBefore.match(/(?:^|\s)@([\w.]*)$/); 48 - 49 - if (match && props.view.current) { 50 - const queryBefore = match[1]; 51 - const from = $from.pos - queryBefore.length - 1; 52 - 53 - // Get text after cursor to find the rest of the handle 54 - const textAfter = $from.parent.textBetween( 55 - $from.parentOffset, 56 - Math.min($from.parent.content.size, $from.parentOffset + 50), 57 - null, 58 - "\ufffc", 59 - ); 60 - 61 - // Match word characters after cursor until space or end 62 - const afterMatch = textAfter.match(/^([\w.]*)/); 63 - const queryAfter = afterMatch ? afterMatch[1] : ""; 64 - 65 - // Combine the full handle 66 - const query = queryBefore + queryAfter; 67 - const to = $from.pos + queryAfter.length; 20 + const [searchQuery, setSearchQuery] = useState(""); 21 + const inputRef = useRef<HTMLInputElement>(null); 22 + const contentRef = useRef<HTMLDivElement>(null); 68 23 69 - setMentionQuery(query); 70 - setMentionRange({ from, to }); 24 + const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = 25 + useMentionSuggestions(searchQuery); 71 26 72 - // Get coordinates for the autocomplete popup 73 - const coords = props.view.current.coordsAtPos(from); 74 - setMentionCoords({ 75 - top: coords.bottom + window.scrollY, 76 - left: coords.left + window.scrollX, 77 - }); 27 + // Clear search when scope changes 28 + const handleScopeChange = useCallback( 29 + (newScope: MentionScope) => { 30 + setSearchQuery(""); 78 31 setSuggestionIndex(0); 79 - } else { 80 - setMentionQuery(null); 81 - setMentionRange(null); 82 - setMentionCoords(null); 83 - } 84 - }, [props.editorState, props.view, setSuggestionIndex]); 32 + setScope(newScope); 33 + }, 34 + [setScope, setSuggestionIndex], 35 + ); 85 36 86 - // Update parent's mention state 37 + // Focus input when opened 87 38 useEffect(() => { 88 - const active = mentionQuery !== null && suggestions.length > 0; 89 - const selectedMention = 90 - active && suggestions[suggestionIndex] 91 - ? suggestions[suggestionIndex] 92 - : null; 93 - props.onMentionStateChange(active, mentionRange, selectedMention); 94 - }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 39 + if (props.open && inputRef.current) { 40 + // Small delay to ensure the popover is mounted 41 + setTimeout(() => inputRef.current?.focus(), 0); 42 + } 43 + }, [props.open]); 95 44 96 - // Handle keyboard navigation for arrow keys only 45 + // Reset state when closed 97 46 useEffect(() => { 98 - if (!mentionQuery || !props.view.current) return; 47 + if (!props.open) { 48 + setSearchQuery(""); 49 + setScope({ type: "default" }); 50 + setSuggestionIndex(0); 51 + } 52 + }, [props.open, setScope, setSuggestionIndex]); 99 53 100 - const handleKeyDown = (e: KeyboardEvent) => { 101 - if (suggestions.length === 0) return; 54 + // Handle keyboard navigation 55 + const handleKeyDown = (e: React.KeyboardEvent) => { 56 + if (e.key === "Escape") { 57 + e.preventDefault(); 58 + props.onOpenChange(false); 59 + props.view.current?.focus(); 60 + return; 61 + } 102 62 103 - if (e.key === "ArrowUp") { 104 - e.preventDefault(); 105 - e.stopPropagation(); 63 + if (e.key === "Backspace" && searchQuery === "") { 64 + // Backspace at the start of input closes autocomplete and refocuses editor 65 + e.preventDefault(); 66 + props.onOpenChange(false); 67 + props.view.current?.focus(); 68 + return; 69 + } 106 70 107 - if (suggestionIndex > 0) { 108 - setSuggestionIndex((i) => i - 1); 109 - } 110 - } else if (e.key === "ArrowDown") { 71 + // Reverse arrow key direction when popover is rendered above 72 + const isReversed = contentRef.current?.dataset.side === "top"; 73 + const upKey = isReversed ? "ArrowDown" : "ArrowUp"; 74 + const downKey = isReversed ? "ArrowUp" : "ArrowDown"; 75 + 76 + if (e.key === upKey) { 77 + e.preventDefault(); 78 + if (suggestionIndex > 0) { 79 + setSuggestionIndex((i) => i - 1); 80 + } 81 + } else if (e.key === downKey) { 82 + e.preventDefault(); 83 + if (suggestionIndex < suggestions.length - 1) { 84 + setSuggestionIndex((i) => i + 1); 85 + } 86 + } else if (e.key === "Tab") { 87 + const selectedSuggestion = suggestions[suggestionIndex]; 88 + if (selectedSuggestion?.type === "publication") { 111 89 e.preventDefault(); 112 - e.stopPropagation(); 113 - 114 - if (suggestionIndex < suggestions.length - 1) { 115 - setSuggestionIndex((i) => i + 1); 116 - } 90 + handleScopeChange({ 91 + type: "publication", 92 + uri: selectedSuggestion.uri, 93 + name: selectedSuggestion.name, 94 + }); 117 95 } 118 - }; 119 - 120 - const dom = props.view.current.dom; 121 - dom.addEventListener("keydown", handleKeyDown, true); 122 - 123 - return () => { 124 - dom.removeEventListener("keydown", handleKeyDown, true); 125 - }; 126 - }, [ 127 - mentionQuery, 128 - suggestions, 129 - suggestionIndex, 130 - props.view, 131 - setSuggestionIndex, 132 - ]); 96 + } else if (e.key === "Enter") { 97 + e.preventDefault(); 98 + const selectedSuggestion = suggestions[suggestionIndex]; 99 + if (selectedSuggestion) { 100 + props.onSelect(selectedSuggestion); 101 + props.onOpenChange(false); 102 + } 103 + } else if ( 104 + e.key === " " && 105 + searchQuery === "" && 106 + scope.type === "default" 107 + ) { 108 + // Space immediately after opening closes the autocomplete 109 + e.preventDefault(); 110 + props.onOpenChange(false); 111 + // Insert a space after the @ in the editor 112 + if (props.view.current) { 113 + const view = props.view.current; 114 + const tr = view.state.tr.insertText(" "); 115 + view.dispatch(tr); 116 + view.focus(); 117 + } 118 + } 119 + }; 133 120 134 - if (!mentionCoords || suggestions.length === 0) return null; 121 + if (!props.open || !props.coords) return null; 135 122 136 123 const getHeader = (type: Mention["type"]) => { 137 124 switch (type) { ··· 139 126 return "People"; 140 127 case "publication": 141 128 return "Publications"; 129 + case "post": 130 + return "Posts"; 142 131 } 143 132 }; 144 133 145 134 const sortedSuggestions = [...suggestions].sort((a, b) => { 146 - const order: Mention["type"][] = ["did", "publication"]; 135 + const order: Mention["type"][] = ["did", "publication", "post"]; 147 136 return order.indexOf(a.type) - order.indexOf(b.type); 148 137 }); 149 138 ··· 152 141 {createPortal( 153 142 <Popover.Anchor 154 143 style={{ 155 - top: mentionCoords.top, 156 - left: mentionCoords.left, 144 + top: props.coords.top - 24, 145 + left: props.coords.left, 146 + height: 24, 157 147 position: "absolute", 158 148 }} 159 149 />, ··· 161 151 )} 162 152 <Popover.Portal> 163 153 <Popover.Content 164 - side="bottom" 154 + ref={contentRef} 165 155 align="start" 166 156 sideOffset={4} 167 157 collisionPadding={20} 168 158 onOpenAutoFocus={(e) => e.preventDefault()} 169 - className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-1 border border-border rounded-md shadow-md sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 159 + className={`dropdownMenu group/mention-menu z-20 bg-bg-page 160 + flex data-[side=top]:flex-col-reverse flex-col 161 + p-1 gap-1 162 + border border-border rounded-md shadow-md 163 + sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 170 164 max-h-(--radix-popover-content-available-height) 171 165 overflow-y-scroll`} 172 166 > 173 - <ul className="list-none p-0 text-sm"> 174 - {sortedSuggestions.map((result, index) => { 175 - const prevResult = sortedSuggestions[index - 1]; 176 - const showHeader = prevResult && prevResult.type !== result.type; 177 - 178 - const [key, resultText, subtext] = 179 - result.type === "did" 180 - ? [ 181 - result.did, 182 - result.displayName || `@${result.handle}`, 183 - result.displayName ? `@${result.handle}` : undefined, 184 - ] 185 - : [result.uri, result.name, undefined]; 167 + {/* Search input */} 168 + <div className="flex 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"> 169 + {scope.type === "publication" && ( 170 + <button 171 + className="p-1 rounded hover:bg-accent-light text-tertiary hover:text-accent-contrast shrink-0" 172 + onClick={() => handleScopeChange({ type: "default" })} 173 + onMouseDown={(e) => e.preventDefault()} 174 + > 175 + <GoBackSmall className="w-4 h-4" /> 176 + </button> 177 + )} 178 + {scope.type === "publication" && ( 179 + <span className="text-sm font-bold text-secondary truncate shrink-0 max-w-[100px]"> 180 + {scope.name} 181 + </span> 182 + )} 183 + <div className="flex items-center gap-1 flex-1 min-w-0"> 184 + <SearchTiny className="w-4 h-4 text-tertiary shrink-0" /> 185 + <input 186 + ref={inputRef} 187 + type="text" 188 + value={searchQuery} 189 + onChange={(e) => { 190 + setSearchQuery(e.target.value); 191 + setSuggestionIndex(0); 192 + }} 193 + onKeyDown={handleKeyDown} 194 + autoFocus 195 + placeholder={ 196 + scope.type === "publication" 197 + ? "Search posts..." 198 + : "Search people & publications..." 199 + } 200 + className="flex-1 min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" 201 + /> 202 + </div> 203 + </div> 204 + {sortedSuggestions.length === 0 ? ( 205 + <div className="text-sm text-tertiary italic px-3 py-2"> 206 + {searchQuery 207 + ? "No results found" 208 + : scope.type === "publication" 209 + ? "Type to search posts" 210 + : "Type to search"} 211 + </div> 212 + ) : ( 213 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 214 + {sortedSuggestions.map((result, index) => { 215 + const prevResult = sortedSuggestions[index - 1]; 216 + const showHeader = 217 + prevResult && prevResult.type !== result.type; 186 218 187 - return ( 188 - <> 189 - {showHeader && ( 190 - <> 191 - {index > 0 && ( 192 - <hr className="border-border-light mx-1 my-1" /> 193 - )} 194 - <div className="text-xs text-tertiary font-bold pt-1 px-2"> 195 - {getHeader(result.type)} 196 - </div> 197 - </> 198 - )} 199 - {result.type === "did" ? ( 200 - <DidResult 201 - key={result.did} 202 - onClick={() => { 203 - if (mentionRange) { 204 - props.onSelect(result, mentionRange); 205 - setMentionQuery(null); 206 - setMentionRange(null); 207 - setMentionCoords(null); 208 - } 209 - }} 210 - onMouseDown={(e) => e.preventDefault()} 211 - displayName={result.displayName} 212 - handle={result.handle} 213 - selected={index === suggestionIndex} 214 - /> 215 - ) : ( 216 - <PublicationResult 217 - key={result.uri} 218 - onClick={() => { 219 - if (mentionRange) { 220 - props.onSelect(result, mentionRange); 221 - setMentionQuery(null); 222 - setMentionRange(null); 223 - setMentionCoords(null); 224 - } 225 - }} 226 - onMouseDown={(e) => e.preventDefault()} 227 - pubName={result.name} 228 - selected={index === suggestionIndex} 229 - /> 230 - )} 231 - </> 232 - ); 233 - })} 234 - </ul> 219 + return ( 220 + <Fragment 221 + key={result.type === "did" ? result.did : result.uri} 222 + > 223 + {showHeader && ( 224 + <> 225 + {index > 0 && ( 226 + <hr className="border-border-light mx-1 my-1" /> 227 + )} 228 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 229 + {getHeader(result.type)} 230 + </div> 231 + </> 232 + )} 233 + {result.type === "did" ? ( 234 + <DidResult 235 + onClick={() => { 236 + props.onSelect(result); 237 + props.onOpenChange(false); 238 + }} 239 + onMouseDown={(e) => e.preventDefault()} 240 + displayName={result.displayName} 241 + handle={result.handle} 242 + selected={index === suggestionIndex} 243 + /> 244 + ) : result.type === "publication" ? ( 245 + <PublicationResult 246 + onClick={() => { 247 + props.onSelect(result); 248 + props.onOpenChange(false); 249 + }} 250 + onMouseDown={(e) => e.preventDefault()} 251 + pubName={result.name} 252 + selected={index === suggestionIndex} 253 + onPostsClick={() => { 254 + handleScopeChange({ 255 + type: "publication", 256 + uri: result.uri, 257 + name: result.name, 258 + }); 259 + }} 260 + /> 261 + ) : ( 262 + <PostResult 263 + onClick={() => { 264 + props.onSelect(result); 265 + props.onOpenChange(false); 266 + }} 267 + onMouseDown={(e) => e.preventDefault()} 268 + title={result.title} 269 + selected={index === suggestionIndex} 270 + /> 271 + )} 272 + </Fragment> 273 + ); 274 + })} 275 + </ul> 276 + )} 235 277 </Popover.Content> 236 278 </Popover.Portal> 237 279 </Popover.Root> ··· 251 293 menuItem w-full flex-col! gap-0! 252 294 text-secondary leading-snug text-sm 253 295 ${props.subtext ? "py-1!" : "py-2!"} 254 - ${props.selected ? "bg-[var(--accent-light)]" : ""}`} 296 + ${props.selected ? "bg-[var(--accent-light)]!" : ""}`} 255 297 onClick={() => { 256 298 props.onClick(); 257 299 }} ··· 314 356 onClick: () => void; 315 357 onMouseDown: (e: React.MouseEvent) => void; 316 358 selected?: boolean; 359 + onPostsClick: () => void; 317 360 }) => { 318 361 return ( 319 362 <Result 320 363 result={ 321 364 <> 322 365 <div className="truncate w-full grow min-w-0">{props.pubName}</div> 323 - <ScopeButton onClick={() => {}}>Posts</ScopeButton> 366 + <ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton> 324 367 </> 325 368 } 326 - {...props} 369 + onClick={props.onClick} 370 + onMouseDown={props.onMouseDown} 371 + selected={props.selected} 372 + /> 373 + ); 374 + }; 375 + 376 + const PostResult = (props: { 377 + title: string; 378 + onClick: () => void; 379 + onMouseDown: (e: React.MouseEvent) => void; 380 + selected?: boolean; 381 + }) => { 382 + return ( 383 + <Result 384 + result={<div className="truncate w-full">{props.title}</div>} 385 + onClick={props.onClick} 386 + onMouseDown={props.onMouseDown} 387 + selected={props.selected} 327 388 /> 328 389 ); 329 390 }; 330 391 331 392 export type Mention = 332 393 | { type: "did"; handle: string; did: string; displayName?: string } 394 + | { type: "publication"; uri: string; name: string } 395 + | { type: "post"; uri: string; title: string }; 396 + 397 + export type MentionScope = 398 + | { type: "default" } 333 399 | { type: "publication"; uri: string; name: string }; 334 400 function useMentionSuggestions(query: string | null) { 335 401 const [suggestionIndex, setSuggestionIndex] = useState(0); 336 402 const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 403 + const [scope, setScope] = useState<MentionScope>({ type: "default" }); 337 404 338 405 useDebouncedEffect( 339 406 async () => { 340 - if (!query) { 407 + if (!query && scope.type === "default") { 341 408 setSuggestions([]); 342 409 return; 343 410 } 344 411 345 - const agent = new Agent("https://public.api.bsky.app"); 346 - const [result, publications] = await Promise.all([ 347 - agent.searchActorsTypeahead({ 348 - q: query, 349 - limit: 8, 350 - }), 351 - callRPC(`search_publication_names`, { query, limit: 8 }), 352 - ]); 353 - setSuggestions([ 354 - ...result.data.actors.map((actor) => ({ 355 - type: "did" as const, 356 - handle: actor.handle, 357 - did: actor.did, 358 - displayName: actor.displayName, 359 - })), 360 - ...publications.result.publications.map((p) => ({ 361 - type: "publication" as const, 362 - uri: p.uri, 363 - name: p.name, 364 - })), 365 - ]); 412 + if (scope.type === "publication") { 413 + // Search within the publication's documents 414 + const documents = await callRPC(`search_publication_documents`, { 415 + publication_uri: scope.uri, 416 + query: query || "", 417 + limit: 10, 418 + }); 419 + setSuggestions( 420 + documents.result.documents.map((d) => ({ 421 + type: "post" as const, 422 + uri: d.uri, 423 + title: d.title, 424 + })), 425 + ); 426 + } else { 427 + // Default scope: search people and publications 428 + const agent = new Agent("https://public.api.bsky.app"); 429 + const [result, publications] = await Promise.all([ 430 + agent.searchActorsTypeahead({ 431 + q: query || "", 432 + limit: 8, 433 + }), 434 + callRPC(`search_publication_names`, { query: query || "", limit: 8 }), 435 + ]); 436 + setSuggestions([ 437 + ...result.data.actors.map((actor) => ({ 438 + type: "did" as const, 439 + handle: actor.handle, 440 + did: actor.did, 441 + displayName: actor.displayName, 442 + })), 443 + ...publications.result.publications.map((p) => ({ 444 + type: "publication" as const, 445 + uri: p.uri, 446 + name: p.name, 447 + })), 448 + ]); 449 + } 366 450 }, 367 451 300, 368 - [query], 452 + [query, scope], 369 453 ); 370 454 371 455 useEffect(() => { ··· 378 462 suggestions, 379 463 suggestionIndex, 380 464 setSuggestionIndex, 465 + scope, 466 + setScope, 381 467 }; 382 468 }