a tool for shared writing and social publishing

add comment mentions and notifications

+529 -20
+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 + };
+4
app/(home-pages)/notifications/NotificationList.tsx
··· 9 9 import { FollowNotification } from "./FollowNotification"; 10 10 import { QuoteNotification } from "./QuoteNotification"; 11 11 import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 12 13 13 14 export function NotificationList({ 14 15 notifications, ··· 49 50 } 50 51 if (n.type === "mention") { 51 52 return <MentionNotification key={n.id} {...n} />; 53 + } 54 + if (n.type === "comment_mention") { 55 + return <CommentMentionNotification key={n.id} {...n} />; 52 56 } 53 57 })} 54 58 </div>
+5 -4
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 379 379 range: { from: number; to: number }, 380 380 view: EditorView, 381 381 ) => { 382 + console.log("view", view); 382 383 if (!view) return; 383 384 const { from, to } = range; 384 385 const tr = view.state.tr; ··· 393 394 text: mentionText, 394 395 }); 395 396 tr.insert(from, didMentionNode); 396 - // Add a space after the mention 397 - tr.insertText(" ", from + 1); 398 397 } 399 398 if (mention.type === "publication" || mention.type === "post") { 400 399 // Delete the @ and any query text ··· 406 405 text: name, 407 406 }); 408 407 tr.insert(from, atMentionNode); 409 - // Add a space after the mention 410 - tr.insertText(" ", from + 1); 411 408 } 409 + console.log("yo", mention); 410 + 411 + // Add a space after the mention 412 + tr.insertText(" ", from + 1); 412 413 413 414 view.dispatch(tr); 414 415 view.focus();
+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 + }
+101 -4
src/notifications.ts
··· 17 17 | { type: "quote"; bsky_post_uri: string; document_uri: string } 18 18 | { type: "mention"; document_uri: string; mention_type: "did" } 19 19 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 20 - | { type: "mention"; document_uri: string; mention_type: "document"; 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 }; 21 24 22 25 export type HydratedNotification = 23 26 | HydratedCommentNotification 24 27 | HydratedSubscribeNotification 25 28 | HydratedQuoteNotification 26 - | HydratedMentionNotification; 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 27 31 export async function hydrateNotifications( 28 32 notifications: NotificationRow[], 29 33 ): Promise<Array<HydratedNotification>> { 30 34 // Call all hydrators in parallel 31 - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications] = await Promise.all([ 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 32 36 hydrateCommentNotifications(notifications), 33 37 hydrateSubscribeNotifications(notifications), 34 38 hydrateQuoteNotifications(notifications), 35 39 hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 36 41 ]); 37 42 38 43 // Combine all hydrated notifications 39 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications]; 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 40 45 41 46 // Sort by created_at to maintain order 42 47 allHydrated.sort( ··· 255 260 mentioned_uri: mentionedUri, 256 261 document: documents?.find((d) => d.uri === notification.data.document_uri)!, 257 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, 258 355 mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 259 356 mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 260 357 };