a tool for shared writing and social publishing

wire up notifications

+153 -44
+2
actions/getIdentityData.ts
··· 17 17 identities( 18 18 *, 19 19 bsky_profiles(*), 20 + notifications(count), 20 21 publication_subscriptions(*), 21 22 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 22 23 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, ··· 33 34 ) 34 35 )`, 35 36 ) 37 + .eq("identities.notifications.read", false) 36 38 .eq("id", auth_token) 37 39 .eq("confirmed", true) 38 40 .single()
+14 -13
app/(home-pages)/notifications/CommentNotication.tsx
··· 3 3 AppBskyActorProfile, 4 4 PubLeafletComment, 5 5 PubLeafletDocument, 6 + PubLeafletPublication, 6 7 } from "lexicons/api"; 7 8 import { HydratedCommentNotification } from "src/notifications"; 8 9 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 20 21 let commentRecord = props.commentData.record as PubLeafletComment.Record; 21 22 let profileRecord = props.commentData.bsky_profiles 22 23 ?.record as AppBskyActorProfile.Record; 23 - const displayName = profileRecord.displayName || "Someone"; 24 + const displayName = 25 + profileRecord.displayName || 26 + props.commentData.bsky_profiles?.handle || 27 + "Someone"; 28 + const publication = props.commentData.documents?.documents_in_publications[0] 29 + ?.publications?.record as PubLeafletPublication.Record; 24 30 return ( 25 31 <Notification 26 32 icon={<CommentTiny />} 27 - actionText={ 28 - <> 29 - {displayName} commented on your post 30 - </> 31 - } 33 + actionText={<>{displayName} commented on your post</>} 32 34 content={ 33 - <ContentLayout postTitle={docRecord.title}> 35 + <ContentLayout postTitle={docRecord.title} publication={publication}> 34 36 <CommentInNotification 35 37 className="" 36 38 avatar={ ··· 40 42 props.commentData.bsky_profiles?.did || "", 41 43 ) 42 44 } 43 - displayName={ 44 - profileRecord?.displayName || 45 - props.commentData.bsky_profiles?.handle || 46 - "Someone" 47 - } 45 + displayName={displayName} 48 46 index={[]} 49 47 plaintext={commentRecord.plaintext} 50 48 facets={commentRecord.facets} ··· 61 59 icon={<CommentTiny />} 62 60 actionText={<>celine commented on your post</>} 63 61 content={ 64 - <ContentLayout postTitle="This is the Post Title"> 62 + <ContentLayout 63 + postTitle="This is the Post Title" 64 + publication={{ name: "My Publication" } as PubLeafletPublication.Record} 65 + > 65 66 <CommentInNotification 66 67 className="" 67 68 avatar={undefined}
+8 -2
app/(home-pages)/notifications/MentionNotification.tsx
··· 7 7 icon={<MentionTiny />} 8 8 actionText={<>celine mentioned your post</>} 9 9 content={ 10 - <ContentLayout postTitle={"Post Title Here"}> 10 + <ContentLayout 11 + postTitle={"Post Title Here"} 12 + publication={{ name: "My Publication" } as any} 13 + > 11 14 I'm just gonna put the description here. The surrounding context is 12 15 just sort of a pain to figure out 13 16 <div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary"> ··· 28 31 icon={<MentionTiny />} 29 32 actionText={<>celine mentioned you</>} 30 33 content={ 31 - <ContentLayout postTitle={"Post Title Here"}> 34 + <ContentLayout 35 + postTitle={"Post Title Here"} 36 + publication={{ name: "My Publication" } as any} 37 + > 32 38 <div> 33 39 ...llo this is the content of a post or whatever here it comes{" "} 34 40 <span className="text-accent-contrast">@celine </span> and here it
+7 -3
app/(home-pages)/notifications/Notification.tsx
··· 1 1 import { Avatar } from "components/Avatar"; 2 2 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 - import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { 4 + PubLeafletDocument, 5 + PubLeafletPublication, 6 + PubLeafletRichtextFacet, 7 + } from "lexicons/api"; 4 8 5 9 export const Notification = (props: { 6 10 icon: React.ReactNode; ··· 26 30 export const ContentLayout = (props: { 27 31 children: React.ReactNode; 28 32 postTitle: string; 33 + publication: PubLeafletPublication.Record; 29 34 }) => { 30 35 return ( 31 36 <div className="border border-border-light rounded-md px-2 py-[6px] w-full"> ··· 36 41 <hr className="mt-2 mb-1 border-border-light" /> 37 42 38 43 <div className="text-xs text-tertiary flex gap-[6px] items-center"> 39 - <div className="bg-test rounded-full w-[12px] h-[12px] " /> 40 - Pub Name Here 44 + {props.publication.name} 41 45 </div> 42 46 </div> 43 47 );
+36
app/(home-pages)/notifications/NotificationList.tsx
··· 1 + "use client"; 2 + 3 + import { HydratedNotification } from "src/notifications"; 4 + import { CommentNotification } from "./CommentNotication"; 5 + import { useEntity, useReplicache } from "src/replicache"; 6 + import { useEffect } from "react"; 7 + import { markAsRead } from "./getNotifications"; 8 + 9 + export function NotificationList({ 10 + notifications, 11 + }: { 12 + notifications: HydratedNotification[]; 13 + }) { 14 + useEffect(() => { 15 + setTimeout(() => { 16 + markAsRead(); 17 + }, 500); 18 + }, []); 19 + let { rootEntity } = useReplicache(); 20 + 21 + let showPageBackground = !useEntity(rootEntity, "theme/card-border-hidden") 22 + ?.data.value; 23 + 24 + return ( 25 + <div className="max-w-prose mx-auto w-full"> 26 + <div className="flex flex-col gap-6 pt-1"> 27 + {notifications.map((n) => { 28 + if (n.type === "comment") { 29 + n; 30 + return <CommentNotification key={n.id} {...n} />; 31 + } 32 + })} 33 + </div> 34 + </div> 35 + ); 36 + }
+4 -1
app/(home-pages)/notifications/ReplyNotification.tsx
··· 13 13 icon={<ReplyTiny />} 14 14 actionText={<>jared replied to your comment</>} 15 15 content={ 16 - <ContentLayout postTitle="This is the Post Title"> 16 + <ContentLayout 17 + postTitle="This is the Post Title" 18 + publication={{ name: "My Publication" } as any} 19 + > 17 20 <CommentInNotification 18 21 className="text-tertiary italic line-clamp-1!" 19 22 avatar={undefined}
+28
app/(home-pages)/notifications/getNotifications.ts
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { hydrateNotifications } from "src/notifications"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function getNotifications(limit?: number) { 7 + let identity = await getIdentityData(); 8 + if (!identity?.atp_did) return []; 9 + let query = supabaseServerClient 10 + .from("notifications") 11 + .select("*") 12 + .eq("recipient", identity.atp_did) 13 + .order("created_at", { ascending: false }); 14 + if (limit) query.limit(limit); 15 + let { data } = await query; 16 + let notifications = await hydrateNotifications(data || []); 17 + return notifications; 18 + } 19 + 20 + export async function markAsRead() { 21 + let identity = await getIdentityData(); 22 + if (!identity?.atp_did) return []; 23 + await supabaseServerClient 24 + .from("notifications") 25 + .update({ read: true }) 26 + .eq("recipient", identity.atp_did); 27 + return; 28 + }
+2 -12
app/(home-pages)/notifications/page.tsx
··· 4 4 import { hydrateNotifications } from "src/notifications"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 6 import { CommentNotification } from "./CommentNotication"; 7 + import { NotificationList } from "./NotificationList"; 7 8 8 9 export default async function Notifications() { 9 10 return ( ··· 31 32 .select("*") 32 33 .eq("recipient", identity.atp_did); 33 34 let notifications = await hydrateNotifications(data || []); 34 - return ( 35 - <div className="max-w-prose mx-auto w-full"> 36 - <div className="flex flex-col gap-6 pt-1"> 37 - {notifications.map((n) => { 38 - if (n.type === "comment") { 39 - n; 40 - return <CommentNotification key={n.id} {...n} />; 41 - } 42 - })} 43 - </div> 44 - </div> 45 - ); 35 + return <NotificationList notifications={notifications} />; 46 36 };
+43 -11
components/ActionBar/Navigation.tsx
··· 33 33 DummyPostMentionNotification, 34 34 DummyUserMentionNotification, 35 35 } from "app/(home-pages)/notifications/MentionNotification"; 36 + import useSWR from "swr"; 37 + import { 38 + getNotifications, 39 + markAsRead, 40 + } from "app/(home-pages)/notifications/getNotifications"; 41 + import { DotLoader } from "components/utils/DotLoader"; 36 42 37 43 export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 38 44 ··· 40 46 currentPage: navPages; 41 47 publication?: string; 42 48 }) => { 49 + let { identity } = useIdentityData(); 43 50 return ( 44 51 <div className="flex flex-col gap-2"> 45 - <Sidebar alwaysOpen> 46 - <NotificationButton /> 47 - </Sidebar> 52 + {identity?.atp_did && ( 53 + <Sidebar alwaysOpen> 54 + <NotificationButton /> 55 + </Sidebar> 56 + )} 48 57 <Sidebar alwaysOpen> 49 58 <NavigationOptions 50 59 currentPage={props.currentPage} ··· 81 90 isMobile 82 91 /> 83 92 </Popover> 84 - <Separator /> 85 - <NotificationButton /> 93 + {identity?.atp_did && ( 94 + <> 95 + <Separator /> 96 + <NotificationButton /> 97 + </> 98 + )} 86 99 </div> 87 100 ); 88 101 }; ··· 161 174 }; 162 175 163 176 export function NotificationButton(props: { current?: boolean }) { 164 - let unreads = true; 177 + let { identity, mutate } = useIdentityData(); 178 + let unreads = identity?.notifications[0]?.count; 165 179 let isMobile = useIsMobile(); 180 + let { data: notifications, isLoading } = useSWR("notifications", () => 181 + getNotifications(3), 182 + ); 166 183 // let identity = await getIdentityData(); 167 184 // if (!identity?.atp_did) return; 168 185 // let { data } = await supabaseServerClient ··· 173 190 174 191 return ( 175 192 <Popover 193 + onOpenChange={async (open) => { 194 + if (open) { 195 + console.log(open); 196 + await markAsRead(); 197 + mutate(); 198 + } 199 + }} 176 200 asChild 177 201 side={isMobile ? "top" : "right"} 178 202 align={isMobile ? "center" : "start"} ··· 194 218 } 195 219 > 196 220 <div className="flex flex-col gap-5 text-sm"> 197 - <DummyCommentNotification /> 198 - <DummyReplyNotification /> 199 - <DummyFollowNotification /> 200 - <DummyPostMentionNotification /> 201 - <DummyUserMentionNotification /> 221 + {isLoading ? ( 222 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 223 + <span>loading</span> 224 + <DotLoader /> 225 + </div> 226 + ) : ( 227 + notifications?.map((n) => { 228 + if (n.type === "comment") { 229 + n; 230 + return <CommentNotification key={n.id} {...n} />; 231 + } 232 + }) 233 + )} 202 234 </div> 203 235 <SpeedyLink 204 236 className="flex justify-end pt-2 text-sm"
+9 -2
src/notifications.ts
··· 13 13 | { type: "comment"; comment_uri: string } 14 14 | { type: "subscribe"; subscription_uri: string }; 15 15 16 - export async function hydrateNotifications(notifications: NotificationRow[]) { 16 + export type HydratedNotification = 17 + | HydratedCommentNotification 18 + | HydratedSubscribeNotification; 19 + export async function hydrateNotifications( 20 + notifications: NotificationRow[], 21 + ): Promise<Array<HydratedNotification>> { 17 22 // Call all hydrators in parallel 18 23 const [commentNotifications, subscribeNotifications] = await Promise.all([ 19 24 hydrateCommentNotifications(notifications), ··· 56 61 const commentUris = commentNotifications.map((n) => n.data.comment_uri); 57 62 const { data: comments } = await supabaseServerClient 58 63 .from("comments_on_documents") 59 - .select("*,bsky_profiles(*), documents(*)") 64 + .select( 65 + "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 66 + ) 60 67 .in("uri", commentUris); 61 68 62 69 return commentNotifications.map((notification) => ({