diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx new file mode 100644 index 00000000..b664c1f6 --- /dev/null +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -0,0 +1,581 @@ +"use client"; +import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; +import { + useState, + useCallback, + useRef, + useLayoutEffect, + useEffect, +} from "react"; +import { createPortal } from "react-dom"; +import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import * as Popover from "@radix-ui/react-popover"; +import { EditorState, TextSelection, Plugin } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { Schema, MarkSpec } from "prosemirror-model"; +import { baseKeymap } from "prosemirror-commands"; +import { keymap } from "prosemirror-keymap"; +import { history, undo, redo } from "prosemirror-history"; +import { inputRules, InputRule } from "prosemirror-inputrules"; +import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; + +// Schema with only links, mentions, and hashtags marks +const bskyPostSchema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM: () => ["p", 0] as const, + }, + text: { + group: "inline", + }, + }, + marks: { + link: { + attrs: { + href: {}, + }, + inclusive: false, + parseDOM: [ + { + tag: "a[href]", + getAttrs(dom: HTMLElement) { + return { + href: dom.getAttribute("href"), + }; + }, + }, + ], + toDOM(node) { + let { href } = node.attrs; + return ["a", { href, target: "_blank", class: "text-accent" }, 0]; + }, + } as MarkSpec, + mention: { + attrs: { + did: {}, + }, + inclusive: false, + parseDOM: [ + { + tag: "span.mention", + getAttrs(dom: HTMLElement) { + return { + did: dom.getAttribute("data-did"), + }; + }, + }, + ], + toDOM(node) { + let { did } = node.attrs; + return [ + "span", + { + class: "mention text-accent-contrast", + "data-did": did, + }, + 0, + ]; + }, + } as MarkSpec, + hashtag: { + attrs: { + tag: {}, + }, + inclusive: false, + parseDOM: [ + { + tag: "span.hashtag", + getAttrs(dom: HTMLElement) { + return { + tag: dom.getAttribute("data-tag"), + }; + }, + }, + ], + toDOM(node) { + let { tag } = node.attrs; + return [ + "span", + { + class: "hashtag text-accent-contrast", + "data-tag": tag, + }, + 0, + ]; + }, + } as MarkSpec, + }, +}); + +// Input rule to automatically apply hashtag mark +function createHashtagInputRule() { + return new InputRule(/#([\w]+)\s$/, (state, match, start, end) => { + const [fullMatch, tag] = match; + const tr = state.tr; + + // Replace the matched text (including space) with just the hashtag and space + tr.replaceWith(start, end, [ + state.schema.text("#" + tag), + state.schema.text(" "), + ]); + + // Apply hashtag mark to # and tag text only (not the space) + tr.addMark( + start, + start + tag.length + 1, + bskyPostSchema.marks.hashtag.create({ tag }), + ); + + return tr; + }); +} + +export function BlueskyPostEditorProsemirror(props: { + editorStateRef: React.MutableRefObject; + initialContent?: string; + onCharCountChange?: (count: number) => void; +}) { + const mountRef = useRef(null); + const viewRef = useRef(null); + const [editorState, setEditorState] = useState(null); + const [mentionState, setMentionState] = useState<{ + active: boolean; + range: { from: number; to: number } | null; + selectedMention: { handle: string; did: string } | null; + }>({ active: false, range: null, selectedMention: null }); + + const handleMentionSelect = useCallback( + ( + mention: { handle: string; did: string }, + range: { from: number; to: number }, + ) => { + if (!viewRef.current) return; + const view = viewRef.current; + const { from, to } = range; + const tr = view.state.tr; + + // Delete the query text (keep the @) + tr.delete(from + 1, to); + + // Insert the mention text after the @ + const mentionText = mention.handle; + tr.insertText(mentionText, from + 1); + + // Apply mention mark to @ and handle + tr.addMark( + from, + from + 1 + mentionText.length, + bskyPostSchema.marks.mention.create({ did: mention.did }), + ); + + // Add a space after the mention + tr.insertText(" ", from + 1 + mentionText.length); + + view.dispatch(tr); + view.focus(); + }, + [], + ); + + const mentionStateRef = useRef(mentionState); + mentionStateRef.current = mentionState; + + useLayoutEffect(() => { + if (!mountRef.current) return; + + const initialState = EditorState.create({ + schema: bskyPostSchema, + doc: props.initialContent + ? bskyPostSchema.nodeFromJSON({ + type: "doc", + content: props.initialContent.split("\n").map((line) => ({ + type: "paragraph", + content: line ? [{ type: "text", text: line }] : undefined, + })), + }) + : undefined, + plugins: [ + inputRules({ rules: [createHashtagInputRule()] }), + keymap({ + "Mod-z": undo, + "Mod-y": redo, + "Shift-Mod-z": redo, + Enter: (state, dispatch) => { + // Check if mention autocomplete is active + const currentMentionState = mentionStateRef.current; + if ( + currentMentionState.active && + currentMentionState.selectedMention && + currentMentionState.range + ) { + handleMentionSelect( + currentMentionState.selectedMention, + currentMentionState.range, + ); + return true; + } + // Otherwise let the default Enter behavior happen (new paragraph) + return false; + }, + }), + keymap(baseKeymap), + autolink({ + type: bskyPostSchema.marks.link, + shouldAutoLink: () => true, + defaultProtocol: "https", + }), + history(), + ], + }); + + setEditorState(initialState); + props.editorStateRef.current = initialState; + + const view = new EditorView( + { mount: mountRef.current }, + { + state: initialState, + dispatchTransaction(tr) { + const newState = view.state.apply(tr); + view.updateState(newState); + setEditorState(newState); + props.editorStateRef.current = newState; + props.onCharCountChange?.(newState.doc.textContent.length); + }, + }, + ); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + }, [handleMentionSelect]); + + return ( +
+ {editorState && ( + { + setMentionState({ active, range, selectedMention }); + }} + /> + )} +
+
+ ); +} + +function MentionAutocomplete(props: { + editorState: EditorState; + view: React.RefObject; + onSelect: ( + mention: { handle: string; did: string }, + range: { from: number; to: number }, + ) => void; + onMentionStateChange: ( + active: boolean, + range: { from: number; to: number } | null, + selectedMention: { handle: string; did: string } | null, + ) => void; +}) { + const [mentionQuery, setMentionQuery] = useState(null); + const [mentionRange, setMentionRange] = useState<{ + from: number; + to: number; + } | null>(null); + const [mentionCoords, setMentionCoords] = useState<{ + top: number; + left: number; + } | null>(null); + + const { suggestionIndex, setSuggestionIndex, suggestions } = + useMentionSuggestions(mentionQuery); + + // Check for mention pattern whenever editor state changes + useEffect(() => { + const { $from } = props.editorState.selection; + const textBefore = $from.parent.textBetween( + Math.max(0, $from.parentOffset - 50), + $from.parentOffset, + null, + "\ufffc", + ); + + // Look for @ followed by word characters before cursor + const match = textBefore.match(/@([\w.]*)$/); + + if (match && props.view.current) { + const queryBefore = match[1]; + const from = $from.pos - queryBefore.length - 1; + + // Get text after cursor to find the rest of the handle + const textAfter = $from.parent.textBetween( + $from.parentOffset, + Math.min($from.parent.content.size, $from.parentOffset + 50), + null, + "\ufffc", + ); + + // Match word characters after cursor until space or end + const afterMatch = textAfter.match(/^([\w.]*)/); + const queryAfter = afterMatch ? afterMatch[1] : ""; + + // Combine the full handle + const query = queryBefore + queryAfter; + const to = $from.pos + queryAfter.length; + + setMentionQuery(query); + setMentionRange({ from, to }); + + // Get coordinates for the autocomplete popup + const coords = props.view.current.coordsAtPos(from); + setMentionCoords({ + top: coords.bottom + window.scrollY, + left: coords.left + window.scrollX, + }); + setSuggestionIndex(0); + } else { + setMentionQuery(null); + setMentionRange(null); + setMentionCoords(null); + } + }, [props.editorState, props.view, setSuggestionIndex]); + + // Update parent's mention state + useEffect(() => { + const active = mentionQuery !== null && suggestions.length > 0; + const selectedMention = + active && suggestions[suggestionIndex] + ? suggestions[suggestionIndex] + : null; + props.onMentionStateChange(active, mentionRange, selectedMention); + }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); + + // Handle keyboard navigation for arrow keys only + useEffect(() => { + if (!mentionQuery || !props.view.current) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (suggestions.length === 0) return; + + if (e.key === "ArrowUp") { + e.preventDefault(); + if (suggestionIndex > 0) { + setSuggestionIndex((i) => i - 1); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (suggestionIndex < suggestions.length - 1) { + setSuggestionIndex((i) => i + 1); + } + } + }; + + const dom = props.view.current.dom; + dom.addEventListener("keydown", handleKeyDown); + + return () => { + dom.removeEventListener("keydown", handleKeyDown); + }; + }, [ + mentionQuery, + suggestions, + suggestionIndex, + props.view, + setSuggestionIndex, + ]); + + if (!mentionCoords || suggestions.length === 0) return null; + + // The styles in this component should match the Menu styles in components/Layout.tsx + return ( + + {createPortal( + , + document.body, + )} + + e.preventDefault()} + className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} + > +
    + {suggestions.map((result, index) => { + return ( +
    { + if (mentionRange) { + props.onSelect(result, mentionRange); + setMentionQuery(null); + setMentionRange(null); + setMentionCoords(null); + } + }} + onMouseDown={(e) => e.preventDefault()} + > + @{result.handle} +
    + ); + })} +
+
+
+
+ ); +} + +function useMentionSuggestions(query: string | null) { + const [suggestionIndex, setSuggestionIndex] = useState(0); + const [suggestions, setSuggestions] = useState< + { handle: string; did: string }[] + >([]); + + useDebouncedEffect( + async () => { + if (!query) { + setSuggestions([]); + return; + } + + const agent = new Agent("https://public.api.bsky.app"); + const result = await agent.searchActorsTypeahead({ + q: query, + limit: 8, + }); + setSuggestions( + result.data.actors.map((actor) => ({ + handle: actor.handle, + did: actor.did, + })), + ); + }, + 300, + [query], + ); + + useEffect(() => { + if (suggestionIndex > suggestions.length - 1) { + setSuggestionIndex(Math.max(0, suggestions.length - 1)); + } + }, [suggestionIndex, suggestions.length]); + + return { + suggestions, + suggestionIndex, + setSuggestionIndex, + }; +} + +/** + * Converts a ProseMirror editor state to Bluesky post facets. + * Extracts mentions, links, and hashtags from the editor state and returns them + * as an array of Bluesky richtext facets with proper byte positions. + */ +export function editorStateToFacets( + state: EditorState, +): AppBskyRichtextFacet.Main[] { + const facets: AppBskyRichtextFacet.Main[] = []; + const fullText = state.doc.textContent; + const unicodeString = new UnicodeString(fullText); + + let byteOffset = 0; + + // Walk through the document to extract marks with their positions + state.doc.descendants((node, pos) => { + if (node.isText && node.text) { + const text = node.text; + const textLength = new UnicodeString(text).length; + + // Check for mention mark + const mentionMark = node.marks.find((m) => m.type.name === "mention"); + if (mentionMark) { + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + textLength, + }, + features: [ + { + $type: "app.bsky.richtext.facet#mention", + did: mentionMark.attrs.did, + }, + ], + }); + } + + // Check for link mark + const linkMark = node.marks.find((m) => m.type.name === "link"); + if (linkMark) { + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + textLength, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: linkMark.attrs.href, + }, + ], + }); + } + + // Check for hashtag mark + const hashtagMark = node.marks.find((m) => m.type.name === "hashtag"); + if (hashtagMark) { + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + textLength, + }, + features: [ + { + $type: "app.bsky.richtext.facet#tag", + tag: hashtagMark.attrs.tag, + }, + ], + }); + } + + byteOffset += textLength; + } + }); + + return facets; +} diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 10bd8499..c1927d5b 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -1,7 +1,7 @@ "use client"; import { publishToPublication } from "actions/publishToPublication"; import { DotLoader } from "components/utils/DotLoader"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { ButtonPrimary } from "components/Buttons"; import { Radio } from "components/Checkbox"; import { useParams } from "next/navigation"; @@ -13,6 +13,11 @@ import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/act import { AtUri } from "@atproto/syntax"; import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; import { useReplicache } from "src/replicache"; +import { + BlueskyPostEditorProsemirror, + editorStateToFacets, +} from "./BskyPostEditorProsemirror"; +import { EditorState } from "prosemirror-state"; type Props = { title: string; @@ -51,8 +56,9 @@ const PublishPostForm = ( } & Props, ) => { let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); - let [postContent, setPostContent] = useState(""); + let editorStateRef = useRef(null); let [isLoading, setIsLoading] = useState(false); + let [charCount, setCharCount] = useState(0); let params = useParams(); let { rep } = useReplicache(); @@ -70,9 +76,13 @@ const PublishPostForm = ( if (!doc) return; let post_url = `https://${props.record?.base_path}/${doc.rkey}`; + let facets = editorStateRef.current + ? editorStateToFacets(editorStateRef.current) + : []; if (shareOption === "bluesky") await publishPostToBsky({ - text: postContent, + facets, + text: editorStateRef.current?.doc.textContent || "", title: props.title, url: post_url, description: props.description, @@ -145,12 +155,9 @@ const PublishPostForm = (

@{props.profile.handle}

- - setPostContent(e.currentTarget.value.slice(0, 300)) - } - placeholder="Write a post to share your writing!" +
@@ -165,7 +172,7 @@ const PublishPostForm = (
- {postContent.length}/300 + {charCount}/300
@@ -178,7 +185,11 @@ const PublishPostForm = ( > Back - + 300} + > {isLoading ? : "Publish this Post!"} diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts index 0d8c2218..7fa16dd6 100644 --- a/app/[leaflet_id]/publish/publishBskyPost.ts +++ b/app/[leaflet_id]/publish/publishBskyPost.ts @@ -1,6 +1,10 @@ "use server"; -import { Agent as BskyAgent } from "@atproto/api"; +import { + AppBskyRichtextFacet, + Agent as BskyAgent, + UnicodeString, +} from "@atproto/api"; import sharp from "sharp"; import { TID } from "@atproto/common"; import { getIdentityData } from "actions/getIdentityData"; @@ -16,6 +20,7 @@ export async function publishPostToBsky(args: { description: string; document_record: PubLeafletDocument.Record; rkey: string; + facets: AppBskyRichtextFacet.Main[]; }) { const oauthClient = await createOauthClient(); let identity = await getIdentityData(); @@ -56,6 +61,7 @@ export async function publishPostToBsky(args: { { text: args.text, createdAt: new Date().toISOString(), + facets: args.facets, embed: { $type: "app.bsky.embed.external", external: {