a tool for shared writing and social publishing

add mention on enter

+234 -89
+121 -45
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 19 19 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 20 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 21 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 22 + import { callRPC } from "app/api/rpc/client"; 23 + import { schema } from "components/Blocks/TextBlock/schema"; 22 24 23 25 // Schema with only links, mentions, and hashtags marks 24 26 const bskyPostSchema = new Schema({ ··· 134 136 return tr; 135 137 }); 136 138 } 137 - 139 + export type MentionState = { 140 + active: boolean; 141 + range: { from: number; to: number } | null; 142 + selectedMention: Mention | null; 143 + }; 138 144 export function BlueskyPostEditorProsemirror(props: { 139 - editorStateRef: React.MutableRefObject<EditorState | null>; 145 + editorStateRef: React.RefObject<EditorState | null>; 140 146 initialContent?: string; 141 147 onCharCountChange?: (count: number) => void; 142 148 }) { 143 149 const mountRef = useRef<HTMLDivElement | null>(null); 144 150 const viewRef = useRef<EditorView | null>(null); 145 151 const [editorState, setEditorState] = useState<EditorState | null>(null); 146 - const [mentionState, setMentionState] = useState<{ 147 - active: boolean; 148 - range: { from: number; to: number } | null; 149 - selectedMention: { handle: string; did: string } | null; 150 - }>({ active: false, range: null, selectedMention: null }); 152 + const [mentionState, setMentionState] = useState<MentionState>({ 153 + active: false, 154 + range: null, 155 + selectedMention: null, 156 + }); 151 157 152 158 const handleMentionSelect = useCallback( 153 - ( 154 - mention: { handle: string; did: string }, 155 - range: { from: number; to: number }, 156 - ) => { 159 + (mention: Mention, range: { from: number; to: number }) => { 160 + if (mention.type !== "did") return; 157 161 if (!viewRef.current) return; 158 162 const view = viewRef.current; 159 163 const { from, to } = range; ··· 288 292 ); 289 293 } 290 294 291 - function MentionAutocomplete(props: { 295 + export function MentionAutocomplete(props: { 292 296 editorState: EditorState; 293 297 view: React.RefObject<EditorView | null>; 294 - onSelect: ( 295 - mention: { handle: string; did: string }, 296 - range: { from: number; to: number }, 297 - ) => void; 298 + onSelect: (mention: Mention, range: { from: number; to: number }) => void; 298 299 onMentionStateChange: ( 299 300 active: boolean, 300 301 range: { from: number; to: number } | null, 301 - selectedMention: { handle: string; did: string } | null, 302 + selectedMention: Mention | null, 302 303 ) => void; 303 304 }) { 304 305 const [mentionQuery, setMentionQuery] = useState<string | null>(null); ··· 325 326 ); 326 327 327 328 // Look for @ followed by word characters before cursor 328 - const match = textBefore.match(/@([\w.]*)$/); 329 + const match = textBefore.match(/(?:^|\s)@([\w.]*)$/); 329 330 330 331 if (match && props.view.current) { 331 332 const queryBefore = match[1]; ··· 434 435 > 435 436 <ul className="list-none p-0 text-sm"> 436 437 {suggestions.map((result, index) => { 437 - return ( 438 - <div 439 - className={` 438 + if (result.type === "did") 439 + return ( 440 + <div 441 + className={` 440 442 MenuItem 441 443 font-bold z-10 py-1 px-3 442 444 text-left text-secondary ··· 445 447 hover:bg-border-light hover:text-secondary 446 448 outline-none 447 449 `} 448 - key={result.did} 449 - onClick={() => { 450 - if (mentionRange) { 451 - props.onSelect(result, mentionRange); 452 - setMentionQuery(null); 453 - setMentionRange(null); 454 - setMentionCoords(null); 455 - } 456 - }} 457 - onMouseDown={(e) => e.preventDefault()} 458 - > 459 - @{result.handle} 460 - </div> 461 - ); 450 + key={result.did} 451 + onClick={() => { 452 + if (mentionRange) { 453 + props.onSelect(result, mentionRange); 454 + setMentionQuery(null); 455 + setMentionRange(null); 456 + setMentionCoords(null); 457 + } 458 + }} 459 + onMouseDown={(e) => e.preventDefault()} 460 + > 461 + @{result.handle} 462 + </div> 463 + ); 464 + if (result.type == "publication") { 465 + return ( 466 + <div 467 + className={` 468 + text-test 469 + MenuItem 470 + font-bold z-10 py-1 px-3 471 + text-left 472 + flex gap-2 473 + ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 474 + hover:bg-border-light hover:text-secondary 475 + outline-none 476 + `} 477 + key={result.uri} 478 + onClick={() => { 479 + if (mentionRange) { 480 + props.onSelect(result, mentionRange); 481 + setMentionQuery(null); 482 + setMentionRange(null); 483 + setMentionCoords(null); 484 + } 485 + }} 486 + onMouseDown={(e) => e.preventDefault()} 487 + > 488 + {result.name} 489 + </div> 490 + ); 491 + } 462 492 })} 463 493 </ul> 464 494 </Popover.Content> ··· 467 497 ); 468 498 } 469 499 500 + export type Mention = 501 + | { type: "did"; handle: string; did: string } 502 + | { type: "publication"; uri: string; name: string }; 470 503 function useMentionSuggestions(query: string | null) { 471 504 const [suggestionIndex, setSuggestionIndex] = useState(0); 472 - const [suggestions, setSuggestions] = useState< 473 - { handle: string; did: string }[] 474 - >([]); 505 + const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 475 506 476 507 useDebouncedEffect( 477 508 async () => { ··· 481 512 } 482 513 483 514 const agent = new Agent("https://public.api.bsky.app"); 484 - const result = await agent.searchActorsTypeahead({ 485 - q: query, 486 - limit: 8, 487 - }); 488 - setSuggestions( 489 - result.data.actors.map((actor) => ({ 515 + const [result, publications] = await Promise.all([ 516 + agent.searchActorsTypeahead({ 517 + q: query, 518 + limit: 8, 519 + }), 520 + callRPC(`search_publication_names`, { query, limit: 8 }), 521 + ]); 522 + setSuggestions([ 523 + ...result.data.actors.map((actor) => ({ 524 + type: "did" as const, 490 525 handle: actor.handle, 491 526 did: actor.did, 492 527 })), 493 - ); 528 + ...publications.result.publications.map((p) => ({ 529 + type: "publication" as const, 530 + uri: p.uri, 531 + name: p.name, 532 + })), 533 + ]); 494 534 }, 495 535 300, 496 536 [query], ··· 593 633 594 634 return features; 595 635 } 636 + 637 + export const addMentionToEditor = ( 638 + mention: Mention, 639 + range: { from: number; to: number }, 640 + view: EditorView, 641 + ) => { 642 + if (!view) return; 643 + const { from, to } = range; 644 + const tr = view.state.tr; 645 + // Delete the query text (keep the @) 646 + tr.delete(from + 1, to); 647 + 648 + if (mention.type == "did") { 649 + tr.insertText(mention.handle, from + 1); 650 + tr.addMark( 651 + from, 652 + from + 1 + mention.handle.length, 653 + schema.marks.didMention.create({ did: mention.did }), 654 + ); 655 + tr.insertText(" ", from + 1 + mention.handle.length); 656 + } 657 + if (mention.type === "publication") { 658 + tr.insertText(mention.name, from + 1); 659 + tr.addMark( 660 + from, 661 + from + 1 + mention.name.length, 662 + schema.marks.atMention.create({ atURI: mention.uri }), 663 + ); 664 + tr.insertText(" ", from + 1 + mention.name.length); 665 + } 666 + 667 + // Insert the mention text after the @ 668 + 669 + view.dispatch(tr); 670 + view.focus(); 671 + };
+2
app/api/rpc/[command]/route.ts
··· 11 11 } from "./domain_routes"; 12 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 13 import { get_publication_data } from "./get_publication_data"; 14 + import { search_publication_names } from "./search_publication_names"; 14 15 15 16 let supabase = createClient<Database>( 16 17 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 36 get_leaflet_subdomain_status, 36 37 get_leaflet_data, 37 38 get_publication_data, 39 + search_publication_names, 38 40 ]; 39 41 export async function POST( 40 42 req: Request,
+32
app/api/rpc/[command]/search_publication_names.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + 5 + export type SearchPublicationNamesReturnType = Awaited< 6 + ReturnType<(typeof search_publication_names)["handler"]> 7 + >; 8 + 9 + export const search_publication_names = makeRoute({ 10 + route: "search_publication_names", 11 + input: z.object({ 12 + query: z.string(), 13 + limit: z.number().optional().default(10), 14 + }), 15 + handler: async ( 16 + { query, limit }, 17 + { supabase }: Pick<Env, "supabase">, 18 + ) => { 19 + // Search publications by name (case-insensitive partial match) 20 + const { data: publications, error } = await supabase 21 + .from("publications") 22 + .select("uri, name, identity_did, record") 23 + .ilike("name", `%${query}%`) 24 + .limit(limit); 25 + 26 + if (error) { 27 + throw new Error(`Failed to search publications: ${error.message}`); 28 + } 29 + 30 + return { result: { publications } }; 31 + }, 32 + });
+3
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 22 22 let isStrikethrough = segment.facet?.find( 23 23 PubLeafletRichtextFacet.isStrikethrough, 24 24 ); 25 + let isDidMention = segment.facet?.find( 26 + PubLeafletRichtextFacet.isDidMention, 27 + ); 25 28 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 29 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 27 30 let isHighlighted = segment.facet?.find(
+14 -32
components/Blocks/TextBlock/index.tsx
··· 25 25 import { DotLoader } from "components/utils/DotLoader"; 26 26 import { useMountProsemirror } from "./mountProsemirror"; 27 27 import { schema } from "./schema"; 28 - import { MentionAutocomplete } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 28 + import { 29 + addMentionToEditor, 30 + Mention, 31 + MentionAutocomplete, 32 + } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 29 33 30 34 const HeadingStyle = { 31 35 1: "text-xl font-bold", ··· 186 190 let editorState = useEditorStates( 187 191 (s) => s.editorStates[props.entityID], 188 192 )?.editor; 189 - let { viewRef, handleMentionSelect, setMentionState } = useMentionState( 190 - props.entityID, 191 - ); 193 + let { viewRef, handleMentionSelect, setMentionState, mentionStateRef } = 194 + useMentionState(props.entityID); 192 195 193 - let { mountRef, actionTimeout } = useMountProsemirror({ props }); 196 + let { mountRef, actionTimeout } = useMountProsemirror({ 197 + props, 198 + mentionStateRef, 199 + }); 194 200 195 201 return ( 196 202 <> ··· 458 464 const [mentionState, setMentionState] = useState<{ 459 465 active: boolean; 460 466 range: { from: number; to: number } | null; 461 - selectedMention: { handle: string; did: string } | null; 467 + selectedMention: Mention | null; 462 468 }>({ active: false, range: null, selectedMention: null }); 463 469 const mentionStateRef = useRef(mentionState); 464 470 mentionStateRef.current = mentionState; 465 471 466 472 const handleMentionSelect = useCallback( 467 - ( 468 - mention: { handle: string; did: string }, 469 - range: { from: number; to: number }, 470 - ) => { 473 + (mention: Mention, range: { from: number; to: number }) => { 471 474 let view = useEditorStates.getState().editorStates[entityID]?.view; 472 475 if (!view) return; 473 - const { from, to } = range; 474 - const tr = view.state.tr; 475 - 476 - // Delete the query text (keep the @) 477 - tr.delete(from + 1, to); 478 - 479 - // Insert the mention text after the @ 480 - const mentionText = mention.handle; 481 - tr.insertText(mentionText, from + 1); 482 - 483 - // Apply mention mark to @ and handle 484 - tr.addMark( 485 - from, 486 - from + 1 + mentionText.length, 487 - schema.marks.didMention.create({ did: mention.did }), 488 - ); 489 - 490 - // Add a space after the mention 491 - tr.insertText(" ", from + 1 + mentionText.length); 492 - 493 - view.dispatch(tr); 494 - view.focus(); 476 + addMentionToEditor(mention, range, view); 495 477 }, 496 478 [], 497 479 );
+22 -9
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"; 27 31 28 32 type PropsRef = RefObject< 29 33 BlockProps & { ··· 35 39 propsRef: PropsRef, 36 40 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 41 um: UndoManager, 38 - multiLine?: boolean, 42 + mentionStateRef: RefObject<MentionState>, 39 43 ) => 40 44 ({ 41 45 "Meta-b": toggleMark(schema.marks.strong), ··· 138 142 ), 139 143 "Shift-Backspace": backspace(propsRef, repRef), 140 144 Enter: (state, dispatch, view) => { 141 - if (multiLine && state.doc.content.size - state.selection.anchor > 1) 142 - return false; 143 - return um.withUndoGroup(() => 144 - enter(propsRef, repRef)(state, dispatch, view), 145 - ); 145 + 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 + return enter(propsRef, repRef)(state, dispatch, view); 161 + }); 146 162 }, 147 163 "Shift-Enter": (state, dispatch, view) => { 148 - if (multiLine) { 149 - return baseKeymap.Enter(state, dispatch, view); 150 - } 151 164 return um.withUndoGroup(() => 152 165 enter(propsRef, repRef)(state, dispatch, view), 153 166 );
+14 -2
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"; 26 27 27 - export function useMountProsemirror({ props }: { props: BlockProps }) { 28 + export function useMountProsemirror({ 29 + props, 30 + mentionStateRef, 31 + }: { 32 + props: BlockProps; 33 + mentionStateRef: React.RefObject<MentionState>; 34 + }) { 28 35 let { entityID, parent } = props; 29 36 let rep = useReplicache(); 30 37 let mountRef = useRef<HTMLPreElement | null>(null); ··· 44 51 useLayoutEffect(() => { 45 52 if (!mountRef.current) return; 46 53 47 - const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + mentionStateRef, 59 + ); 48 60 const editor = EditorState.create({ 49 61 schema: schema, 50 62 plugins: [
+26 -1
components/Blocks/TextBlock/schema.ts
··· 103 103 return ["a", { href, target: "_blank" }, 0]; 104 104 }, 105 105 } as MarkSpec, 106 - 106 + atMention: { 107 + attrs: { 108 + atURI: {}, 109 + }, 110 + inclusive: false, 111 + parseDOM: [ 112 + { 113 + tag: "span.atMention", 114 + getAttrs(dom: HTMLElement) { 115 + return { 116 + atURI: dom.getAttribute("data-at-uri"), 117 + }; 118 + }, 119 + }, 120 + ], 121 + toDOM(node) { 122 + return [ 123 + "span", 124 + { 125 + class: "atMention text-accent-contrast", 126 + "data-at-uri": node.attrs.atURI, 127 + }, 128 + 0, 129 + ]; 130 + }, 131 + } as MarkSpec, 107 132 didMention: { 108 133 attrs: { 109 134 did: {},