a tool for shared writing and social publishing

implement mention notifications

+275 -27
+123
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, ··· 208 210 .delete() 209 211 .in("id", entitiesToDelete); 210 212 } 213 + } 214 + 215 + // Create notifications for mentions (only on first publish) 216 + if (!existingDocUri) { 217 + await createMentionNotifications(result.uri, record, credentialSession.did!); 211 218 } 212 219 213 220 return { rkey, record: JSON.parse(JSON.stringify(record)) }; ··· 735 742 736 743 return undefined; 737 744 } 745 + 746 + /** 747 + * Extract mentions from a published document and create notifications 748 + */ 749 + async function createMentionNotifications( 750 + documentUri: string, 751 + record: PubLeafletDocument.Record, 752 + authorDid: string, 753 + ) { 754 + const mentionedDids = new Set<string>(); 755 + const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 756 + const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 757 + 758 + // Extract mentions from all text blocks in all pages 759 + for (const page of record.pages) { 760 + if (page.$type === "pub.leaflet.pages.linearDocument") { 761 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 762 + for (const blockWrapper of linearPage.blocks) { 763 + const block = blockWrapper.block; 764 + if (block.$type === "pub.leaflet.blocks.text") { 765 + const textBlock = block as PubLeafletBlocksText.Main; 766 + if (textBlock.facets) { 767 + for (const facet of textBlock.facets) { 768 + for (const feature of facet.features) { 769 + // Check for DID mentions 770 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 771 + if (feature.did !== authorDid) { 772 + mentionedDids.add(feature.did); 773 + } 774 + } 775 + // Check for AT URI mentions (publications and documents) 776 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 777 + const uri = new AtUri(feature.atURI); 778 + 779 + if (uri.collection === "pub.leaflet.publication") { 780 + // Get the publication owner's DID 781 + const { data: publication } = await supabaseServerClient 782 + .from("publications") 783 + .select("identity_did") 784 + .eq("uri", feature.atURI) 785 + .single(); 786 + 787 + if (publication && publication.identity_did !== authorDid) { 788 + mentionedPublications.set(publication.identity_did, feature.atURI); 789 + } 790 + } else if (uri.collection === "pub.leaflet.document") { 791 + // Get the document owner's DID 792 + const { data: document } = await supabaseServerClient 793 + .from("documents") 794 + .select("uri, data") 795 + .eq("uri", feature.atURI) 796 + .single(); 797 + 798 + if (document) { 799 + const docRecord = document.data as PubLeafletDocument.Record; 800 + if (docRecord.author !== authorDid) { 801 + mentionedDocuments.set(docRecord.author, feature.atURI); 802 + } 803 + } 804 + } 805 + } 806 + } 807 + } 808 + } 809 + } 810 + } 811 + } 812 + } 813 + 814 + // Create notifications for DID mentions 815 + for (const did of mentionedDids) { 816 + const notification: Notification = { 817 + id: v7(), 818 + recipient: did, 819 + data: { 820 + type: "mention", 821 + document_uri: documentUri, 822 + mention_type: "did", 823 + }, 824 + }; 825 + await supabaseServerClient.from("notifications").insert(notification); 826 + await pingIdentityToUpdateNotification(did); 827 + } 828 + 829 + // Create notifications for publication mentions 830 + for (const [recipientDid, publicationUri] of mentionedPublications) { 831 + const notification: Notification = { 832 + id: v7(), 833 + recipient: recipientDid, 834 + data: { 835 + type: "mention", 836 + document_uri: documentUri, 837 + mention_type: "publication", 838 + mentioned_uri: publicationUri, 839 + }, 840 + }; 841 + await supabaseServerClient.from("notifications").insert(notification); 842 + await pingIdentityToUpdateNotification(recipientDid); 843 + } 844 + 845 + // Create notifications for document mentions 846 + for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { 847 + const notification: Notification = { 848 + id: v7(), 849 + recipient: recipientDid, 850 + data: { 851 + type: "mention", 852 + document_uri: documentUri, 853 + mention_type: "document", 854 + mentioned_uri: mentionedDocUri, 855 + }, 856 + }; 857 + await supabaseServerClient.from("notifications").insert(notification); 858 + await pingIdentityToUpdateNotification(recipientDid); 859 + } 860 + }
+25 -22
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 5 import { AtUri } from "@atproto/api"; 6 - import { Avatar } from "components/Avatar"; 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 + 22 + if (props.mention_type === "did") { 23 + actionText = <>mentioned you</>; 24 + } else if (props.mention_type === "publication" && props.mentionedPublication) { 25 + const mentionedPubRecord = props.mentionedPublication.record as PubLeafletPublication.Record; 26 + mentionedItemName = mentionedPubRecord.name; 27 + actionText = <>mentioned your publication <span className="italic">{mentionedItemName}</span></>; 28 + } else if (props.mention_type === "document" && props.mentionedDocument) { 29 + const mentionedDocRecord = props.mentionedDocument.data as PubLeafletDocument.Record; 30 + mentionedItemName = mentionedDocRecord.title; 31 + actionText = <>mentioned your post <span className="italic">{mentionedItemName}</span></>; 32 + } else { 33 + actionText = <>mentioned you</>; 34 + } 35 + 24 36 return ( 25 37 <Notification 26 38 timestamp={props.created_at} 27 39 href={href} 28 - icon={<QuoteTiny />} 29 - actionText={<>{displayName} quoted your post</>} 40 + icon={<MentionTiny />} 41 + actionText={actionText} 30 42 content={ 31 43 <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> 44 + <div className="text-sm text-secondary"> 45 + in <span className="italic">{docRecord.title}</span> 43 46 </div> 44 47 </ContentLayout> 45 48 }
+5 -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"; 11 12 12 13 export function NotificationList({ 13 14 notifications, ··· 45 46 } 46 47 if (n.type === "quote") { 47 48 return <QuoteNotification key={n.id} {...n} />; 49 + } 50 + if (n.type === "mention") { 51 + return <MentionNotification key={n.id} {...n} />; 48 52 } 49 53 })} 50 54 </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 + };
+74 -4
src/notifications.ts
··· 12 12 export type NotificationData = 13 13 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 14 | { type: "subscribe"; subscription_uri: string } 15 - | { type: "quote"; bsky_post_uri: string; document_uri: string }; 15 + | { type: "quote"; bsky_post_uri: string; document_uri: string } 16 + | { type: "mention"; document_uri: string; mention_type: "did" } 17 + | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 18 + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }; 16 19 17 20 export type HydratedNotification = 18 21 | HydratedCommentNotification 19 22 | HydratedSubscribeNotification 20 - | HydratedQuoteNotification; 23 + | HydratedQuoteNotification 24 + | HydratedMentionNotification; 21 25 export async function hydrateNotifications( 22 26 notifications: NotificationRow[], 23 27 ): Promise<Array<HydratedNotification>> { 24 28 // Call all hydrators in parallel 25 - const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([ 29 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications] = await Promise.all([ 26 30 hydrateCommentNotifications(notifications), 27 31 hydrateSubscribeNotifications(notifications), 28 32 hydrateQuoteNotifications(notifications), 33 + hydrateMentionNotifications(notifications), 29 34 ]); 30 35 31 36 // Combine all hydrated notifications 32 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications]; 37 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications]; 33 38 34 39 // Sort by created_at to maintain order 35 40 allHydrated.sort( ··· 163 168 bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!, 164 169 document: documents?.find((d) => d.uri === notification.data.document_uri)!, 165 170 })); 171 + } 172 + 173 + export type HydratedMentionNotification = Awaited< 174 + ReturnType<typeof hydrateMentionNotifications> 175 + >[0]; 176 + 177 + async function hydrateMentionNotifications(notifications: NotificationRow[]) { 178 + const mentionNotifications = notifications.filter( 179 + (n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } => 180 + (n.data as NotificationData)?.type === "mention", 181 + ); 182 + 183 + if (mentionNotifications.length === 0) { 184 + return []; 185 + } 186 + 187 + // Fetch document data from the database 188 + const documentUris = mentionNotifications.map((n) => n.data.document_uri); 189 + const { data: documents } = await supabaseServerClient 190 + .from("documents") 191 + .select("*, documents_in_publications(publications(*))") 192 + .in("uri", documentUris); 193 + 194 + // Fetch mentioned publications and documents 195 + const mentionedPublicationUris = mentionNotifications 196 + .filter((n) => n.data.mention_type === "publication") 197 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri); 198 + 199 + const mentionedDocumentUris = mentionNotifications 200 + .filter((n) => n.data.mention_type === "document") 201 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri); 202 + 203 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 204 + mentionedPublicationUris.length > 0 205 + ? supabaseServerClient 206 + .from("publications") 207 + .select("*") 208 + .in("uri", mentionedPublicationUris) 209 + : Promise.resolve({ data: [] }), 210 + mentionedDocumentUris.length > 0 211 + ? supabaseServerClient 212 + .from("documents") 213 + .select("*, documents_in_publications(publications(*))") 214 + .in("uri", mentionedDocumentUris) 215 + : Promise.resolve({ data: [] }), 216 + ]); 217 + 218 + return mentionNotifications.map((notification) => { 219 + const mentionedUri = notification.data.mention_type !== "did" 220 + ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 221 + : undefined; 222 + 223 + return { 224 + id: notification.id, 225 + recipient: notification.recipient, 226 + created_at: notification.created_at, 227 + type: "mention" as const, 228 + document_uri: notification.data.document_uri, 229 + mention_type: notification.data.mention_type, 230 + mentioned_uri: mentionedUri, 231 + document: documents?.find((d) => d.uri === notification.data.document_uri)!, 232 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 233 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 234 + }; 235 + }); 166 236 } 167 237 168 238 export async function pingIdentityToUpdateNotification(did: string) {