a tool for shared writing and social publishing

add basic notification page

+159 -76
+87 -1
app/(home-pages)/notifications/page.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 4 + import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 5 + import { redirect } from "next/navigation"; 6 + import { 7 + HydratedCommentNotification, 8 + hydrateNotifications, 9 + } from "src/notifications"; 10 + import { supabaseServerClient } from "supabase/serverClient"; 11 + 1 12 export default async function Notifications() { 2 - return <div>Notifications</div>; 13 + let identity = await getIdentityData(); 14 + if (!identity?.atp_did) return redirect("/home"); 15 + let { data, error } = await supabaseServerClient 16 + .from("notifications") 17 + .select("*") 18 + .eq("recipient", identity.atp_did); 19 + let notifications = await hydrateNotifications(data || []); 20 + return ( 21 + <DashboardLayout 22 + id="discover" 23 + cardBorderHidden={false} 24 + currentPage="notifications" 25 + defaultTab="default" 26 + actions={null} 27 + tabs={{ 28 + default: { 29 + controls: null, 30 + content: ( 31 + <div> 32 + <h2>Notifications</h2> 33 + {notifications.map((n) => { 34 + if (n.type === "comment") { 35 + n; 36 + return <CommentNotification key={n.id} {...n} />; 37 + } 38 + })} 39 + </div> 40 + ), 41 + }, 42 + }} 43 + /> 44 + ); 3 45 } 46 + 47 + const CommentNotification = (props: HydratedCommentNotification) => { 48 + let docRecord = props.commentData.documents 49 + ?.data as PubLeafletDocument.Record; 50 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 51 + return ( 52 + <Notification 53 + identity={props.commentData.bsky_profiles?.handle || "Someone"} 54 + action="commented on your post" 55 + content={ 56 + <div> 57 + <h4>{docRecord.title}</h4> 58 + <div className="border"> 59 + <pre 60 + style={{ wordBreak: "break-word" }} 61 + className="whitespace-pre-wrap text-secondary pb-[4px] " 62 + > 63 + <BaseTextBlock 64 + index={[]} 65 + plaintext={commentRecord.plaintext} 66 + facets={commentRecord.facets} 67 + /> 68 + </pre> 69 + </div> 70 + </div> 71 + } 72 + /> 73 + ); 74 + }; 75 + 76 + const Notification = (props: { 77 + identity: string; 78 + action: string; 79 + content: React.ReactNode; 80 + }) => { 81 + return ( 82 + <div className="flex flex-col gap-2 border"> 83 + <div> 84 + {props.identity} {props.action} 85 + </div> 86 + {props.content} 87 + </div> 88 + ); 89 + };
+22 -15
components/ActionBar/Navigation.tsx
··· 11 11 ReaderReadSmall, 12 12 ReaderUnreadSmall, 13 13 } from "components/Icons/ReaderSmall"; 14 + import { NotificationsUnreadSmall } from "components/Icons/NotificationSmall"; 15 + import { SpeedyLink } from "components/SpeedyLink"; 14 16 15 - export type navPages = "home" | "reader" | "pub" | "discover"; 17 + export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 16 18 17 19 export const DesktopNavigation = (props: { 18 20 currentPage: navPages; ··· 27 29 /> 28 30 </Sidebar> 29 31 {/*<Sidebar alwaysOpen> 30 - <ActionButton 31 - icon={ 32 - unreadNotifications ? ( 33 - <NotificationsUnreadSmall /> 34 - ) : ( 35 - <NotificationsReadSmall /> 36 - ) 37 - } 38 - label="Notifications" 39 - /> 40 32 </Sidebar>*/} 41 33 </div> 42 34 ); ··· 97 89 /> 98 90 <DiscoverButton current={props.currentPage === "discover"} /> 99 91 92 + {identity && ( 93 + <SpeedyLink href={"/notifications"}> 94 + <ActionButton 95 + nav 96 + icon={<NotificationsUnreadSmall />} 97 + label="Notifications" 98 + className={ 99 + props.currentPage === "notifications" 100 + ? "bg-bg-page! border-border-light!" 101 + : "" 102 + } 103 + /> 104 + </SpeedyLink> 105 + )} 106 + 100 107 <hr className="border-border-light my-1" /> 101 108 <PublicationButtons currentPubUri={thisPublication?.uri} /> 102 109 </> ··· 105 112 106 113 const HomeButton = (props: { current?: boolean }) => { 107 114 return ( 108 - <Link href={"/home"} className="hover:!no-underline"> 115 + <SpeedyLink href={"/home"} className="hover:!no-underline"> 109 116 <ActionButton 110 117 nav 111 118 icon={<HomeSmall />} 112 119 label="Home" 113 120 className={props.current ? "bg-bg-page! border-border-light!" : ""} 114 121 /> 115 - </Link> 122 + </SpeedyLink> 116 123 ); 117 124 }; 118 125 ··· 121 128 122 129 if (!props.subs) return; 123 130 return ( 124 - <Link href={"/reader"} className="hover:no-underline!"> 131 + <SpeedyLink href={"/reader"} className="hover:no-underline!"> 125 132 <ActionButton 126 133 nav 127 134 icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} ··· 131 138 ${props.current && "border-accent-contrast!"} 132 139 `} 133 140 /> 134 - </Link> 141 + </SpeedyLink> 135 142 ); 136 143 }; 137 144
+17
components/Icons/NotificationSmall.tsx
··· 26 26 viewBox="0 0 24 24" 27 27 fill="none" 28 28 xmlns="http://www.w3.org/2000/svg" 29 + > 30 + <path 31 + d="M12.3779 0.890636C13.5297 0.868361 14.2312 1.35069 14.6104 1.8047C15.1942 2.50387 15.2636 3.34086 15.2129 3.95314C17.7074 4.96061 18.8531 7.45818 19.375 10.3975C19.5903 11.1929 20.0262 11.5635 20.585 11.9336C21.1502 12.3079 22.0847 12.7839 22.5879 13.7998C23.4577 15.556 22.8886 17.8555 20.9297 19.083C20.1439 19.5754 19.2029 20.1471 17.8496 20.5869C17.1962 20.7993 16.454 20.9768 15.5928 21.1055C15.2068 22.4811 13.9287 23.4821 12.4238 23.4824C10.9225 23.4824 9.64464 22.4867 9.25489 21.1162C8.37384 20.9871 7.61998 20.8046 6.95899 20.5869C5.62158 20.1464 4.69688 19.5723 3.91602 19.083C1.95717 17.8555 1.38802 15.556 2.25782 13.7998C2.76329 12.7794 3.60199 12.3493 4.18653 12.0068C4.7551 11.6737 5.1753 11.386 5.45606 10.7432C5.62517 9.31217 5.93987 8.01645 6.4668 6.92482C7.1312 5.54855 8.13407 4.49633 9.56251 3.92482C9.53157 3.34709 9.6391 2.63284 10.1133 1.98927C10.1972 1.87543 10.4043 1.594 10.7822 1.34669C11.1653 1.09611 11.6872 0.904101 12.3779 0.890636ZM14.1709 21.2608C13.6203 21.3007 13.0279 21.3242 12.3887 21.3242C11.7757 21.3242 11.2072 21.3024 10.6777 21.2656C11.0335 21.8421 11.6776 22.2324 12.4238 22.2324C13.1718 22.2321 13.816 21.8396 14.1709 21.2608ZM12.4004 2.38966C11.9872 2.39776 11.7419 2.50852 11.5996 2.60157C11.4528 2.6977 11.3746 2.801 11.3193 2.87599C11.088 3.19 11.031 3.56921 11.0664 3.92677C11.084 4.10311 11.1233 4.258 11.1631 4.37013C11.1875 4.43883 11.205 4.47361 11.21 4.48341C11.452 4.78119 11.4299 5.22068 11.1484 5.49415C10.8507 5.78325 10.3748 5.77716 10.0869 5.48048C10.0533 5.44582 10.0231 5.40711 9.99415 5.3672C9.0215 5.79157 8.31886 6.53162 7.81641 7.57228C7.21929 8.80941 6.91013 10.4656 6.82129 12.4746L6.81934 12.5137L6.81446 12.5518C6.73876 13.0607 6.67109 13.5103 6.53418 13.9121C6.38567 14.3476 6.16406 14.7061 5.82032 15.0899C5.54351 15.3988 5.06973 15.4268 4.76172 15.1514C4.45392 14.8758 4.42871 14.4019 4.70508 14.0928C4.93763 13.8332 5.04272 13.6453 5.11524 13.4326C5.14365 13.3492 5.16552 13.2588 5.18848 13.1553C5.10586 13.2062 5.02441 13.2544 4.94532 13.3008C4.28651 13.6868 3.87545 13.9129 3.60157 14.4658C3.08548 15.5082 3.38433 16.9793 4.71192 17.8115C5.4776 18.2913 6.27423 18.7818 7.42872 19.1621C8.58507 19.543 10.1358 19.8242 12.3887 19.8242C14.6416 19.8242 16.2108 19.5429 17.3857 19.1611C18.5582 18.7801 19.3721 18.2882 20.1328 17.8115C21.4611 16.9793 21.7595 15.5084 21.2432 14.4658C20.9668 13.9081 20.515 13.6867 19.7568 13.1846C19.7553 13.1835 19.7535 13.1827 19.752 13.1817C19.799 13.3591 19.8588 13.5202 19.9287 13.6514C20.021 13.8244 20.1034 13.8927 20.1533 13.917C20.5249 14.0981 20.6783 14.5465 20.4961 14.919C20.3135 15.2913 19.8639 15.4467 19.4922 15.2656C19.0607 15.0553 18.7821 14.6963 18.6035 14.3613C18.4238 14.0242 18.3154 13.6559 18.2471 13.3379C18.1778 13.0155 18.1437 12.7147 18.127 12.4971C18.1185 12.3873 18.1145 12.2956 18.1123 12.2305C18.1115 12.2065 18.1107 12.1856 18.1104 12.169C18.0569 11.6585 17.9885 11.1724 17.9082 10.7109C17.9002 10.6794 17.8913 10.6476 17.8838 10.6152L17.8906 10.6133C17.4166 7.97573 16.4732 6.17239 14.791 5.40821C14.5832 5.64607 14.2423 5.73912 13.9365 5.61036C13.5557 5.44988 13.3777 5.01056 13.5391 4.62892C13.5394 4.62821 13.5397 4.62699 13.54 4.62599C13.5425 4.61977 13.5479 4.6087 13.5537 4.59278C13.5658 4.55999 13.5837 4.50758 13.6035 4.44142C13.6438 4.30713 13.6903 4.12034 13.7139 3.91212C13.7631 3.47644 13.7038 3.06402 13.457 2.76857C13.3434 2.63264 13.0616 2.37678 12.4004 2.38966ZM10.1055 16.625C11.6872 16.8411 12.8931 16.8585 13.8174 16.7539C14.2287 16.7076 14.5997 17.0028 14.6465 17.4141C14.693 17.8256 14.3969 18.1976 13.9854 18.2442C12.9038 18.3665 11.5684 18.3389 9.90235 18.1113C9.49223 18.0551 9.20488 17.6768 9.26075 17.2666C9.3168 16.8563 9.6952 16.5691 10.1055 16.625ZM16.3887 16.3047C16.7403 16.086 17.203 16.1935 17.4219 16.5449C17.6406 16.8967 17.5324 17.3594 17.1807 17.5781C16.9689 17.7097 16.6577 17.8424 16.4033 17.9131C16.0045 18.0237 15.5914 17.7904 15.4805 17.3916C15.3696 16.9926 15.6031 16.5788 16.002 16.4678C16.1344 16.431 16.3112 16.3527 16.3887 16.3047Z" 32 + fill="currentColor" 33 + /> 34 + </svg> 35 + ); 36 + }; 37 + 38 + export const ReaderUnread = (props: Props) => { 39 + return ( 40 + <svg 41 + width="24" 42 + height="24" 43 + viewBox="0 0 24 24" 44 + fill="none" 45 + xmlns="http://www.w3.org/2000/svg" 29 46 {...props} 30 47 > 31 48 <path
+33 -60
src/notifications.ts
··· 8 8 export type Notification = Omit<TablesInsert<"notifications">, "data"> & { 9 9 data: NotificationData; 10 10 }; 11 - // Notification data types (for writing to the notifications table) 11 + 12 12 export type NotificationData = 13 13 | { type: "comment"; comment_uri: string } 14 14 | { type: "subscribe"; subscription_uri: string }; 15 15 16 - // Hydrated notification types 17 - export type HydratedCommentNotification = { 18 - id: string; 19 - recipient: string; 20 - created_at: string; 21 - type: "comment"; 22 - comment_uri: string; 23 - commentData?: Tables<"comments_on_documents">; 24 - }; 16 + export async function hydrateNotifications(notifications: NotificationRow[]) { 17 + // Call all hydrators in parallel 18 + const [commentNotifications, subscribeNotifications] = await Promise.all([ 19 + hydrateCommentNotifications(notifications), 20 + hydrateSubscribeNotifications(notifications), 21 + ]); 25 22 26 - export type HydratedSubscribeNotification = { 27 - id: string; 28 - recipient: string; 29 - created_at: string; 30 - type: "subscribe"; 31 - subscription_uri: string; 32 - subscriptionData?: Tables<"publication_subscriptions">; 33 - }; 23 + // Combine all hydrated notifications 24 + const allHydrated = [...commentNotifications, ...subscribeNotifications]; 25 + 26 + // Sort by created_at to maintain order 27 + allHydrated.sort( 28 + (a, b) => 29 + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), 30 + ); 34 31 35 - export type HydratedNotification = 36 - | HydratedCommentNotification 37 - | HydratedSubscribeNotification; 32 + return allHydrated; 33 + } 38 34 39 35 // Type guard to extract notification type 40 36 type ExtractNotificationType<T extends NotificationData["type"]> = Extract< ··· 42 38 { type: T } 43 39 >; 44 40 45 - // Hydrator function type 46 - type NotificationHydrator<T extends NotificationData["type"]> = ( 47 - notifications: NotificationRow[], 48 - ) => Promise<Array<HydratedNotification & { type: T }>>; 41 + export type HydratedCommentNotification = Awaited< 42 + ReturnType<typeof hydrateCommentNotifications> 43 + >[0]; 49 44 50 - /** 51 - * Hydrates comment notifications 52 - */ 53 - async function hydrateCommentNotifications( 54 - notifications: NotificationRow[], 55 - ): Promise<HydratedCommentNotification[]> { 45 + async function hydrateCommentNotifications(notifications: NotificationRow[]) { 56 46 const commentNotifications = notifications.filter( 57 47 (n): n is NotificationRow & { data: ExtractNotificationType<"comment"> } => 58 48 (n.data as NotificationData)?.type === "comment", ··· 66 56 const commentUris = commentNotifications.map((n) => n.data.comment_uri); 67 57 const { data: comments } = await supabaseServerClient 68 58 .from("comments_on_documents") 69 - .select("*") 59 + .select("*,bsky_profiles(*), documents(*)") 70 60 .in("uri", commentUris); 71 61 72 62 return commentNotifications.map((notification) => ({ ··· 75 65 created_at: notification.created_at, 76 66 type: "comment" as const, 77 67 comment_uri: notification.data.comment_uri, 78 - commentData: comments?.find((c) => c.uri === notification.data.comment_uri), 68 + commentData: comments?.find( 69 + (c) => c.uri === notification.data.comment_uri, 70 + )!, 79 71 })); 80 72 } 81 73 82 - /** 83 - * Hydrates subscribe notifications 84 - */ 74 + export type HydratedSubscribeNotification = { 75 + id: string; 76 + recipient: string; 77 + created_at: string; 78 + type: "subscribe"; 79 + subscription_uri: string; 80 + subscriptionData?: Tables<"publication_subscriptions">; 81 + }; 85 82 async function hydrateSubscribeNotifications( 86 83 notifications: NotificationRow[], 87 84 ): Promise<HydratedSubscribeNotification[]> { ··· 116 113 ), 117 114 })); 118 115 } 119 - 120 - /** 121 - * Main hydration function that processes all notifications 122 - */ 123 - export async function hydrateNotifications( 124 - notifications: NotificationRow[], 125 - ): Promise<HydratedNotification[]> { 126 - // Call all hydrators in parallel 127 - const [commentNotifications, subscribeNotifications] = await Promise.all([ 128 - hydrateCommentNotifications(notifications), 129 - hydrateSubscribeNotifications(notifications), 130 - ]); 131 - 132 - // Combine all hydrated notifications 133 - const allHydrated = [...commentNotifications, ...subscribeNotifications]; 134 - 135 - // Sort by created_at to maintain order 136 - allHydrated.sort( 137 - (a, b) => 138 - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), 139 - ); 140 - 141 - return allHydrated; 142 - }