From d504924515770624ff7750d144b771347fc5b135 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 22 Jan 2026 13:39:11 -0500 Subject: [PATCH] add notification for bluesky posts mentioned in docs --- actions/publishToPublication.ts | 164 ++++++++++++------ .../BskyPostEmbedNotification.tsx | 44 +++++ .../notifications/NotificationList.tsx | 4 + src/notifications.ts | 108 +++++++++++- 4 files changed, 265 insertions(+), 55 deletions(-) create mode 100644 app/(home-pages)/notifications/BskyPostEmbedNotification.tsx diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 0e65d822..bad2144c 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -903,6 +903,7 @@ async function createMentionNotifications( const mentionedDids = new Set(); const mentionedPublications = new Map(); // Map of DID -> publication URI const mentionedDocuments = new Map(); // Map of DID -> document URI + const embeddedBskyPosts = new Map(); // Map of author DID -> post URI // Extract pages from either format let pages: PubLeafletContent.Main["pages"] | undefined; @@ -917,60 +918,89 @@ async function createMentionNotifications( if (!pages) return; - // Extract mentions from all text blocks in all pages - for (const page of pages) { - if (page.$type === "pub.leaflet.pages.linearDocument") { - const linearPage = page as PubLeafletPagesLinearDocument.Main; - for (const blockWrapper of linearPage.blocks) { - const block = blockWrapper.block; - if (block.$type === "pub.leaflet.blocks.text") { - const textBlock = block as PubLeafletBlocksText.Main; - if (textBlock.facets) { - for (const facet of textBlock.facets) { - for (const feature of facet.features) { - // Check for DID mentions - if (PubLeafletRichtextFacet.isDidMention(feature)) { - if (feature.did !== authorDid) { - mentionedDids.add(feature.did); - } + // Helper to extract blocks from all pages (both linear and canvas) + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { + const blocks: ( + | PubLeafletPagesLinearDocument.Block["block"] + | PubLeafletPagesCanvas.Block["block"] + )[] = []; + for (const page of pages) { + if (page.$type === "pub.leaflet.pages.linearDocument") { + const linearPage = page as PubLeafletPagesLinearDocument.Main; + for (const blockWrapper of linearPage.blocks) { + blocks.push(blockWrapper.block); + } + } else if (page.$type === "pub.leaflet.pages.canvas") { + const canvasPage = page as PubLeafletPagesCanvas.Main; + for (const blockWrapper of canvasPage.blocks) { + blocks.push(blockWrapper.block); + } + } + } + return blocks; + } + + const allBlocks = getAllBlocks(pages); + + // Extract mentions from all text blocks and embedded Bluesky posts + for (const block of allBlocks) { + // Check for embedded Bluesky posts + if (PubLeafletBlocksBskyPost.isMain(block)) { + const bskyPostUri = block.postRef.uri; + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) + const postAuthorDid = new AtUri(bskyPostUri).host; + if (postAuthorDid !== authorDid) { + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); + } + } + + // Check for text blocks with mentions + if (block.$type === "pub.leaflet.blocks.text") { + const textBlock = block as PubLeafletBlocksText.Main; + if (textBlock.facets) { + for (const facet of textBlock.facets) { + for (const feature of facet.features) { + // Check for DID mentions + if (PubLeafletRichtextFacet.isDidMention(feature)) { + if (feature.did !== authorDid) { + mentionedDids.add(feature.did); + } + } + // Check for AT URI mentions (publications and documents) + if (PubLeafletRichtextFacet.isAtMention(feature)) { + const uri = new AtUri(feature.atURI); + + if (isPublicationCollection(uri.collection)) { + // Get the publication owner's DID + const { data: publication } = await supabaseServerClient + .from("publications") + .select("identity_did") + .eq("uri", feature.atURI) + .single(); + + if (publication && publication.identity_did !== authorDid) { + mentionedPublications.set( + publication.identity_did, + feature.atURI, + ); } - // Check for AT URI mentions (publications and documents) - if (PubLeafletRichtextFacet.isAtMention(feature)) { - const uri = new AtUri(feature.atURI); - - if (isPublicationCollection(uri.collection)) { - // Get the publication owner's DID - const { data: publication } = await supabaseServerClient - .from("publications") - .select("identity_did") - .eq("uri", feature.atURI) - .single(); - - if (publication && publication.identity_did !== authorDid) { - mentionedPublications.set( - publication.identity_did, - feature.atURI, - ); - } - } else if (isDocumentCollection(uri.collection)) { - // Get the document owner's DID - const { data: document } = await supabaseServerClient - .from("documents") - .select("uri, data") - .eq("uri", feature.atURI) - .single(); - - if (document) { - const normalizedMentionedDoc = normalizeDocumentRecord( - document.data, - ); - // Get the author from the document URI (the DID is the host part) - const mentionedUri = new AtUri(feature.atURI); - const docAuthor = mentionedUri.host; - if (normalizedMentionedDoc && docAuthor !== authorDid) { - mentionedDocuments.set(docAuthor, feature.atURI); - } - } + } else if (isDocumentCollection(uri.collection)) { + // Get the document owner's DID + const { data: document } = await supabaseServerClient + .from("documents") + .select("uri, data") + .eq("uri", feature.atURI) + .single(); + + if (document) { + const normalizedMentionedDoc = normalizeDocumentRecord( + document.data, + ); + // Get the author from the document URI (the DID is the host part) + const mentionedUri = new AtUri(feature.atURI); + const docAuthor = mentionedUri.host; + if (normalizedMentionedDoc && docAuthor !== authorDid) { + mentionedDocuments.set(docAuthor, feature.atURI); } } } @@ -1027,4 +1057,32 @@ async function createMentionNotifications( await supabaseServerClient.from("notifications").insert(notification); await pingIdentityToUpdateNotification(recipientDid); } + + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) + if (embeddedBskyPosts.size > 0) { + // Check which of the Bluesky post authors have Leaflet accounts + const { data: identities } = await supabaseServerClient + .from("identities") + .select("atp_did") + .in("atp_did", Array.from(embeddedBskyPosts.keys())); + + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); + + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { + // Only notify if the post author has a Leaflet account + if (leafletUserDids.has(postAuthorDid)) { + const notification: Notification = { + id: v7(), + recipient: postAuthorDid, + data: { + type: "bsky_post_embed", + document_uri: documentUri, + bsky_post_uri: bskyPostUri, + }, + }; + await supabaseServerClient.from("notifications").insert(notification); + await pingIdentityToUpdateNotification(postAuthorDid); + } + } + } } diff --git a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx new file mode 100644 index 00000000..2b433771 --- /dev/null +++ b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx @@ -0,0 +1,44 @@ +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { ContentLayout, Notification } from "./Notification"; +import { HydratedBskyPostEmbedNotification } from "src/notifications"; +import { AtUri } from "@atproto/api"; + +export const BskyPostEmbedNotification = ( + props: HydratedBskyPostEmbedNotification, +) => { + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + + if (!docRecord) return null; + + const docUri = new AtUri(props.document.uri); + const rkey = docUri.rkey; + const did = docUri.host; + + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; + + const embedder = props.documentCreatorHandle + ? `@${props.documentCreatorHandle}` + : "Someone"; + + return ( + } + actionText={<>{embedder} embedded your Bluesky post} + content={ + + {props.bskyPostText && ( +
+              {props.bskyPostText}
+            
+ )} +
+ } + /> + ); +}; diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx index 96d931eb..a0ec12ee 100644 --- a/app/(home-pages)/notifications/NotificationList.tsx +++ b/app/(home-pages)/notifications/NotificationList.tsx @@ -8,6 +8,7 @@ import { ReplyNotification } from "./ReplyNotification"; import { useIdentityData } from "components/IdentityProvider"; import { FollowNotification } from "./FollowNotification"; import { QuoteNotification } from "./QuoteNotification"; +import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; import { MentionNotification } from "./MentionNotification"; import { CommentMentionNotification } from "./CommentMentionNotification"; @@ -48,6 +49,9 @@ export function NotificationList({ if (n.type === "quote") { return ; } + if (n.type === "bsky_post_embed") { + return ; + } if (n.type === "mention") { return ; } diff --git a/src/notifications.ts b/src/notifications.ts index 5e0711fd..f4353d2b 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -21,6 +21,7 @@ export type NotificationData = | { type: "comment"; comment_uri: string; parent_uri?: string } | { type: "subscribe"; subscription_uri: string } | { type: "quote"; bsky_post_uri: string; document_uri: string } + | { type: "bsky_post_embed"; document_uri: string; bsky_post_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 } @@ -32,22 +33,24 @@ export type HydratedNotification = | HydratedCommentNotification | HydratedSubscribeNotification | HydratedQuoteNotification + | HydratedBskyPostEmbedNotification | HydratedMentionNotification | HydratedCommentMentionNotification; export async function hydrateNotifications( notifications: NotificationRow[], ): Promise> { // Call all hydrators in parallel - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ hydrateCommentNotifications(notifications), hydrateSubscribeNotifications(notifications), hydrateQuoteNotifications(notifications), + hydrateBskyPostEmbedNotifications(notifications), hydrateMentionNotifications(notifications), hydrateCommentMentionNotifications(notifications), ]); // Combine all hydrated notifications - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; // Sort by created_at to maintain order allHydrated.sort( @@ -207,6 +210,107 @@ async function hydrateQuoteNotifications(notifications: NotificationRow[]) { .filter((n) => n !== null); } +export type HydratedBskyPostEmbedNotification = Awaited< + ReturnType +>[0]; + +async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) { + const bskyPostEmbedNotifications = notifications.filter( + (n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } => + (n.data as NotificationData)?.type === "bsky_post_embed", + ); + + if (bskyPostEmbedNotifications.length === 0) { + return []; + } + + // Fetch document data (the leaflet that embedded the post) + const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri); + const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri); + + const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([ + supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", documentUris), + supabaseServerClient + .from("bsky_posts") + .select("*") + .in("uri", bskyPostUris), + ]); + + // Find which posts we need to fetch from the API + const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []); + const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri)); + + // Fetch missing posts from Bluesky API + const fetchedPosts = new Map(); + if (missingPostUris.length > 0) { + try { + const { AtpAgent } = await import("@atproto/api"); + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); + const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris }); + for (const post of response.data.posts) { + const record = post.record as { text?: string }; + fetchedPosts.set(post.uri, { text: record.text ?? "" }); + } + } catch (error) { + console.error("Failed to fetch Bluesky posts:", error); + } + } + + // Extract unique DIDs from document URIs to resolve handles + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; + + // Resolve DIDs to handles in parallel + const didToHandleMap = new Map(); + await Promise.all( + documentCreatorDids.map(async (did) => { + try { + const resolved = await idResolver.did.resolve(did); + const handle = resolved?.alsoKnownAs?.[0] + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix + : null; + didToHandleMap.set(did, handle); + } catch (error) { + console.error(`Failed to resolve DID ${did}:`, error); + didToHandleMap.set(did, null); + } + }), + ); + + return bskyPostEmbedNotifications + .map((notification) => { + const document = documents?.find((d) => d.uri === notification.data.document_uri); + if (!document) return null; + + const documentCreatorDid = new AtUri(notification.data.document_uri).host; + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; + + // Get post text from cache or fetched data + const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); + const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined; + const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null; + + return { + id: notification.id, + recipient: notification.recipient, + created_at: notification.created_at, + type: "bsky_post_embed" as const, + document_uri: notification.data.document_uri, + bsky_post_uri: notification.data.bsky_post_uri, + document, + documentCreatorHandle, + bskyPostText, + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), + }; + }) + .filter((n) => n !== null); +} + export type HydratedMentionNotification = Awaited< ReturnType >[0]; -- 2.51.2