a tool for shared writing and social publishing

add notifications for bsky post mentions

+105 -48
+28 -39
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 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"; 3 7 4 - export const DummyPostMentionNotification = (props: {}) => { 5 - return ( 6 - <Notification 7 - timestamp={""} 8 - href="/" 9 - icon={<MentionTiny />} 10 - actionText={<>celine mentioned your post</>} 11 - content={ 12 - <ContentLayout 13 - postTitle={"Post Title Here"} 14 - pubRecord={{ name: "My Publication" } as any} 15 - > 16 - I'm just gonna put the description here. The surrounding context is 17 - just sort of a pain to figure out 18 - <div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary"> 19 - <div className="font-bold">Title of the Mentioned Post</div> 20 - <div className="text-tertiary"> 21 - And here is the description that follows it 22 - </div> 23 - </div> 24 - </ContentLayout> 25 - } 26 - /> 27 - ); 28 - }; 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; 15 + const rkey = new AtUri(props.document.uri).rkey; 16 + const postText = postView.record?.text || ""; 29 17 30 - export const DummyUserMentionNotification = (props: { 31 - cardBorderHidden: boolean; 32 - }) => { 33 18 return ( 34 19 <Notification 35 - timestamp={""} 36 - href="/" 20 + timestamp={props.created_at} 21 + href={`https://${pubRecord.base_path}/${rkey}`} 37 22 icon={<MentionTiny />} 38 - actionText={<>celine mentioned you</>} 23 + actionText={<>{displayName} quoted your post</>} 39 24 content={ 40 - <ContentLayout 41 - postTitle={"Post Title Here"} 42 - pubRecord={{ name: "My Publication" } as any} 43 - > 44 - <div> 45 - ...llo this is the content of a post or whatever here it comes{" "} 46 - <span className="text-accent-contrast">@celine </span> and here it 47 - was! ooooh heck yeah the high is unre... 25 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 26 + <div className="flex gap-2 text-sm w-full"> 27 + <Avatar 28 + src={author.avatar} 29 + displayName={displayName} 30 + /> 31 + <pre 32 + style={{ wordBreak: "break-word" }} 33 + className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 34 + > 35 + {postText} 36 + </pre> 48 37 </div> 49 38 </ContentLayout> 50 39 }
+4
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 11 11 12 export function NotificationList({ 12 13 notifications, ··· 41 42 } 42 43 if (n.type === "subscribe") { 43 44 return <FollowNotification key={n.id} {...n} />; 45 + } 46 + if (n.type === "quote") { 47 + return <QuoteNotification key={n.id} {...n} />; 44 48 } 45 49 })} 46 50 </div>
+26 -5
app/api/inngest/functions/index_post_mention.ts
··· 3 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 4 import { Json } from "supabase/database.types"; 5 5 import { ids } from "lexicons/api/lexicons"; 6 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 7 + import { v7 } from "uuid"; 6 8 7 9 export const index_post_mention = inngest.createFunction( 8 10 { id: "index_post_mention" }, ··· 37 39 return { message: `No post found for ${event.data.post_uri}` }; 38 40 } 39 41 42 + const documentUri = AtUri.make( 43 + pub.identity_did, 44 + ids.PubLeafletDocument, 45 + path[0], 46 + ).toString(); 47 + 40 48 await step.run("index-bsky-post", async () => { 41 49 await supabaseServerClient.from("bsky_posts").insert({ 42 50 uri: bsky_post.uri, ··· 45 53 }); 46 54 await supabaseServerClient.from("document_mentions_in_bsky").insert({ 47 55 uri: bsky_post.uri, 48 - document: AtUri.make( 49 - pub.identity_did, 50 - ids.PubLeafletDocument, 51 - path[0], 52 - ).toString(), 56 + document: documentUri, 53 57 link: event.data.document_link, 54 58 }); 59 + }); 60 + 61 + await step.run("create-notification", async () => { 62 + // Only create notification if the quote is from someone other than the author 63 + if (bsky_post.author.did !== pub.identity_did) { 64 + const notification: Notification = { 65 + id: v7(), 66 + recipient: pub.identity_did, 67 + data: { 68 + type: "quote", 69 + bsky_post_uri: bsky_post.uri, 70 + document_uri: documentUri, 71 + }, 72 + }; 73 + await supabaseServerClient.from("notifications").insert(notification); 74 + await pingIdentityToUpdateNotification(pub.identity_did); 75 + } 55 76 }); 56 77 }, 57 78 );
+47 -4
src/notifications.ts
··· 11 11 12 12 export type NotificationData = 13 13 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 - | { type: "subscribe"; subscription_uri: string }; 14 + | { type: "subscribe"; subscription_uri: string } 15 + | { type: "quote"; bsky_post_uri: string; document_uri: string }; 15 16 16 17 export type HydratedNotification = 17 18 | HydratedCommentNotification 18 - | HydratedSubscribeNotification; 19 + | HydratedSubscribeNotification 20 + | HydratedQuoteNotification; 19 21 export async function hydrateNotifications( 20 22 notifications: NotificationRow[], 21 23 ): Promise<Array<HydratedNotification>> { 22 24 // Call all hydrators in parallel 23 - const [commentNotifications, subscribeNotifications] = await Promise.all([ 25 + const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([ 24 26 hydrateCommentNotifications(notifications), 25 27 hydrateSubscribeNotifications(notifications), 28 + hydrateQuoteNotifications(notifications), 26 29 ]); 27 30 28 31 // Combine all hydrated notifications 29 - const allHydrated = [...commentNotifications, ...subscribeNotifications]; 32 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications]; 30 33 31 34 // Sort by created_at to maintain order 32 35 allHydrated.sort( ··· 119 122 subscriptionData: subscriptions?.find( 120 123 (s) => s.uri === notification.data.subscription_uri, 121 124 )!, 125 + })); 126 + } 127 + 128 + export type HydratedQuoteNotification = Awaited< 129 + ReturnType<typeof hydrateQuoteNotifications> 130 + >[0]; 131 + 132 + async function hydrateQuoteNotifications(notifications: NotificationRow[]) { 133 + const quoteNotifications = notifications.filter( 134 + (n): n is NotificationRow & { data: ExtractNotificationType<"quote"> } => 135 + (n.data as NotificationData)?.type === "quote", 136 + ); 137 + 138 + if (quoteNotifications.length === 0) { 139 + return []; 140 + } 141 + 142 + // Fetch bsky post data and document data 143 + const bskyPostUris = quoteNotifications.map((n) => n.data.bsky_post_uri); 144 + const documentUris = quoteNotifications.map((n) => n.data.document_uri); 145 + 146 + const { data: bskyPosts } = await supabaseServerClient 147 + .from("bsky_posts") 148 + .select("*") 149 + .in("uri", bskyPostUris); 150 + 151 + const { data: documents } = await supabaseServerClient 152 + .from("documents") 153 + .select("*, documents_in_publications(publications(*))") 154 + .in("uri", documentUris); 155 + 156 + return quoteNotifications.map((notification) => ({ 157 + id: notification.id, 158 + recipient: notification.recipient, 159 + created_at: notification.created_at, 160 + type: "quote" as const, 161 + bsky_post_uri: notification.data.bsky_post_uri, 162 + document_uri: notification.data.document_uri, 163 + bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!, 164 + document: documents?.find((d) => d.uri === notification.data.document_uri)!, 122 165 })); 123 166 } 124 167