a tool for shared writing and social publishing

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 <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
64e83f27 e3bc6698

+2457 -382
+183 -9
actions/publishToPublication.ts
··· 50 50 ColorToRGBA, 51 51 } from "components/ThemeManager/colorToLexicons"; 52 52 import { parseColor } from "@react-stately/color"; 53 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 54 + import { v7 } from "uuid"; 53 55 54 56 export async function publishToPublication({ 55 57 root_entity, ··· 210 212 } 211 213 } 212 214 215 + // Create notifications for mentions (only on first publish) 216 + if (!existingDocUri) { 217 + await createMentionNotifications(result.uri, record, credentialSession.did!); 218 + } 219 + 213 220 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 214 221 } 215 222 ··· 342 349 Y.applyUpdate(doc, update); 343 350 let nodes = doc.getXmlElement("prosemirror").toArray(); 344 351 let stringValue = YJSFragmentToString(nodes[0]); 345 - let facets = YJSFragmentToFacets(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 346 353 return [stringValue, facets] as const; 347 354 }; 348 355 if (b.type === "card") { ··· 603 610 604 611 function YJSFragmentToFacets( 605 612 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 606 - ): PubLeafletRichtextFacet.Main[] { 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 607 615 if (node.constructor === Y.XmlElement) { 608 - return node 609 - .toArray() 610 - .map((f) => YJSFragmentToFacets(f)) 611 - .flat(); 616 + // Handle inline mention nodes 617 + if (node.nodeName === "didMention") { 618 + const text = node.getAttribute("text") || ""; 619 + const unicodestring = new UnicodeString(text); 620 + const facet: PubLeafletRichtextFacet.Main = { 621 + index: { 622 + byteStart: byteOffset, 623 + byteEnd: byteOffset + unicodestring.length, 624 + }, 625 + features: [ 626 + { 627 + $type: "pub.leaflet.richtext.facet#didMention", 628 + did: node.getAttribute("did"), 629 + }, 630 + ], 631 + }; 632 + return { facets: [facet], byteLength: unicodestring.length }; 633 + } 634 + 635 + if (node.nodeName === "atMention") { 636 + const text = node.getAttribute("text") || ""; 637 + const unicodestring = new UnicodeString(text); 638 + const facet: PubLeafletRichtextFacet.Main = { 639 + index: { 640 + byteStart: byteOffset, 641 + byteEnd: byteOffset + unicodestring.length, 642 + }, 643 + features: [ 644 + { 645 + $type: "pub.leaflet.richtext.facet#atMention", 646 + atURI: node.getAttribute("atURI"), 647 + }, 648 + ], 649 + }; 650 + return { facets: [facet], byteLength: unicodestring.length }; 651 + } 652 + 653 + if (node.nodeName === "hard_break") { 654 + const unicodestring = new UnicodeString("\n"); 655 + return { facets: [], byteLength: unicodestring.length }; 656 + } 657 + 658 + // For other elements (like paragraph), process children 659 + let allFacets: PubLeafletRichtextFacet.Main[] = []; 660 + let currentOffset = byteOffset; 661 + for (const child of node.toArray()) { 662 + const result = YJSFragmentToFacets(child, currentOffset); 663 + allFacets.push(...result.facets); 664 + currentOffset += result.byteLength; 665 + } 666 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 612 667 } 668 + 613 669 if (node.constructor === Y.XmlText) { 614 670 let facets: PubLeafletRichtextFacet.Main[] = []; 615 671 let delta = node.toDelta() as Delta[]; 616 - let byteStart = 0; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 617 674 for (let d of delta) { 618 675 let unicodestring = new UnicodeString(d.insert); 619 676 let facet: PubLeafletRichtextFacet.Main = { ··· 646 703 }); 647 704 if (facet.features.length > 0) facets.push(facet); 648 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 649 707 } 650 - return facets; 708 + return { facets, byteLength: totalLength }; 651 709 } 652 - return []; 710 + return { facets: [], byteLength: 0 }; 653 711 } 654 712 655 713 type ExcludeString<T> = T extends string ··· 725 783 726 784 return undefined; 727 785 } 786 + 787 + /** 788 + * Extract mentions from a published document and create notifications 789 + */ 790 + async function createMentionNotifications( 791 + documentUri: string, 792 + record: PubLeafletDocument.Record, 793 + authorDid: string, 794 + ) { 795 + const mentionedDids = new Set<string>(); 796 + const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 797 + const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 798 + 799 + // Extract mentions from all text blocks in all pages 800 + for (const page of record.pages) { 801 + if (page.$type === "pub.leaflet.pages.linearDocument") { 802 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 803 + for (const blockWrapper of linearPage.blocks) { 804 + const block = blockWrapper.block; 805 + if (block.$type === "pub.leaflet.blocks.text") { 806 + const textBlock = block as PubLeafletBlocksText.Main; 807 + if (textBlock.facets) { 808 + for (const facet of textBlock.facets) { 809 + for (const feature of facet.features) { 810 + // Check for DID mentions 811 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 812 + if (feature.did !== authorDid) { 813 + mentionedDids.add(feature.did); 814 + } 815 + } 816 + // Check for AT URI mentions (publications and documents) 817 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 818 + const uri = new AtUri(feature.atURI); 819 + 820 + if (uri.collection === "pub.leaflet.publication") { 821 + // Get the publication owner's DID 822 + const { data: publication } = await supabaseServerClient 823 + .from("publications") 824 + .select("identity_did") 825 + .eq("uri", feature.atURI) 826 + .single(); 827 + 828 + if (publication && publication.identity_did !== authorDid) { 829 + mentionedPublications.set(publication.identity_did, feature.atURI); 830 + } 831 + } else if (uri.collection === "pub.leaflet.document") { 832 + // Get the document owner's DID 833 + const { data: document } = await supabaseServerClient 834 + .from("documents") 835 + .select("uri, data") 836 + .eq("uri", feature.atURI) 837 + .single(); 838 + 839 + if (document) { 840 + const docRecord = document.data as PubLeafletDocument.Record; 841 + if (docRecord.author !== authorDid) { 842 + mentionedDocuments.set(docRecord.author, feature.atURI); 843 + } 844 + } 845 + } 846 + } 847 + } 848 + } 849 + } 850 + } 851 + } 852 + } 853 + } 854 + 855 + // Create notifications for DID mentions 856 + for (const did of mentionedDids) { 857 + const notification: Notification = { 858 + id: v7(), 859 + recipient: did, 860 + data: { 861 + type: "mention", 862 + document_uri: documentUri, 863 + mention_type: "did", 864 + }, 865 + }; 866 + await supabaseServerClient.from("notifications").insert(notification); 867 + await pingIdentityToUpdateNotification(did); 868 + } 869 + 870 + // Create notifications for publication mentions 871 + for (const [recipientDid, publicationUri] of mentionedPublications) { 872 + const notification: Notification = { 873 + id: v7(), 874 + recipient: recipientDid, 875 + data: { 876 + type: "mention", 877 + document_uri: documentUri, 878 + mention_type: "publication", 879 + mentioned_uri: publicationUri, 880 + }, 881 + }; 882 + await supabaseServerClient.from("notifications").insert(notification); 883 + await pingIdentityToUpdateNotification(recipientDid); 884 + } 885 + 886 + // Create notifications for document mentions 887 + for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { 888 + const notification: Notification = { 889 + id: v7(), 890 + recipient: recipientDid, 891 + data: { 892 + type: "mention", 893 + document_uri: documentUri, 894 + mention_type: "document", 895 + mentioned_uri: mentionedDocUri, 896 + }, 897 + }; 898 + await supabaseServerClient.from("notifications").insert(notification); 899 + await pingIdentityToUpdateNotification(recipientDid); 900 + } 901 + }
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
··· 1 + import { 2 + AppBskyActorProfile, 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { HydratedCommentMentionNotification } from "src/notifications"; 8 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 + import { MentionTiny } from "components/Icons/MentionTiny"; 10 + import { 11 + CommentInNotification, 12 + ContentLayout, 13 + Notification, 14 + } from "./Notification"; 15 + import { AtUri } from "@atproto/api"; 16 + 17 + export const CommentMentionNotification = ( 18 + props: HydratedCommentMentionNotification, 19 + ) => { 20 + const docRecord = props.commentData.documents 21 + ?.data as PubLeafletDocument.Record; 22 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 23 + const profileRecord = props.commentData.bsky_profiles 24 + ?.record as AppBskyActorProfile.Record; 25 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 26 + ?.publications?.record as PubLeafletPublication.Record | undefined; 27 + const docUri = new AtUri(props.commentData.documents?.uri!); 28 + const rkey = docUri.rkey; 29 + const did = docUri.host; 30 + 31 + const href = pubRecord 32 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 33 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 34 + 35 + const commenter = props.commenterHandle 36 + ? `@${props.commenterHandle}` 37 + : "Someone"; 38 + 39 + let actionText: React.ReactNode; 40 + let mentionedDocRecord = props.mentionedDocument 41 + ?.data as PubLeafletDocument.Record; 42 + 43 + if (props.mention_type === "did") { 44 + actionText = <>{commenter} mentioned you in a comment</>; 45 + } else if ( 46 + props.mention_type === "publication" && 47 + props.mentionedPublication 48 + ) { 49 + const mentionedPubRecord = props.mentionedPublication 50 + .record as PubLeafletPublication.Record; 51 + actionText = ( 52 + <> 53 + {commenter} mentioned your publication{" "} 54 + <span className="italic">{mentionedPubRecord.name}</span> in a comment 55 + </> 56 + ); 57 + } else if (props.mention_type === "document" && props.mentionedDocument) { 58 + actionText = ( 59 + <> 60 + {commenter} mentioned your post{" "} 61 + <span className="italic">{mentionedDocRecord.title}</span> in a comment 62 + </> 63 + ); 64 + } else { 65 + actionText = <>{commenter} mentioned you in a comment</>; 66 + } 67 + 68 + return ( 69 + <Notification 70 + timestamp={props.created_at} 71 + href={href} 72 + icon={<MentionTiny />} 73 + actionText={actionText} 74 + content={ 75 + <ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}> 76 + <CommentInNotification 77 + className="" 78 + avatar={ 79 + profileRecord?.avatar?.ref && 80 + blobRefToSrc( 81 + profileRecord?.avatar?.ref, 82 + props.commentData.bsky_profiles?.did || "", 83 + ) 84 + } 85 + displayName={ 86 + profileRecord?.displayName || 87 + props.commentData.bsky_profiles?.handle || 88 + "Someone" 89 + } 90 + index={[]} 91 + plaintext={commentRecord.plaintext} 92 + facets={commentRecord.facets} 93 + /> 94 + </ContentLayout> 95 + } 96 + /> 97 + ); 98 + };
+44 -24
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 1 + import { MentionTiny } from "components/Icons/MentionTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 - import { HydratedQuoteNotification } from "src/notifications"; 3 + import { HydratedMentionNotification } from "src/notifications"; 4 4 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 - import { AtUri } from "@atproto/api"; 6 - import { Avatar } from "components/Avatar"; 5 + import { Agent, AtUri } from "@atproto/api"; 7 6 8 - export const QuoteNotification = (props: HydratedQuoteNotification) => { 9 - const postView = props.bskyPost.post_view as any; 10 - const author = postView.author; 11 - const displayName = author.displayName || author.handle || "Someone"; 7 + export const MentionNotification = (props: HydratedMentionNotification) => { 12 8 const docRecord = props.document.data as PubLeafletDocument.Record; 13 - const pubRecord = props.document.documents_in_publications[0]?.publications 9 + const pubRecord = props.document.documents_in_publications?.[0]?.publications 14 10 ?.record as PubLeafletPublication.Record | undefined; 15 11 const docUri = new AtUri(props.document.uri); 16 12 const rkey = docUri.rkey; 17 13 const did = docUri.host; 18 - const postText = postView.record?.text || ""; 19 14 20 15 const href = pubRecord 21 16 ? `https://${pubRecord.base_path}/${rkey}` 22 17 : `/p/${did}/${rkey}`; 23 18 19 + let actionText: React.ReactNode; 20 + let mentionedItemName: string | undefined; 21 + let mentionedDocRecord = props.mentionedDocument 22 + ?.data as PubLeafletDocument.Record; 23 + 24 + const mentioner = props.documentCreatorHandle 25 + ? `@${props.documentCreatorHandle}` 26 + : "Someone"; 27 + 28 + if (props.mention_type === "did") { 29 + actionText = <>{mentioner} mentioned you</>; 30 + } else if ( 31 + props.mention_type === "publication" && 32 + props.mentionedPublication 33 + ) { 34 + const mentionedPubRecord = props.mentionedPublication 35 + .record as PubLeafletPublication.Record; 36 + mentionedItemName = mentionedPubRecord.name; 37 + actionText = ( 38 + <> 39 + {mentioner} mentioned your publication{" "} 40 + <span className="italic">{mentionedItemName}</span> 41 + </> 42 + ); 43 + } else if (props.mention_type === "document" && props.mentionedDocument) { 44 + mentionedItemName = mentionedDocRecord.title; 45 + actionText = ( 46 + <> 47 + {mentioner} mentioned your post{" "} 48 + <span className="italic">{mentionedItemName}</span> 49 + </> 50 + ); 51 + } else { 52 + actionText = <>{mentioner} mentioned you</>; 53 + } 54 + 24 55 return ( 25 56 <Notification 26 57 timestamp={props.created_at} 27 58 href={href} 28 - icon={<QuoteTiny />} 29 - actionText={<>{displayName} quoted your post</>} 59 + icon={<MentionTiny />} 60 + actionText={actionText} 30 61 content={ 31 62 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 - <div className="flex gap-2 text-sm w-full"> 33 - <Avatar 34 - src={author.avatar} 35 - displayName={displayName} 36 - /> 37 - <pre 38 - style={{ wordBreak: "break-word" }} 39 - className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 40 - > 41 - {postText} 42 - </pre> 43 - </div> 63 + {docRecord.description && docRecord.description} 44 64 </ContentLayout> 45 65 } 46 66 />
+3 -3
app/(home-pages)/notifications/Notification.tsx
··· 69 69 <div 70 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 71 > 72 - <div className="text-tertiary text-sm italic font-bold pb-1"> 72 + <div className="text-tertiary text-sm italic font-bold "> 73 73 {props.postTitle} 74 74 </div> 75 - {props.children} 75 + {props.children && <div className="mb-2 text-sm">{props.children}</div>} 76 76 {props.pubRecord && ( 77 77 <> 78 - <hr className="mt-3 mb-1 border-border-light" /> 78 + <hr className="mt-1 mb-1 border-border-light" /> 79 79 <a 80 80 href={`https://${props.pubRecord.base_path}`} 81 81 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
+9 -1
app/(home-pages)/notifications/NotificationList.tsx
··· 7 7 import { ReplyNotification } from "./ReplyNotification"; 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 9 import { FollowNotification } from "./FollowNotification"; 10 - import { QuoteNotification } from "./MentionNotification"; 10 + import { QuoteNotification } from "./QuoteNotification"; 11 + import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 11 13 12 14 export function NotificationList({ 13 15 notifications, ··· 45 47 } 46 48 if (n.type === "quote") { 47 49 return <QuoteNotification key={n.id} {...n} />; 50 + } 51 + if (n.type === "mention") { 52 + return <MentionNotification key={n.id} {...n} />; 53 + } 54 + if (n.type === "comment_mention") { 55 + return <CommentMentionNotification key={n.id} {...n} />; 48 56 } 49 57 })} 50 58 </div>
+48
app/(home-pages)/notifications/QuoteNotification.tsx
··· 1 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedQuoteNotification } from "src/notifications"; 4 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Avatar } from "components/Avatar"; 7 + 8 + export const QuoteNotification = (props: HydratedQuoteNotification) => { 9 + const postView = props.bskyPost.post_view as any; 10 + const author = postView.author; 11 + const displayName = author.displayName || author.handle || "Someone"; 12 + const docRecord = props.document.data as PubLeafletDocument.Record; 13 + const pubRecord = props.document.documents_in_publications[0]?.publications 14 + ?.record as PubLeafletPublication.Record | undefined; 15 + const docUri = new AtUri(props.document.uri); 16 + const rkey = docUri.rkey; 17 + const did = docUri.host; 18 + const postText = postView.record?.text || ""; 19 + 20 + const href = pubRecord 21 + ? `https://${pubRecord.base_path}/${rkey}` 22 + : `/p/${did}/${rkey}`; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<QuoteTiny />} 29 + actionText={<>{displayName} quoted your post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + <div className="flex gap-2 text-sm w-full"> 33 + <Avatar 34 + src={author.avatar} 35 + displayName={displayName} 36 + /> 37 + <pre 38 + style={{ wordBreak: "break-word" }} 39 + className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 40 + > 41 + {postText} 42 + </pre> 43 + </div> 44 + </ContentLayout> 45 + } 46 + /> 47 + ); 48 + };
+105 -286
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 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"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 14 5 import { EditorView } from "prosemirror-view"; 15 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 7 import { baseKeymap } from "prosemirror-commands"; ··· 19 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 12 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 13 + import { schema } from "components/Blocks/TextBlock/schema"; 14 + import { Mention, MentionAutocomplete } from "components/Mention"; 22 15 23 16 // Schema with only links, mentions, and hashtags marks 24 17 const bskyPostSchema = new Schema({ ··· 134 127 return tr; 135 128 }); 136 129 } 137 - 138 130 export function BlueskyPostEditorProsemirror(props: { 139 - editorStateRef: React.MutableRefObject<EditorState | null>; 131 + editorStateRef: React.RefObject<EditorState | null>; 140 132 initialContent?: string; 141 133 onCharCountChange?: (count: number) => void; 142 134 }) { 143 135 const mountRef = useRef<HTMLDivElement | null>(null); 144 136 const viewRef = useRef<EditorView | null>(null); 145 137 const [editorState, setEditorState] = useState<EditorState | null>(null); 146 - const [mentionState, setMentionState] = useState<{ 147 - active: boolean; 148 - range: { from: number; to: number } | null; 149 - selectedMention: { handle: string; did: string } | null; 150 - }>({ active: false, range: null, selectedMention: null }); 138 + const [mentionOpen, setMentionOpen] = useState(false); 139 + const [mentionCoords, setMentionCoords] = useState<{ 140 + top: number; 141 + left: number; 142 + } | null>(null); 143 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 144 + 145 + const openMentionAutocomplete = useCallback(() => { 146 + if (!viewRef.current) return; 147 + const view = viewRef.current; 148 + const pos = view.state.selection.from; 149 + setMentionInsertPos(pos); 150 + const coords = view.coordsAtPos(pos - 1); 151 + setMentionCoords({ 152 + top: coords.bottom + window.scrollY, 153 + left: coords.left + window.scrollX, 154 + }); 155 + setMentionOpen(true); 156 + }, []); 151 157 152 158 const handleMentionSelect = useCallback( 153 - ( 154 - mention: { handle: string; did: string }, 155 - range: { from: number; to: number }, 156 - ) => { 157 - if (!viewRef.current) return; 159 + (mention: Mention) => { 160 + if (mention.type !== "did") return; 161 + if (!viewRef.current || mentionInsertPos === null) return; 158 162 const view = viewRef.current; 159 - const { from, to } = range; 163 + const from = mentionInsertPos - 1; 164 + const to = mentionInsertPos; 160 165 const tr = view.state.tr; 161 166 162 - // Delete the query text (keep the @) 163 - tr.delete(from + 1, to); 167 + // Delete the @ symbol 168 + tr.delete(from, to); 164 169 165 - // Insert the mention text after the @ 166 - const mentionText = mention.handle; 167 - tr.insertText(mentionText, from + 1); 170 + // Insert @handle 171 + const mentionText = "@" + mention.handle; 172 + tr.insertText(mentionText, from); 168 173 169 - // Apply mention mark to @ and handle 174 + // Apply mention mark 170 175 tr.addMark( 171 176 from, 172 - from + 1 + mentionText.length, 177 + from + mentionText.length, 173 178 bskyPostSchema.marks.mention.create({ did: mention.did }), 174 179 ); 175 180 176 181 // Add a space after the mention 177 - tr.insertText(" ", from + 1 + mentionText.length); 182 + tr.insertText(" ", from + mentionText.length); 178 183 179 184 view.dispatch(tr); 180 185 view.focus(); 181 186 }, 182 - [], 187 + [mentionInsertPos], 183 188 ); 184 189 185 - const mentionStateRef = useRef(mentionState); 186 - mentionStateRef.current = mentionState; 190 + const handleMentionOpenChange = useCallback((open: boolean) => { 191 + setMentionOpen(open); 192 + if (!open) { 193 + setMentionCoords(null); 194 + setMentionInsertPos(null); 195 + } 196 + }, []); 187 197 188 198 useLayoutEffect(() => { 189 199 if (!mountRef.current) return; 190 200 201 + // Input rule to trigger mention autocomplete when @ is typed 202 + const mentionInputRule = new InputRule( 203 + /(?:^|\s)@$/, 204 + (state, match, start, end) => { 205 + setTimeout(() => openMentionAutocomplete(), 0); 206 + return null; 207 + }, 208 + ); 209 + 191 210 const initialState = EditorState.create({ 192 211 schema: bskyPostSchema, 193 212 doc: props.initialContent ··· 200 219 }) 201 220 : undefined, 202 221 plugins: [ 203 - inputRules({ rules: [createHashtagInputRule()] }), 222 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 204 223 keymap({ 205 224 "Mod-z": undo, 206 225 "Mod-y": redo, 207 226 "Shift-Mod-z": redo, 208 - Enter: (state, dispatch) => { 209 - // Check if mention autocomplete is active 210 - const currentMentionState = mentionStateRef.current; 211 - if ( 212 - currentMentionState.active && 213 - currentMentionState.selectedMention && 214 - currentMentionState.range 215 - ) { 216 - handleMentionSelect( 217 - currentMentionState.selectedMention, 218 - currentMentionState.range, 219 - ); 220 - return true; 221 - } 222 - // Otherwise let the default Enter behavior happen (new paragraph) 223 - return false; 224 - }, 225 227 }), 226 228 keymap(baseKeymap), 227 229 autolink({ ··· 258 260 view.destroy(); 259 261 viewRef.current = null; 260 262 }; 261 - }, [handleMentionSelect]); 263 + }, [openMentionAutocomplete]); 262 264 263 265 return ( 264 266 <div className="relative w-full h-full group"> 265 - {editorState && ( 266 - <MentionAutocomplete 267 - editorState={editorState} 268 - view={viewRef} 269 - onSelect={handleMentionSelect} 270 - onMentionStateChange={(active, range, selectedMention) => { 271 - setMentionState({ active, range, selectedMention }); 272 - }} 273 - /> 274 - )} 267 + <MentionAutocomplete 268 + open={mentionOpen} 269 + onOpenChange={handleMentionOpenChange} 270 + view={viewRef} 271 + onSelect={handleMentionSelect} 272 + coords={mentionCoords} 273 + /> 275 274 {editorState?.doc.textContent.length === 0 && ( 276 275 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 277 276 Write a post to share your writing! ··· 290 289 ); 291 290 } 292 291 293 - function MentionAutocomplete(props: { 294 - editorState: EditorState; 295 - view: React.RefObject<EditorView | null>; 296 - onSelect: ( 297 - mention: { handle: string; did: string }, 298 - range: { from: number; to: number }, 299 - ) => void; 300 - onMentionStateChange: ( 301 - active: boolean, 302 - range: { from: number; to: number } | null, 303 - selectedMention: { handle: string; did: string } | null, 304 - ) => void; 305 - }) { 306 - const [mentionQuery, setMentionQuery] = useState<string | null>(null); 307 - const [mentionRange, setMentionRange] = useState<{ 308 - from: number; 309 - to: number; 310 - } | null>(null); 311 - const [mentionCoords, setMentionCoords] = useState<{ 312 - top: number; 313 - left: number; 314 - } | null>(null); 315 - 316 - const { suggestionIndex, setSuggestionIndex, suggestions } = 317 - useMentionSuggestions(mentionQuery); 318 - 319 - // Check for mention pattern whenever editor state changes 320 - useEffect(() => { 321 - const { $from } = props.editorState.selection; 322 - const textBefore = $from.parent.textBetween( 323 - Math.max(0, $from.parentOffset - 50), 324 - $from.parentOffset, 325 - null, 326 - "\ufffc", 327 - ); 328 - 329 - // Look for @ followed by word characters before cursor 330 - const match = textBefore.match(/@([\w.]*)$/); 331 - 332 - if (match && props.view.current) { 333 - const queryBefore = match[1]; 334 - const from = $from.pos - queryBefore.length - 1; 335 - 336 - // Get text after cursor to find the rest of the handle 337 - const textAfter = $from.parent.textBetween( 338 - $from.parentOffset, 339 - Math.min($from.parent.content.size, $from.parentOffset + 50), 340 - null, 341 - "\ufffc", 342 - ); 343 - 344 - // Match word characters after cursor until space or end 345 - const afterMatch = textAfter.match(/^([\w.]*)/); 346 - const queryAfter = afterMatch ? afterMatch[1] : ""; 347 - 348 - // Combine the full handle 349 - const query = queryBefore + queryAfter; 350 - const to = $from.pos + queryAfter.length; 351 - 352 - setMentionQuery(query); 353 - setMentionRange({ from, to }); 354 - 355 - // Get coordinates for the autocomplete popup 356 - const coords = props.view.current.coordsAtPos(from); 357 - setMentionCoords({ 358 - top: coords.bottom + window.scrollY, 359 - left: coords.left + window.scrollX, 360 - }); 361 - setSuggestionIndex(0); 362 - } else { 363 - setMentionQuery(null); 364 - setMentionRange(null); 365 - setMentionCoords(null); 366 - } 367 - }, [props.editorState, props.view, setSuggestionIndex]); 368 - 369 - // Update parent's mention state 370 - useEffect(() => { 371 - const active = mentionQuery !== null && suggestions.length > 0; 372 - const selectedMention = 373 - active && suggestions[suggestionIndex] 374 - ? suggestions[suggestionIndex] 375 - : null; 376 - props.onMentionStateChange(active, mentionRange, selectedMention); 377 - }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 378 - 379 - // Handle keyboard navigation for arrow keys only 380 - useEffect(() => { 381 - if (!mentionQuery || !props.view.current) return; 382 - 383 - const handleKeyDown = (e: KeyboardEvent) => { 384 - if (suggestions.length === 0) return; 385 - 386 - if (e.key === "ArrowUp") { 387 - e.preventDefault(); 388 - if (suggestionIndex > 0) { 389 - setSuggestionIndex((i) => i - 1); 390 - } 391 - } else if (e.key === "ArrowDown") { 392 - e.preventDefault(); 393 - if (suggestionIndex < suggestions.length - 1) { 394 - setSuggestionIndex((i) => i + 1); 395 - } 396 - } 397 - }; 398 - 399 - const dom = props.view.current.dom; 400 - dom.addEventListener("keydown", handleKeyDown); 401 - 402 - return () => { 403 - dom.removeEventListener("keydown", handleKeyDown); 404 - }; 405 - }, [ 406 - mentionQuery, 407 - suggestions, 408 - suggestionIndex, 409 - props.view, 410 - setSuggestionIndex, 411 - ]); 412 - 413 - if (!mentionCoords || suggestions.length === 0) return null; 414 - 415 - // The styles in this component should match the Menu styles in components/Layout.tsx 416 - return ( 417 - <Popover.Root open> 418 - {createPortal( 419 - <Popover.Anchor 420 - style={{ 421 - top: mentionCoords.top, 422 - left: mentionCoords.left, 423 - position: "absolute", 424 - }} 425 - />, 426 - document.body, 427 - )} 428 - <Popover.Portal> 429 - <Popover.Content 430 - side="bottom" 431 - align="start" 432 - sideOffset={4} 433 - collisionPadding={20} 434 - onOpenAutoFocus={(e) => e.preventDefault()} 435 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 436 - > 437 - <ul className="list-none p-0 text-sm"> 438 - {suggestions.map((result, index) => { 439 - return ( 440 - <div 441 - className={` 442 - MenuItem 443 - font-bold z-10 py-1 px-3 444 - text-left text-secondary 445 - flex gap-2 446 - ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 447 - hover:bg-border-light hover:text-secondary 448 - outline-none 449 - `} 450 - key={result.did} 451 - onClick={() => { 452 - if (mentionRange) { 453 - props.onSelect(result, mentionRange); 454 - setMentionQuery(null); 455 - setMentionRange(null); 456 - setMentionCoords(null); 457 - } 458 - }} 459 - onMouseDown={(e) => e.preventDefault()} 460 - > 461 - @{result.handle} 462 - </div> 463 - ); 464 - })} 465 - </ul> 466 - </Popover.Content> 467 - </Popover.Portal> 468 - </Popover.Root> 469 - ); 470 - } 471 - 472 - function useMentionSuggestions(query: string | null) { 473 - const [suggestionIndex, setSuggestionIndex] = useState(0); 474 - const [suggestions, setSuggestions] = useState< 475 - { handle: string; did: string }[] 476 - >([]); 477 - 478 - useDebouncedEffect( 479 - async () => { 480 - if (!query) { 481 - setSuggestions([]); 482 - return; 483 - } 484 - 485 - const agent = new Agent("https://public.api.bsky.app"); 486 - const result = await agent.searchActorsTypeahead({ 487 - q: query, 488 - limit: 8, 489 - }); 490 - setSuggestions( 491 - result.data.actors.map((actor) => ({ 492 - handle: actor.handle, 493 - did: actor.did, 494 - })), 495 - ); 496 - }, 497 - 300, 498 - [query], 499 - ); 500 - 501 - useEffect(() => { 502 - if (suggestionIndex > suggestions.length - 1) { 503 - setSuggestionIndex(Math.max(0, suggestions.length - 1)); 504 - } 505 - }, [suggestionIndex, suggestions.length]); 506 - 507 - return { 508 - suggestions, 509 - suggestionIndex, 510 - setSuggestionIndex, 511 - }; 512 - } 513 - 514 292 /** 515 293 * Converts a ProseMirror editor state to Bluesky post facets. 516 294 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 595 373 596 374 return features; 597 375 } 376 + 377 + export const addMentionToEditor = ( 378 + mention: Mention, 379 + range: { from: number; to: number }, 380 + view: EditorView, 381 + ) => { 382 + console.log("view", view); 383 + if (!view) return; 384 + const { from, to } = range; 385 + const tr = view.state.tr; 386 + 387 + if (mention.type == "did") { 388 + // Delete the @ and any query text 389 + tr.delete(from, to); 390 + // Insert didMention inline node 391 + const mentionText = "@" + mention.handle; 392 + const didMentionNode = schema.nodes.didMention.create({ 393 + did: mention.did, 394 + text: mentionText, 395 + }); 396 + tr.insert(from, didMentionNode); 397 + } 398 + if (mention.type === "publication" || mention.type === "post") { 399 + // Delete the @ and any query text 400 + tr.delete(from, to); 401 + let name = mention.type == "post" ? mention.title : mention.name; 402 + // Insert atMention inline node 403 + const atMentionNode = schema.nodes.atMention.create({ 404 + atURI: mention.uri, 405 + text: name, 406 + }); 407 + tr.insert(from, atMentionNode); 408 + } 409 + console.log("yo", mention); 410 + 411 + // Add a space after the mention 412 + tr.insertText(" ", from + 1); 413 + 414 + view.dispatch(tr); 415 + view.focus(); 416 + };
+145
app/api/pub_icon/route.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { IdResolver } from "@atproto/identity"; 3 + import { NextRequest, NextResponse } from "next/server"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import sharp from "sharp"; 7 + 8 + const idResolver = new IdResolver(); 9 + 10 + export const runtime = "nodejs"; 11 + 12 + export async function GET(req: NextRequest) { 13 + const searchParams = req.nextUrl.searchParams; 14 + const bgColor = searchParams.get("bg") || "#0000E1"; 15 + const fgColor = searchParams.get("fg") || "#FFFFFF"; 16 + 17 + try { 18 + const at_uri = searchParams.get("at_uri"); 19 + 20 + if (!at_uri) { 21 + return new NextResponse(null, { status: 400 }); 22 + } 23 + 24 + // Parse the AT URI 25 + let uri: AtUri; 26 + try { 27 + uri = new AtUri(at_uri); 28 + } catch (e) { 29 + return new NextResponse(null, { status: 400 }); 30 + } 31 + 32 + let publicationRecord: PubLeafletPublication.Record | null = null; 33 + let publicationUri: string; 34 + 35 + // Check if it's a document or publication 36 + if (uri.collection === "pub.leaflet.document") { 37 + // Query the documents_in_publications table to get the publication 38 + const { data: docInPub } = await supabaseServerClient 39 + .from("documents_in_publications") 40 + .select("publication, publications(record)") 41 + .eq("document", at_uri) 42 + .single(); 43 + 44 + if (!docInPub || !docInPub.publications) { 45 + return new NextResponse(null, { status: 404 }); 46 + } 47 + 48 + publicationUri = docInPub.publication; 49 + publicationRecord = docInPub.publications 50 + .record as PubLeafletPublication.Record; 51 + } else if (uri.collection === "pub.leaflet.publication") { 52 + // Query the publications table directly 53 + const { data: publication } = await supabaseServerClient 54 + .from("publications") 55 + .select("record, uri") 56 + .eq("uri", at_uri) 57 + .single(); 58 + 59 + if (!publication || !publication.record) { 60 + return new NextResponse(null, { status: 404 }); 61 + } 62 + 63 + publicationUri = publication.uri; 64 + publicationRecord = publication.record as PubLeafletPublication.Record; 65 + } else { 66 + // Not a supported collection 67 + return new NextResponse(null, { status: 404 }); 68 + } 69 + 70 + // Check if the publication has an icon 71 + if (!publicationRecord?.icon) { 72 + // Generate a placeholder with the first letter of the publication name 73 + const firstLetter = (publicationRecord?.name || "?") 74 + .slice(0, 1) 75 + .toUpperCase(); 76 + 77 + // Create a simple SVG placeholder with theme colors 78 + const svg = `<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg"> 79 + <rect width="96" height="96" rx="48" ry="48" fill="${bgColor}"/> 80 + <text x="50%" y="50%" font-size="64" font-weight="bold" font-family="Arial, Helvetica, sans-serif" fill="${fgColor}" text-anchor="middle" dominant-baseline="central">${firstLetter}</text> 81 + </svg>`; 82 + 83 + return new NextResponse(svg, { 84 + headers: { 85 + "Content-Type": "image/svg+xml", 86 + "Cache-Control": 87 + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 88 + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 89 + }, 90 + }); 91 + } 92 + 93 + // Parse the publication URI to get the DID 94 + const pubUri = new AtUri(publicationUri); 95 + 96 + // Get the CID from the icon blob 97 + const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 98 + "$link" 99 + ]; 100 + 101 + // Fetch the blob from the PDS 102 + const identity = await idResolver.did.resolve(pubUri.host); 103 + const service = identity?.service?.find((f) => f.id === "#atproto_pds"); 104 + if (!service) return new NextResponse(null, { status: 404 }); 105 + 106 + const blobResponse = await fetch( 107 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, 108 + { 109 + headers: { 110 + "Accept-Encoding": "gzip, deflate, br, zstd", 111 + }, 112 + }, 113 + ); 114 + 115 + if (!blobResponse.ok) { 116 + return new NextResponse(null, { status: 404 }); 117 + } 118 + 119 + // Get the image buffer 120 + const imageBuffer = await blobResponse.arrayBuffer(); 121 + 122 + // Resize to 96x96 using Sharp 123 + const resizedImage = await sharp(Buffer.from(imageBuffer)) 124 + .resize(96, 96, { 125 + fit: "cover", 126 + position: "center", 127 + }) 128 + .webp({ quality: 90 }) 129 + .toBuffer(); 130 + 131 + // Return with caching headers 132 + return new NextResponse(resizedImage, { 133 + headers: { 134 + "Content-Type": "image/webp", 135 + // Cache for 1 hour, but serve stale for much longer while revalidating 136 + "Cache-Control": 137 + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 138 + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 139 + }, 140 + }); 141 + } catch (error) { 142 + console.error("Error fetching publication icon:", error); 143 + return new NextResponse(null, { status: 500 }); 144 + } 145 + }
+4
app/api/rpc/[command]/route.ts
··· 11 11 } from "./domain_routes"; 12 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 13 import { get_publication_data } from "./get_publication_data"; 14 + import { search_publication_names } from "./search_publication_names"; 15 + import { search_publication_documents } from "./search_publication_documents"; 14 16 15 17 let supabase = createClient<Database>( 16 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 37 get_leaflet_subdomain_status, 36 38 get_leaflet_data, 37 39 get_publication_data, 40 + search_publication_names, 41 + search_publication_documents, 38 42 ]; 39 43 export async function POST( 40 44 req: Request,
+41
app/api/rpc/[command]/search_publication_documents.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + 5 + export type SearchPublicationDocumentsReturnType = Awaited< 6 + ReturnType<(typeof search_publication_documents)["handler"]> 7 + >; 8 + 9 + export const search_publication_documents = makeRoute({ 10 + route: "search_publication_documents", 11 + input: z.object({ 12 + publication_uri: z.string(), 13 + query: z.string(), 14 + limit: z.number().optional().default(10), 15 + }), 16 + handler: async ( 17 + { publication_uri, query, limit }, 18 + { supabase }: Pick<Env, "supabase">, 19 + ) => { 20 + // Get documents in the publication, filtering by title using JSON operator 21 + const { data: documents, error } = await supabase 22 + .from("documents_in_publications") 23 + .select("document, documents!inner(uri, data)") 24 + .eq("publication", publication_uri) 25 + .ilike("documents.data->>title", `%${query}%`) 26 + .limit(limit); 27 + 28 + if (error) { 29 + throw new Error( 30 + `Failed to search publication documents: ${error.message}`, 31 + ); 32 + } 33 + 34 + const result = documents.map((d) => ({ 35 + uri: d.documents.uri, 36 + title: (d.documents.data as { title?: string })?.title || "Untitled", 37 + })); 38 + 39 + return { result: { documents: result } }; 40 + }, 41 + });
+37
app/api/rpc/[command]/search_publication_names.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + 5 + export type SearchPublicationNamesReturnType = Awaited< 6 + ReturnType<(typeof search_publication_names)["handler"]> 7 + >; 8 + 9 + export const search_publication_names = makeRoute({ 10 + route: "search_publication_names", 11 + input: z.object({ 12 + query: z.string(), 13 + limit: z.number().optional().default(10), 14 + }), 15 + handler: async ( 16 + { query, limit }, 17 + { supabase }: Pick<Env, "supabase">, 18 + ) => { 19 + // Search publications by name in record (case-insensitive partial match) 20 + const { data: publications, error } = await supabase 21 + .from("publications") 22 + .select("uri, record") 23 + .ilike("record->>name", `%${query}%`) 24 + .limit(limit); 25 + 26 + if (error) { 27 + throw new Error(`Failed to search publications: ${error.message}`); 28 + } 29 + 30 + const result = publications.map((p) => ({ 31 + uri: p.uri, 32 + name: (p.record as { name?: string })?.name || "Untitled", 33 + })); 34 + 35 + return { result: { publications: result } }; 36 + }, 37 + });
+8
app/globals.css
··· 291 291 @apply py-[1.5px]; 292 292 } 293 293 294 + /* Underline mention nodes when selected in ProseMirror */ 295 + .ProseMirror .atMention.ProseMirror-selectednode, 296 + .ProseMirror .didMention.ProseMirror-selectednode { 297 + text-decoration: underline; 298 + } 299 + 294 300 .ProseMirror:focus-within .selection-highlight { 295 301 background-color: transparent; 296 302 } ··· 414 420 outline: none !important; 415 421 cursor: pointer; 416 422 background-color: transparent; 423 + display: flex; 424 + gap: 0.5rem; 417 425 418 426 :hover { 419 427 text-decoration: none !important;
+30
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 1 import { UnicodeString } from "@atproto/api"; 2 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 3 5 4 6 type Facet = PubLeafletRichtextFacet.Main; 5 7 export function BaseTextBlock(props: { ··· 21 23 let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 22 24 let isStrikethrough = segment.facet?.find( 23 25 PubLeafletRichtextFacet.isStrikethrough, 26 + ); 27 + let isDidMention = segment.facet?.find( 28 + PubLeafletRichtextFacet.isDidMention, 29 + ); 30 + let isAtMention = segment.facet?.find( 31 + PubLeafletRichtextFacet.isAtMention, 24 32 ); 25 33 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 34 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); ··· 47 55 <code key={counter} className={className} id={id?.id}> 48 56 {renderedText} 49 57 </code>, 58 + ); 59 + } else if (isDidMention) { 60 + children.push( 61 + <a 62 + key={counter} 63 + href={didToBlueskyUrl(isDidMention.did)} 64 + className={`text-accent-contrast hover:underline cursor-pointer ${className}`} 65 + target="_blank" 66 + rel="noopener noreferrer" 67 + > 68 + {renderedText} 69 + </a>, 70 + ); 71 + } else if (isAtMention) { 72 + children.push( 73 + <AtMentionLink 74 + key={counter} 75 + atURI={isAtMention.atURI} 76 + className={className} 77 + > 78 + {renderedText} 79 + </AtMentionLink>, 50 80 ); 51 81 } else if (link) { 52 82 children.push(
+223 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 8 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 9 import { EditorView } from "prosemirror-view"; 10 10 import { history, redo, undo } from "prosemirror-history"; 11 + import { InputRule, inputRules } from "prosemirror-inputrules"; 11 12 import { 12 13 MutableRefObject, 13 14 RefObject, 15 + useCallback, 14 16 useEffect, 15 17 useLayoutEffect, 16 18 useRef, ··· 36 38 import { CloseTiny } from "components/Icons/CloseTiny"; 37 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 38 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { Mention, MentionAutocomplete } from "components/Mention"; 42 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 + 44 + const addMentionToEditor = ( 45 + mention: Mention, 46 + range: { from: number; to: number }, 47 + view: EditorView, 48 + ) => { 49 + if (!view) return; 50 + const { from, to } = range; 51 + const tr = view.state.tr; 52 + 53 + if (mention.type === "did") { 54 + // Delete the @ and any query text 55 + tr.delete(from, to); 56 + // Insert didMention inline node 57 + const mentionText = "@" + mention.handle; 58 + const didMentionNode = multiBlockSchema.nodes.didMention.create({ 59 + did: mention.did, 60 + text: mentionText, 61 + }); 62 + tr.insert(from, didMentionNode); 63 + // Add a space after the mention 64 + tr.insertText(" ", from + 1); 65 + } 66 + if (mention.type === "publication" || mention.type === "post") { 67 + // Delete the @ and any query text 68 + tr.delete(from, to); 69 + let name = mention.type === "post" ? mention.title : mention.name; 70 + // Insert atMention inline node 71 + const atMentionNode = multiBlockSchema.nodes.atMention.create({ 72 + atURI: mention.uri, 73 + text: name, 74 + }); 75 + tr.insert(from, atMentionNode); 76 + // Add a space after the mention 77 + tr.insertText(" ", from + 1); 78 + } 79 + 80 + view.dispatch(tr); 81 + view.focus(); 82 + }; 39 83 40 84 export function CommentBox(props: { 41 85 doc_uri: string; ··· 50 94 commentBox: { quote }, 51 95 } = useInteractionState(props.doc_uri); 52 96 let [loading, setLoading] = useState(false); 97 + let view = useRef<null | EditorView>(null); 98 + 99 + // Mention autocomplete state 100 + const [mentionOpen, setMentionOpen] = useState(false); 101 + const [mentionCoords, setMentionCoords] = useState<{ 102 + top: number; 103 + left: number; 104 + } | null>(null); 105 + // Use a ref for insert position to avoid stale closure issues 106 + const mentionInsertPosRef = useRef<number | null>(null); 107 + 108 + // Use a ref for the callback so input rules can access it 109 + const openMentionAutocompleteRef = useRef<() => void>(() => {}); 110 + openMentionAutocompleteRef.current = () => { 111 + if (!view.current) return; 53 112 54 - const handleSubmit = async () => { 113 + const pos = view.current.state.selection.from; 114 + mentionInsertPosRef.current = pos; 115 + 116 + // Get coordinates for the popup relative to the positioned parent 117 + const coords = view.current.coordsAtPos(pos - 1); 118 + 119 + // Find the relative positioned parent container 120 + const editorEl = view.current.dom; 121 + const container = editorEl.closest(".relative") as HTMLElement | null; 122 + 123 + if (container) { 124 + const containerRect = container.getBoundingClientRect(); 125 + setMentionCoords({ 126 + top: coords.bottom - containerRect.top, 127 + left: coords.left - containerRect.left, 128 + }); 129 + } else { 130 + setMentionCoords({ 131 + top: coords.bottom, 132 + left: coords.left, 133 + }); 134 + } 135 + setMentionOpen(true); 136 + }; 137 + 138 + const handleMentionSelect = useCallback((mention: Mention) => { 139 + if (!view.current || mentionInsertPosRef.current === null) return; 140 + 141 + const from = mentionInsertPosRef.current - 1; 142 + const to = mentionInsertPosRef.current; 143 + 144 + addMentionToEditor(mention, { from, to }, view.current); 145 + view.current.focus(); 146 + }, []); 147 + 148 + const handleMentionOpenChange = useCallback((open: boolean) => { 149 + setMentionOpen(open); 150 + if (!open) { 151 + setMentionCoords(null); 152 + mentionInsertPosRef.current = null; 153 + } 154 + }, []); 155 + 156 + // Use a ref for handleSubmit so keyboard shortcuts can access it 157 + const handleSubmitRef = useRef<() => Promise<void>>(async () => {}); 158 + handleSubmitRef.current = async () => { 55 159 if (loading || !view.current) return; 56 160 57 161 setLoading(true); ··· 114 218 "Mod-y": redo, 115 219 "Shift-Mod-z": redo, 116 220 "Ctrl-Enter": () => { 117 - handleSubmit(); 221 + handleSubmitRef.current(); 118 222 return true; 119 223 }, 120 224 "Meta-Enter": () => { 121 - handleSubmit(); 225 + handleSubmitRef.current(); 122 226 return true; 123 227 }, 124 228 }), ··· 128 232 shouldAutoLink: () => true, 129 233 defaultProtocol: "https", 130 234 }), 235 + // Input rules for @ mentions 236 + inputRules({ 237 + rules: [ 238 + // @ at start of line or after space 239 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 240 + setTimeout(() => openMentionAutocompleteRef.current(), 0); 241 + return null; 242 + }), 243 + ], 244 + }), 131 245 history(), 132 246 ], 133 247 }), 134 248 ); 135 - let view = useRef<null | EditorView>(null); 136 249 useLayoutEffect(() => { 137 250 if (!mountRef.current) return; 138 251 view.current = new EditorView( ··· 187 300 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 188 301 if (!direct) return; 189 302 if (node.nodeSize - 2 <= _pos) return; 303 + 304 + const nodeAt1 = node.nodeAt(_pos - 1); 305 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 306 + 307 + // Check for link marks 190 308 let mark = 191 - node 192 - .nodeAt(_pos - 1) 193 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || 194 - node 195 - .nodeAt(Math.max(_pos - 2, 0)) 196 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link); 309 + nodeAt1?.marks.find( 310 + (f) => f.type === multiBlockSchema.marks.link, 311 + ) || 312 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 197 313 if (mark) { 198 314 window.open(mark.attrs.href, "_blank"); 315 + return; 316 + } 317 + 318 + // Check for didMention inline nodes 319 + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { 320 + window.open( 321 + didToBlueskyUrl(nodeAt1.attrs.did), 322 + "_blank", 323 + "noopener,noreferrer", 324 + ); 325 + return; 326 + } 327 + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { 328 + window.open( 329 + didToBlueskyUrl(nodeAt2.attrs.did), 330 + "_blank", 331 + "noopener,noreferrer", 332 + ); 333 + return; 334 + } 335 + 336 + // Check for atMention inline nodes (publications/documents) 337 + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { 338 + window.open( 339 + atUriToUrl(nodeAt1.attrs.atURI), 340 + "_blank", 341 + "noopener,noreferrer", 342 + ); 343 + return; 344 + } 345 + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { 346 + window.open( 347 + atUriToUrl(nodeAt2.attrs.atURI), 348 + "_blank", 349 + "noopener,noreferrer", 350 + ); 351 + return; 199 352 } 200 353 }, 201 354 dispatchTransaction(tr) { ··· 236 389 <div className="w-full relative group"> 237 390 <pre 238 391 ref={mountRef} 392 + onFocus={() => { 393 + // Close mention dropdown when editor gains focus (reset stale state) 394 + handleMentionOpenChange(false); 395 + }} 396 + onBlur={(e) => { 397 + // Close mention dropdown when editor loses focus 398 + // But not if focus moved to the mention autocomplete 399 + const relatedTarget = e.relatedTarget as HTMLElement | null; 400 + if (!relatedTarget?.closest(".dropdownMenu")) { 401 + handleMentionOpenChange(false); 402 + } 403 + }} 239 404 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 240 405 /> 241 406 <IOSBS view={view} /> 407 + <MentionAutocomplete 408 + open={mentionOpen} 409 + onOpenChange={handleMentionOpenChange} 410 + view={view} 411 + onSelect={handleMentionSelect} 412 + coords={mentionCoords} 413 + /> 242 414 </div> 243 415 <div className="flex justify-between pt-1"> 244 416 <div className="flex gap-1"> ··· 261 433 view={view} 262 434 /> 263 435 </div> 264 - <ButtonPrimary compact onClick={handleSubmit}> 436 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 265 437 {loading ? <DotLoader /> : <ShareSmall />} 266 438 </ButtonPrimary> 267 439 </div> ··· 328 500 facets.push(facet); 329 501 } 330 502 } 503 + 504 + fullText += text; 505 + byteOffset += unicodeString.length; 506 + } else if (node.type.name === "didMention") { 507 + // Handle DID mention nodes 508 + const text = node.attrs.text || ""; 509 + const unicodeString = new UnicodeString(text); 510 + 511 + facets.push({ 512 + index: { 513 + byteStart: byteOffset, 514 + byteEnd: byteOffset + unicodeString.length, 515 + }, 516 + features: [ 517 + { 518 + $type: "pub.leaflet.richtext.facet#didMention", 519 + did: node.attrs.did, 520 + }, 521 + ], 522 + }); 523 + 524 + fullText += text; 525 + byteOffset += unicodeString.length; 526 + } else if (node.type.name === "atMention") { 527 + // Handle AT-URI mention nodes (publications and documents) 528 + const text = node.attrs.text || ""; 529 + const unicodeString = new UnicodeString(text); 530 + 531 + facets.push({ 532 + index: { 533 + byteStart: byteOffset, 534 + byteEnd: byteOffset + unicodeString.length, 535 + }, 536 + features: [ 537 + { 538 + $type: "pub.leaflet.richtext.facet#atMention", 539 + atURI: node.attrs.atURI, 540 + }, 541 + ], 542 + }); 331 543 332 544 fullText += text; 333 545 byteOffset += unicodeString.length;
+98 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 10 10 import { Json } from "supabase/database.types"; 11 11 import { 12 12 Notification, 13 + NotificationData, 13 14 pingIdentityToUpdateNotification, 14 15 } from "src/notifications"; 15 16 import { v7 } from "uuid"; ··· 84 85 parent_uri: args.comment.replyTo, 85 86 }, 86 87 }); 88 + } 89 + 90 + // Create mention notifications from comment facets 91 + const mentionNotifications = createCommentMentionNotifications( 92 + args.comment.facets, 93 + uri.toString(), 94 + credentialSession.did!, 95 + ); 96 + notifications.push(...mentionNotifications); 97 + 98 + // Insert all notifications and ping recipients 99 + if (notifications.length > 0) { 87 100 // SOMEDAY: move this out the action with inngest or workflows 88 101 await supabaseServerClient.from("notifications").insert(notifications); 89 - await pingIdentityToUpdateNotification(recipient); 102 + 103 + // Ping all unique recipients 104 + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; 105 + await Promise.all( 106 + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), 107 + ); 90 108 } 91 109 92 110 return { ··· 95 113 uri: uri.toString(), 96 114 }; 97 115 } 116 + 117 + /** 118 + * Creates mention notifications from comment facets 119 + * Handles didMention (people) and atMention (publications/documents) 120 + */ 121 + function createCommentMentionNotifications( 122 + facets: PubLeafletRichtextFacet.Main[], 123 + commentUri: string, 124 + commenterDid: string, 125 + ): Notification[] { 126 + const notifications: Notification[] = []; 127 + const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications 128 + 129 + for (const facet of facets) { 130 + for (const feature of facet.features) { 131 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 132 + // DID mention - notify the mentioned person directly 133 + const recipientDid = feature.did; 134 + 135 + // Don't notify yourself 136 + if (recipientDid === commenterDid) continue; 137 + // Avoid duplicate notifications to the same person 138 + if (notifiedRecipients.has(recipientDid)) continue; 139 + notifiedRecipients.add(recipientDid); 140 + 141 + notifications.push({ 142 + id: v7(), 143 + recipient: recipientDid, 144 + data: { 145 + type: "comment_mention", 146 + comment_uri: commentUri, 147 + mention_type: "did", 148 + }, 149 + }); 150 + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { 151 + // AT-URI mention - notify the owner of the publication/document 152 + try { 153 + const mentionedUri = new AtUri(feature.atURI); 154 + const recipientDid = mentionedUri.host; 155 + 156 + // Don't notify yourself 157 + if (recipientDid === commenterDid) continue; 158 + // Avoid duplicate notifications to the same person for the same mentioned item 159 + const dedupeKey = `${recipientDid}:${feature.atURI}`; 160 + if (notifiedRecipients.has(dedupeKey)) continue; 161 + notifiedRecipients.add(dedupeKey); 162 + 163 + if (mentionedUri.collection === "pub.leaflet.publication") { 164 + notifications.push({ 165 + id: v7(), 166 + recipient: recipientDid, 167 + data: { 168 + type: "comment_mention", 169 + comment_uri: commentUri, 170 + mention_type: "publication", 171 + mentioned_uri: feature.atURI, 172 + }, 173 + }); 174 + } else if (mentionedUri.collection === "pub.leaflet.document") { 175 + notifications.push({ 176 + id: v7(), 177 + recipient: recipientDid, 178 + data: { 179 + type: "comment_mention", 180 + comment_uri: commentUri, 181 + mention_type: "document", 182 + mentioned_uri: feature.atURI, 183 + }, 184 + }); 185 + } 186 + } catch (error) { 187 + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); 188 + } 189 + } 190 + } 191 + } 192 + 193 + return notifications; 194 + }
+10 -7
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 293 293 } 294 294 case PubLeafletBlocksImage.isMain(b.block): { 295 295 return ( 296 - <div className={`relative flex ${alignment}`} {...blockProps}> 296 + <div 297 + className={`imageBlock relative flex ${alignment}`} 298 + {...blockProps} 299 + > 297 300 <img 298 301 alt={b.block.alt} 299 302 height={b.block.aspectRatio?.height} ··· 321 324 return ( 322 325 // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding. 323 326 <blockquote 324 - className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 327 + className={`blockquoteBlock py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 325 328 {...blockProps} 326 329 > 327 330 <TextBlock ··· 336 339 } 337 340 case PubLeafletBlocksText.isMain(b.block): 338 341 return ( 339 - <p className={` ${className}`} {...blockProps}> 342 + <p className={`textBlock ${className}`} {...blockProps}> 340 343 <TextBlock 341 344 facets={b.block.facets} 342 345 plaintext={b.block.plaintext} ··· 349 352 case PubLeafletBlocksHeader.isMain(b.block): { 350 353 if (b.block.level === 1) 351 354 return ( 352 - <h2 className={`${className}`} {...blockProps}> 355 + <h2 className={`h1Block ${className}`} {...blockProps}> 353 356 <TextBlock 354 357 {...b.block} 355 358 index={index} ··· 360 363 ); 361 364 if (b.block.level === 2) 362 365 return ( 363 - <h3 className={`${className}`} {...blockProps}> 366 + <h3 className={`h2Block ${className}`} {...blockProps}> 364 367 <TextBlock 365 368 {...b.block} 366 369 index={index} ··· 371 374 ); 372 375 if (b.block.level === 3) 373 376 return ( 374 - <h4 className={`${className}`} {...blockProps}> 377 + <h4 className={`h3Block ${className}`} {...blockProps}> 375 378 <TextBlock 376 379 {...b.block} 377 380 index={index} ··· 383 386 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 384 387 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 385 388 return ( 386 - <h6 className={`${className}`} {...blockProps}> 389 + <h6 className={`h6Block ${className}`} {...blockProps}> 387 390 <TextBlock 388 391 {...b.block} 389 392 index={index}
+91
app/lish/uri/[uri]/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { AtUri } from "@atproto/api"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + 6 + /** 7 + * Redirect route for AT URIs (publications and documents) 8 + * Redirects to the actual hosted domains from publication records 9 + */ 10 + export async function GET( 11 + request: NextRequest, 12 + { params }: { params: Promise<{ uri: string }> } 13 + ) { 14 + try { 15 + const { uri: uriParam } = await params; 16 + const atUriString = decodeURIComponent(uriParam); 17 + const uri = new AtUri(atUriString); 18 + 19 + if (uri.collection === "pub.leaflet.publication") { 20 + // Get the publication record to retrieve base_path 21 + const { data: publication } = await supabaseServerClient 22 + .from("publications") 23 + .select("record") 24 + .eq("uri", atUriString) 25 + .single(); 26 + 27 + if (!publication?.record) { 28 + return new NextResponse("Publication not found", { status: 404 }); 29 + } 30 + 31 + const record = publication.record as PubLeafletPublication.Record; 32 + const basePath = record.base_path; 33 + 34 + if (!basePath) { 35 + return new NextResponse("Publication has no base_path", { status: 404 }); 36 + } 37 + 38 + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 39 + return NextResponse.redirect(basePath, 307); 40 + } else if (uri.collection === "pub.leaflet.document") { 41 + // Document link - need to find the publication it belongs to 42 + const { data: docInPub } = await supabaseServerClient 43 + .from("documents_in_publications") 44 + .select("publication, publications!inner(record)") 45 + .eq("document", atUriString) 46 + .single(); 47 + 48 + if (docInPub?.publication && docInPub.publications) { 49 + // Document is in a publication - redirect to domain/rkey 50 + const record = docInPub.publications.record as PubLeafletPublication.Record; 51 + const basePath = record.base_path; 52 + 53 + if (!basePath) { 54 + return new NextResponse("Publication has no base_path", { status: 404 }); 55 + } 56 + 57 + // Ensure basePath ends without trailing slash 58 + const cleanBasePath = basePath.endsWith("/") 59 + ? basePath.slice(0, -1) 60 + : basePath; 61 + 62 + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 63 + return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); 64 + } 65 + 66 + // If not in a publication, check if it's a standalone document 67 + const { data: doc } = await supabaseServerClient 68 + .from("documents") 69 + .select("uri") 70 + .eq("uri", atUriString) 71 + .single(); 72 + 73 + if (doc) { 74 + // Standalone document - redirect to /p/did/rkey (temporary redirect) 75 + return NextResponse.redirect( 76 + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), 77 + 307 78 + ); 79 + } 80 + 81 + // Document not found 82 + return new NextResponse("Document not found", { status: 404 }); 83 + } 84 + 85 + // Unsupported collection type 86 + return new NextResponse("Unsupported URI type", { status: 400 }); 87 + } catch (error) { 88 + console.error("Error resolving AT URI:", error); 89 + return new NextResponse("Invalid URI", { status: 400 }); 90 + } 91 + }
+46
components/AtMentionLink.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { atUriToUrl } from "src/utils/mentionUtils"; 3 + 4 + /** 5 + * Component for rendering at-uri mentions (publications and documents) as clickable links. 6 + * NOTE: This component's styling and behavior should match the ProseMirror schema rendering 7 + * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other. 8 + */ 9 + export function AtMentionLink({ 10 + atURI, 11 + children, 12 + className = "", 13 + }: { 14 + atURI: string; 15 + children: React.ReactNode; 16 + className?: string; 17 + }) { 18 + const aturi = new AtUri(atURI); 19 + const isPublication = aturi.collection === "pub.leaflet.publication"; 20 + const isDocument = aturi.collection === "pub.leaflet.document"; 21 + 22 + // Show publication icon if available 23 + const icon = 24 + isPublication || isDocument ? ( 25 + <img 26 + src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 + className="inline-block w-5 h-5 rounded-full mr-1 align-text-top" 28 + alt="" 29 + width="20" 30 + height="20" 31 + loading="lazy" 32 + /> 33 + ) : null; 34 + 35 + return ( 36 + <a 37 + href={atUriToUrl(atURI)} 38 + target="_blank" 39 + rel="noopener noreferrer" 40 + className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 + > 42 + {icon} 43 + {children} 44 + </a> 45 + ); 46 + }
+4 -2
components/Blocks/BlockCommandBar.tsx
··· 37 37 const clearCommandSearchText = () => { 38 38 if (!props.entityID) return; 39 39 const entityID = props.entityID; 40 - 40 + 41 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 42 if (!existingState) return; 43 43 ··· 69 69 setHighlighted(commandResults[0].name); 70 70 } 71 71 }, [commandResults, setHighlighted, highlighted]); 72 + 72 73 useEffect(() => { 73 74 let listener = async (e: KeyboardEvent) => { 74 75 let reverseDir = ref.current?.dataset.side === "top"; ··· 118 119 return; 119 120 } 120 121 }; 122 + 121 123 window.addEventListener("keydown", listener); 122 124 123 125 return () => window.removeEventListener("keydown", listener); ··· 200 202 201 203 return ( 202 204 <button 203 - className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 205 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 204 206 onMouseOver={() => { 205 207 props.setHighlighted(props.name); 206 208 }}
+34
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 3 import { CSSProperties, Fragment } from "react"; 4 4 import { theme } from "tailwind.config"; 5 5 import * as base64 from "base64-js"; 6 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 + import { AtMentionLink } from "components/AtMentionLink"; 6 8 7 9 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 10 export function RenderYJSFragment({ ··· 64 66 return <br key={index} />; 65 67 } 66 68 69 + // Handle didMention inline nodes 70 + if (node.constructor === XmlElement && node.nodeName === "didMention") { 71 + const did = node.getAttribute("did") || ""; 72 + const text = node.getAttribute("text") || ""; 73 + return ( 74 + <a 75 + href={didToBlueskyUrl(did)} 76 + target="_blank" 77 + rel="noopener noreferrer" 78 + key={index} 79 + className="text-accent-contrast hover:underline cursor-pointer" 80 + > 81 + {text} 82 + </a> 83 + ); 84 + } 85 + 86 + // Handle atMention inline nodes 87 + if (node.constructor === XmlElement && node.nodeName === "atMention") { 88 + const atURI = node.getAttribute("atURI") || ""; 89 + const text = node.getAttribute("text") || ""; 90 + return ( 91 + <AtMentionLink key={index} atURI={atURI}> 92 + {text} 93 + </AtMentionLink> 94 + ); 95 + } 96 + 67 97 return null; 68 98 }) 69 99 )} ··· 151 181 // Handle hard_break nodes specially 152 182 if (node.nodeName === "hard_break") { 153 183 return "\n"; 184 + } 185 + // Handle inline mention nodes 186 + if (node.nodeName === "didMention" || node.nodeName === "atMention") { 187 + return node.getAttribute("text") || ""; 154 188 } 155 189 return node 156 190 .toArray()
+109 -14
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 3 import { useReplicache, useEntity } from "src/replicache"; 4 4 import { isVisible } from "src/utils/isVisible"; 5 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 6 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 7 8 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 8 9 import { BlockProps } from "../Block"; ··· 23 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24 25 import { DotLoader } from "components/utils/DotLoader"; 25 26 import { useMountProsemirror } from "./mountProsemirror"; 27 + import { schema } from "./schema"; 28 + 29 + import { Mention, MentionAutocomplete } from "components/Mention"; 30 + import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 26 31 27 32 const HeadingStyle = { 28 33 1: "text-xl font-bold", ··· 183 188 let editorState = useEditorStates( 184 189 (s) => s.editorStates[props.entityID], 185 190 )?.editor; 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 186 199 187 200 let { mountRef, actionTimeout } = useMountProsemirror({ 188 201 props, 202 + openMentionAutocomplete, 189 203 }); 190 204 191 205 return ( ··· 199 213 ? "blockquote pt-3" 200 214 : "blockquote" 201 215 : "" 202 - } 203 - 204 - `} 216 + }`} 205 217 > 206 218 <pre 207 219 data-entityid={props.entityID} ··· 224 236 } 225 237 }} 226 238 onFocus={() => { 239 + handleMentionOpenChange(false); 227 240 setTimeout(() => { 228 241 useUIState.getState().setSelectedBlock(props); 229 242 useUIState.setState(() => ({ ··· 249 262 ${props.className}`} 250 263 ref={mountRef} 251 264 /> 265 + {focused && ( 266 + <MentionAutocomplete 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 269 + view={viewRef} 270 + onSelect={handleMentionSelect} 271 + coords={mentionCoords} 272 + /> 273 + )} 252 274 {editorState?.doc.textContent.length === 0 && 253 275 props.previousBlock === null && 254 276 props.nextBlock === null ? ( ··· 439 461 ); 440 462 }; 441 463 442 - const useMentionState = () => { 443 - const [editorState, setEditorState] = useState<EditorState | null>(null); 444 - const [mentionState, setMentionState] = useState<{ 445 - active: boolean; 446 - range: { from: number; to: number } | null; 447 - selectedMention: { handle: string; did: string } | null; 448 - }>({ active: false, range: null, selectedMention: null }); 449 - const mentionStateRef = useRef(mentionState); 450 - mentionStateRef.current = mentionState; 451 - return { mentionStateRef }; 464 + const useMentionState = (entityID: string) => { 465 + let view = useEditorStates((s) => s.editorStates[entityID])?.view; 466 + let viewRef = useRef(view || null); 467 + viewRef.current = view || null; 468 + 469 + const [mentionOpen, setMentionOpen] = useState(false); 470 + const [mentionCoords, setMentionCoords] = useState<{ 471 + top: number; 472 + left: number; 473 + } | null>(null); 474 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 475 + 476 + // Close autocomplete when this block is no longer focused 477 + const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); 478 + useEffect(() => { 479 + if (!isFocused) { 480 + setMentionOpen(false); 481 + setMentionCoords(null); 482 + setMentionInsertPos(null); 483 + } 484 + }, [isFocused]); 485 + 486 + const openMentionAutocomplete = useCallback(() => { 487 + const view = useEditorStates.getState().editorStates[entityID]?.view; 488 + if (!view) return; 489 + 490 + // Get the position right after the @ we just inserted 491 + const pos = view.state.selection.from; 492 + setMentionInsertPos(pos); 493 + 494 + // Get coordinates for the popup relative to the positioned parent 495 + const coords = view.coordsAtPos(pos - 1); // Position of the @ 496 + 497 + // Find the relative positioned parent container 498 + const editorEl = view.dom; 499 + const container = editorEl.closest('.relative') as HTMLElement | null; 500 + 501 + if (container) { 502 + const containerRect = container.getBoundingClientRect(); 503 + setMentionCoords({ 504 + top: coords.bottom - containerRect.top, 505 + left: coords.left - containerRect.left, 506 + }); 507 + } else { 508 + setMentionCoords({ 509 + top: coords.bottom, 510 + left: coords.left, 511 + }); 512 + } 513 + setMentionOpen(true); 514 + }, [entityID]); 515 + 516 + const handleMentionSelect = useCallback( 517 + (mention: Mention) => { 518 + const view = useEditorStates.getState().editorStates[entityID]?.view; 519 + if (!view || mentionInsertPos === null) return; 520 + 521 + // The @ is at mentionInsertPos - 1, we need to replace it with the mention 522 + const from = mentionInsertPos - 1; 523 + const to = mentionInsertPos; 524 + 525 + addMentionToEditor(mention, { from, to }, view); 526 + view.focus(); 527 + }, 528 + [entityID, mentionInsertPos], 529 + ); 530 + 531 + const handleMentionOpenChange = useCallback((open: boolean) => { 532 + setMentionOpen(open); 533 + if (!open) { 534 + setMentionCoords(null); 535 + setMentionInsertPos(null); 536 + } 537 + }, []); 538 + 539 + return { 540 + viewRef, 541 + mentionOpen, 542 + mentionCoords, 543 + openMentionAutocomplete, 544 + handleMentionSelect, 545 + handleMentionOpenChange, 546 + }; 452 547 };
+20
components/Blocks/TextBlock/inputRules.ts
··· 15 15 export const inputrules = ( 16 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 17 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 + openMentionAutocomplete?: () => void, 18 19 ) => 19 20 inputRules({ 20 21 //Strikethrough ··· 189 190 data: { type: "number", value: headingLevel }, 190 191 }); 191 192 return tr; 193 + }), 194 + 195 + // Mention - @ at start of line, after space, or after hard break 196 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 197 + if (!openMentionAutocomplete) return null; 198 + // Schedule opening the autocomplete after the transaction is applied 199 + setTimeout(() => openMentionAutocomplete(), 0); 200 + return null; // Let the @ be inserted normally 201 + }), 202 + // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 203 + new InputRule(/@$/, (state, match, start, end) => { 204 + if (!openMentionAutocomplete) return null; 205 + // Check if the character before @ is a hard break node 206 + const $pos = state.doc.resolve(start); 207 + const nodeBefore = $pos.nodeBefore; 208 + if (nodeBefore && nodeBefore.type.name === "hard_break") { 209 + setTimeout(() => openMentionAutocomplete(), 0); 210 + } 211 + return null; // Let the @ be inserted normally 192 212 }), 193 213 ], 194 214 });
+4 -7
components/Blocks/TextBlock/keymap.ts
··· 24 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 26 import { UndoManager } from "src/undoManager"; 27 - 28 27 type PropsRef = RefObject< 29 28 BlockProps & { 30 29 entity_set: { set: string }; ··· 35 34 propsRef: PropsRef, 36 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 36 um: UndoManager, 38 - multiLine?: boolean, 37 + openMentionAutocomplete: () => void, 39 38 ) => 40 39 ({ 41 40 "Meta-b": toggleMark(schema.marks.strong), ··· 138 137 ), 139 138 "Shift-Backspace": backspace(propsRef, repRef), 140 139 Enter: (state, dispatch, view) => { 141 - if (multiLine && state.doc.content.size - state.selection.anchor > 1) 142 - return false; 143 - return um.withUndoGroup(() => 144 - enter(propsRef, repRef)(state, dispatch, view), 145 - ); 140 + return um.withUndoGroup(() => { 141 + return enter(propsRef, repRef)(state, dispatch, view); 142 + }); 146 143 }, 147 144 "Shift-Enter": (state, dispatch, view) => { 148 145 // Insert a hard break
+48 -12
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 23 import { useHandlePaste } from "./useHandlePaste"; 24 24 import { BlockProps } from "../Block"; 25 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 26 27 27 - export function useMountProsemirror({ props }: { props: BlockProps }) { 28 + export function useMountProsemirror({ 29 + props, 30 + openMentionAutocomplete, 31 + }: { 32 + props: BlockProps; 33 + openMentionAutocomplete: () => void; 34 + }) { 28 35 let { entityID, parent } = props; 29 36 let rep = useReplicache(); 30 37 let mountRef = useRef<HTMLPreElement | null>(null); ··· 44 51 useLayoutEffect(() => { 45 52 if (!mountRef.current) return; 46 53 47 - const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + openMentionAutocomplete, 59 + ); 48 60 const editor = EditorState.create({ 49 61 schema: schema, 50 62 plugins: [ 51 63 ySyncPlugin(value), 52 64 keymap(km), 53 - inputrules(propsRef, repRef), 65 + inputrules(propsRef, repRef, openMentionAutocomplete), 54 66 keymap(baseKeymap), 55 67 highlightSelectionPlugin, 56 68 autolink({ ··· 69 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 82 if (!direct) return; 71 83 if (node.nodeSize - 2 <= _pos) return; 72 - let mark = 73 - node 74 - .nodeAt(_pos - 1) 75 - ?.marks.find((f) => f.type === schema.marks.link) || 76 - node 77 - .nodeAt(Math.max(_pos - 2, 0)) 78 - ?.marks.find((f) => f.type === schema.marks.link); 79 - if (mark) { 80 - window.open(mark.attrs.href, "_blank"); 84 + 85 + // Check for marks at the clicked position 86 + const nodeAt1 = node.nodeAt(_pos - 1); 87 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 + 89 + // Check for link marks 90 + let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 + nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 + if (linkMark) { 93 + window.open(linkMark.attrs.href, "_blank"); 94 + return; 95 + } 96 + 97 + // Check for didMention inline nodes 98 + if (nodeAt1?.type === schema.nodes.didMention) { 99 + window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 + return; 101 + } 102 + if (nodeAt2?.type === schema.nodes.didMention) { 103 + window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 104 + return; 105 + } 106 + 107 + // Check for atMention inline nodes 108 + if (nodeAt1?.type === schema.nodes.atMention) { 109 + const url = atUriToUrl(nodeAt1.attrs.atURI); 110 + window.open(url, "_blank", "noopener,noreferrer"); 111 + return; 112 + } 113 + if (nodeAt2?.type === schema.nodes.atMention) { 114 + const url = atUriToUrl(nodeAt2.attrs.atURI); 115 + window.open(url, "_blank", "noopener,noreferrer"); 116 + return; 81 117 } 82 118 }, 83 119 dispatchTransaction,
+100 -1
components/Blocks/TextBlock/schema.ts
··· 1 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 1 + import { AtUri } from "@atproto/api"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 2 3 import { marks } from "prosemirror-schema-basic"; 3 4 import { theme } from "tailwind.config"; 4 5 ··· 122 123 parseDOM: [{ tag: "br" }], 123 124 toDOM: () => ["br"] as const, 124 125 }, 126 + atMention: { 127 + attrs: { 128 + atURI: {}, 129 + text: { default: "" }, 130 + }, 131 + group: "inline", 132 + inline: true, 133 + atom: true, 134 + selectable: true, 135 + draggable: true, 136 + parseDOM: [ 137 + { 138 + tag: "span.atMention", 139 + getAttrs(dom: HTMLElement) { 140 + return { 141 + atURI: dom.getAttribute("data-at-uri"), 142 + text: dom.textContent || "", 143 + }; 144 + }, 145 + }, 146 + ], 147 + toDOM(node) { 148 + // NOTE: This rendering should match the AtMentionLink component in 149 + // components/AtMentionLink.tsx. If you update one, update the other. 150 + let className = "atMention text-accent-contrast"; 151 + let aturi = new AtUri(node.attrs.atURI); 152 + if (aturi.collection === "pub.leaflet.publication") 153 + className += " font-bold"; 154 + if (aturi.collection === "pub.leaflet.document") className += " italic"; 155 + 156 + // For publications and documents, show icon 157 + if ( 158 + aturi.collection === "pub.leaflet.publication" || 159 + aturi.collection === "pub.leaflet.document" 160 + ) { 161 + return [ 162 + "span", 163 + { 164 + class: className, 165 + "data-at-uri": node.attrs.atURI, 166 + }, 167 + [ 168 + "img", 169 + { 170 + src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 171 + class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top", 172 + alt: "", 173 + width: "16", 174 + height: "16", 175 + loading: "lazy", 176 + }, 177 + ], 178 + node.attrs.text, 179 + ]; 180 + } 181 + 182 + return [ 183 + "span", 184 + { 185 + class: className, 186 + "data-at-uri": node.attrs.atURI, 187 + }, 188 + node.attrs.text, 189 + ]; 190 + }, 191 + } as NodeSpec, 192 + didMention: { 193 + attrs: { 194 + did: {}, 195 + text: { default: "" }, 196 + }, 197 + group: "inline", 198 + inline: true, 199 + atom: true, 200 + selectable: true, 201 + draggable: true, 202 + parseDOM: [ 203 + { 204 + tag: "span.didMention", 205 + getAttrs(dom: HTMLElement) { 206 + return { 207 + did: dom.getAttribute("data-did"), 208 + text: dom.textContent || "", 209 + }; 210 + }, 211 + }, 212 + ], 213 + toDOM(node) { 214 + return [ 215 + "span", 216 + { 217 + class: "didMention text-accent-contrast", 218 + "data-did": node.attrs.did, 219 + }, 220 + node.attrs.text, 221 + ]; 222 + }, 223 + } as NodeSpec, 125 224 }, 126 225 }; 127 226 export const schema = new Schema(baseSchema);
+21
components/Icons/GoBackTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const GoBackTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M7.40426 3L2.19592 8M2.19592 8L7.40426 13M2.19592 8H13.8041" 14 + stroke="currentColor" 15 + strokeWidth="2" 16 + strokeLinecap="round" 17 + strokeLinejoin="round" 18 + /> 19 + </svg> 20 + ); 21 + };
+540
components/Mention.tsx
··· 1 + "use client"; 2 + import { Agent } from "@atproto/api"; 3 + import { useState, useEffect, Fragment, useRef, useCallback } from "react"; 4 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 + import * as Popover from "@radix-ui/react-popover"; 6 + import { EditorView } from "prosemirror-view"; 7 + import { callRPC } from "app/api/rpc/client"; 8 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 9 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 10 + import { SearchTiny } from "components/Icons/SearchTiny"; 11 + import { CloseTiny } from "./Icons/CloseTiny"; 12 + import { GoToArrow } from "./Icons/GoToArrow"; 13 + import { GoBackTiny } from "./Icons/GoBackTiny"; 14 + 15 + export function MentionAutocomplete(props: { 16 + open: boolean; 17 + onOpenChange: (open: boolean) => void; 18 + view: React.RefObject<EditorView | null>; 19 + onSelect: (mention: Mention) => void; 20 + coords: { top: number; left: number } | null; 21 + }) { 22 + const [searchQuery, setSearchQuery] = useState(""); 23 + const [noResults, setNoResults] = useState(false); 24 + const inputRef = useRef<HTMLInputElement>(null); 25 + const contentRef = useRef<HTMLDivElement>(null); 26 + 27 + const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = 28 + useMentionSuggestions(searchQuery); 29 + 30 + // Clear search when scope changes 31 + const handleScopeChange = useCallback( 32 + (newScope: MentionScope) => { 33 + setSearchQuery(""); 34 + setSuggestionIndex(0); 35 + setScope(newScope); 36 + }, 37 + [setScope, setSuggestionIndex], 38 + ); 39 + 40 + // Focus input when opened 41 + useEffect(() => { 42 + if (props.open && inputRef.current) { 43 + // Small delay to ensure the popover is mounted 44 + setTimeout(() => inputRef.current?.focus(), 0); 45 + } 46 + }, [props.open]); 47 + 48 + // Reset state when closed 49 + useEffect(() => { 50 + if (!props.open) { 51 + setSearchQuery(""); 52 + setScope({ type: "default" }); 53 + setSuggestionIndex(0); 54 + setNoResults(false); 55 + } 56 + }, [props.open, setScope, setSuggestionIndex]); 57 + 58 + // Handle timeout for showing "No results found" 59 + useEffect(() => { 60 + if (searchQuery && suggestions.length === 0) { 61 + setNoResults(false); 62 + const timer = setTimeout(() => { 63 + setNoResults(true); 64 + }, 2000); 65 + return () => clearTimeout(timer); 66 + } else { 67 + setNoResults(false); 68 + } 69 + }, [searchQuery, suggestions.length]); 70 + 71 + // Handle keyboard navigation 72 + const handleKeyDown = (e: React.KeyboardEvent) => { 73 + if (e.key === "Escape") { 74 + e.preventDefault(); 75 + props.onOpenChange(false); 76 + props.view.current?.focus(); 77 + return; 78 + } 79 + 80 + if (e.key === "Backspace" && searchQuery === "") { 81 + // Backspace at the start of input closes autocomplete and refocuses editor 82 + e.preventDefault(); 83 + props.onOpenChange(false); 84 + props.view.current?.focus(); 85 + return; 86 + } 87 + 88 + // Reverse arrow key direction when popover is rendered above 89 + const isReversed = contentRef.current?.dataset.side === "top"; 90 + const upKey = isReversed ? "ArrowDown" : "ArrowUp"; 91 + const downKey = isReversed ? "ArrowUp" : "ArrowDown"; 92 + 93 + if (e.key === upKey) { 94 + e.preventDefault(); 95 + if (suggestionIndex > 0) { 96 + setSuggestionIndex((i) => i - 1); 97 + } 98 + } else if (e.key === downKey) { 99 + e.preventDefault(); 100 + if (suggestionIndex < suggestions.length - 1) { 101 + setSuggestionIndex((i) => i + 1); 102 + } 103 + } else if (e.key === "Tab") { 104 + const selectedSuggestion = suggestions[suggestionIndex]; 105 + if (selectedSuggestion?.type === "publication") { 106 + e.preventDefault(); 107 + handleScopeChange({ 108 + type: "publication", 109 + uri: selectedSuggestion.uri, 110 + name: selectedSuggestion.name, 111 + }); 112 + } 113 + } else if (e.key === "Enter") { 114 + e.preventDefault(); 115 + const selectedSuggestion = suggestions[suggestionIndex]; 116 + if (selectedSuggestion) { 117 + props.onSelect(selectedSuggestion); 118 + props.onOpenChange(false); 119 + } 120 + } else if ( 121 + e.key === " " && 122 + searchQuery === "" && 123 + scope.type === "default" 124 + ) { 125 + // Space immediately after opening closes the autocomplete 126 + e.preventDefault(); 127 + props.onOpenChange(false); 128 + // Insert a space after the @ in the editor 129 + if (props.view.current) { 130 + const view = props.view.current; 131 + const tr = view.state.tr.insertText(" "); 132 + view.dispatch(tr); 133 + view.focus(); 134 + } 135 + } 136 + }; 137 + 138 + if (!props.open || !props.coords) return null; 139 + 140 + const getHeader = (type: Mention["type"], scope?: MentionScope) => { 141 + switch (type) { 142 + case "did": 143 + return "People"; 144 + case "publication": 145 + return "Publications"; 146 + case "post": 147 + if (scope) { 148 + return ( 149 + <ScopeHeader 150 + scope={scope} 151 + handleScopeChange={() => { 152 + handleScopeChange({ type: "default" }); 153 + }} 154 + /> 155 + ); 156 + } else return "Posts"; 157 + } 158 + }; 159 + 160 + const sortedSuggestions = [...suggestions].sort((a, b) => { 161 + const order: Mention["type"][] = ["did", "publication", "post"]; 162 + return order.indexOf(a.type) - order.indexOf(b.type); 163 + }); 164 + 165 + return ( 166 + <Popover.Root open> 167 + <Popover.Anchor 168 + style={{ 169 + top: props.coords.top - 24, 170 + left: props.coords.left, 171 + height: 24, 172 + position: "absolute", 173 + }} 174 + /> 175 + <Popover.Portal> 176 + <Popover.Content 177 + ref={contentRef} 178 + align="start" 179 + sideOffset={4} 180 + collisionPadding={32} 181 + onOpenAutoFocus={(e) => e.preventDefault()} 182 + className={`dropdownMenu group/mention-menu z-20 bg-bg-page 183 + flex data-[side=top]:flex-col-reverse flex-col 184 + p-1 gap-1 text-primary 185 + border border-border rounded-md shadow-md 186 + sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 187 + max-h-(--radix-popover-content-available-height) 188 + overflow-hidden`} 189 + > 190 + {/* Dropdown Header - sticky */} 191 + <div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0"> 192 + <div className="flex items-center gap-1 flex-1 min-w-0 text-primary"> 193 + <div className="text-tertiary"> 194 + <SearchTiny className="w-4 h-4 shrink-0" /> 195 + </div> 196 + <input 197 + ref={inputRef} 198 + size={100} 199 + type="text" 200 + value={searchQuery} 201 + onChange={(e) => { 202 + setSearchQuery(e.target.value); 203 + setSuggestionIndex(0); 204 + }} 205 + onKeyDown={handleKeyDown} 206 + autoFocus 207 + placeholder={ 208 + scope.type === "publication" 209 + ? "Search posts..." 210 + : "Search people & publications..." 211 + } 212 + className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" 213 + /> 214 + </div> 215 + </div> 216 + <div className="overflow-y-auto flex-1 min-h-0"> 217 + {sortedSuggestions.length === 0 && noResults && ( 218 + <div className="text-sm text-tertiary italic px-3 py-1 text-center"> 219 + No results found 220 + </div> 221 + )} 222 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 223 + {sortedSuggestions.map((result, index) => { 224 + const prevResult = sortedSuggestions[index - 1]; 225 + const showHeader = 226 + index === 0 || 227 + (prevResult && prevResult.type !== result.type); 228 + 229 + return ( 230 + <Fragment 231 + key={result.type === "did" ? result.did : result.uri} 232 + > 233 + {showHeader && ( 234 + <> 235 + {index > 0 && ( 236 + <hr className="border-border-light mx-1 my-1" /> 237 + )} 238 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 239 + {getHeader(result.type, scope)} 240 + </div> 241 + </> 242 + )} 243 + {result.type === "did" ? ( 244 + <DidResult 245 + onClick={() => { 246 + props.onSelect(result); 247 + props.onOpenChange(false); 248 + }} 249 + onMouseDown={(e) => e.preventDefault()} 250 + displayName={result.displayName} 251 + handle={result.handle} 252 + avatar={result.avatar} 253 + selected={index === suggestionIndex} 254 + /> 255 + ) : result.type === "publication" ? ( 256 + <PublicationResult 257 + onClick={() => { 258 + props.onSelect(result); 259 + props.onOpenChange(false); 260 + }} 261 + onMouseDown={(e) => e.preventDefault()} 262 + pubName={result.name} 263 + uri={result.uri} 264 + selected={index === suggestionIndex} 265 + onPostsClick={() => { 266 + handleScopeChange({ 267 + type: "publication", 268 + uri: result.uri, 269 + name: result.name, 270 + }); 271 + }} 272 + /> 273 + ) : ( 274 + <PostResult 275 + onClick={() => { 276 + props.onSelect(result); 277 + props.onOpenChange(false); 278 + }} 279 + onMouseDown={(e) => e.preventDefault()} 280 + title={result.title} 281 + selected={index === suggestionIndex} 282 + /> 283 + )} 284 + </Fragment> 285 + ); 286 + })} 287 + </ul> 288 + </div> 289 + </Popover.Content> 290 + </Popover.Portal> 291 + </Popover.Root> 292 + ); 293 + } 294 + 295 + const Result = (props: { 296 + result: React.ReactNode; 297 + subtext?: React.ReactNode; 298 + icon?: React.ReactNode; 299 + onClick: () => void; 300 + onMouseDown: (e: React.MouseEvent) => void; 301 + selected?: boolean; 302 + }) => { 303 + return ( 304 + <button 305 + className={` 306 + menuItem w-full flex-row! gap-2! 307 + text-secondary leading-snug text-sm 308 + ${props.subtext ? "py-1!" : "py-2!"} 309 + ${props.selected ? "bg-[var(--accent-light)]!" : ""}`} 310 + onClick={() => { 311 + props.onClick(); 312 + }} 313 + onMouseDown={(e) => props.onMouseDown(e)} 314 + > 315 + {props.icon} 316 + <div className="flex flex-col min-w-0 flex-1"> 317 + <div 318 + className={`flex gap-2 items-center w-full truncate justify-between`} 319 + > 320 + {props.result} 321 + </div> 322 + {props.subtext && ( 323 + <div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-[1px]"> 324 + {props.subtext} 325 + </div> 326 + )} 327 + </div> 328 + </button> 329 + ); 330 + }; 331 + 332 + const ScopeButton = (props: { 333 + onClick: () => void; 334 + children: React.ReactNode; 335 + }) => { 336 + return ( 337 + <span 338 + className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer" 339 + onClick={(e) => { 340 + e.preventDefault(); 341 + e.stopPropagation(); 342 + props.onClick(); 343 + }} 344 + onMouseDown={(e) => { 345 + e.preventDefault(); 346 + e.stopPropagation(); 347 + }} 348 + > 349 + {props.children} <ArrowRightTiny className="scale-80" /> 350 + </span> 351 + ); 352 + }; 353 + 354 + const DidResult = (props: { 355 + displayName?: string; 356 + handle: string; 357 + avatar?: string; 358 + onClick: () => void; 359 + onMouseDown: (e: React.MouseEvent) => void; 360 + selected?: boolean; 361 + }) => { 362 + return ( 363 + <Result 364 + icon={ 365 + props.avatar ? ( 366 + <img 367 + src={props.avatar} 368 + alt="" 369 + className="w-5 h-5 rounded-full shrink-0" 370 + /> 371 + ) : ( 372 + <div className="w-5 h-5 rounded-full bg-border shrink-0" /> 373 + ) 374 + } 375 + result={props.displayName ? props.displayName : props.handle} 376 + subtext={props.displayName && `@${props.handle}`} 377 + onClick={props.onClick} 378 + onMouseDown={props.onMouseDown} 379 + selected={props.selected} 380 + /> 381 + ); 382 + }; 383 + 384 + const PublicationResult = (props: { 385 + pubName: string; 386 + uri: string; 387 + onClick: () => void; 388 + onMouseDown: (e: React.MouseEvent) => void; 389 + selected?: boolean; 390 + onPostsClick: () => void; 391 + }) => { 392 + return ( 393 + <Result 394 + icon={ 395 + <img 396 + src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`} 397 + alt="" 398 + className="w-5 h-5 rounded-full shrink-0" 399 + /> 400 + } 401 + result={ 402 + <> 403 + <div className="truncate w-full grow min-w-0">{props.pubName}</div> 404 + <ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton> 405 + </> 406 + } 407 + onClick={props.onClick} 408 + onMouseDown={props.onMouseDown} 409 + selected={props.selected} 410 + /> 411 + ); 412 + }; 413 + 414 + const PostResult = (props: { 415 + title: string; 416 + onClick: () => void; 417 + onMouseDown: (e: React.MouseEvent) => void; 418 + selected?: boolean; 419 + }) => { 420 + return ( 421 + <Result 422 + result={<div className="truncate w-full">{props.title}</div>} 423 + onClick={props.onClick} 424 + onMouseDown={props.onMouseDown} 425 + selected={props.selected} 426 + /> 427 + ); 428 + }; 429 + 430 + const ScopeHeader = (props: { 431 + scope: MentionScope; 432 + handleScopeChange: () => void; 433 + }) => { 434 + if (props.scope.type === "default") return; 435 + if (props.scope.type === "publication") 436 + return ( 437 + <button 438 + className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs" 439 + onClick={() => props.handleScopeChange()} 440 + onMouseDown={(e) => e.preventDefault()} 441 + > 442 + <GoBackTiny className="shrink-0 " /> 443 + 444 + <div className="grow w-full truncate text-left"> 445 + Posts from {props.scope.name} 446 + </div> 447 + </button> 448 + ); 449 + }; 450 + 451 + export type Mention = 452 + | { 453 + type: "did"; 454 + handle: string; 455 + did: string; 456 + displayName?: string; 457 + avatar?: string; 458 + } 459 + | { type: "publication"; uri: string; name: string } 460 + | { type: "post"; uri: string; title: string }; 461 + 462 + export type MentionScope = 463 + | { type: "default" } 464 + | { type: "publication"; uri: string; name: string }; 465 + function useMentionSuggestions(query: string | null) { 466 + const [suggestionIndex, setSuggestionIndex] = useState(0); 467 + const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 468 + const [scope, setScope] = useState<MentionScope>({ type: "default" }); 469 + 470 + // Clear suggestions immediately when scope changes 471 + const setScopeAndClear = useCallback((newScope: MentionScope) => { 472 + setSuggestions([]); 473 + setScope(newScope); 474 + }, []); 475 + 476 + useDebouncedEffect( 477 + async () => { 478 + if (!query && scope.type === "default") { 479 + setSuggestions([]); 480 + return; 481 + } 482 + 483 + if (scope.type === "publication") { 484 + // Search within the publication's documents 485 + const documents = await callRPC(`search_publication_documents`, { 486 + publication_uri: scope.uri, 487 + query: query || "", 488 + limit: 10, 489 + }); 490 + setSuggestions( 491 + documents.result.documents.map((d) => ({ 492 + type: "post" as const, 493 + uri: d.uri, 494 + title: d.title, 495 + })), 496 + ); 497 + } else { 498 + // Default scope: search people and publications 499 + const agent = new Agent("https://public.api.bsky.app"); 500 + const [result, publications] = await Promise.all([ 501 + agent.searchActorsTypeahead({ 502 + q: query || "", 503 + limit: 8, 504 + }), 505 + callRPC(`search_publication_names`, { query: query || "", limit: 8 }), 506 + ]); 507 + setSuggestions([ 508 + ...result.data.actors.map((actor) => ({ 509 + type: "did" as const, 510 + handle: actor.handle, 511 + did: actor.did, 512 + displayName: actor.displayName, 513 + avatar: actor.avatar, 514 + })), 515 + ...publications.result.publications.map((p) => ({ 516 + type: "publication" as const, 517 + uri: p.uri, 518 + name: p.name, 519 + })), 520 + ]); 521 + } 522 + }, 523 + 300, 524 + [query, scope], 525 + ); 526 + 527 + useEffect(() => { 528 + if (suggestionIndex > suggestions.length - 1) { 529 + setSuggestionIndex(Math.max(0, suggestions.length - 1)); 530 + } 531 + }, [suggestionIndex, suggestions.length]); 532 + 533 + return { 534 + suggestions, 535 + suggestionIndex, 536 + setSuggestionIndex, 537 + scope, 538 + setScope: setScopeAndClear, 539 + }; 540 + }
+24
lexicons/api/lexicons.ts
··· 1865 1865 type: 'union', 1866 1866 refs: [ 1867 1867 'lex:pub.leaflet.richtext.facet#link', 1868 + 'lex:pub.leaflet.richtext.facet#didMention', 1869 + 'lex:pub.leaflet.richtext.facet#atMention', 1868 1870 'lex:pub.leaflet.richtext.facet#code', 1869 1871 'lex:pub.leaflet.richtext.facet#highlight', 1870 1872 'lex:pub.leaflet.richtext.facet#underline', ··· 1901 1903 properties: { 1902 1904 uri: { 1903 1905 type: 'string', 1906 + }, 1907 + }, 1908 + }, 1909 + didMention: { 1910 + type: 'object', 1911 + description: 'Facet feature for mentioning a did.', 1912 + required: ['did'], 1913 + properties: { 1914 + did: { 1915 + type: 'string', 1916 + format: 'did', 1917 + }, 1918 + }, 1919 + }, 1920 + atMention: { 1921 + type: 'object', 1922 + description: 'Facet feature for mentioning an AT URI.', 1923 + required: ['atURI'], 1924 + properties: { 1925 + atURI: { 1926 + type: 'string', 1927 + format: 'uri', 1904 1928 }, 1905 1929 }, 1906 1930 },
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 20 index: ByteSlice 21 21 features: ( 22 22 | $Typed<Link> 23 + | $Typed<DidMention> 24 + | $Typed<AtMention> 23 25 | $Typed<Code> 24 26 | $Typed<Highlight> 25 27 | $Typed<Underline> ··· 72 74 73 75 export function validateLink<V>(v: V) { 74 76 return validate<Link & V>(v, id, hashLink) 77 + } 78 + 79 + /** Facet feature for mentioning a did. */ 80 + export interface DidMention { 81 + $type?: 'pub.leaflet.richtext.facet#didMention' 82 + did: string 83 + } 84 + 85 + const hashDidMention = 'didMention' 86 + 87 + export function isDidMention<V>(v: V) { 88 + return is$typed(v, id, hashDidMention) 89 + } 90 + 91 + export function validateDidMention<V>(v: V) { 92 + return validate<DidMention & V>(v, id, hashDidMention) 93 + } 94 + 95 + /** Facet feature for mentioning an AT URI. */ 96 + export interface AtMention { 97 + $type?: 'pub.leaflet.richtext.facet#atMention' 98 + atURI: string 99 + } 100 + 101 + const hashAtMention = 'atMention' 102 + 103 + export function isAtMention<V>(v: V) { 104 + return is$typed(v, id, hashAtMention) 105 + } 106 + 107 + export function validateAtMention<V>(v: V) { 108 + return validate<AtMention & V>(v, id, hashAtMention) 75 109 } 76 110 77 111 /** Facet feature for inline code. */
+28
lexicons/pub/leaflet/richtext/facet.json
··· 20 20 "type": "union", 21 21 "refs": [ 22 22 "#link", 23 + "#didMention", 24 + "#atMention", 23 25 "#code", 24 26 "#highlight", 25 27 "#underline", ··· 59 61 "properties": { 60 62 "uri": { 61 63 "type": "string" 64 + } 65 + } 66 + }, 67 + "didMention": { 68 + "type": "object", 69 + "description": "Facet feature for mentioning a did.", 70 + "required": [ 71 + "did" 72 + ], 73 + "properties": { 74 + "did": { 75 + "type": "string", 76 + "format": "did" 77 + } 78 + } 79 + }, 80 + "atMention": { 81 + "type": "object", 82 + "description": "Facet feature for mentioning an AT URI.", 83 + "required": [ 84 + "atURI" 85 + ], 86 + "properties": { 87 + "atURI": { 88 + "type": "string", 89 + "format": "uri" 62 90 } 63 91 } 64 92 },
+12
lexicons/src/facet.ts
··· 9 9 uri: { type: "string" }, 10 10 }, 11 11 }, 12 + didMention: { 13 + type: "object", 14 + description: "Facet feature for mentioning a did.", 15 + required: ["did"], 16 + properties: { did: { type: "string", format: "did" } }, 17 + }, 18 + atMention: { 19 + type: "object", 20 + description: "Facet feature for mentioning an AT URI.", 21 + required: ["atURI"], 22 + properties: { atURI: { type: "string", format: "uri" } }, 23 + }, 12 24 code: { 13 25 type: "object", 14 26 description: "Facet feature for inline code.",
+197 -4
src/notifications.ts
··· 2 2 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 7 6 8 type NotificationRow = Tables<"notifications">; 7 9 ··· 12 14 export type NotificationData = 13 15 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 16 | { type: "subscribe"; subscription_uri: string } 15 - | { type: "quote"; bsky_post_uri: string; document_uri: string }; 17 + | { type: "quote"; bsky_post_uri: string; document_uri: string } 18 + | { type: "mention"; document_uri: string; mention_type: "did" } 19 + | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 20 + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } 21 + | { type: "comment_mention"; comment_uri: string; mention_type: "did" } 22 + | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } 23 + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; 16 24 17 25 export type HydratedNotification = 18 26 | HydratedCommentNotification 19 27 | HydratedSubscribeNotification 20 - | HydratedQuoteNotification; 28 + | HydratedQuoteNotification 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 21 31 export async function hydrateNotifications( 22 32 notifications: NotificationRow[], 23 33 ): Promise<Array<HydratedNotification>> { 24 34 // Call all hydrators in parallel 25 - const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([ 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 26 36 hydrateCommentNotifications(notifications), 27 37 hydrateSubscribeNotifications(notifications), 28 38 hydrateQuoteNotifications(notifications), 39 + hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 29 41 ]); 30 42 31 43 // Combine all hydrated notifications 32 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications]; 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 33 45 34 46 // Sort by created_at to maintain order 35 47 allHydrated.sort( ··· 163 175 bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!, 164 176 document: documents?.find((d) => d.uri === notification.data.document_uri)!, 165 177 })); 178 + } 179 + 180 + export type HydratedMentionNotification = Awaited< 181 + ReturnType<typeof hydrateMentionNotifications> 182 + >[0]; 183 + 184 + async function hydrateMentionNotifications(notifications: NotificationRow[]) { 185 + const mentionNotifications = notifications.filter( 186 + (n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } => 187 + (n.data as NotificationData)?.type === "mention", 188 + ); 189 + 190 + if (mentionNotifications.length === 0) { 191 + return []; 192 + } 193 + 194 + // Fetch document data from the database 195 + const documentUris = mentionNotifications.map((n) => n.data.document_uri); 196 + const { data: documents } = await supabaseServerClient 197 + .from("documents") 198 + .select("*, documents_in_publications(publications(*))") 199 + .in("uri", documentUris); 200 + 201 + // Extract unique DIDs from document URIs to resolve handles 202 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 203 + 204 + // Resolve DIDs to handles in parallel 205 + const didToHandleMap = new Map<string, string | null>(); 206 + await Promise.all( 207 + documentCreatorDids.map(async (did) => { 208 + try { 209 + const resolved = await idResolver.did.resolve(did); 210 + const handle = resolved?.alsoKnownAs?.[0] 211 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 212 + : null; 213 + didToHandleMap.set(did, handle); 214 + } catch (error) { 215 + console.error(`Failed to resolve DID ${did}:`, error); 216 + didToHandleMap.set(did, null); 217 + } 218 + }), 219 + ); 220 + 221 + // Fetch mentioned publications and documents 222 + const mentionedPublicationUris = mentionNotifications 223 + .filter((n) => n.data.mention_type === "publication") 224 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri); 225 + 226 + const mentionedDocumentUris = mentionNotifications 227 + .filter((n) => n.data.mention_type === "document") 228 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri); 229 + 230 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 231 + mentionedPublicationUris.length > 0 232 + ? supabaseServerClient 233 + .from("publications") 234 + .select("*") 235 + .in("uri", mentionedPublicationUris) 236 + : Promise.resolve({ data: [] }), 237 + mentionedDocumentUris.length > 0 238 + ? supabaseServerClient 239 + .from("documents") 240 + .select("*, documents_in_publications(publications(*))") 241 + .in("uri", mentionedDocumentUris) 242 + : Promise.resolve({ data: [] }), 243 + ]); 244 + 245 + return mentionNotifications.map((notification) => { 246 + const mentionedUri = notification.data.mention_type !== "did" 247 + ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 248 + : undefined; 249 + 250 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 251 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 252 + 253 + return { 254 + id: notification.id, 255 + recipient: notification.recipient, 256 + created_at: notification.created_at, 257 + type: "mention" as const, 258 + document_uri: notification.data.document_uri, 259 + mention_type: notification.data.mention_type, 260 + mentioned_uri: mentionedUri, 261 + document: documents?.find((d) => d.uri === notification.data.document_uri)!, 262 + documentCreatorHandle, 263 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 264 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 265 + }; 266 + }); 267 + } 268 + 269 + export type HydratedCommentMentionNotification = Awaited< 270 + ReturnType<typeof hydrateCommentMentionNotifications> 271 + >[0]; 272 + 273 + async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) { 274 + const commentMentionNotifications = notifications.filter( 275 + (n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } => 276 + (n.data as NotificationData)?.type === "comment_mention", 277 + ); 278 + 279 + if (commentMentionNotifications.length === 0) { 280 + return []; 281 + } 282 + 283 + // Fetch comment data from the database 284 + const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri); 285 + const { data: comments } = await supabaseServerClient 286 + .from("comments_on_documents") 287 + .select( 288 + "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 289 + ) 290 + .in("uri", commentUris); 291 + 292 + // Extract unique DIDs from comment URIs to resolve handles 293 + const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; 294 + 295 + // Resolve DIDs to handles in parallel 296 + const didToHandleMap = new Map<string, string | null>(); 297 + await Promise.all( 298 + commenterDids.map(async (did) => { 299 + try { 300 + const resolved = await idResolver.did.resolve(did); 301 + const handle = resolved?.alsoKnownAs?.[0] 302 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 303 + : null; 304 + didToHandleMap.set(did, handle); 305 + } catch (error) { 306 + console.error(`Failed to resolve DID ${did}:`, error); 307 + didToHandleMap.set(did, null); 308 + } 309 + }), 310 + ); 311 + 312 + // Fetch mentioned publications and documents 313 + const mentionedPublicationUris = commentMentionNotifications 314 + .filter((n) => n.data.mention_type === "publication") 315 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri); 316 + 317 + const mentionedDocumentUris = commentMentionNotifications 318 + .filter((n) => n.data.mention_type === "document") 319 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri); 320 + 321 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 322 + mentionedPublicationUris.length > 0 323 + ? supabaseServerClient 324 + .from("publications") 325 + .select("*") 326 + .in("uri", mentionedPublicationUris) 327 + : Promise.resolve({ data: [] }), 328 + mentionedDocumentUris.length > 0 329 + ? supabaseServerClient 330 + .from("documents") 331 + .select("*, documents_in_publications(publications(*))") 332 + .in("uri", mentionedDocumentUris) 333 + : Promise.resolve({ data: [] }), 334 + ]); 335 + 336 + return commentMentionNotifications.map((notification) => { 337 + const mentionedUri = notification.data.mention_type !== "did" 338 + ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 339 + : undefined; 340 + 341 + const commenterDid = new AtUri(notification.data.comment_uri).host; 342 + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 343 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 344 + 345 + return { 346 + id: notification.id, 347 + recipient: notification.recipient, 348 + created_at: notification.created_at, 349 + type: "comment_mention" as const, 350 + comment_uri: notification.data.comment_uri, 351 + mention_type: notification.data.mention_type, 352 + mentioned_uri: mentionedUri, 353 + commentData: commentData!, 354 + commenterHandle, 355 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 356 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 357 + }; 358 + }); 166 359 } 167 360 168 361 export async function pingIdentityToUpdateNotification(did: string) {
+59
src/utils/mentionUtils.ts
··· 1 + import { AtUri } from "@atproto/api"; 2 + 3 + /** 4 + * Converts a DID to a Bluesky profile URL 5 + */ 6 + export function didToBlueskyUrl(did: string): string { 7 + return `https://bsky.app/profile/${did}`; 8 + } 9 + 10 + /** 11 + * Converts an AT URI (publication or document) to the appropriate URL 12 + */ 13 + export function atUriToUrl(atUri: string): string { 14 + try { 15 + const uri = new AtUri(atUri); 16 + 17 + if (uri.collection === "pub.leaflet.publication") { 18 + // Publication URL: /lish/{did}/{rkey} 19 + return `/lish/${uri.host}/${uri.rkey}`; 20 + } else if (uri.collection === "pub.leaflet.document") { 21 + // Document URL - we need to resolve this via the API 22 + // For now, create a redirect route that will handle it 23 + return `/lish/uri/${encodeURIComponent(atUri)}`; 24 + } 25 + 26 + return "#"; 27 + } catch (e) { 28 + console.error("Failed to parse AT URI:", atUri, e); 29 + return "#"; 30 + } 31 + } 32 + 33 + /** 34 + * Opens a mention link in the appropriate way 35 + * - DID mentions open in a new tab (external Bluesky) 36 + * - Publication/document mentions navigate in the same tab 37 + */ 38 + export function handleMentionClick( 39 + e: MouseEvent | React.MouseEvent, 40 + type: "did" | "at-uri", 41 + value: string 42 + ) { 43 + e.preventDefault(); 44 + e.stopPropagation(); 45 + 46 + if (type === "did") { 47 + // Open Bluesky profile in new tab 48 + window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer"); 49 + } else { 50 + // Navigate to publication/document in same tab 51 + const url = atUriToUrl(value); 52 + if (url.startsWith("/lish/uri/")) { 53 + // Redirect route - navigate to it 54 + window.location.href = url; 55 + } else { 56 + window.location.href = url; 57 + } 58 + } 59 + }