diff --git a/src/notifications.ts b/src/notifications.ts --- a/src/notifications.ts +++ b/src/notifications.ts @@ -17,26 +17,31 @@ | { type: "quote"; bsky_post_uri: string; document_uri: string } | { type: "mention"; document_uri: string; mention_type: "did" } | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } - | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }; + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } + | { type: "comment_mention"; comment_uri: string; mention_type: "did" } + | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; export type HydratedNotification = | HydratedCommentNotification | HydratedSubscribeNotification | HydratedQuoteNotification - | HydratedMentionNotification; + | HydratedMentionNotification + | HydratedCommentMentionNotification; export async function hydrateNotifications( notifications: NotificationRow[], ): Promise> { // Call all hydrators in parallel - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications] = await Promise.all([ + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ hydrateCommentNotifications(notifications), hydrateSubscribeNotifications(notifications), hydrateQuoteNotifications(notifications), hydrateMentionNotifications(notifications), + hydrateCommentMentionNotifications(notifications), ]); // Combine all hydrated notifications - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications]; + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; // Sort by created_at to maintain order allHydrated.sort( @@ -255,6 +260,98 @@ mentioned_uri: mentionedUri, document: documents?.find((d) => d.uri === notification.data.document_uri)!, documentCreatorHandle, + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + }; + }); +} + +export type HydratedCommentMentionNotification = Awaited< + ReturnType +>[0]; + +async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) { + const commentMentionNotifications = notifications.filter( + (n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } => + (n.data as NotificationData)?.type === "comment_mention", + ); + + if (commentMentionNotifications.length === 0) { + return []; + } + + // Fetch comment data from the database + const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri); + const { data: comments } = await supabaseServerClient + .from("comments_on_documents") + .select( + "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", + ) + .in("uri", commentUris); + + // Extract unique DIDs from comment URIs to resolve handles + const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; + + // Resolve DIDs to handles in parallel + const didToHandleMap = new Map(); + await Promise.all( + commenterDids.map(async (did) => { + try { + const resolved = await idResolver.did.resolve(did); + const handle = resolved?.alsoKnownAs?.[0] + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix + : null; + didToHandleMap.set(did, handle); + } catch (error) { + console.error(`Failed to resolve DID ${did}:`, error); + didToHandleMap.set(did, null); + } + }), + ); + + // Fetch mentioned publications and documents + const mentionedPublicationUris = commentMentionNotifications + .filter((n) => n.data.mention_type === "publication") + .map((n) => (n.data as Extract, { mention_type: "publication" }>).mentioned_uri); + + const mentionedDocumentUris = commentMentionNotifications + .filter((n) => n.data.mention_type === "document") + .map((n) => (n.data as Extract, { mention_type: "document" }>).mentioned_uri); + + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ + mentionedPublicationUris.length > 0 + ? supabaseServerClient + .from("publications") + .select("*") + .in("uri", mentionedPublicationUris) + : Promise.resolve({ data: [] }), + mentionedDocumentUris.length > 0 + ? supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", mentionedDocumentUris) + : Promise.resolve({ data: [] }), + ]); + + return commentMentionNotifications.map((notification) => { + const mentionedUri = notification.data.mention_type !== "did" + ? (notification.data as Extract, { mentioned_uri: string }>).mentioned_uri + : undefined; + + const commenterDid = new AtUri(notification.data.comment_uri).host; + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); + + return { + id: notification.id, + recipient: notification.recipient, + created_at: notification.created_at, + type: "comment_mention" as const, + comment_uri: notification.data.comment_uri, + mention_type: notification.data.mention_type, + mentioned_uri: mentionedUri, + commentData: commentData!, + commenterHandle, mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, }; diff --git a/app/(home-pages)/notifications/CommentMentionNotification.tsx b/app/(home-pages)/notifications/CommentMentionNotification.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/notifications/CommentMentionNotification.tsx @@ -0,0 +1,98 @@ +import { + AppBskyActorProfile, + PubLeafletComment, + PubLeafletDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { HydratedCommentMentionNotification } from "src/notifications"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { MentionTiny } from "components/Icons/MentionTiny"; +import { + CommentInNotification, + ContentLayout, + Notification, +} from "./Notification"; +import { AtUri } from "@atproto/api"; + +export const CommentMentionNotification = ( + props: HydratedCommentMentionNotification, +) => { + const docRecord = props.commentData.documents + ?.data as PubLeafletDocument.Record; + const commentRecord = props.commentData.record as PubLeafletComment.Record; + const profileRecord = props.commentData.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const pubRecord = props.commentData.documents?.documents_in_publications[0] + ?.publications?.record as PubLeafletPublication.Record | undefined; + const docUri = new AtUri(props.commentData.documents?.uri!); + const rkey = docUri.rkey; + const did = docUri.host; + + const href = pubRecord + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + : `/p/${did}/${rkey}?interactionDrawer=comments`; + + const commenter = props.commenterHandle + ? `@${props.commenterHandle}` + : "Someone"; + + let actionText: React.ReactNode; + let mentionedDocRecord = props.mentionedDocument + ?.data as PubLeafletDocument.Record; + + if (props.mention_type === "did") { + actionText = <>{commenter} mentioned you in a comment; + } else if ( + props.mention_type === "publication" && + props.mentionedPublication + ) { + const mentionedPubRecord = props.mentionedPublication + .record as PubLeafletPublication.Record; + actionText = ( + <> + {commenter} mentioned your publication{" "} + {mentionedPubRecord.name} in a comment + + ); + } else if (props.mention_type === "document" && props.mentionedDocument) { + actionText = ( + <> + {commenter} mentioned your post{" "} + {mentionedDocRecord.title} in a comment + + ); + } else { + actionText = <>{commenter} mentioned you in a comment; + } + + return ( + } + actionText={actionText} + content={ + + + + } + /> + ); +}; diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx --- a/app/(home-pages)/notifications/NotificationList.tsx +++ b/app/(home-pages)/notifications/NotificationList.tsx @@ -9,6 +9,7 @@ import { FollowNotification } from "./FollowNotification"; import { QuoteNotification } from "./QuoteNotification"; import { MentionNotification } from "./MentionNotification"; +import { CommentMentionNotification } from "./CommentMentionNotification"; export function NotificationList({ notifications, @@ -49,6 +50,9 @@ } if (n.type === "mention") { return ; + } + if (n.type === "comment_mention") { + return ; } })} diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -379,6 +379,7 @@ range: { from: number; to: number }, view: EditorView, ) => { + console.log("view", view); if (!view) return; const { from, to } = range; const tr = view.state.tr; @@ -393,8 +394,6 @@ text: mentionText, }); tr.insert(from, didMentionNode); - // Add a space after the mention - tr.insertText(" ", from + 1); } if (mention.type === "publication" || mention.type === "post") { // Delete the @ and any query text @@ -406,9 +405,11 @@ text: name, }); tr.insert(from, atMentionNode); - // Add a space after the mention - tr.insertText(" ", from + 1); } + console.log("yo", mention); + + // Add a space after the mention + tr.insertText(" ", from + 1); view.dispatch(tr); view.focus(); diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -8,9 +8,11 @@ import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { history, redo, undo } from "prosemirror-history"; +import { InputRule, inputRules } from "prosemirror-inputrules"; import { MutableRefObject, RefObject, + useCallback, useEffect, useLayoutEffect, useRef, @@ -36,6 +38,48 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { CloseFillTiny } from "components/Icons/CloseFillTiny"; import { betterIsUrl } from "src/utils/isURL"; +import { Mention, MentionAutocomplete } from "components/Mention"; +import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; + +const addMentionToEditor = ( + mention: Mention, + range: { from: number; to: number }, + view: EditorView, +) => { + if (!view) return; + const { from, to } = range; + const tr = view.state.tr; + + if (mention.type === "did") { + // Delete the @ and any query text + tr.delete(from, to); + // Insert didMention inline node + const mentionText = "@" + mention.handle; + const didMentionNode = multiBlockSchema.nodes.didMention.create({ + did: mention.did, + text: mentionText, + }); + tr.insert(from, didMentionNode); + // Add a space after the mention + tr.insertText(" ", from + 1); + } + if (mention.type === "publication" || mention.type === "post") { + // Delete the @ and any query text + tr.delete(from, to); + let name = mention.type === "post" ? mention.title : mention.name; + // Insert atMention inline node + const atMentionNode = multiBlockSchema.nodes.atMention.create({ + atURI: mention.uri, + text: name, + }); + tr.insert(from, atMentionNode); + // Add a space after the mention + tr.insertText(" ", from + 1); + } + + view.dispatch(tr); + view.focus(); +}; export function CommentBox(props: { doc_uri: string; @@ -50,8 +94,68 @@ commentBox: { quote }, } = useInteractionState(props.doc_uri); let [loading, setLoading] = useState(false); + let view = useRef(null); - const handleSubmit = async () => { + // Mention autocomplete state + const [mentionOpen, setMentionOpen] = useState(false); + const [mentionCoords, setMentionCoords] = useState<{ + top: number; + left: number; + } | null>(null); + // Use a ref for insert position to avoid stale closure issues + const mentionInsertPosRef = useRef(null); + + // Use a ref for the callback so input rules can access it + const openMentionAutocompleteRef = useRef<() => void>(() => {}); + openMentionAutocompleteRef.current = () => { + if (!view.current) return; + + const pos = view.current.state.selection.from; + mentionInsertPosRef.current = pos; + + // Get coordinates for the popup relative to the positioned parent + const coords = view.current.coordsAtPos(pos - 1); + + // Find the relative positioned parent container + const editorEl = view.current.dom; + const container = editorEl.closest(".relative") as HTMLElement | null; + + if (container) { + const containerRect = container.getBoundingClientRect(); + setMentionCoords({ + top: coords.bottom - containerRect.top, + left: coords.left - containerRect.left, + }); + } else { + setMentionCoords({ + top: coords.bottom, + left: coords.left, + }); + } + setMentionOpen(true); + }; + + const handleMentionSelect = useCallback((mention: Mention) => { + if (!view.current || mentionInsertPosRef.current === null) return; + + const from = mentionInsertPosRef.current - 1; + const to = mentionInsertPosRef.current; + + addMentionToEditor(mention, { from, to }, view.current); + view.current.focus(); + }, []); + + const handleMentionOpenChange = useCallback((open: boolean) => { + setMentionOpen(open); + if (!open) { + setMentionCoords(null); + mentionInsertPosRef.current = null; + } + }, []); + + // Use a ref for handleSubmit so keyboard shortcuts can access it + const handleSubmitRef = useRef<() => Promise>(async () => {}); + handleSubmitRef.current = async () => { if (loading || !view.current) return; setLoading(true); @@ -114,11 +218,11 @@ "Mod-y": redo, "Shift-Mod-z": redo, "Ctrl-Enter": () => { - handleSubmit(); + handleSubmitRef.current(); return true; }, "Meta-Enter": () => { - handleSubmit(); + handleSubmitRef.current(); return true; }, }), @@ -128,11 +232,20 @@ shouldAutoLink: () => true, defaultProtocol: "https", }), + // Input rules for @ mentions + inputRules({ + rules: [ + // @ at start of line or after space + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { + setTimeout(() => openMentionAutocompleteRef.current(), 0); + return null; + }), + ], + }), history(), ], }), ); - let view = useRef(null); useLayoutEffect(() => { if (!mountRef.current) return; view.current = new EditorView( @@ -187,15 +300,55 @@ handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { if (!direct) return; if (node.nodeSize - 2 <= _pos) return; + + const nodeAt1 = node.nodeAt(_pos - 1); + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); + + // Check for link marks let mark = - node - .nodeAt(_pos - 1) - ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || - node - .nodeAt(Math.max(_pos - 2, 0)) - ?.marks.find((f) => f.type === multiBlockSchema.marks.link); + nodeAt1?.marks.find( + (f) => f.type === multiBlockSchema.marks.link, + ) || + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); if (mark) { window.open(mark.attrs.href, "_blank"); + return; + } + + // Check for didMention inline nodes + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { + window.open( + didToBlueskyUrl(nodeAt1.attrs.did), + "_blank", + "noopener,noreferrer", + ); + return; + } + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { + window.open( + didToBlueskyUrl(nodeAt2.attrs.did), + "_blank", + "noopener,noreferrer", + ); + return; + } + + // Check for atMention inline nodes (publications/documents) + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { + window.open( + atUriToUrl(nodeAt1.attrs.atURI), + "_blank", + "noopener,noreferrer", + ); + return; + } + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { + window.open( + atUriToUrl(nodeAt2.attrs.atURI), + "_blank", + "noopener,noreferrer", + ); + return; } }, dispatchTransaction(tr) { @@ -236,9 +389,28 @@
 {
+            // Close mention dropdown when editor gains focus (reset stale state)
+            handleMentionOpenChange(false);
+          }}
+          onBlur={(e) => {
+            // Close mention dropdown when editor loses focus
+            // But not if focus moved to the mention autocomplete
+            const relatedTarget = e.relatedTarget as HTMLElement | null;
+            if (!relatedTarget?.closest(".dropdownMenu")) {
+              handleMentionOpenChange(false);
+            }
+          }}
           className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
         />
         
+        
       
@@ -261,7 +433,7 @@ view={view} />
- + handleSubmitRef.current()}> {loading ? : }
@@ -328,6 +500,46 @@ facets.push(facet); } } + + fullText += text; + byteOffset += unicodeString.length; + } else if (node.type.name === "didMention") { + // Handle DID mention nodes + const text = node.attrs.text || ""; + const unicodeString = new UnicodeString(text); + + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodeString.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#didMention", + did: node.attrs.did, + }, + ], + }); + + fullText += text; + byteOffset += unicodeString.length; + } else if (node.type.name === "atMention") { + // Handle AT-URI mention nodes (publications and documents) + const text = node.attrs.text || ""; + const unicodeString = new UnicodeString(text); + + facets.push({ + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodeString.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#atMention", + atURI: node.attrs.atURI, + }, + ], + }); fullText += text; byteOffset += unicodeString.length; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -10,6 +10,7 @@ import { Json } from "supabase/database.types"; import { Notification, + NotificationData, pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; @@ -84,9 +85,26 @@ parent_uri: args.comment.replyTo, }, }); + } + + // Create mention notifications from comment facets + const mentionNotifications = createCommentMentionNotifications( + args.comment.facets, + uri.toString(), + credentialSession.did!, + ); + notifications.push(...mentionNotifications); + + // Insert all notifications and ping recipients + if (notifications.length > 0) { // SOMEDAY: move this out the action with inngest or workflows await supabaseServerClient.from("notifications").insert(notifications); - await pingIdentityToUpdateNotification(recipient); + + // Ping all unique recipients + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; + await Promise.all( + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), + ); } return { @@ -94,4 +112,83 @@ profile: lexToJson(profile.value), uri: uri.toString(), }; +} + +/** + * Creates mention notifications from comment facets + * Handles didMention (people) and atMention (publications/documents) + */ +function createCommentMentionNotifications( + facets: PubLeafletRichtextFacet.Main[], + commentUri: string, + commenterDid: string, +): Notification[] { + const notifications: Notification[] = []; + const notifiedRecipients = new Set(); // Avoid duplicate notifications + + for (const facet of facets) { + for (const feature of facet.features) { + if (PubLeafletRichtextFacet.isDidMention(feature)) { + // DID mention - notify the mentioned person directly + const recipientDid = feature.did; + + // Don't notify yourself + if (recipientDid === commenterDid) continue; + // Avoid duplicate notifications to the same person + if (notifiedRecipients.has(recipientDid)) continue; + notifiedRecipients.add(recipientDid); + + notifications.push({ + id: v7(), + recipient: recipientDid, + data: { + type: "comment_mention", + comment_uri: commentUri, + mention_type: "did", + }, + }); + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { + // AT-URI mention - notify the owner of the publication/document + try { + const mentionedUri = new AtUri(feature.atURI); + const recipientDid = mentionedUri.host; + + // Don't notify yourself + if (recipientDid === commenterDid) continue; + // Avoid duplicate notifications to the same person for the same mentioned item + const dedupeKey = `${recipientDid}:${feature.atURI}`; + if (notifiedRecipients.has(dedupeKey)) continue; + notifiedRecipients.add(dedupeKey); + + if (mentionedUri.collection === "pub.leaflet.publication") { + notifications.push({ + id: v7(), + recipient: recipientDid, + data: { + type: "comment_mention", + comment_uri: commentUri, + mention_type: "publication", + mentioned_uri: feature.atURI, + }, + }); + } else if (mentionedUri.collection === "pub.leaflet.document") { + notifications.push({ + id: v7(), + recipient: recipientDid, + data: { + type: "comment_mention", + comment_uri: commentUri, + mention_type: "document", + mentioned_uri: feature.atURI, + }, + }); + } + } catch (error) { + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); + } + } + } + } + + return notifications; }