a tool for shared writing and social publishing

add notification for bluesky posts mentioned in docs

+263 -53
+109 -51
actions/publishToPublication.ts
··· 903 const mentionedDids = new Set<string>(); 904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 906 907 // Extract pages from either format 908 let pages: PubLeafletContent.Main["pages"] | undefined; ··· 917 918 if (!pages) return; 919 920 - // Extract mentions from all text blocks in all pages 921 - for (const page of pages) { 922 - if (page.$type === "pub.leaflet.pages.linearDocument") { 923 - const linearPage = page as PubLeafletPagesLinearDocument.Main; 924 - for (const blockWrapper of linearPage.blocks) { 925 - const block = blockWrapper.block; 926 - if (block.$type === "pub.leaflet.blocks.text") { 927 - const textBlock = block as PubLeafletBlocksText.Main; 928 - if (textBlock.facets) { 929 - for (const facet of textBlock.facets) { 930 - for (const feature of facet.features) { 931 - // Check for DID mentions 932 - if (PubLeafletRichtextFacet.isDidMention(feature)) { 933 - if (feature.did !== authorDid) { 934 - mentionedDids.add(feature.did); 935 - } 936 - } 937 - // Check for AT URI mentions (publications and documents) 938 - if (PubLeafletRichtextFacet.isAtMention(feature)) { 939 - const uri = new AtUri(feature.atURI); 940 941 - if (isPublicationCollection(uri.collection)) { 942 - // Get the publication owner's DID 943 - const { data: publication } = await supabaseServerClient 944 - .from("publications") 945 - .select("identity_did") 946 - .eq("uri", feature.atURI) 947 - .single(); 948 949 - if (publication && publication.identity_did !== authorDid) { 950 - mentionedPublications.set( 951 - publication.identity_did, 952 - feature.atURI, 953 - ); 954 - } 955 - } else if (isDocumentCollection(uri.collection)) { 956 - // Get the document owner's DID 957 - const { data: document } = await supabaseServerClient 958 - .from("documents") 959 - .select("uri, data") 960 - .eq("uri", feature.atURI) 961 - .single(); 962 963 - if (document) { 964 - const normalizedMentionedDoc = normalizeDocumentRecord( 965 - document.data, 966 - ); 967 - // Get the author from the document URI (the DID is the host part) 968 - const mentionedUri = new AtUri(feature.atURI); 969 - const docAuthor = mentionedUri.host; 970 - if (normalizedMentionedDoc && docAuthor !== authorDid) { 971 - mentionedDocuments.set(docAuthor, feature.atURI); 972 - } 973 - } 974 } 975 } 976 } ··· 1026 }; 1027 await supabaseServerClient.from("notifications").insert(notification); 1028 await pingIdentityToUpdateNotification(recipientDid); 1029 } 1030 }
··· 903 const mentionedDids = new Set<string>(); 904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 906 + const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI 907 908 // Extract pages from either format 909 let pages: PubLeafletContent.Main["pages"] | undefined; ··· 918 919 if (!pages) return; 920 921 + // Helper to extract blocks from all pages (both linear and canvas) 922 + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { 923 + const blocks: ( 924 + | PubLeafletPagesLinearDocument.Block["block"] 925 + | PubLeafletPagesCanvas.Block["block"] 926 + )[] = []; 927 + for (const page of pages) { 928 + if (page.$type === "pub.leaflet.pages.linearDocument") { 929 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 930 + for (const blockWrapper of linearPage.blocks) { 931 + blocks.push(blockWrapper.block); 932 + } 933 + } else if (page.$type === "pub.leaflet.pages.canvas") { 934 + const canvasPage = page as PubLeafletPagesCanvas.Main; 935 + for (const blockWrapper of canvasPage.blocks) { 936 + blocks.push(blockWrapper.block); 937 + } 938 + } 939 + } 940 + return blocks; 941 + } 942 943 + const allBlocks = getAllBlocks(pages); 944 945 + // Extract mentions from all text blocks and embedded Bluesky posts 946 + for (const block of allBlocks) { 947 + // Check for embedded Bluesky posts 948 + if (PubLeafletBlocksBskyPost.isMain(block)) { 949 + const bskyPostUri = block.postRef.uri; 950 + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) 951 + const postAuthorDid = new AtUri(bskyPostUri).host; 952 + if (postAuthorDid !== authorDid) { 953 + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); 954 + } 955 + } 956 957 + // Check for text blocks with mentions 958 + if (block.$type === "pub.leaflet.blocks.text") { 959 + const textBlock = block as PubLeafletBlocksText.Main; 960 + if (textBlock.facets) { 961 + for (const facet of textBlock.facets) { 962 + for (const feature of facet.features) { 963 + // Check for DID mentions 964 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 965 + if (feature.did !== authorDid) { 966 + mentionedDids.add(feature.did); 967 + } 968 + } 969 + // Check for AT URI mentions (publications and documents) 970 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 971 + const uri = new AtUri(feature.atURI); 972 + 973 + if (isPublicationCollection(uri.collection)) { 974 + // Get the publication owner's DID 975 + const { data: publication } = await supabaseServerClient 976 + .from("publications") 977 + .select("identity_did") 978 + .eq("uri", feature.atURI) 979 + .single(); 980 + 981 + if (publication && publication.identity_did !== authorDid) { 982 + mentionedPublications.set( 983 + publication.identity_did, 984 + feature.atURI, 985 + ); 986 + } 987 + } else if (isDocumentCollection(uri.collection)) { 988 + // Get the document owner's DID 989 + const { data: document } = await supabaseServerClient 990 + .from("documents") 991 + .select("uri, data") 992 + .eq("uri", feature.atURI) 993 + .single(); 994 + 995 + if (document) { 996 + const normalizedMentionedDoc = normalizeDocumentRecord( 997 + document.data, 998 + ); 999 + // Get the author from the document URI (the DID is the host part) 1000 + const mentionedUri = new AtUri(feature.atURI); 1001 + const docAuthor = mentionedUri.host; 1002 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 1003 + mentionedDocuments.set(docAuthor, feature.atURI); 1004 } 1005 } 1006 } ··· 1056 }; 1057 await supabaseServerClient.from("notifications").insert(notification); 1058 await pingIdentityToUpdateNotification(recipientDid); 1059 + } 1060 + 1061 + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) 1062 + if (embeddedBskyPosts.size > 0) { 1063 + // Check which of the Bluesky post authors have Leaflet accounts 1064 + const { data: identities } = await supabaseServerClient 1065 + .from("identities") 1066 + .select("atp_did") 1067 + .in("atp_did", Array.from(embeddedBskyPosts.keys())); 1068 + 1069 + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); 1070 + 1071 + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { 1072 + // Only notify if the post author has a Leaflet account 1073 + if (leafletUserDids.has(postAuthorDid)) { 1074 + const notification: Notification = { 1075 + id: v7(), 1076 + recipient: postAuthorDid, 1077 + data: { 1078 + type: "bsky_post_embed", 1079 + document_uri: documentUri, 1080 + bsky_post_uri: bskyPostUri, 1081 + }, 1082 + }; 1083 + await supabaseServerClient.from("notifications").insert(notification); 1084 + await pingIdentityToUpdateNotification(postAuthorDid); 1085 + } 1086 + } 1087 } 1088 }
+44
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
···
··· 1 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedBskyPostEmbedNotification } from "src/notifications"; 4 + import { AtUri } from "@atproto/api"; 5 + 6 + export const BskyPostEmbedNotification = ( 7 + props: HydratedBskyPostEmbedNotification, 8 + ) => { 9 + const docRecord = props.normalizedDocument; 10 + const pubRecord = props.normalizedPublication; 11 + 12 + if (!docRecord) return null; 13 + 14 + const docUri = new AtUri(props.document.uri); 15 + const rkey = docUri.rkey; 16 + const did = docUri.host; 17 + 18 + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 19 + 20 + const embedder = props.documentCreatorHandle 21 + ? `@${props.documentCreatorHandle}` 22 + : "Someone"; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<BlueskyTiny />} 29 + actionText={<>{embedder} embedded your Bluesky post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + {props.bskyPostText && ( 33 + <pre 34 + style={{ wordBreak: "break-word" }} 35 + className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm" 36 + > 37 + {props.bskyPostText} 38 + </pre> 39 + )} 40 + </ContentLayout> 41 + } 42 + /> 43 + ); 44 + };
+4
app/(home-pages)/notifications/NotificationList.tsx
··· 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 import { QuoteNotification } from "./QuoteNotification"; 11 import { MentionNotification } from "./MentionNotification"; 12 import { CommentMentionNotification } from "./CommentMentionNotification"; 13 ··· 47 } 48 if (n.type === "quote") { 49 return <QuoteNotification key={n.id} {...n} />; 50 } 51 if (n.type === "mention") { 52 return <MentionNotification key={n.id} {...n} />;
··· 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 import { QuoteNotification } from "./QuoteNotification"; 11 + import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; 12 import { MentionNotification } from "./MentionNotification"; 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 14 ··· 48 } 49 if (n.type === "quote") { 50 return <QuoteNotification key={n.id} {...n} />; 51 + } 52 + if (n.type === "bsky_post_embed") { 53 + return <BskyPostEmbedNotification key={n.id} {...n} />; 54 } 55 if (n.type === "mention") { 56 return <MentionNotification key={n.id} {...n} />;
+106 -2
src/notifications.ts
··· 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 22 | { type: "subscribe"; subscription_uri: string } 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 | { type: "mention"; document_uri: string; mention_type: "did" } 25 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 26 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 32 | HydratedCommentNotification 33 | HydratedSubscribeNotification 34 | HydratedQuoteNotification 35 | HydratedMentionNotification 36 | HydratedCommentMentionNotification; 37 export async function hydrateNotifications( 38 notifications: NotificationRow[], 39 ): Promise<Array<HydratedNotification>> { 40 // Call all hydrators in parallel 41 - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 42 hydrateCommentNotifications(notifications), 43 hydrateSubscribeNotifications(notifications), 44 hydrateQuoteNotifications(notifications), 45 hydrateMentionNotifications(notifications), 46 hydrateCommentMentionNotifications(notifications), 47 ]); 48 49 // Combine all hydrated notifications 50 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 51 52 // Sort by created_at to maintain order 53 allHydrated.sort( ··· 198 document_uri: notification.data.document_uri, 199 bskyPost, 200 document, 201 normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 202 normalizedPublication: normalizePublicationRecord( 203 document.documents_in_publications[0]?.publications?.record,
··· 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 22 | { type: "subscribe"; subscription_uri: string } 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 + | { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string } 25 | { type: "mention"; document_uri: string; mention_type: "did" } 26 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 27 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 33 | HydratedCommentNotification 34 | HydratedSubscribeNotification 35 | HydratedQuoteNotification 36 + | HydratedBskyPostEmbedNotification 37 | HydratedMentionNotification 38 | HydratedCommentMentionNotification; 39 export async function hydrateNotifications( 40 notifications: NotificationRow[], 41 ): Promise<Array<HydratedNotification>> { 42 // Call all hydrators in parallel 43 + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 44 hydrateCommentNotifications(notifications), 45 hydrateSubscribeNotifications(notifications), 46 hydrateQuoteNotifications(notifications), 47 + hydrateBskyPostEmbedNotifications(notifications), 48 hydrateMentionNotifications(notifications), 49 hydrateCommentMentionNotifications(notifications), 50 ]); 51 52 // Combine all hydrated notifications 53 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; 54 55 // Sort by created_at to maintain order 56 allHydrated.sort( ··· 201 document_uri: notification.data.document_uri, 202 bskyPost, 203 document, 204 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 205 + normalizedPublication: normalizePublicationRecord( 206 + document.documents_in_publications[0]?.publications?.record, 207 + ), 208 + }; 209 + }) 210 + .filter((n) => n !== null); 211 + } 212 + 213 + export type HydratedBskyPostEmbedNotification = Awaited< 214 + ReturnType<typeof hydrateBskyPostEmbedNotifications> 215 + >[0]; 216 + 217 + async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) { 218 + const bskyPostEmbedNotifications = notifications.filter( 219 + (n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } => 220 + (n.data as NotificationData)?.type === "bsky_post_embed", 221 + ); 222 + 223 + if (bskyPostEmbedNotifications.length === 0) { 224 + return []; 225 + } 226 + 227 + // Fetch document data (the leaflet that embedded the post) 228 + const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri); 229 + const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri); 230 + 231 + const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([ 232 + supabaseServerClient 233 + .from("documents") 234 + .select("*, documents_in_publications(publications(*))") 235 + .in("uri", documentUris), 236 + supabaseServerClient 237 + .from("bsky_posts") 238 + .select("*") 239 + .in("uri", bskyPostUris), 240 + ]); 241 + 242 + // Find which posts we need to fetch from the API 243 + const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []); 244 + const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri)); 245 + 246 + // Fetch missing posts from Bluesky API 247 + const fetchedPosts = new Map<string, { text: string } | null>(); 248 + if (missingPostUris.length > 0) { 249 + try { 250 + const { AtpAgent } = await import("@atproto/api"); 251 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 252 + const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris }); 253 + for (const post of response.data.posts) { 254 + const record = post.record as { text?: string }; 255 + fetchedPosts.set(post.uri, { text: record.text ?? "" }); 256 + } 257 + } catch (error) { 258 + console.error("Failed to fetch Bluesky posts:", error); 259 + } 260 + } 261 + 262 + // Extract unique DIDs from document URIs to resolve handles 263 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 264 + 265 + // Resolve DIDs to handles in parallel 266 + const didToHandleMap = new Map<string, string | null>(); 267 + await Promise.all( 268 + documentCreatorDids.map(async (did) => { 269 + try { 270 + const resolved = await idResolver.did.resolve(did); 271 + const handle = resolved?.alsoKnownAs?.[0] 272 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 273 + : null; 274 + didToHandleMap.set(did, handle); 275 + } catch (error) { 276 + console.error(`Failed to resolve DID ${did}:`, error); 277 + didToHandleMap.set(did, null); 278 + } 279 + }), 280 + ); 281 + 282 + return bskyPostEmbedNotifications 283 + .map((notification) => { 284 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 285 + if (!document) return null; 286 + 287 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 288 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 289 + 290 + // Get post text from cache or fetched data 291 + const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 292 + const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined; 293 + const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null; 294 + 295 + return { 296 + id: notification.id, 297 + recipient: notification.recipient, 298 + created_at: notification.created_at, 299 + type: "bsky_post_embed" as const, 300 + document_uri: notification.data.document_uri, 301 + bsky_post_uri: notification.data.bsky_post_uri, 302 + document, 303 + documentCreatorHandle, 304 + bskyPostText, 305 normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 306 normalizedPublication: normalizePublicationRecord( 307 document.documents_in_publications[0]?.publications?.record,