a tool for shared writing and social publishing

Feature/mentions in bsky editor (#209)

* add bsky editor w/ unstyled mentioned autocomplete

* use prosemirror editor instead of textarea

* implement facet calculation on the client, publish tags and links

authored by awarm.space and committed by

GitHub 4d7b4dc4 39f1d492

+610 -12
+581
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 + "use client"; 2 + import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { 4 + useState, 5 + useCallback, 6 + useRef, 7 + useLayoutEffect, 8 + useEffect, 9 + } from "react"; 10 + import { createPortal } from "react-dom"; 11 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12 + import * as Popover from "@radix-ui/react-popover"; 13 + import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14 + import { EditorView } from "prosemirror-view"; 15 + import { Schema, MarkSpec } from "prosemirror-model"; 16 + import { baseKeymap } from "prosemirror-commands"; 17 + import { keymap } from "prosemirror-keymap"; 18 + import { history, undo, redo } from "prosemirror-history"; 19 + import { inputRules, InputRule } from "prosemirror-inputrules"; 20 + import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 + 22 + // Schema with only links, mentions, and hashtags marks 23 + const bskyPostSchema = new Schema({ 24 + nodes: { 25 + doc: { content: "block+" }, 26 + paragraph: { 27 + content: "inline*", 28 + group: "block", 29 + parseDOM: [{ tag: "p" }], 30 + toDOM: () => ["p", 0] as const, 31 + }, 32 + text: { 33 + group: "inline", 34 + }, 35 + }, 36 + marks: { 37 + link: { 38 + attrs: { 39 + href: {}, 40 + }, 41 + inclusive: false, 42 + parseDOM: [ 43 + { 44 + tag: "a[href]", 45 + getAttrs(dom: HTMLElement) { 46 + return { 47 + href: dom.getAttribute("href"), 48 + }; 49 + }, 50 + }, 51 + ], 52 + toDOM(node) { 53 + let { href } = node.attrs; 54 + return ["a", { href, target: "_blank", class: "text-accent" }, 0]; 55 + }, 56 + } as MarkSpec, 57 + mention: { 58 + attrs: { 59 + did: {}, 60 + }, 61 + inclusive: false, 62 + parseDOM: [ 63 + { 64 + tag: "span.mention", 65 + getAttrs(dom: HTMLElement) { 66 + return { 67 + did: dom.getAttribute("data-did"), 68 + }; 69 + }, 70 + }, 71 + ], 72 + toDOM(node) { 73 + let { did } = node.attrs; 74 + return [ 75 + "span", 76 + { 77 + class: "mention text-accent-contrast", 78 + "data-did": did, 79 + }, 80 + 0, 81 + ]; 82 + }, 83 + } as MarkSpec, 84 + hashtag: { 85 + attrs: { 86 + tag: {}, 87 + }, 88 + inclusive: false, 89 + parseDOM: [ 90 + { 91 + tag: "span.hashtag", 92 + getAttrs(dom: HTMLElement) { 93 + return { 94 + tag: dom.getAttribute("data-tag"), 95 + }; 96 + }, 97 + }, 98 + ], 99 + toDOM(node) { 100 + let { tag } = node.attrs; 101 + return [ 102 + "span", 103 + { 104 + class: "hashtag text-accent-contrast", 105 + "data-tag": tag, 106 + }, 107 + 0, 108 + ]; 109 + }, 110 + } as MarkSpec, 111 + }, 112 + }); 113 + 114 + // Input rule to automatically apply hashtag mark 115 + function createHashtagInputRule() { 116 + return new InputRule(/#([\w]+)\s$/, (state, match, start, end) => { 117 + const [fullMatch, tag] = match; 118 + const tr = state.tr; 119 + 120 + // Replace the matched text (including space) with just the hashtag and space 121 + tr.replaceWith(start, end, [ 122 + state.schema.text("#" + tag), 123 + state.schema.text(" "), 124 + ]); 125 + 126 + // Apply hashtag mark to # and tag text only (not the space) 127 + tr.addMark( 128 + start, 129 + start + tag.length + 1, 130 + bskyPostSchema.marks.hashtag.create({ tag }), 131 + ); 132 + 133 + return tr; 134 + }); 135 + } 136 + 137 + export function BlueskyPostEditorProsemirror(props: { 138 + editorStateRef: React.MutableRefObject<EditorState | null>; 139 + initialContent?: string; 140 + onCharCountChange?: (count: number) => void; 141 + }) { 142 + const mountRef = useRef<HTMLDivElement | null>(null); 143 + const viewRef = useRef<EditorView | null>(null); 144 + const [editorState, setEditorState] = useState<EditorState | null>(null); 145 + const [mentionState, setMentionState] = useState<{ 146 + active: boolean; 147 + range: { from: number; to: number } | null; 148 + selectedMention: { handle: string; did: string } | null; 149 + }>({ active: false, range: null, selectedMention: null }); 150 + 151 + const handleMentionSelect = useCallback( 152 + ( 153 + mention: { handle: string; did: string }, 154 + range: { from: number; to: number }, 155 + ) => { 156 + if (!viewRef.current) return; 157 + const view = viewRef.current; 158 + const { from, to } = range; 159 + const tr = view.state.tr; 160 + 161 + // Delete the query text (keep the @) 162 + tr.delete(from + 1, to); 163 + 164 + // Insert the mention text after the @ 165 + const mentionText = mention.handle; 166 + tr.insertText(mentionText, from + 1); 167 + 168 + // Apply mention mark to @ and handle 169 + tr.addMark( 170 + from, 171 + from + 1 + mentionText.length, 172 + bskyPostSchema.marks.mention.create({ did: mention.did }), 173 + ); 174 + 175 + // Add a space after the mention 176 + tr.insertText(" ", from + 1 + mentionText.length); 177 + 178 + view.dispatch(tr); 179 + view.focus(); 180 + }, 181 + [], 182 + ); 183 + 184 + const mentionStateRef = useRef(mentionState); 185 + mentionStateRef.current = mentionState; 186 + 187 + useLayoutEffect(() => { 188 + if (!mountRef.current) return; 189 + 190 + const initialState = EditorState.create({ 191 + schema: bskyPostSchema, 192 + doc: props.initialContent 193 + ? bskyPostSchema.nodeFromJSON({ 194 + type: "doc", 195 + content: props.initialContent.split("\n").map((line) => ({ 196 + type: "paragraph", 197 + content: line ? [{ type: "text", text: line }] : undefined, 198 + })), 199 + }) 200 + : undefined, 201 + plugins: [ 202 + inputRules({ rules: [createHashtagInputRule()] }), 203 + keymap({ 204 + "Mod-z": undo, 205 + "Mod-y": redo, 206 + "Shift-Mod-z": redo, 207 + Enter: (state, dispatch) => { 208 + // Check if mention autocomplete is active 209 + const currentMentionState = mentionStateRef.current; 210 + if ( 211 + currentMentionState.active && 212 + currentMentionState.selectedMention && 213 + currentMentionState.range 214 + ) { 215 + handleMentionSelect( 216 + currentMentionState.selectedMention, 217 + currentMentionState.range, 218 + ); 219 + return true; 220 + } 221 + // Otherwise let the default Enter behavior happen (new paragraph) 222 + return false; 223 + }, 224 + }), 225 + keymap(baseKeymap), 226 + autolink({ 227 + type: bskyPostSchema.marks.link, 228 + shouldAutoLink: () => true, 229 + defaultProtocol: "https", 230 + }), 231 + history(), 232 + ], 233 + }); 234 + 235 + setEditorState(initialState); 236 + props.editorStateRef.current = initialState; 237 + 238 + const view = new EditorView( 239 + { mount: mountRef.current }, 240 + { 241 + state: initialState, 242 + dispatchTransaction(tr) { 243 + const newState = view.state.apply(tr); 244 + view.updateState(newState); 245 + setEditorState(newState); 246 + props.editorStateRef.current = newState; 247 + props.onCharCountChange?.(newState.doc.textContent.length); 248 + }, 249 + }, 250 + ); 251 + 252 + viewRef.current = view; 253 + 254 + return () => { 255 + view.destroy(); 256 + viewRef.current = null; 257 + }; 258 + }, [handleMentionSelect]); 259 + 260 + return ( 261 + <div className="relative w-full h-full"> 262 + {editorState && ( 263 + <MentionAutocomplete 264 + editorState={editorState} 265 + view={viewRef} 266 + onSelect={handleMentionSelect} 267 + onMentionStateChange={(active, range, selectedMention) => { 268 + setMentionState({ active, range, selectedMention }); 269 + }} 270 + /> 271 + )} 272 + <div 273 + ref={mountRef} 274 + className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 275 + style={{ 276 + wordWrap: "break-word", 277 + overflowWrap: "break-word", 278 + }} 279 + /> 280 + </div> 281 + ); 282 + } 283 + 284 + function MentionAutocomplete(props: { 285 + editorState: EditorState; 286 + view: React.RefObject<EditorView | null>; 287 + onSelect: ( 288 + mention: { handle: string; did: string }, 289 + range: { from: number; to: number }, 290 + ) => void; 291 + onMentionStateChange: ( 292 + active: boolean, 293 + range: { from: number; to: number } | null, 294 + selectedMention: { handle: string; did: string } | null, 295 + ) => void; 296 + }) { 297 + const [mentionQuery, setMentionQuery] = useState<string | null>(null); 298 + const [mentionRange, setMentionRange] = useState<{ 299 + from: number; 300 + to: number; 301 + } | null>(null); 302 + const [mentionCoords, setMentionCoords] = useState<{ 303 + top: number; 304 + left: number; 305 + } | null>(null); 306 + 307 + const { suggestionIndex, setSuggestionIndex, suggestions } = 308 + useMentionSuggestions(mentionQuery); 309 + 310 + // Check for mention pattern whenever editor state changes 311 + useEffect(() => { 312 + const { $from } = props.editorState.selection; 313 + const textBefore = $from.parent.textBetween( 314 + Math.max(0, $from.parentOffset - 50), 315 + $from.parentOffset, 316 + null, 317 + "\ufffc", 318 + ); 319 + 320 + // Look for @ followed by word characters before cursor 321 + const match = textBefore.match(/@([\w.]*)$/); 322 + 323 + if (match && props.view.current) { 324 + const queryBefore = match[1]; 325 + const from = $from.pos - queryBefore.length - 1; 326 + 327 + // Get text after cursor to find the rest of the handle 328 + const textAfter = $from.parent.textBetween( 329 + $from.parentOffset, 330 + Math.min($from.parent.content.size, $from.parentOffset + 50), 331 + null, 332 + "\ufffc", 333 + ); 334 + 335 + // Match word characters after cursor until space or end 336 + const afterMatch = textAfter.match(/^([\w.]*)/); 337 + const queryAfter = afterMatch ? afterMatch[1] : ""; 338 + 339 + // Combine the full handle 340 + const query = queryBefore + queryAfter; 341 + const to = $from.pos + queryAfter.length; 342 + 343 + setMentionQuery(query); 344 + setMentionRange({ from, to }); 345 + 346 + // Get coordinates for the autocomplete popup 347 + const coords = props.view.current.coordsAtPos(from); 348 + setMentionCoords({ 349 + top: coords.bottom + window.scrollY, 350 + left: coords.left + window.scrollX, 351 + }); 352 + setSuggestionIndex(0); 353 + } else { 354 + setMentionQuery(null); 355 + setMentionRange(null); 356 + setMentionCoords(null); 357 + } 358 + }, [props.editorState, props.view, setSuggestionIndex]); 359 + 360 + // Update parent's mention state 361 + useEffect(() => { 362 + const active = mentionQuery !== null && suggestions.length > 0; 363 + const selectedMention = 364 + active && suggestions[suggestionIndex] 365 + ? suggestions[suggestionIndex] 366 + : null; 367 + props.onMentionStateChange(active, mentionRange, selectedMention); 368 + }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 369 + 370 + // Handle keyboard navigation for arrow keys only 371 + useEffect(() => { 372 + if (!mentionQuery || !props.view.current) return; 373 + 374 + const handleKeyDown = (e: KeyboardEvent) => { 375 + if (suggestions.length === 0) return; 376 + 377 + if (e.key === "ArrowUp") { 378 + e.preventDefault(); 379 + if (suggestionIndex > 0) { 380 + setSuggestionIndex((i) => i - 1); 381 + } 382 + } else if (e.key === "ArrowDown") { 383 + e.preventDefault(); 384 + if (suggestionIndex < suggestions.length - 1) { 385 + setSuggestionIndex((i) => i + 1); 386 + } 387 + } 388 + }; 389 + 390 + const dom = props.view.current.dom; 391 + dom.addEventListener("keydown", handleKeyDown); 392 + 393 + return () => { 394 + dom.removeEventListener("keydown", handleKeyDown); 395 + }; 396 + }, [ 397 + mentionQuery, 398 + suggestions, 399 + suggestionIndex, 400 + props.view, 401 + setSuggestionIndex, 402 + ]); 403 + 404 + if (!mentionCoords || suggestions.length === 0) return null; 405 + 406 + // The styles in this component should match the Menu styles in components/Layout.tsx 407 + return ( 408 + <Popover.Root open> 409 + {createPortal( 410 + <Popover.Anchor 411 + style={{ 412 + top: mentionCoords.top, 413 + left: mentionCoords.left, 414 + position: "absolute", 415 + }} 416 + />, 417 + document.body, 418 + )} 419 + <Popover.Portal> 420 + <Popover.Content 421 + side="bottom" 422 + align="start" 423 + sideOffset={4} 424 + collisionPadding={20} 425 + onOpenAutoFocus={(e) => e.preventDefault()} 426 + className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 427 + > 428 + <ul className="list-none p-0 text-sm"> 429 + {suggestions.map((result, index) => { 430 + return ( 431 + <div 432 + className={` 433 + MenuItem 434 + font-bold z-10 py-1 px-3 435 + text-left text-secondary 436 + flex gap-2 437 + ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 438 + hover:bg-border-light hover:text-secondary 439 + outline-none 440 + `} 441 + key={result.did} 442 + onClick={() => { 443 + if (mentionRange) { 444 + props.onSelect(result, mentionRange); 445 + setMentionQuery(null); 446 + setMentionRange(null); 447 + setMentionCoords(null); 448 + } 449 + }} 450 + onMouseDown={(e) => e.preventDefault()} 451 + > 452 + @{result.handle} 453 + </div> 454 + ); 455 + })} 456 + </ul> 457 + </Popover.Content> 458 + </Popover.Portal> 459 + </Popover.Root> 460 + ); 461 + } 462 + 463 + function useMentionSuggestions(query: string | null) { 464 + const [suggestionIndex, setSuggestionIndex] = useState(0); 465 + const [suggestions, setSuggestions] = useState< 466 + { handle: string; did: string }[] 467 + >([]); 468 + 469 + useDebouncedEffect( 470 + async () => { 471 + if (!query) { 472 + setSuggestions([]); 473 + return; 474 + } 475 + 476 + const agent = new Agent("https://public.api.bsky.app"); 477 + const result = await agent.searchActorsTypeahead({ 478 + q: query, 479 + limit: 8, 480 + }); 481 + setSuggestions( 482 + result.data.actors.map((actor) => ({ 483 + handle: actor.handle, 484 + did: actor.did, 485 + })), 486 + ); 487 + }, 488 + 300, 489 + [query], 490 + ); 491 + 492 + useEffect(() => { 493 + if (suggestionIndex > suggestions.length - 1) { 494 + setSuggestionIndex(Math.max(0, suggestions.length - 1)); 495 + } 496 + }, [suggestionIndex, suggestions.length]); 497 + 498 + return { 499 + suggestions, 500 + suggestionIndex, 501 + setSuggestionIndex, 502 + }; 503 + } 504 + 505 + /** 506 + * Converts a ProseMirror editor state to Bluesky post facets. 507 + * Extracts mentions, links, and hashtags from the editor state and returns them 508 + * as an array of Bluesky richtext facets with proper byte positions. 509 + */ 510 + export function editorStateToFacets( 511 + state: EditorState, 512 + ): AppBskyRichtextFacet.Main[] { 513 + const facets: AppBskyRichtextFacet.Main[] = []; 514 + const fullText = state.doc.textContent; 515 + const unicodeString = new UnicodeString(fullText); 516 + 517 + let byteOffset = 0; 518 + 519 + // Walk through the document to extract marks with their positions 520 + state.doc.descendants((node, pos) => { 521 + if (node.isText && node.text) { 522 + const text = node.text; 523 + const textLength = new UnicodeString(text).length; 524 + 525 + // Check for mention mark 526 + const mentionMark = node.marks.find((m) => m.type.name === "mention"); 527 + if (mentionMark) { 528 + facets.push({ 529 + index: { 530 + byteStart: byteOffset, 531 + byteEnd: byteOffset + textLength, 532 + }, 533 + features: [ 534 + { 535 + $type: "app.bsky.richtext.facet#mention", 536 + did: mentionMark.attrs.did, 537 + }, 538 + ], 539 + }); 540 + } 541 + 542 + // Check for link mark 543 + const linkMark = node.marks.find((m) => m.type.name === "link"); 544 + if (linkMark) { 545 + facets.push({ 546 + index: { 547 + byteStart: byteOffset, 548 + byteEnd: byteOffset + textLength, 549 + }, 550 + features: [ 551 + { 552 + $type: "app.bsky.richtext.facet#link", 553 + uri: linkMark.attrs.href, 554 + }, 555 + ], 556 + }); 557 + } 558 + 559 + // Check for hashtag mark 560 + const hashtagMark = node.marks.find((m) => m.type.name === "hashtag"); 561 + if (hashtagMark) { 562 + facets.push({ 563 + index: { 564 + byteStart: byteOffset, 565 + byteEnd: byteOffset + textLength, 566 + }, 567 + features: [ 568 + { 569 + $type: "app.bsky.richtext.facet#tag", 570 + tag: hashtagMark.attrs.tag, 571 + }, 572 + ], 573 + }); 574 + } 575 + 576 + byteOffset += textLength; 577 + } 578 + }); 579 + 580 + return facets; 581 + }
+22 -11
app/[leaflet_id]/publish/PublishPost.tsx
··· 1 1 "use client"; 2 2 import { publishToPublication } from "actions/publishToPublication"; 3 3 import { DotLoader } from "components/utils/DotLoader"; 4 - import { useState } from "react"; 4 + import { useState, useRef } from "react"; 5 5 import { ButtonPrimary } from "components/Buttons"; 6 6 import { Radio } from "components/Checkbox"; 7 7 import { useParams } from "next/navigation"; ··· 13 13 import { AtUri } from "@atproto/syntax"; 14 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 15 import { useReplicache } from "src/replicache"; 16 + import { 17 + BlueskyPostEditorProsemirror, 18 + editorStateToFacets, 19 + } from "./BskyPostEditorProsemirror"; 20 + import { EditorState } from "prosemirror-state"; 16 21 17 22 type Props = { 18 23 title: string; ··· 51 56 } & Props, 52 57 ) => { 53 58 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 54 - let [postContent, setPostContent] = useState(""); 59 + let editorStateRef = useRef<EditorState | null>(null); 55 60 let [isLoading, setIsLoading] = useState(false); 61 + let [charCount, setCharCount] = useState(0); 56 62 let params = useParams(); 57 63 let { rep } = useReplicache(); 58 64 ··· 70 76 if (!doc) return; 71 77 72 78 let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 79 + let facets = editorStateRef.current 80 + ? editorStateToFacets(editorStateRef.current) 81 + : []; 73 82 if (shareOption === "bluesky") 74 83 await publishPostToBsky({ 75 - text: postContent, 84 + facets, 85 + text: editorStateRef.current?.doc.textContent || "", 76 86 title: props.title, 77 87 url: post_url, 78 88 description: props.description, ··· 145 155 <p className="text-tertiary">@{props.profile.handle}</p> 146 156 </div> 147 157 <div className="flex flex-col"> 148 - <AutosizeTextarea 149 - value={postContent} 150 - onChange={(e) => 151 - setPostContent(e.currentTarget.value.slice(0, 300)) 152 - } 153 - placeholder="Write a post to share your writing!" 158 + <BlueskyPostEditorProsemirror 159 + editorStateRef={editorStateRef} 160 + onCharCountChange={setCharCount} 154 161 /> 155 162 </div> 156 163 <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> ··· 165 172 </div> 166 173 </div> 167 174 <div className="text-xs text-secondary italic place-self-end pt-2"> 168 - {postContent.length}/300 175 + {charCount}/300 169 176 </div> 170 177 </div> 171 178 </div> ··· 178 185 > 179 186 Back 180 187 </Link> 181 - <ButtonPrimary type="submit" className="place-self-end h-[30px]"> 188 + <ButtonPrimary 189 + type="submit" 190 + className="place-self-end h-[30px]" 191 + disabled={charCount > 300} 192 + > 182 193 {isLoading ? <DotLoader /> : "Publish this Post!"} 183 194 </ButtonPrimary> 184 195 </div>
+7 -1
app/[leaflet_id]/publish/publishBskyPost.ts
··· 1 1 "use server"; 2 2 3 - import { Agent as BskyAgent } from "@atproto/api"; 3 + import { 4 + AppBskyRichtextFacet, 5 + Agent as BskyAgent, 6 + UnicodeString, 7 + } from "@atproto/api"; 4 8 import sharp from "sharp"; 5 9 import { TID } from "@atproto/common"; 6 10 import { getIdentityData } from "actions/getIdentityData"; ··· 16 20 description: string; 17 21 document_record: PubLeafletDocument.Record; 18 22 rkey: string; 23 + facets: AppBskyRichtextFacet.Main[]; 19 24 }) { 20 25 const oauthClient = await createOauthClient(); 21 26 let identity = await getIdentityData(); ··· 56 61 { 57 62 text: args.text, 58 63 createdAt: new Date().toISOString(), 64 + facets: args.facets, 59 65 embed: { 60 66 $type: "app.bsky.embed.external", 61 67 external: {