From 64e83f272cc27204151a49643f69ff1ed5c1499b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 5 Dec 2025 15:15:17 -0500 Subject: [PATCH] Feature/at mentions (#241) * add did mention facet * add mention on enter * wip add at-mention * first pass at mention styling * WIP styling the popover for mentions * some changes to how the results array is organized * simplify mention logic * tweak * added a posts button to scope publications in the @ mention dropdown * don't nest buttons * refactor autocomplete and implement post mentions * add pubicon to atmention mark * caching and stuff * render mentions and handle as links * implement mention notifications * styling mention with scoped to publicaiton * style the actual mention * add generic pub icon if user did not specify one, adjust styling to be reflected in rendered version * added a little styling to the mention notifications * hydrate mentions with @handles * set scope and clear * lil things * added descriptions to the mention notifcation * make mentions inline nodes * render profile images and pub images * bunch of small fixes * remove placeholder text * add comment mentions and notifications --------- Co-authored-by: celine --- actions/publishToPublication.ts | 192 ++++++- .../CommentMentionNotification.tsx | 98 ++++ .../notifications/MentionNotification.tsx | 68 ++- .../notifications/Notification.tsx | 6 +- .../notifications/NotificationList.tsx | 10 +- .../notifications/QuoteNotification.tsx | 48 ++ .../publish/BskyPostEditorProsemirror.tsx | 391 ++++--------- app/api/pub_icon/route.ts | 145 +++++ app/api/rpc/[command]/route.ts | 4 + .../[command]/search_publication_documents.ts | 41 ++ .../rpc/[command]/search_publication_names.ts | 37 ++ app/globals.css | 8 + .../[publication]/[rkey]/BaseTextBlock.tsx | 30 + .../Interactions/Comments/CommentBox.tsx | 234 +++++++- .../Interactions/Comments/commentAction.ts | 99 +++- .../[publication]/[rkey]/PostContent.tsx | 17 +- app/lish/uri/[uri]/route.ts | 91 +++ components/AtMentionLink.tsx | 46 ++ components/Blocks/BlockCommandBar.tsx | 6 +- .../Blocks/TextBlock/RenderYJSFragment.tsx | 34 ++ components/Blocks/TextBlock/index.tsx | 123 +++- components/Blocks/TextBlock/inputRules.ts | 20 + components/Blocks/TextBlock/keymap.ts | 11 +- .../Blocks/TextBlock/mountProsemirror.ts | 62 +- components/Blocks/TextBlock/schema.ts | 101 +++- components/Icons/GoBackTiny.tsx | 21 + components/Mention.tsx | 540 ++++++++++++++++++ lexicons/api/lexicons.ts | 24 + .../api/types/pub/leaflet/richtext/facet.ts | 34 ++ lexicons/pub/leaflet/richtext/facet.json | 28 + lexicons/src/facet.ts | 12 + src/notifications.ts | 201 ++++++- src/utils/mentionUtils.ts | 59 ++ 33 files changed, 2458 insertions(+), 383 deletions(-) create mode 100644 app/(home-pages)/notifications/CommentMentionNotification.tsx create mode 100644 app/(home-pages)/notifications/QuoteNotification.tsx create mode 100644 app/api/pub_icon/route.ts create mode 100644 app/api/rpc/[command]/search_publication_documents.ts create mode 100644 app/api/rpc/[command]/search_publication_names.ts create mode 100644 app/lish/uri/[uri]/route.ts create mode 100644 components/AtMentionLink.tsx create mode 100644 components/Icons/GoBackTiny.tsx create mode 100644 components/Mention.tsx create mode 100644 src/utils/mentionUtils.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index a03b4e17..ec6a22c6 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -50,6 +50,8 @@ import { ColorToRGBA, } from "components/ThemeManager/colorToLexicons"; import { parseColor } from "@react-stately/color"; +import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; +import { v7 } from "uuid"; export async function publishToPublication({ root_entity, @@ -210,6 +212,11 @@ export async function publishToPublication({ } } + // Create notifications for mentions (only on first publish) + if (!existingDocUri) { + await createMentionNotifications(result.uri, record, credentialSession.did!); + } + return { rkey, record: JSON.parse(JSON.stringify(record)) }; } @@ -342,7 +349,7 @@ async function processBlocksToPages( Y.applyUpdate(doc, update); let nodes = doc.getXmlElement("prosemirror").toArray(); let stringValue = YJSFragmentToString(nodes[0]); - let facets = YJSFragmentToFacets(nodes[0]); + let { facets } = YJSFragmentToFacets(nodes[0]); return [stringValue, facets] as const; }; if (b.type === "card") { @@ -603,17 +610,67 @@ async function processBlocksToPages( function YJSFragmentToFacets( node: Y.XmlElement | Y.XmlText | Y.XmlHook, -): PubLeafletRichtextFacet.Main[] { + byteOffset: number = 0, +): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { if (node.constructor === Y.XmlElement) { - return node - .toArray() - .map((f) => YJSFragmentToFacets(f)) - .flat(); + // Handle inline mention nodes + if (node.nodeName === "didMention") { + const text = node.getAttribute("text") || ""; + const unicodestring = new UnicodeString(text); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#didMention", + did: node.getAttribute("did"), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "atMention") { + const text = node.getAttribute("text") || ""; + const unicodestring = new UnicodeString(text); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#atMention", + atURI: node.getAttribute("atURI"), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "hard_break") { + const unicodestring = new UnicodeString("\n"); + return { facets: [], byteLength: unicodestring.length }; + } + + // For other elements (like paragraph), process children + let allFacets: PubLeafletRichtextFacet.Main[] = []; + let currentOffset = byteOffset; + for (const child of node.toArray()) { + const result = YJSFragmentToFacets(child, currentOffset); + allFacets.push(...result.facets); + currentOffset += result.byteLength; + } + return { facets: allFacets, byteLength: currentOffset - byteOffset }; } + if (node.constructor === Y.XmlText) { let facets: PubLeafletRichtextFacet.Main[] = []; let delta = node.toDelta() as Delta[]; - let byteStart = 0; + let byteStart = byteOffset; + let totalLength = 0; for (let d of delta) { let unicodestring = new UnicodeString(d.insert); let facet: PubLeafletRichtextFacet.Main = { @@ -646,10 +703,11 @@ function YJSFragmentToFacets( }); if (facet.features.length > 0) facets.push(facet); byteStart += unicodestring.length; + totalLength += unicodestring.length; } - return facets; + return { facets, byteLength: totalLength }; } - return []; + return { facets: [], byteLength: 0 }; } type ExcludeString = T extends string @@ -725,3 +783,119 @@ async function extractThemeFromFacts( return undefined; } + +/** + * Extract mentions from a published document and create notifications + */ +async function createMentionNotifications( + documentUri: string, + record: PubLeafletDocument.Record, + authorDid: string, +) { + const mentionedDids = new Set(); + const mentionedPublications = new Map(); // Map of DID -> publication URI + const mentionedDocuments = new Map(); // Map of DID -> document URI + + // Extract mentions from all text blocks in all pages + for (const page of record.pages) { + if (page.$type === "pub.leaflet.pages.linearDocument") { + const linearPage = page as PubLeafletPagesLinearDocument.Main; + for (const blockWrapper of linearPage.blocks) { + const block = blockWrapper.block; + if (block.$type === "pub.leaflet.blocks.text") { + const textBlock = block as PubLeafletBlocksText.Main; + if (textBlock.facets) { + for (const facet of textBlock.facets) { + for (const feature of facet.features) { + // Check for DID mentions + if (PubLeafletRichtextFacet.isDidMention(feature)) { + if (feature.did !== authorDid) { + mentionedDids.add(feature.did); + } + } + // Check for AT URI mentions (publications and documents) + if (PubLeafletRichtextFacet.isAtMention(feature)) { + const uri = new AtUri(feature.atURI); + + if (uri.collection === "pub.leaflet.publication") { + // Get the publication owner's DID + const { data: publication } = await supabaseServerClient + .from("publications") + .select("identity_did") + .eq("uri", feature.atURI) + .single(); + + if (publication && publication.identity_did !== authorDid) { + mentionedPublications.set(publication.identity_did, feature.atURI); + } + } else if (uri.collection === "pub.leaflet.document") { + // Get the document owner's DID + const { data: document } = await supabaseServerClient + .from("documents") + .select("uri, data") + .eq("uri", feature.atURI) + .single(); + + if (document) { + const docRecord = document.data as PubLeafletDocument.Record; + if (docRecord.author !== authorDid) { + mentionedDocuments.set(docRecord.author, feature.atURI); + } + } + } + } + } + } + } + } + } + } + } + + // Create notifications for DID mentions + for (const did of mentionedDids) { + const notification: Notification = { + id: v7(), + recipient: did, + data: { + type: "mention", + document_uri: documentUri, + mention_type: "did", + }, + }; + await supabaseServerClient.from("notifications").insert(notification); + await pingIdentityToUpdateNotification(did); + } + + // Create notifications for publication mentions + for (const [recipientDid, publicationUri] of mentionedPublications) { + const notification: Notification = { + id: v7(), + recipient: recipientDid, + data: { + type: "mention", + document_uri: documentUri, + mention_type: "publication", + mentioned_uri: publicationUri, + }, + }; + await supabaseServerClient.from("notifications").insert(notification); + await pingIdentityToUpdateNotification(recipientDid); + } + + // Create notifications for document mentions + for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { + const notification: Notification = { + id: v7(), + recipient: recipientDid, + data: { + type: "mention", + document_uri: documentUri, + mention_type: "document", + mentioned_uri: mentionedDocUri, + }, + }; + await supabaseServerClient.from("notifications").insert(notification); + await pingIdentityToUpdateNotification(recipientDid); + } +} diff --git a/app/(home-pages)/notifications/CommentMentionNotification.tsx b/app/(home-pages)/notifications/CommentMentionNotification.tsx new file mode 100644 index 00000000..0e9520cb --- /dev/null +++ b/app/(home-pages)/notifications/CommentMentionNotification.tsx @@ -0,0 +1,98 @@ +import { + AppBskyActorProfile, + PubLeafletComment, + PubLeafletDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { HydratedCommentMentionNotification } from "src/notifications"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { MentionTiny } from "components/Icons/MentionTiny"; +import { + CommentInNotification, + ContentLayout, + Notification, +} from "./Notification"; +import { AtUri } from "@atproto/api"; + +export const CommentMentionNotification = ( + props: HydratedCommentMentionNotification, +) => { + const docRecord = props.commentData.documents + ?.data as PubLeafletDocument.Record; + const commentRecord = props.commentData.record as PubLeafletComment.Record; + const profileRecord = props.commentData.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const pubRecord = props.commentData.documents?.documents_in_publications[0] + ?.publications?.record as PubLeafletPublication.Record | undefined; + const docUri = new AtUri(props.commentData.documents?.uri!); + const rkey = docUri.rkey; + const did = docUri.host; + + const href = pubRecord + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + : `/p/${did}/${rkey}?interactionDrawer=comments`; + + const commenter = props.commenterHandle + ? `@${props.commenterHandle}` + : "Someone"; + + let actionText: React.ReactNode; + let mentionedDocRecord = props.mentionedDocument + ?.data as PubLeafletDocument.Record; + + if (props.mention_type === "did") { + actionText = <>{commenter} mentioned you in a comment; + } else if ( + props.mention_type === "publication" && + props.mentionedPublication + ) { + const mentionedPubRecord = props.mentionedPublication + .record as PubLeafletPublication.Record; + actionText = ( + <> + {commenter} mentioned your publication{" "} + {mentionedPubRecord.name} in a comment + + ); + } else if (props.mention_type === "document" && props.mentionedDocument) { + actionText = ( + <> + {commenter} mentioned your post{" "} + {mentionedDocRecord.title} in a comment + + ); + } else { + actionText = <>{commenter} mentioned you in a comment; + } + + return ( + } + actionText={actionText} + content={ + + + + } + /> + ); +}; diff --git a/app/(home-pages)/notifications/MentionNotification.tsx b/app/(home-pages)/notifications/MentionNotification.tsx index 94c283b6..917b2cdf 100644 --- a/app/(home-pages)/notifications/MentionNotification.tsx +++ b/app/(home-pages)/notifications/MentionNotification.tsx @@ -1,46 +1,66 @@ -import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { MentionTiny } from "components/Icons/MentionTiny"; import { ContentLayout, Notification } from "./Notification"; -import { HydratedQuoteNotification } from "src/notifications"; +import { HydratedMentionNotification } from "src/notifications"; import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import { AtUri } from "@atproto/api"; -import { Avatar } from "components/Avatar"; +import { Agent, AtUri } from "@atproto/api"; -export const QuoteNotification = (props: HydratedQuoteNotification) => { - const postView = props.bskyPost.post_view as any; - const author = postView.author; - const displayName = author.displayName || author.handle || "Someone"; +export const MentionNotification = (props: HydratedMentionNotification) => { const docRecord = props.document.data as PubLeafletDocument.Record; - const pubRecord = props.document.documents_in_publications[0]?.publications + const pubRecord = props.document.documents_in_publications?.[0]?.publications ?.record as PubLeafletPublication.Record | undefined; const docUri = new AtUri(props.document.uri); const rkey = docUri.rkey; const did = docUri.host; - const postText = postView.record?.text || ""; const href = pubRecord ? `https://${pubRecord.base_path}/${rkey}` : `/p/${did}/${rkey}`; + let actionText: React.ReactNode; + let mentionedItemName: string | undefined; + let mentionedDocRecord = props.mentionedDocument + ?.data as PubLeafletDocument.Record; + + const mentioner = props.documentCreatorHandle + ? `@${props.documentCreatorHandle}` + : "Someone"; + + if (props.mention_type === "did") { + actionText = <>{mentioner} mentioned you; + } else if ( + props.mention_type === "publication" && + props.mentionedPublication + ) { + const mentionedPubRecord = props.mentionedPublication + .record as PubLeafletPublication.Record; + mentionedItemName = mentionedPubRecord.name; + actionText = ( + <> + {mentioner} mentioned your publication{" "} + {mentionedItemName} + + ); + } else if (props.mention_type === "document" && props.mentionedDocument) { + mentionedItemName = mentionedDocRecord.title; + actionText = ( + <> + {mentioner} mentioned your post{" "} + {mentionedItemName} + + ); + } else { + actionText = <>{mentioner} mentioned you; + } + return ( } - actionText={<>{displayName} quoted your post} + icon={} + actionText={actionText} content={ -
- -
-              {postText}
-            
-
+ {docRecord.description && docRecord.description}
} /> diff --git a/app/(home-pages)/notifications/Notification.tsx b/app/(home-pages)/notifications/Notification.tsx index 2d5c2710..ea85bfff 100644 --- a/app/(home-pages)/notifications/Notification.tsx +++ b/app/(home-pages)/notifications/Notification.tsx @@ -69,13 +69,13 @@ export const ContentLayout = (props: {
-
+
{props.postTitle}
- {props.children} + {props.children &&
{props.children}
} {props.pubRecord && ( <> -
+
; } + if (n.type === "mention") { + return ; + } + if (n.type === "comment_mention") { + return ; + } })}
diff --git a/app/(home-pages)/notifications/QuoteNotification.tsx b/app/(home-pages)/notifications/QuoteNotification.tsx new file mode 100644 index 00000000..94c283b6 --- /dev/null +++ b/app/(home-pages)/notifications/QuoteNotification.tsx @@ -0,0 +1,48 @@ +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { ContentLayout, Notification } from "./Notification"; +import { HydratedQuoteNotification } from "src/notifications"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { AtUri } from "@atproto/api"; +import { Avatar } from "components/Avatar"; + +export const QuoteNotification = (props: HydratedQuoteNotification) => { + const postView = props.bskyPost.post_view as any; + const author = postView.author; + const displayName = author.displayName || author.handle || "Someone"; + const docRecord = props.document.data as PubLeafletDocument.Record; + const pubRecord = props.document.documents_in_publications[0]?.publications + ?.record as PubLeafletPublication.Record | undefined; + const docUri = new AtUri(props.document.uri); + const rkey = docUri.rkey; + const did = docUri.host; + const postText = postView.record?.text || ""; + + const href = pubRecord + ? `https://${pubRecord.base_path}/${rkey}` + : `/p/${did}/${rkey}`; + + return ( + } + actionText={<>{displayName} quoted your post} + content={ + +
+ +
+              {postText}
+            
+
+
+ } + /> + ); +}; diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index 12af9a55..37c62312 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -1,16 +1,7 @@ "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 { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; +import { useState, useCallback, useRef, useLayoutEffect } from "react"; +import { EditorState } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Schema, MarkSpec, Mark } from "prosemirror-model"; import { baseKeymap } from "prosemirror-commands"; @@ -19,6 +10,8 @@ import { history, undo, redo } from "prosemirror-history"; import { inputRules, InputRule } from "prosemirror-inputrules"; import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; +import { schema } from "components/Blocks/TextBlock/schema"; +import { Mention, MentionAutocomplete } from "components/Mention"; // Schema with only links, mentions, and hashtags marks const bskyPostSchema = new Schema({ @@ -134,60 +127,86 @@ function createHashtagInputRule() { return tr; }); } - export function BlueskyPostEditorProsemirror(props: { - editorStateRef: React.MutableRefObject; + editorStateRef: React.RefObject; 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 [mentionOpen, setMentionOpen] = useState(false); + const [mentionCoords, setMentionCoords] = useState<{ + top: number; + left: number; + } | null>(null); + const [mentionInsertPos, setMentionInsertPos] = useState(null); + + const openMentionAutocomplete = useCallback(() => { + if (!viewRef.current) return; + const view = viewRef.current; + const pos = view.state.selection.from; + setMentionInsertPos(pos); + const coords = view.coordsAtPos(pos - 1); + setMentionCoords({ + top: coords.bottom + window.scrollY, + left: coords.left + window.scrollX, + }); + setMentionOpen(true); + }, []); const handleMentionSelect = useCallback( - ( - mention: { handle: string; did: string }, - range: { from: number; to: number }, - ) => { - if (!viewRef.current) return; + (mention: Mention) => { + if (mention.type !== "did") return; + if (!viewRef.current || mentionInsertPos === null) return; const view = viewRef.current; - const { from, to } = range; + const from = mentionInsertPos - 1; + const to = mentionInsertPos; const tr = view.state.tr; - // Delete the query text (keep the @) - tr.delete(from + 1, to); + // Delete the @ symbol + tr.delete(from, to); - // Insert the mention text after the @ - const mentionText = mention.handle; - tr.insertText(mentionText, from + 1); + // Insert @handle + const mentionText = "@" + mention.handle; + tr.insertText(mentionText, from); - // Apply mention mark to @ and handle + // Apply mention mark tr.addMark( from, - from + 1 + mentionText.length, + from + mentionText.length, bskyPostSchema.marks.mention.create({ did: mention.did }), ); // Add a space after the mention - tr.insertText(" ", from + 1 + mentionText.length); + tr.insertText(" ", from + mentionText.length); view.dispatch(tr); view.focus(); }, - [], + [mentionInsertPos], ); - const mentionStateRef = useRef(mentionState); - mentionStateRef.current = mentionState; + const handleMentionOpenChange = useCallback((open: boolean) => { + setMentionOpen(open); + if (!open) { + setMentionCoords(null); + setMentionInsertPos(null); + } + }, []); useLayoutEffect(() => { if (!mountRef.current) return; + // Input rule to trigger mention autocomplete when @ is typed + const mentionInputRule = new InputRule( + /(?:^|\s)@$/, + (state, match, start, end) => { + setTimeout(() => openMentionAutocomplete(), 0); + return null; + }, + ); + const initialState = EditorState.create({ schema: bskyPostSchema, doc: props.initialContent @@ -200,28 +219,11 @@ export function BlueskyPostEditorProsemirror(props: { }) : undefined, plugins: [ - inputRules({ rules: [createHashtagInputRule()] }), + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 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({ @@ -258,20 +260,17 @@ export function BlueskyPostEditorProsemirror(props: { view.destroy(); viewRef.current = null; }; - }, [handleMentionSelect]); + }, [openMentionAutocomplete]); return (
- {editorState && ( - { - setMentionState({ active, range, selectedMention }); - }} - /> - )} + {editorState?.doc.textContent.length === 0 && (
Write a post to share your writing! @@ -290,227 +289,6 @@ export function BlueskyPostEditorProsemirror(props: { ); } -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 @@ -595,3 +373,44 @@ function marksToFeatures(marks: readonly Mark[]) { return features; } + +export const addMentionToEditor = ( + mention: Mention, + range: { from: number; to: number }, + view: EditorView, +) => { + console.log("view", view); + if (!view) return; + const { from, to } = range; + const tr = view.state.tr; + + if (mention.type == "did") { + // Delete the @ and any query text + tr.delete(from, to); + // Insert didMention inline node + const mentionText = "@" + mention.handle; + const didMentionNode = schema.nodes.didMention.create({ + did: mention.did, + text: mentionText, + }); + tr.insert(from, didMentionNode); + } + if (mention.type === "publication" || mention.type === "post") { + // Delete the @ and any query text + tr.delete(from, to); + let name = mention.type == "post" ? mention.title : mention.name; + // Insert atMention inline node + const atMentionNode = schema.nodes.atMention.create({ + atURI: mention.uri, + text: name, + }); + tr.insert(from, atMentionNode); + } + console.log("yo", mention); + + // Add a space after the mention + tr.insertText(" ", from + 1); + + view.dispatch(tr); + view.focus(); +}; diff --git a/app/api/pub_icon/route.ts b/app/api/pub_icon/route.ts new file mode 100644 index 00000000..16ecfb7d --- /dev/null +++ b/app/api/pub_icon/route.ts @@ -0,0 +1,145 @@ +import { AtUri } from "@atproto/syntax"; +import { IdResolver } from "@atproto/identity"; +import { NextRequest, NextResponse } from "next/server"; +import { PubLeafletPublication } from "lexicons/api"; +import { supabaseServerClient } from "supabase/serverClient"; +import sharp from "sharp"; + +const idResolver = new IdResolver(); + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const bgColor = searchParams.get("bg") || "#0000E1"; + const fgColor = searchParams.get("fg") || "#FFFFFF"; + + try { + const at_uri = searchParams.get("at_uri"); + + if (!at_uri) { + return new NextResponse(null, { status: 400 }); + } + + // Parse the AT URI + let uri: AtUri; + try { + uri = new AtUri(at_uri); + } catch (e) { + return new NextResponse(null, { status: 400 }); + } + + let publicationRecord: PubLeafletPublication.Record | null = null; + let publicationUri: string; + + // Check if it's a document or publication + if (uri.collection === "pub.leaflet.document") { + // Query the documents_in_publications table to get the publication + const { data: docInPub } = await supabaseServerClient + .from("documents_in_publications") + .select("publication, publications(record)") + .eq("document", at_uri) + .single(); + + if (!docInPub || !docInPub.publications) { + return new NextResponse(null, { status: 404 }); + } + + publicationUri = docInPub.publication; + publicationRecord = docInPub.publications + .record as PubLeafletPublication.Record; + } else if (uri.collection === "pub.leaflet.publication") { + // Query the publications table directly + const { data: publication } = await supabaseServerClient + .from("publications") + .select("record, uri") + .eq("uri", at_uri) + .single(); + + if (!publication || !publication.record) { + return new NextResponse(null, { status: 404 }); + } + + publicationUri = publication.uri; + publicationRecord = publication.record as PubLeafletPublication.Record; + } else { + // Not a supported collection + return new NextResponse(null, { status: 404 }); + } + + // Check if the publication has an icon + if (!publicationRecord?.icon) { + // Generate a placeholder with the first letter of the publication name + const firstLetter = (publicationRecord?.name || "?") + .slice(0, 1) + .toUpperCase(); + + // Create a simple SVG placeholder with theme colors + const svg = ` + + ${firstLetter} +`; + + return new NextResponse(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", + }, + }); + } + + // Parse the publication URI to get the DID + const pubUri = new AtUri(publicationUri); + + // Get the CID from the icon blob + const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ + "$link" + ]; + + // Fetch the blob from the PDS + const identity = await idResolver.did.resolve(pubUri.host); + const service = identity?.service?.find((f) => f.id === "#atproto_pds"); + if (!service) return new NextResponse(null, { status: 404 }); + + const blobResponse = await fetch( + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, + { + headers: { + "Accept-Encoding": "gzip, deflate, br, zstd", + }, + }, + ); + + if (!blobResponse.ok) { + return new NextResponse(null, { status: 404 }); + } + + // Get the image buffer + const imageBuffer = await blobResponse.arrayBuffer(); + + // Resize to 96x96 using Sharp + const resizedImage = await sharp(Buffer.from(imageBuffer)) + .resize(96, 96, { + fit: "cover", + position: "center", + }) + .webp({ quality: 90 }) + .toBuffer(); + + // Return with caching headers + return new NextResponse(resizedImage, { + headers: { + "Content-Type": "image/webp", + // Cache for 1 hour, but serve stale for much longer while revalidating + "Cache-Control": + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", + }, + }); + } catch (error) { + console.error("Error fetching publication icon:", error); + return new NextResponse(null, { status: 500 }); + } +} diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index c6627540..4767ffb4 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -11,6 +11,8 @@ import { } from "./domain_routes"; import { get_leaflet_data } from "./get_leaflet_data"; import { get_publication_data } from "./get_publication_data"; +import { search_publication_names } from "./search_publication_names"; +import { search_publication_documents } from "./search_publication_documents"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -35,6 +37,8 @@ let Routes = [ get_leaflet_subdomain_status, get_leaflet_data, get_publication_data, + search_publication_names, + search_publication_documents, ]; export async function POST( req: Request, diff --git a/app/api/rpc/[command]/search_publication_documents.ts b/app/api/rpc/[command]/search_publication_documents.ts new file mode 100644 index 00000000..382820f7 --- /dev/null +++ b/app/api/rpc/[command]/search_publication_documents.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; + +export type SearchPublicationDocumentsReturnType = Awaited< + ReturnType<(typeof search_publication_documents)["handler"]> +>; + +export const search_publication_documents = makeRoute({ + route: "search_publication_documents", + input: z.object({ + publication_uri: z.string(), + query: z.string(), + limit: z.number().optional().default(10), + }), + handler: async ( + { publication_uri, query, limit }, + { supabase }: Pick, + ) => { + // Get documents in the publication, filtering by title using JSON operator + const { data: documents, error } = await supabase + .from("documents_in_publications") + .select("document, documents!inner(uri, data)") + .eq("publication", publication_uri) + .ilike("documents.data->>title", `%${query}%`) + .limit(limit); + + if (error) { + throw new Error( + `Failed to search publication documents: ${error.message}`, + ); + } + + const result = documents.map((d) => ({ + uri: d.documents.uri, + title: (d.documents.data as { title?: string })?.title || "Untitled", + })); + + return { result: { documents: result } }; + }, +}); diff --git a/app/api/rpc/[command]/search_publication_names.ts b/app/api/rpc/[command]/search_publication_names.ts new file mode 100644 index 00000000..cd042311 --- /dev/null +++ b/app/api/rpc/[command]/search_publication_names.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; + +export type SearchPublicationNamesReturnType = Awaited< + ReturnType<(typeof search_publication_names)["handler"]> +>; + +export const search_publication_names = makeRoute({ + route: "search_publication_names", + input: z.object({ + query: z.string(), + limit: z.number().optional().default(10), + }), + handler: async ( + { query, limit }, + { supabase }: Pick, + ) => { + // Search publications by name in record (case-insensitive partial match) + const { data: publications, error } = await supabase + .from("publications") + .select("uri, record") + .ilike("record->>name", `%${query}%`) + .limit(limit); + + if (error) { + throw new Error(`Failed to search publications: ${error.message}`); + } + + const result = publications.map((p) => ({ + uri: p.uri, + name: (p.record as { name?: string })?.name || "Untitled", + })); + + return { result: { publications: result } }; + }, +}); diff --git a/app/globals.css b/app/globals.css index 4193cdbd..b42e4e50 100644 --- a/app/globals.css +++ b/app/globals.css @@ -291,6 +291,12 @@ pre.shiki { @apply py-[1.5px]; } +/* Underline mention nodes when selected in ProseMirror */ +.ProseMirror .atMention.ProseMirror-selectednode, +.ProseMirror .didMention.ProseMirror-selectednode { + text-decoration: underline; +} + .ProseMirror:focus-within .selection-highlight { background-color: transparent; } @@ -414,6 +420,8 @@ pre.shiki { outline: none !important; cursor: pointer; background-color: transparent; + display: flex; + gap: 0.5rem; :hover { text-decoration: none !important; diff --git a/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx index ee45e009..6c6de80d 100644 --- a/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx @@ -1,5 +1,7 @@ import { UnicodeString } from "@atproto/api"; import { PubLeafletRichtextFacet } from "lexicons/api"; +import { didToBlueskyUrl } from "src/utils/mentionUtils"; +import { AtMentionLink } from "components/AtMentionLink"; type Facet = PubLeafletRichtextFacet.Main; export function BaseTextBlock(props: { @@ -22,6 +24,12 @@ export function BaseTextBlock(props: { let isStrikethrough = segment.facet?.find( PubLeafletRichtextFacet.isStrikethrough, ); + let isDidMention = segment.facet?.find( + PubLeafletRichtextFacet.isDidMention, + ); + let isAtMention = segment.facet?.find( + PubLeafletRichtextFacet.isAtMention, + ); let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); let isHighlighted = segment.facet?.find( @@ -48,6 +56,28 @@ export function BaseTextBlock(props: { {renderedText} , ); + } else if (isDidMention) { + children.push( +
+ {renderedText} + , + ); + } else if (isAtMention) { + children.push( + + {renderedText} + , + ); } else if (link) { children.push( { + if (!view) return; + const { from, to } = range; + const tr = view.state.tr; + + if (mention.type === "did") { + // Delete the @ and any query text + tr.delete(from, to); + // Insert didMention inline node + const mentionText = "@" + mention.handle; + const didMentionNode = multiBlockSchema.nodes.didMention.create({ + did: mention.did, + text: mentionText, + }); + tr.insert(from, didMentionNode); + // Add a space after the mention + tr.insertText(" ", from + 1); + } + if (mention.type === "publication" || mention.type === "post") { + // Delete the @ and any query text + tr.delete(from, to); + let name = mention.type === "post" ? mention.title : mention.name; + // Insert atMention inline node + const atMentionNode = multiBlockSchema.nodes.atMention.create({ + atURI: mention.uri, + text: name, + }); + tr.insert(from, atMentionNode); + // Add a space after the mention + tr.insertText(" ", from + 1); + } + + view.dispatch(tr); + view.focus(); +}; export function CommentBox(props: { doc_uri: string; @@ -50,8 +94,68 @@ export function CommentBox(props: { commentBox: { quote }, } = useInteractionState(props.doc_uri); let [loading, setLoading] = useState(false); + let view = useRef(null); + + // Mention autocomplete state + const [mentionOpen, setMentionOpen] = useState(false); + const [mentionCoords, setMentionCoords] = useState<{ + top: number; + left: number; + } | null>(null); + // Use a ref for insert position to avoid stale closure issues + const mentionInsertPosRef = useRef(null); + + // Use a ref for the callback so input rules can access it + const openMentionAutocompleteRef = useRef<() => void>(() => {}); + openMentionAutocompleteRef.current = () => { + if (!view.current) return; + + const pos = view.current.state.selection.from; + mentionInsertPosRef.current = pos; + + // Get coordinates for the popup relative to the positioned parent + const coords = view.current.coordsAtPos(pos - 1); - const handleSubmit = async () => { + // Find the relative positioned parent container + const editorEl = view.current.dom; + const container = editorEl.closest(".relative") as HTMLElement | null; + + if (container) { + const containerRect = container.getBoundingClientRect(); + setMentionCoords({ + top: coords.bottom - containerRect.top, + left: coords.left - containerRect.left, + }); + } else { + setMentionCoords({ + top: coords.bottom, + left: coords.left, + }); + } + setMentionOpen(true); + }; + + const handleMentionSelect = useCallback((mention: Mention) => { + if (!view.current || mentionInsertPosRef.current === null) return; + + const from = mentionInsertPosRef.current - 1; + const to = mentionInsertPosRef.current; + + addMentionToEditor(mention, { from, to }, view.current); + view.current.focus(); + }, []); + + const handleMentionOpenChange = useCallback((open: boolean) => { + setMentionOpen(open); + if (!open) { + setMentionCoords(null); + mentionInsertPosRef.current = null; + } + }, []); + + // Use a ref for handleSubmit so keyboard shortcuts can access it + const handleSubmitRef = useRef<() => Promise>(async () => {}); + handleSubmitRef.current = async () => { if (loading || !view.current) return; setLoading(true); @@ -114,11 +218,11 @@ export function CommentBox(props: { "Mod-y": redo, "Shift-Mod-z": redo, "Ctrl-Enter": () => { - handleSubmit(); + handleSubmitRef.current(); return true; }, "Meta-Enter": () => { - handleSubmit(); + handleSubmitRef.current(); return true; }, }), @@ -128,11 +232,20 @@ export function CommentBox(props: { shouldAutoLink: () => true, defaultProtocol: "https", }), + // Input rules for @ mentions + inputRules({ + rules: [ + // @ at start of line or after space + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { + setTimeout(() => openMentionAutocompleteRef.current(), 0); + return null; + }), + ], + }), history(), ], }), ); - let view = useRef(null); useLayoutEffect(() => { if (!mountRef.current) return; view.current = new EditorView( @@ -187,15 +300,55 @@ export function CommentBox(props: { handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { if (!direct) return; if (node.nodeSize - 2 <= _pos) return; + + const nodeAt1 = node.nodeAt(_pos - 1); + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); + + // Check for link marks let mark = - node - .nodeAt(_pos - 1) - ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || - node - .nodeAt(Math.max(_pos - 2, 0)) - ?.marks.find((f) => f.type === multiBlockSchema.marks.link); + nodeAt1?.marks.find( + (f) => f.type === multiBlockSchema.marks.link, + ) || + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); if (mark) { window.open(mark.attrs.href, "_blank"); + return; + } + + // Check for didMention inline nodes + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { + window.open( + didToBlueskyUrl(nodeAt1.attrs.did), + "_blank", + "noopener,noreferrer", + ); + return; + } + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { + window.open( + didToBlueskyUrl(nodeAt2.attrs.did), + "_blank", + "noopener,noreferrer", + ); + return; + } + + // Check for atMention inline nodes (publications/documents) + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { + window.open( + atUriToUrl(nodeAt1.attrs.atURI), + "_blank", + "noopener,noreferrer", + ); + return; + } + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { + window.open( + atUriToUrl(nodeAt2.attrs.atURI), + "_blank", + "noopener,noreferrer", + ); + return; } }, dispatchTransaction(tr) { @@ -236,9 +389,28 @@ export function CommentBox(props: {
 {
+            // Close mention dropdown when editor gains focus (reset stale state)
+            handleMentionOpenChange(false);
+          }}
+          onBlur={(e) => {
+            // Close mention dropdown when editor loses focus
+            // But not if focus moved to the mention autocomplete
+            const relatedTarget = e.relatedTarget as HTMLElement | null;
+            if (!relatedTarget?.closest(".dropdownMenu")) {
+              handleMentionOpenChange(false);
+            }
+          }}
           className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
         />
         
+        
       
@@ -261,7 +433,7 @@ export function CommentBox(props: { view={view} />
- + handleSubmitRef.current()}> {loading ? : }
@@ -329,6 +501,46 @@ export function docToFacetedText( } } + fullText += text; + byteOffset += unicodeString.length; + } else if (node.type.name === "didMention") { + // Handle DID mention nodes + const text = node.attrs.text || ""; + const unicodeString = new UnicodeString(text); + + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodeString.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#didMention", + did: node.attrs.did, + }, + ], + }); + + fullText += text; + byteOffset += unicodeString.length; + } else if (node.type.name === "atMention") { + // Handle AT-URI mention nodes (publications and documents) + const text = node.attrs.text || ""; + const unicodeString = new UnicodeString(text); + + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodeString.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#atMention", + atURI: node.attrs.atURI, + }, + ], + }); + fullText += text; byteOffset += unicodeString.length; } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index 124af91f..04fde096 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -10,6 +10,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { Notification, + NotificationData, pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; @@ -84,9 +85,26 @@ export async function publishComment(args: { parent_uri: args.comment.replyTo, }, }); + } + + // Create mention notifications from comment facets + const mentionNotifications = createCommentMentionNotifications( + args.comment.facets, + uri.toString(), + credentialSession.did!, + ); + notifications.push(...mentionNotifications); + + // Insert all notifications and ping recipients + if (notifications.length > 0) { // SOMEDAY: move this out the action with inngest or workflows await supabaseServerClient.from("notifications").insert(notifications); - await pingIdentityToUpdateNotification(recipient); + + // Ping all unique recipients + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; + await Promise.all( + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), + ); } return { @@ -95,3 +113,82 @@ export async function publishComment(args: { uri: uri.toString(), }; } + +/** + * Creates mention notifications from comment facets + * Handles didMention (people) and atMention (publications/documents) + */ +function createCommentMentionNotifications( + facets: PubLeafletRichtextFacet.Main[], + commentUri: string, + commenterDid: string, +): Notification[] { + const notifications: Notification[] = []; + const notifiedRecipients = new Set(); // Avoid duplicate notifications + + for (const facet of facets) { + for (const feature of facet.features) { + if (PubLeafletRichtextFacet.isDidMention(feature)) { + // DID mention - notify the mentioned person directly + const recipientDid = feature.did; + + // Don't notify yourself + if (recipientDid === commenterDid) continue; + // Avoid duplicate notifications to the same person + if (notifiedRecipients.has(recipientDid)) continue; + notifiedRecipients.add(recipientDid); + + notifications.push({ + id: v7(), + recipient: recipientDid, + data: { + type: "comment_mention", + comment_uri: commentUri, + mention_type: "did", + }, + }); + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { + // AT-URI mention - notify the owner of the publication/document + try { + const mentionedUri = new AtUri(feature.atURI); + const recipientDid = mentionedUri.host; + + // Don't notify yourself + if (recipientDid === commenterDid) continue; + // Avoid duplicate notifications to the same person for the same mentioned item + const dedupeKey = `${recipientDid}:${feature.atURI}`; + if (notifiedRecipients.has(dedupeKey)) continue; + notifiedRecipients.add(dedupeKey); + + if (mentionedUri.collection === "pub.leaflet.publication") { + notifications.push({ + id: v7(), + recipient: recipientDid, + data: { + type: "comment_mention", + comment_uri: commentUri, + mention_type: "publication", + mentioned_uri: feature.atURI, + }, + }); + } else if (mentionedUri.collection === "pub.leaflet.document") { + notifications.push({ + id: v7(), + recipient: recipientDid, + data: { + type: "comment_mention", + comment_uri: commentUri, + mention_type: "document", + mentioned_uri: feature.atURI, + }, + }); + } + } catch (error) { + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); + } + } + } + } + + return notifications; +} diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 555f1b51..dc480c6b 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -293,7 +293,10 @@ export let Block = ({ } case PubLeafletBlocksImage.isMain(b.block): { return ( -
+
{b.block.alt} +

+

+

+

{b.block.plaintext}

; // if (b.block.level === 5) return
{b.block.plaintext}
; return ( -
+
} +) { + try { + const { uri: uriParam } = await params; + const atUriString = decodeURIComponent(uriParam); + const uri = new AtUri(atUriString); + + if (uri.collection === "pub.leaflet.publication") { + // Get the publication record to retrieve base_path + const { data: publication } = await supabaseServerClient + .from("publications") + .select("record") + .eq("uri", atUriString) + .single(); + + if (!publication?.record) { + return new NextResponse("Publication not found", { status: 404 }); + } + + const record = publication.record as PubLeafletPublication.Record; + const basePath = record.base_path; + + if (!basePath) { + return new NextResponse("Publication has no base_path", { status: 404 }); + } + + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) + return NextResponse.redirect(basePath, 307); + } else if (uri.collection === "pub.leaflet.document") { + // Document link - need to find the publication it belongs to + const { data: docInPub } = await supabaseServerClient + .from("documents_in_publications") + .select("publication, publications!inner(record)") + .eq("document", atUriString) + .single(); + + if (docInPub?.publication && docInPub.publications) { + // Document is in a publication - redirect to domain/rkey + const record = docInPub.publications.record as PubLeafletPublication.Record; + const basePath = record.base_path; + + if (!basePath) { + return new NextResponse("Publication has no base_path", { status: 404 }); + } + + // Ensure basePath ends without trailing slash + const cleanBasePath = basePath.endsWith("/") + ? basePath.slice(0, -1) + : basePath; + + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) + return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); + } + + // If not in a publication, check if it's a standalone document + const { data: doc } = await supabaseServerClient + .from("documents") + .select("uri") + .eq("uri", atUriString) + .single(); + + if (doc) { + // Standalone document - redirect to /p/did/rkey (temporary redirect) + return NextResponse.redirect( + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), + 307 + ); + } + + // Document not found + return new NextResponse("Document not found", { status: 404 }); + } + + // Unsupported collection type + return new NextResponse("Unsupported URI type", { status: 400 }); + } catch (error) { + console.error("Error resolving AT URI:", error); + return new NextResponse("Invalid URI", { status: 400 }); + } +} diff --git a/components/AtMentionLink.tsx b/components/AtMentionLink.tsx new file mode 100644 index 00000000..ad7ee4ba --- /dev/null +++ b/components/AtMentionLink.tsx @@ -0,0 +1,46 @@ +import { AtUri } from "@atproto/api"; +import { atUriToUrl } from "src/utils/mentionUtils"; + +/** + * Component for rendering at-uri mentions (publications and documents) as clickable links. + * NOTE: This component's styling and behavior should match the ProseMirror schema rendering + * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other. + */ +export function AtMentionLink({ + atURI, + children, + className = "", +}: { + atURI: string; + children: React.ReactNode; + className?: string; +}) { + const aturi = new AtUri(atURI); + const isPublication = aturi.collection === "pub.leaflet.publication"; + const isDocument = aturi.collection === "pub.leaflet.document"; + + // Show publication icon if available + const icon = + isPublication || isDocument ? ( + + ) : null; + + return ( + + {icon} + {children} + + ); +} diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx index 2aaf7f99..3381de97 100644 --- a/components/Blocks/BlockCommandBar.tsx +++ b/components/Blocks/BlockCommandBar.tsx @@ -37,7 +37,7 @@ export const BlockCommandBar = ({ const clearCommandSearchText = () => { if (!props.entityID) return; const entityID = props.entityID; - + const existingState = useEditorStates.getState().editorStates[entityID]; if (!existingState) return; @@ -69,6 +69,7 @@ export const BlockCommandBar = ({ setHighlighted(commandResults[0].name); } }, [commandResults, setHighlighted, highlighted]); + useEffect(() => { let listener = async (e: KeyboardEvent) => { let reverseDir = ref.current?.dataset.side === "top"; @@ -118,6 +119,7 @@ export const BlockCommandBar = ({ return; } }; + window.addEventListener("keydown", listener); return () => window.removeEventListener("keydown", listener); @@ -200,7 +202,7 @@ const CommandResult = (props: { return ( + ); +}; + +const ScopeButton = (props: { + onClick: () => void; + children: React.ReactNode; +}) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + props.onClick(); + }} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + {props.children} + + ); +}; + +const DidResult = (props: { + displayName?: string; + handle: string; + avatar?: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + return ( + + ) : ( +
+ ) + } + result={props.displayName ? props.displayName : props.handle} + subtext={props.displayName && `@${props.handle}`} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + +const PublicationResult = (props: { + pubName: string; + uri: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; + onPostsClick: () => void; +}) => { + return ( + + } + result={ + <> +
{props.pubName}
+ Posts + + } + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + +const PostResult = (props: { + title: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + return ( + {props.title}
} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + +const ScopeHeader = (props: { + scope: MentionScope; + handleScopeChange: () => void; +}) => { + if (props.scope.type === "default") return; + if (props.scope.type === "publication") + return ( + + ); +}; + +export type Mention = + | { + type: "did"; + handle: string; + did: string; + displayName?: string; + avatar?: string; + } + | { type: "publication"; uri: string; name: string } + | { type: "post"; uri: string; title: string }; + +export type MentionScope = + | { type: "default" } + | { type: "publication"; uri: string; name: string }; +function useMentionSuggestions(query: string | null) { + const [suggestionIndex, setSuggestionIndex] = useState(0); + const [suggestions, setSuggestions] = useState>([]); + const [scope, setScope] = useState({ type: "default" }); + + // Clear suggestions immediately when scope changes + const setScopeAndClear = useCallback((newScope: MentionScope) => { + setSuggestions([]); + setScope(newScope); + }, []); + + useDebouncedEffect( + async () => { + if (!query && scope.type === "default") { + setSuggestions([]); + return; + } + + if (scope.type === "publication") { + // Search within the publication's documents + const documents = await callRPC(`search_publication_documents`, { + publication_uri: scope.uri, + query: query || "", + limit: 10, + }); + setSuggestions( + documents.result.documents.map((d) => ({ + type: "post" as const, + uri: d.uri, + title: d.title, + })), + ); + } else { + // Default scope: search people and publications + const agent = new Agent("https://public.api.bsky.app"); + const [result, publications] = await Promise.all([ + agent.searchActorsTypeahead({ + q: query || "", + limit: 8, + }), + callRPC(`search_publication_names`, { query: query || "", limit: 8 }), + ]); + setSuggestions([ + ...result.data.actors.map((actor) => ({ + type: "did" as const, + handle: actor.handle, + did: actor.did, + displayName: actor.displayName, + avatar: actor.avatar, + })), + ...publications.result.publications.map((p) => ({ + type: "publication" as const, + uri: p.uri, + name: p.name, + })), + ]); + } + }, + 300, + [query, scope], + ); + + useEffect(() => { + if (suggestionIndex > suggestions.length - 1) { + setSuggestionIndex(Math.max(0, suggestions.length - 1)); + } + }, [suggestionIndex, suggestions.length]); + + return { + suggestions, + suggestionIndex, + setSuggestionIndex, + scope, + setScope: setScopeAndClear, + }; +} diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 1ebf4d50..c0b24dd8 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1865,6 +1865,8 @@ export const schemaDict = { type: 'union', refs: [ 'lex:pub.leaflet.richtext.facet#link', + 'lex:pub.leaflet.richtext.facet#didMention', + 'lex:pub.leaflet.richtext.facet#atMention', 'lex:pub.leaflet.richtext.facet#code', 'lex:pub.leaflet.richtext.facet#highlight', 'lex:pub.leaflet.richtext.facet#underline', @@ -1904,6 +1906,28 @@ export const schemaDict = { }, }, }, + didMention: { + type: 'object', + description: 'Facet feature for mentioning a did.', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + atMention: { + type: 'object', + description: 'Facet feature for mentioning an AT URI.', + required: ['atURI'], + properties: { + atURI: { + type: 'string', + format: 'uri', + }, + }, + }, code: { type: 'object', description: 'Facet feature for inline code.', diff --git a/lexicons/api/types/pub/leaflet/richtext/facet.ts b/lexicons/api/types/pub/leaflet/richtext/facet.ts index 53ff9c02..466b1df0 100644 --- a/lexicons/api/types/pub/leaflet/richtext/facet.ts +++ b/lexicons/api/types/pub/leaflet/richtext/facet.ts @@ -20,6 +20,8 @@ export interface Main { index: ByteSlice features: ( | $Typed + | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -74,6 +76,38 @@ export function validateLink(v: V) { return validate(v, id, hashLink) } +/** Facet feature for mentioning a did. */ +export interface DidMention { + $type?: 'pub.leaflet.richtext.facet#didMention' + did: string +} + +const hashDidMention = 'didMention' + +export function isDidMention(v: V) { + return is$typed(v, id, hashDidMention) +} + +export function validateDidMention(v: V) { + return validate(v, id, hashDidMention) +} + +/** Facet feature for mentioning an AT URI. */ +export interface AtMention { + $type?: 'pub.leaflet.richtext.facet#atMention' + atURI: string +} + +const hashAtMention = 'atMention' + +export function isAtMention(v: V) { + return is$typed(v, id, hashAtMention) +} + +export function validateAtMention(v: V) { + return validate(v, id, hashAtMention) +} + /** Facet feature for inline code. */ export interface Code { $type?: 'pub.leaflet.richtext.facet#code' diff --git a/lexicons/pub/leaflet/richtext/facet.json b/lexicons/pub/leaflet/richtext/facet.json index 81fd90c1..32c5ca9d 100644 --- a/lexicons/pub/leaflet/richtext/facet.json +++ b/lexicons/pub/leaflet/richtext/facet.json @@ -20,6 +20,8 @@ "type": "union", "refs": [ "#link", + "#didMention", + "#atMention", "#code", "#highlight", "#underline", @@ -62,6 +64,32 @@ } } }, + "didMention": { + "type": "object", + "description": "Facet feature for mentioning a did.", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + } + } + }, + "atMention": { + "type": "object", + "description": "Facet feature for mentioning an AT URI.", + "required": [ + "atURI" + ], + "properties": { + "atURI": { + "type": "string", + "format": "uri" + } + } + }, "code": { "type": "object", "description": "Facet feature for inline code.", diff --git a/lexicons/src/facet.ts b/lexicons/src/facet.ts index 553117b8..93d4c416 100644 --- a/lexicons/src/facet.ts +++ b/lexicons/src/facet.ts @@ -9,6 +9,18 @@ const FacetItems: LexiconDoc["defs"] = { uri: { type: "string" }, }, }, + didMention: { + type: "object", + description: "Facet feature for mentioning a did.", + required: ["did"], + properties: { did: { type: "string", format: "did" } }, + }, + atMention: { + type: "object", + description: "Facet feature for mentioning an AT URI.", + required: ["atURI"], + properties: { atURI: { type: "string", format: "uri" } }, + }, code: { type: "object", description: "Facet feature for inline code.", diff --git a/src/notifications.ts b/src/notifications.ts index cc6acab0..11337dc0 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -2,6 +2,8 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Tables, TablesInsert } from "supabase/database.types"; +import { AtUri } from "@atproto/syntax"; +import { idResolver } from "app/(home-pages)/reader/idResolver"; type NotificationRow = Tables<"notifications">; @@ -12,24 +14,34 @@ export type Notification = Omit, "data"> & { export type NotificationData = | { type: "comment"; comment_uri: string; parent_uri?: string } | { type: "subscribe"; subscription_uri: string } - | { type: "quote"; bsky_post_uri: string; document_uri: string }; + | { type: "quote"; bsky_post_uri: string; document_uri: string } + | { type: "mention"; document_uri: string; mention_type: "did" } + | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } + | { type: "comment_mention"; comment_uri: string; mention_type: "did" } + | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; export type HydratedNotification = | HydratedCommentNotification | HydratedSubscribeNotification - | HydratedQuoteNotification; + | HydratedQuoteNotification + | HydratedMentionNotification + | HydratedCommentMentionNotification; export async function hydrateNotifications( notifications: NotificationRow[], ): Promise> { // Call all hydrators in parallel - const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([ + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ hydrateCommentNotifications(notifications), hydrateSubscribeNotifications(notifications), hydrateQuoteNotifications(notifications), + hydrateMentionNotifications(notifications), + hydrateCommentMentionNotifications(notifications), ]); // Combine all hydrated notifications - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications]; + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; // Sort by created_at to maintain order allHydrated.sort( @@ -165,6 +177,187 @@ async function hydrateQuoteNotifications(notifications: NotificationRow[]) { })); } +export type HydratedMentionNotification = Awaited< + ReturnType +>[0]; + +async function hydrateMentionNotifications(notifications: NotificationRow[]) { + const mentionNotifications = notifications.filter( + (n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } => + (n.data as NotificationData)?.type === "mention", + ); + + if (mentionNotifications.length === 0) { + return []; + } + + // Fetch document data from the database + const documentUris = mentionNotifications.map((n) => n.data.document_uri); + const { data: documents } = await supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", documentUris); + + // Extract unique DIDs from document URIs to resolve handles + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; + + // Resolve DIDs to handles in parallel + const didToHandleMap = new Map(); + await Promise.all( + documentCreatorDids.map(async (did) => { + try { + const resolved = await idResolver.did.resolve(did); + const handle = resolved?.alsoKnownAs?.[0] + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix + : null; + didToHandleMap.set(did, handle); + } catch (error) { + console.error(`Failed to resolve DID ${did}:`, error); + didToHandleMap.set(did, null); + } + }), + ); + + // Fetch mentioned publications and documents + const mentionedPublicationUris = mentionNotifications + .filter((n) => n.data.mention_type === "publication") + .map((n) => (n.data as Extract, { mention_type: "publication" }>).mentioned_uri); + + const mentionedDocumentUris = mentionNotifications + .filter((n) => n.data.mention_type === "document") + .map((n) => (n.data as Extract, { mention_type: "document" }>).mentioned_uri); + + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ + mentionedPublicationUris.length > 0 + ? supabaseServerClient + .from("publications") + .select("*") + .in("uri", mentionedPublicationUris) + : Promise.resolve({ data: [] }), + mentionedDocumentUris.length > 0 + ? supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", mentionedDocumentUris) + : Promise.resolve({ data: [] }), + ]); + + return mentionNotifications.map((notification) => { + const mentionedUri = notification.data.mention_type !== "did" + ? (notification.data as Extract, { mentioned_uri: string }>).mentioned_uri + : undefined; + + const documentCreatorDid = new AtUri(notification.data.document_uri).host; + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; + + return { + id: notification.id, + recipient: notification.recipient, + created_at: notification.created_at, + type: "mention" as const, + document_uri: notification.data.document_uri, + mention_type: notification.data.mention_type, + mentioned_uri: mentionedUri, + document: documents?.find((d) => d.uri === notification.data.document_uri)!, + documentCreatorHandle, + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + }; + }); +} + +export type HydratedCommentMentionNotification = Awaited< + ReturnType +>[0]; + +async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) { + const commentMentionNotifications = notifications.filter( + (n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } => + (n.data as NotificationData)?.type === "comment_mention", + ); + + if (commentMentionNotifications.length === 0) { + return []; + } + + // Fetch comment data from the database + const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri); + const { data: comments } = await supabaseServerClient + .from("comments_on_documents") + .select( + "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", + ) + .in("uri", commentUris); + + // Extract unique DIDs from comment URIs to resolve handles + const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; + + // Resolve DIDs to handles in parallel + const didToHandleMap = new Map(); + await Promise.all( + commenterDids.map(async (did) => { + try { + const resolved = await idResolver.did.resolve(did); + const handle = resolved?.alsoKnownAs?.[0] + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix + : null; + didToHandleMap.set(did, handle); + } catch (error) { + console.error(`Failed to resolve DID ${did}:`, error); + didToHandleMap.set(did, null); + } + }), + ); + + // Fetch mentioned publications and documents + const mentionedPublicationUris = commentMentionNotifications + .filter((n) => n.data.mention_type === "publication") + .map((n) => (n.data as Extract, { mention_type: "publication" }>).mentioned_uri); + + const mentionedDocumentUris = commentMentionNotifications + .filter((n) => n.data.mention_type === "document") + .map((n) => (n.data as Extract, { mention_type: "document" }>).mentioned_uri); + + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ + mentionedPublicationUris.length > 0 + ? supabaseServerClient + .from("publications") + .select("*") + .in("uri", mentionedPublicationUris) + : Promise.resolve({ data: [] }), + mentionedDocumentUris.length > 0 + ? supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", mentionedDocumentUris) + : Promise.resolve({ data: [] }), + ]); + + return commentMentionNotifications.map((notification) => { + const mentionedUri = notification.data.mention_type !== "did" + ? (notification.data as Extract, { mentioned_uri: string }>).mentioned_uri + : undefined; + + const commenterDid = new AtUri(notification.data.comment_uri).host; + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); + + return { + id: notification.id, + recipient: notification.recipient, + created_at: notification.created_at, + type: "comment_mention" as const, + comment_uri: notification.data.comment_uri, + mention_type: notification.data.mention_type, + mentioned_uri: mentionedUri, + commentData: commentData!, + commenterHandle, + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + }; + }); +} + export async function pingIdentityToUpdateNotification(did: string) { let channel = supabaseServerClient.channel(`identity.atp_did:${did}`); await channel.send({ diff --git a/src/utils/mentionUtils.ts b/src/utils/mentionUtils.ts new file mode 100644 index 00000000..0202ecc6 --- /dev/null +++ b/src/utils/mentionUtils.ts @@ -0,0 +1,59 @@ +import { AtUri } from "@atproto/api"; + +/** + * Converts a DID to a Bluesky profile URL + */ +export function didToBlueskyUrl(did: string): string { + return `https://bsky.app/profile/${did}`; +} + +/** + * Converts an AT URI (publication or document) to the appropriate URL + */ +export function atUriToUrl(atUri: string): string { + try { + const uri = new AtUri(atUri); + + if (uri.collection === "pub.leaflet.publication") { + // Publication URL: /lish/{did}/{rkey} + return `/lish/${uri.host}/${uri.rkey}`; + } else if (uri.collection === "pub.leaflet.document") { + // Document URL - we need to resolve this via the API + // For now, create a redirect route that will handle it + return `/lish/uri/${encodeURIComponent(atUri)}`; + } + + return "#"; + } catch (e) { + console.error("Failed to parse AT URI:", atUri, e); + return "#"; + } +} + +/** + * Opens a mention link in the appropriate way + * - DID mentions open in a new tab (external Bluesky) + * - Publication/document mentions navigate in the same tab + */ +export function handleMentionClick( + e: MouseEvent | React.MouseEvent, + type: "did" | "at-uri", + value: string +) { + e.preventDefault(); + e.stopPropagation(); + + if (type === "did") { + // Open Bluesky profile in new tab + window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer"); + } else { + // Navigate to publication/document in same tab + const url = atUriToUrl(value); + if (url.startsWith("/lish/uri/")) { + // Redirect route - navigate to it + window.location.href = url; + } else { + window.location.href = url; + } + } +} -- 2.51.2