From 841029337f9b7333e797af9cb6392e6c6be7a990 Mon Sep 17 00:00:00 2001 From: Tom Sherman Date: Sun, 3 Nov 2024 18:57:05 +0000 Subject: [PATCH] Notifications --- .../notification-indicator-client.tsx | 34 +++++ .../notification-indicator-shared.ts | 1 + .../_components/notification-indicator.tsx | 40 +++++ .../app/(app)/_components/post-list.tsx | 51 ------- .../app/(app)/_components/theme-toggle.tsx | 63 ++++---- packages/frontpage/app/(app)/actions.tsx | 37 ----- packages/frontpage/app/(app)/layout.tsx | 102 +++++++------ .../moderation/_components/report-card.tsx | 12 +- .../app/(app)/notifications/_lib/actions.ts | 11 ++ .../app/(app)/notifications/_lib/constants.ts | 1 + .../_lib/mark-as-read-button.tsx | 54 +++++++ .../notifications/_lib/notification-link.tsx | 34 +++++ .../(app)/notifications/_lib/read-state.tsx | 16 ++ .../app/(app)/notifications/page.tsx | 134 +++++++++++++++++ packages/frontpage/app/(app)/page.tsx | 59 +++++--- .../[postAuthor]/[postRkey]/_lib/comment.tsx | 24 ++- .../app/api/notification-count/route.ts | 7 + .../app/api/notification-read/route.ts | 19 +++ .../frontpage/app/api/receive_hook/route.ts | 15 +- packages/frontpage/drizzle.config.ts | 1 + .../frontpage/lib/data/atproto/comment.ts | 2 +- packages/frontpage/lib/data/atproto/record.ts | 56 +------ packages/frontpage/lib/data/atproto/uri.ts | 60 ++++++++ packages/frontpage/lib/data/atproto/vote.ts | 7 +- packages/frontpage/lib/data/db/comment.ts | 15 +- .../frontpage/lib/data/db/notification.ts | 140 ++++++++++++++++++ packages/frontpage/lib/data/db/vote.ts | 9 +- packages/frontpage/lib/infinite-list.tsx | 113 ++++++++++++++ packages/frontpage/lib/navigation.ts | 20 +++ packages/frontpage/lib/utils.ts | 13 ++ packages/frontpage/package.json | 1 + pnpm-lock.yaml | 3 + 32 files changed, 894 insertions(+), 260 deletions(-) create mode 100644 packages/frontpage/app/(app)/_components/notification-indicator-client.tsx create mode 100644 packages/frontpage/app/(app)/_components/notification-indicator-shared.ts create mode 100644 packages/frontpage/app/(app)/_components/notification-indicator.tsx delete mode 100644 packages/frontpage/app/(app)/_components/post-list.tsx delete mode 100644 packages/frontpage/app/(app)/actions.tsx create mode 100644 packages/frontpage/app/(app)/notifications/_lib/actions.ts create mode 100644 packages/frontpage/app/(app)/notifications/_lib/constants.ts create mode 100644 packages/frontpage/app/(app)/notifications/_lib/mark-as-read-button.tsx create mode 100644 packages/frontpage/app/(app)/notifications/_lib/notification-link.tsx create mode 100644 packages/frontpage/app/(app)/notifications/_lib/read-state.tsx create mode 100644 packages/frontpage/app/(app)/notifications/page.tsx create mode 100644 packages/frontpage/app/api/notification-count/route.ts create mode 100644 packages/frontpage/app/api/notification-read/route.ts create mode 100644 packages/frontpage/lib/data/atproto/uri.ts create mode 100644 packages/frontpage/lib/data/db/notification.ts create mode 100644 packages/frontpage/lib/infinite-list.tsx create mode 100644 packages/frontpage/lib/navigation.ts diff --git a/packages/frontpage/app/(app)/_components/notification-indicator-client.tsx b/packages/frontpage/app/(app)/_components/notification-indicator-client.tsx new file mode 100644 index 00000000..0974dcbf --- /dev/null +++ b/packages/frontpage/app/(app)/_components/notification-indicator-client.tsx @@ -0,0 +1,34 @@ +"use client"; + +import useSWR from "swr"; +import { NotificationCountKey } from "./notification-indicator-shared"; + +async function fetchNotificationCount() { + const response = await fetch("/api/notification-count"); + if (!response.ok) { + throw new Error("Failed to fetch notification count"); + } + const count = await response.json(); + if (typeof count !== "number") { + throw new Error("Invalid notification count"); + } + return count; +} + +export function NotificationIndicatorCount() { + const { data: count } = useSWR(NotificationCountKey, fetchNotificationCount, { + suspense: true, + revalidateOnMount: false, + }); + + if (count === 0) return null; + + return ( +
+ {count > 9 ? "9+" : count} +
+ ); +} diff --git a/packages/frontpage/app/(app)/_components/notification-indicator-shared.ts b/packages/frontpage/app/(app)/_components/notification-indicator-shared.ts new file mode 100644 index 00000000..7f633e5e --- /dev/null +++ b/packages/frontpage/app/(app)/_components/notification-indicator-shared.ts @@ -0,0 +1 @@ +export const NotificationCountKey = "notificationCount"; diff --git a/packages/frontpage/app/(app)/_components/notification-indicator.tsx b/packages/frontpage/app/(app)/_components/notification-indicator.tsx new file mode 100644 index 00000000..69bc7c22 --- /dev/null +++ b/packages/frontpage/app/(app)/_components/notification-indicator.tsx @@ -0,0 +1,40 @@ +import { ReactNode, Suspense } from "react"; +import { NotificationIndicatorCount } from "./notification-indicator-client"; +import { SWRConfig } from "swr"; +import { getUser } from "@/lib/data/user"; +import { getNotificationCount } from "@/lib/data/db/notification"; +import { ErrorBoundary } from "react-error-boundary"; +import { NotificationCountKey } from "./notification-indicator-shared"; + +export async function NotificationIndicator({ + children, +}: { + children: ReactNode; +}) { + const user = await getUser(); + if (user === null) return null; + return ( +
+ {children} + + + + + +
+ ); +} + +function NotificationIndicatorInner() { + return ( + + + + ); +} diff --git a/packages/frontpage/app/(app)/_components/post-list.tsx b/packages/frontpage/app/(app)/_components/post-list.tsx deleted file mode 100644 index 40d0a689..00000000 --- a/packages/frontpage/app/(app)/_components/post-list.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import useSWRInfinite from "swr/infinite"; -import { getMorePostsAction, type Page } from "../actions"; -import { Fragment, startTransition } from "react"; -import { useInView } from "react-intersection-observer"; - -export function PostList() { - const { data, size, setSize } = useSWRInfinite( - (_, previousPageData: Page | null) => { - if (previousPageData && !previousPageData.postCount) return null; // reached the end - return ["posts", previousPageData?.nextCursor ?? 0]; - }, - ([_, cursor]) => { - return getMorePostsAction(cursor); - }, - { suspense: true, revalidateOnMount: false }, - ); - const { ref: inViewRef } = useInView({ - onChange: (inView) => { - if (inView) { - startTransition(() => void setSize(size + 1)); - } - }, - }); - - // Data can't be undefined because we are using suspense. This is likely a bug in the swr types. - const pages = data!; - - return ( -
- {pages.map((page, indx) => { - return ( - - {page.postCards} - - {indx === pages.length - 1 ? ( - page.postCount === 0 ? ( -

No posts remaining

- ) : ( -

- Loading... -

- ) - ) : null} -
- ); - })} -
- ); -} diff --git a/packages/frontpage/app/(app)/_components/theme-toggle.tsx b/packages/frontpage/app/(app)/_components/theme-toggle.tsx index 21f9eec9..260454b4 100644 --- a/packages/frontpage/app/(app)/_components/theme-toggle.tsx +++ b/packages/frontpage/app/(app)/_components/theme-toggle.tsx @@ -1,49 +1,36 @@ "use client"; import * as React from "react"; -import { SunIcon, MoonIcon } from "@radix-ui/react-icons"; +import { SunIcon, MoonIcon, Half2Icon } from "@radix-ui/react-icons"; import { useTheme } from "next-themes"; -import { Button } from "@/lib/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, } from "@/lib/components/ui/dropdown-menu"; -export function ThemeToggle() { - const { theme, setTheme } = useTheme(); - +export function ThemeToggleMenuGroup() { + const { setTheme, theme } = useTheme(); return ( - - - - - - setTheme("light")} - > - Light - - setTheme("dark")} - > - Dark - - setTheme("system")} - > - System - - - + <> + Theme + + + + + Light + + + + Dark + + + + System + + + ); } diff --git a/packages/frontpage/app/(app)/actions.tsx b/packages/frontpage/app/(app)/actions.tsx deleted file mode 100644 index fe2a4c83..00000000 --- a/packages/frontpage/app/(app)/actions.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use server"; - -import { getFrontpagePosts } from "@/lib/data/db/post"; -import { PostCard } from "./_components/post-card"; - -export async function getMorePostsAction(cursor: number) { - const { posts, nextCursor } = await getFrontpagePosts(cursor); - - return { - postCards: ( - <> - {posts.map((post) => ( - - ))} - - ), - /** - * Number of posts in this page. - */ - postCount: posts.length, - nextCursor, - }; -} - -export type Page = Awaited>; diff --git a/packages/frontpage/app/(app)/layout.tsx b/packages/frontpage/app/(app)/layout.tsx index 27a0f0a1..5586e2f1 100644 --- a/packages/frontpage/app/(app)/layout.tsx +++ b/packages/frontpage/app/(app)/layout.tsx @@ -3,8 +3,8 @@ import Link from "next/link"; import { Suspense } from "react"; import { Button } from "@/lib/components/ui/button"; import { isAdmin } from "@/lib/data/user"; -import { OpenInNewWindowIcon } from "@radix-ui/react-icons"; -import { ThemeToggle } from "./_components/theme-toggle"; +import { BellIcon, OpenInNewWindowIcon } from "@radix-ui/react-icons"; +import { ThemeToggleMenuGroup } from "./_components/theme-toggle"; import { getDidFromHandleOrDid, getVerifiedHandle, @@ -22,6 +22,7 @@ import { UserAvatar } from "@/lib/components/user-avatar"; import { FRONTPAGE_ATPROTO_HANDLE } from "@/lib/constants"; import { cookies } from "next/headers"; import { revalidatePath } from "next/cache"; +import { NotificationIndicator } from "./_components/notification-indicator"; export default async function Layout({ children, @@ -43,7 +44,6 @@ export default async function Layout({ New ) : null} - @@ -75,53 +75,63 @@ async function LoginOrLogout() { getVerifiedHandle(session.user.did), ]); return ( - - - {did ? ( - - ) : ( - {handle} - )} - - - {handle} - - - - Profile + <> + + + + + + {did ? ( + + ) : ( + {handle} )} - - -
{ - "use server"; - await signOut(); - deleteAuthCookie(await cookies()); - revalidatePath("/", "layout"); - }} - > + + + {handle} + - + + Profile + - -
-
+ + {isAdmin().then((isAdmin) => + isAdmin ? ( + + + Moderation + + + ) : null, + )} + + + +
{ + "use server"; + await signOut(); + deleteAuthCookie(await cookies()); + revalidatePath("/", "layout"); + }} + > + + + +
+
+
+ ); } diff --git a/packages/frontpage/app/(app)/moderation/_components/report-card.tsx b/packages/frontpage/app/(app)/moderation/_components/report-card.tsx index 25aa0f6c..103d232a 100644 --- a/packages/frontpage/app/(app)/moderation/_components/report-card.tsx +++ b/packages/frontpage/app/(app)/moderation/_components/report-card.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"; import { CommentCollection } from "@/lib/data/atproto/comment"; import { PostCollection } from "@/lib/data/atproto/post"; import { getPostFromComment } from "@/lib/data/db/post"; +import { getCommentLink, getPostLink } from "@/lib/navigation"; const createLink = async ( collection?: string | null, @@ -22,14 +23,21 @@ const createLink = async ( ) => { switch (collection) { case PostCollection: - return `/post/${author}/${rkey}/`; + return getPostLink({ handleOrDid: author!, rkey: rkey! }); case CommentCollection: const { postAuthor, postRkey } = (await getPostFromComment({ rkey: rkey!, did: author!, }))!; - return `/post/${postAuthor}/${postRkey}/${author}/${rkey}/`; + return getCommentLink({ + post: { + handleOrDid: postAuthor, + rkey: postRkey, + }, + handleOrDid: author!, + rkey: rkey!, + }); default: return `/profile/${author}/`; diff --git a/packages/frontpage/app/(app)/notifications/_lib/actions.ts b/packages/frontpage/app/(app)/notifications/_lib/actions.ts new file mode 100644 index 00000000..2a815d46 --- /dev/null +++ b/packages/frontpage/app/(app)/notifications/_lib/actions.ts @@ -0,0 +1,11 @@ +"use server"; + +import { markAllNotificationsRead } from "@/lib/data/db/notification"; +import { revalidatePath } from "next/cache"; + +export async function markAllNotificationsReadAction() { + "use server"; + await markAllNotificationsRead(); + // Revalidating the layout to refresh the notification count + revalidatePath("/notifications", "layout"); +} diff --git a/packages/frontpage/app/(app)/notifications/_lib/constants.ts b/packages/frontpage/app/(app)/notifications/_lib/constants.ts new file mode 100644 index 00000000..ce6a6b13 --- /dev/null +++ b/packages/frontpage/app/(app)/notifications/_lib/constants.ts @@ -0,0 +1 @@ +export const NOTIFICATIONS_CACHE_KEY = "notifications"; diff --git a/packages/frontpage/app/(app)/notifications/_lib/mark-as-read-button.tsx b/packages/frontpage/app/(app)/notifications/_lib/mark-as-read-button.tsx new file mode 100644 index 00000000..74371eb7 --- /dev/null +++ b/packages/frontpage/app/(app)/notifications/_lib/mark-as-read-button.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Button } from "@/lib/components/ui/button"; +import { CheckCircledIcon } from "@radix-ui/react-icons"; +import { useTransition } from "react"; + +import { useMarkAsReadMutation } from "./read-state"; +import { markAllNotificationsReadAction } from "./actions"; +import { revalidateInfiniteListPage } from "@/lib/infinite-list"; +import { NOTIFICATIONS_CACHE_KEY } from "./constants"; + +export function MarkAsReadButton({ + notificationId, +}: { + notificationId: number; +}) { + const markAsRead = useMarkAsReadMutation(notificationId); + const [isPending, startTransition] = useTransition(); + return ( +
startTransition(markAsRead)} aria-busy={isPending}> + +
+ ); +} + +export function MarkAllAsReadButton() { + const [isPending, startTransition] = useTransition(); + return ( +
+ startTransition(async () => { + await markAllNotificationsReadAction(); + // Revalidating the first page should revalidate the entire list as we're passing revalidateAll to the InfiniteList component + await revalidateInfiniteListPage(NOTIFICATIONS_CACHE_KEY, null); + }) + } + > + +
+ ); +} diff --git a/packages/frontpage/app/(app)/notifications/_lib/notification-link.tsx b/packages/frontpage/app/(app)/notifications/_lib/notification-link.tsx new file mode 100644 index 00000000..304f90b8 --- /dev/null +++ b/packages/frontpage/app/(app)/notifications/_lib/notification-link.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { ReactNode } from "react"; +import { useMarkAsReadMutation } from "./read-state"; + +type NotificationLinkCardProps = { + href: string; + read: boolean; + id: number; + children: ReactNode; +}; + +export function NotificationLinkCard({ + href, + read, + id, + children, +}: NotificationLinkCardProps) { + const markAsRead = useMarkAsReadMutation(id); + return ( + { + if (!read) { + void markAsRead(); + } + }} + href={href} + className={`block mb-4 p-4 rounded-lg ${read ? "bg-secondary" : "bg-primary/10"}`} + > + {children} + + ); +} diff --git a/packages/frontpage/app/(app)/notifications/_lib/read-state.tsx b/packages/frontpage/app/(app)/notifications/_lib/read-state.tsx new file mode 100644 index 00000000..40931964 --- /dev/null +++ b/packages/frontpage/app/(app)/notifications/_lib/read-state.tsx @@ -0,0 +1,16 @@ +"use client"; +import { InfiniteListContext } from "@/lib/infinite-list"; +import { useContext } from "react"; +import { mutate } from "swr"; +import { NotificationCountKey } from "../../_components/notification-indicator-shared"; + +export function useMarkAsReadMutation(id: number) { + const { revalidatePage } = useContext(InfiniteListContext); + + return async () => { + await fetch(`/api/notification-read?id=${id}`, { + method: "POST", + }); + await Promise.all([revalidatePage(), mutate(NotificationCountKey)]); + }; +} diff --git a/packages/frontpage/app/(app)/notifications/page.tsx b/packages/frontpage/app/(app)/notifications/page.tsx new file mode 100644 index 00000000..af3756ba --- /dev/null +++ b/packages/frontpage/app/(app)/notifications/page.tsx @@ -0,0 +1,134 @@ +import { TimeAgo } from "@/lib/components/time-ago"; +import { getVerifiedHandle } from "@/lib/data/atproto/identity"; +import { + Cursor, + getNotifications, + Notification as NotificationType, +} from "@/lib/data/db/notification"; +import { InfiniteList, Page } from "@/lib/infinite-list"; +import { exhaustiveCheck } from "@/lib/utils"; +import { ChatBubbleIcon, Link1Icon } from "@radix-ui/react-icons"; +import { + MarkAllAsReadButton, + MarkAsReadButton, +} from "./_lib/mark-as-read-button"; +import { getCommentLink } from "@/lib/navigation"; +import { NotificationLinkCard } from "./_lib/notification-link"; +import { NOTIFICATIONS_CACHE_KEY } from "./_lib/constants"; +import { CommentBody } from "../post/[postAuthor]/[postRkey]/_lib/comment"; + +export default async function NotificationsPage() { + return ( +
+
+

+ Notifications +

+ +
+ + +
+ ); +} + +async function getMoreNotifications( + cursor: Cursor | null, +): Promise> { + "use server"; + const notifications = await getNotifications(40, cursor); + + return { + content: ( + <> + {notifications.notifications.map((notification) => ( + + ))} + + ), + + nextCursor: notifications.cursor, + pageSize: notifications.notifications.length, + }; +} + +async function getNotificationViewModel(notification: NotificationType) { + const replierHandle = await getVerifiedHandle(notification.comment.authorDid); + + const href = getCommentLink({ + post: { + handleOrDid: notification.post.authorDid, + rkey: notification.post.rkey, + }, + handleOrDid: notification.comment.authorDid, + rkey: notification.comment.rkey, + }); + + if (notification.type === "commentReply") { + return { + type: "commentReply", + Icon: ChatBubbleIcon, + title: `@${replierHandle ?? ""} replied to your comment on "${notification.post.title}"`, + body: notification.comment.body, + time: notification.createdAt, + read: notification.read, + id: notification.id, + href, + }; + } + + if (notification.type === "postComment") { + return { + type: "postComment", + Icon: Link1Icon, + title: `@${replierHandle ?? ""} commented on your post: "${notification.post.title}"`, + body: notification.comment.body, + time: notification.createdAt, + read: notification.read, + id: notification.id, + href, + }; + } + + exhaustiveCheck(notification.type); +} + +async function NotificationCard({ + notification, +}: { + notification: NotificationType; +}) { + const model = await getNotificationViewModel(notification); + return ( + +
+
+ +
+

{model.title}

+

+ +

+
+ +
+
+
+ {!notification.read && } +
+
+ ); +} diff --git a/packages/frontpage/app/(app)/page.tsx b/packages/frontpage/app/(app)/page.tsx index 2bbf2f56..cfaca6e5 100644 --- a/packages/frontpage/app/(app)/page.tsx +++ b/packages/frontpage/app/(app)/page.tsx @@ -1,26 +1,49 @@ import { unstable_noStore } from "next/cache"; -import { PostList } from "./_components/post-list"; -import { SWRConfig } from "swr"; -import { unstable_serialize } from "swr/infinite"; -import { getMorePostsAction } from "./actions"; +import { InfiniteList } from "@/lib/infinite-list"; +import { getFrontpagePosts } from "@/lib/data/db/post"; +import { PostCard } from "./_components/post-card"; export default async function Home() { unstable_noStore(); + // Calling an action directly is not recommended in the doc but here we do it as a DRY shortcut. + const initialData = await getMorePostsAction(0); + return ( -
- ["posts", 0])]: [ - // Calling an action directly is not recommended in the doc but here we do it as a DRY shortcut. - await getMorePostsAction(0), - ], - }, - }} - > - - -
+ ); } + +async function getMorePostsAction(cursor: number | null) { + "use server"; + const { posts, nextCursor } = await getFrontpagePosts(cursor ?? 0); + + return { + content: ( + <> + {posts.map((post) => ( + + ))} + + ), + pageSize: posts.length, + nextCursor, + }; +} diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx index 23fc5bf5..f0cba2b7 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx @@ -13,6 +13,7 @@ import { getVerifiedHandle, } from "@/lib/data/atproto/identity"; import { UserHoverCard } from "@/lib/components/user-hover-card"; +import { cn } from "@/lib/utils"; type CommentProps = { comment: CommentModel; @@ -92,9 +93,7 @@ async function LiveComment({ -

- {comment.body} -

+ {comment.body ? : null} {comment.children?.map((comment) => ( @@ -110,6 +109,25 @@ async function LiveComment({ ); } +export function CommentBody({ + body, + exerptOnly = false, +}: { + body: string; + exerptOnly?: boolean; +}) { + return ( +

+ {body} +

+ ); +} + function DeletedComment({ comment, postAuthorParam, diff --git a/packages/frontpage/app/api/notification-count/route.ts b/packages/frontpage/app/api/notification-count/route.ts new file mode 100644 index 00000000..7615f872 --- /dev/null +++ b/packages/frontpage/app/api/notification-count/route.ts @@ -0,0 +1,7 @@ +import { getNotificationCount } from "@/lib/data/db/notification"; + +export async function GET() { + const count = await getNotificationCount(); + + return Response.json(count); +} diff --git a/packages/frontpage/app/api/notification-read/route.ts b/packages/frontpage/app/api/notification-read/route.ts new file mode 100644 index 00000000..35563100 --- /dev/null +++ b/packages/frontpage/app/api/notification-read/route.ts @@ -0,0 +1,19 @@ +import { markNotificationRead } from "@/lib/data/db/notification"; +import { ensureUser } from "@/lib/data/user"; + +export async function POST(request: Request) { + await ensureUser(); + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (id === null) { + return Response.json({ error: "Missing id parameter" }, { status: 400 }); + } + + const parsedId = parseInt(id, 10); + if (isNaN(parsedId)) { + return Response.json({ error: "Invalid id parameter" }, { status: 400 }); + } + + await markNotificationRead(parsedId); + return new Response(null, { status: 204 }); +} diff --git a/packages/frontpage/app/api/receive_hook/route.ts b/packages/frontpage/app/api/receive_hook/route.ts index 7ff14099..2e3ed15b 100644 --- a/packages/frontpage/app/api/receive_hook/route.ts +++ b/packages/frontpage/app/api/receive_hook/route.ts @@ -16,6 +16,7 @@ import { unauthed_deleteVote, unauthed_createCommentVote, } from "@/lib/data/db/vote"; +import { unauthed_createNotification } from "@/lib/data/db/notification"; export async function POST(request: Request) { const auth = request.headers.get("Authorization"); @@ -68,12 +69,24 @@ export async function POST(request: Request) { if (op.action === "create") { const comment = await getComment({ rkey, repo }); - await unauthed_createComment({ + const createdComment = await unauthed_createComment({ cid: comment.cid, comment, repo, rkey, }); + + const didToNotify = createdComment.parent + ? createdComment.parent.authorDid + : createdComment.post.authordid; + + if (didToNotify !== repo) { + await unauthed_createNotification({ + commentId: createdComment.id, + did: didToNotify, + reason: createdComment.parent ? "commentReply" : "postComment", + }); + } } else if (op.action === "delete") { await unauthed_deleteComment({ rkey, repo }); } diff --git a/packages/frontpage/drizzle.config.ts b/packages/frontpage/drizzle.config.ts index 448235ff..6217f78e 100644 --- a/packages/frontpage/drizzle.config.ts +++ b/packages/frontpage/drizzle.config.ts @@ -17,6 +17,7 @@ if (URL.endsWith(".turso.io") && !AUTH_TOKEN) { throw new Error("TURSO_AUTH_TOKEN must be set when connecting to turso.io"); } + export default defineConfig({ dialect: "turso", schema: "./lib/schema.ts", diff --git a/packages/frontpage/lib/data/atproto/comment.ts b/packages/frontpage/lib/data/atproto/comment.ts index a1dff7b9..3482379c 100644 --- a/packages/frontpage/lib/data/atproto/comment.ts +++ b/packages/frontpage/lib/data/atproto/comment.ts @@ -1,10 +1,10 @@ import "server-only"; import { atprotoCreateRecord, - createAtUriParser, atprotoDeleteRecord, atprotoGetRecord, } from "./record"; +import { createAtUriParser } from "./uri"; import { DataLayerError } from "../error"; import { z } from "zod"; import { PostCollection } from "./post"; diff --git a/packages/frontpage/lib/data/atproto/record.ts b/packages/frontpage/lib/data/atproto/record.ts index 1674ad31..229531ca 100644 --- a/packages/frontpage/lib/data/atproto/record.ts +++ b/packages/frontpage/lib/data/atproto/record.ts @@ -2,62 +2,8 @@ import "server-only"; import { z } from "zod"; import { ensureUser } from "../user"; import { DataLayerError } from "../error"; -import { Prettify } from "@/lib/utils"; import { fetchAuthenticatedAtproto } from "@/lib/auth"; - -export const AtUri = z.string().transform((value, ctx) => { - const match = value.match(/^at:\/\/(.+?)(\/.+?)?(\/.+?)?$/); - if (!match) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid AT URI: ${value}`, - }); - return z.NEVER; - } - - const [, authority, collection, rkey] = match; - if (!authority || !collection || !rkey) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Undefined or empty AT URI parts: ${value}`, - }); - return z.NEVER; - } - - return { - authority, - collection: collection.replace("/", ""), - rkey: rkey.replace("/", ""), - value, - }; -}); - -export function createAtUriParser( - collectionSchema: TCollection, -): z.ZodType< - Prettify & { collection: z.infer }>, - z.ZodTypeDef, - string -> { - return AtUri.transform((uri, ctx) => { - const collection = collectionSchema.safeParse(uri.collection); - if (!collection.success) { - collection.error.errors.forEach((e) => { - ctx.addIssue({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - code: e.code as any, - message: e.message, - }); - }); - return z.NEVER; - } - - return { - ...uri, - collection: collection.data, - }; - }); -} +import { AtUri } from "./uri"; const CreateRecordResponse = z.object({ uri: AtUri, diff --git a/packages/frontpage/lib/data/atproto/uri.ts b/packages/frontpage/lib/data/atproto/uri.ts new file mode 100644 index 00000000..c933eecc --- /dev/null +++ b/packages/frontpage/lib/data/atproto/uri.ts @@ -0,0 +1,60 @@ +import { Prettify } from "@/lib/utils"; +import { z } from "zod"; + +export const AtUri = z.string().transform((value, ctx) => { + const match = value.match(/^at:\/\/(.+?)(\/.+?)?(\/.+?)?$/); + if (!match) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid AT URI: ${value}`, + }); + return z.NEVER; + } + + const [, authority, collection, rkey] = match; + if (!authority || !collection || !rkey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Undefined or empty AT URI parts: ${value}`, + }); + return z.NEVER; + } + + return { + authority, + collection: collection.replace("/", ""), + rkey: rkey.replace("/", ""), + }; +}); + +export type AtUriType = z.infer; + +export const atUriToString = (uri: AtUriType) => + `at://${[uri.authority, uri.collection, uri.rkey].join("/")}`; + +export function createAtUriParser( + collectionSchema: TCollection, +): z.ZodType< + Prettify }>, + z.ZodTypeDef, + string +> { + return AtUri.transform((uri, ctx) => { + const collection = collectionSchema.safeParse(uri.collection); + if (!collection.success) { + collection.error.errors.forEach((e) => { + ctx.addIssue({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + code: e.code as any, + message: e.message, + }); + }); + return z.NEVER; + } + + return { + ...uri, + collection: collection.data, + }; + }); +} diff --git a/packages/frontpage/lib/data/atproto/vote.ts b/packages/frontpage/lib/data/atproto/vote.ts index afa32a85..93bdb788 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -1,14 +1,11 @@ import "server-only"; import { ensureUser } from "../user"; -import { - atprotoCreateRecord, - atprotoDeleteRecord, - createAtUriParser, -} from "./record"; +import { atprotoCreateRecord, atprotoDeleteRecord } from "./record"; import { z } from "zod"; import { PostCollection } from "./post"; import { CommentCollection } from "./comment"; import { DID } from "./did"; +import { createAtUriParser } from "./uri"; const VoteSubjectCollection = z.union([ z.literal(PostCollection), diff --git a/packages/frontpage/lib/data/db/comment.ts b/packages/frontpage/lib/data/db/comment.ts index 8033cdde..ec190095 100644 --- a/packages/frontpage/lib/data/db/comment.ts +++ b/packages/frontpage/lib/data/db/comment.ts @@ -266,7 +266,7 @@ export async function unauthed_createComment({ repo, rkey, }: UnauthedCreateCommentInput) { - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const parentComment = comment.parent != null ? ( @@ -316,6 +316,19 @@ export async function unauthed_createComment({ insertedComment.id, tx, ); + + return { + id: insertedComment.id, + parent: parentComment + ? { + id: parentComment.id, + authorDid: parentComment.authorDid, + } + : null, + post: { + authordid: post.authorDid, + }, + }; }); } diff --git a/packages/frontpage/lib/data/db/notification.ts b/packages/frontpage/lib/data/db/notification.ts new file mode 100644 index 00000000..98efc7d9 --- /dev/null +++ b/packages/frontpage/lib/data/db/notification.ts @@ -0,0 +1,140 @@ +import "server-only"; + +import { cache } from "react"; +import { db } from "@/lib/db"; +import * as schema from "@/lib/schema"; +import { and, eq, lt, desc, isNull, count } from "drizzle-orm"; +import { invariant } from "@/lib/utils"; +import { ensureUser } from "../user"; +import { DID } from "../atproto/did"; + +declare const tag: unique symbol; +export type Cursor = { readonly [tag]: "Cursor" }; + +function cursorToDate(cursor: Cursor): Date { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new Date(cursor as any); +} + +function createCursor(date: Date): Cursor { + return date.toISOString() as unknown as Cursor; +} + +export type Notification = Awaited< + ReturnType +>["notifications"][number]; + +export const getNotifications = cache( + async (limit: number, cursor: Cursor | null) => { + const user = await ensureUser(); + + const joins = await db + .select() + .from(schema.Notification) + .where( + and( + eq(schema.Notification.did, user.did), + cursor + ? lt(schema.Notification.createdAt, cursorToDate(cursor)) + : undefined, + ), + ) + .leftJoin( + schema.Comment, + eq(schema.Comment.id, schema.Notification.commentId), + ) + .leftJoin(schema.Post, eq(schema.Post.id, schema.Comment.postId)) + .groupBy(schema.Notification.id) + .orderBy(desc(schema.Notification.id)) + .limit(limit); + + const newCursor = + joins.length > 0 + ? createCursor(joins.at(-1)!.notifications.createdAt) + : null; + + return { + cursor: newCursor, + notifications: joins.map((notification) => { + const post = notification.posts; + const comment = notification.comments; + invariant(post, "Post should exist if it's in the notification"); + invariant(comment, "Comment should exist if it's in the notification"); + return { + type: notification.notifications.reason, + createdAt: notification.notifications.createdAt, + read: !!notification.notifications.readAt, + id: notification.notifications.id, + post, + comment, + }; + }), + }; + }, +); + +export const getNotificationCount = cache(async () => { + const user = await ensureUser(); + const [row] = await db + .select({ + count: count(), + }) + .from(schema.Notification) + .where( + and( + eq(schema.Notification.did, user.did), + isNull(schema.Notification.readAt), + ), + ); + + invariant(row, "Row should exist"); + return row.count; +}); + +export async function markNotificationRead(notificationId: number) { + const user = await ensureUser(); + await db + .update(schema.Notification) + .set({ + readAt: new Date(), + }) + .where( + and( + eq(schema.Notification.id, notificationId), + eq(schema.Notification.did, user.did), + ), + ); +} + +export async function markAllNotificationsRead() { + const user = await ensureUser(); + await db + .update(schema.Notification) + .set({ + readAt: new Date(), + }) + .where( + and( + isNull(schema.Notification.readAt), + eq(schema.Notification.did, user.did), + ), + ); +} + +type CreateNotificationInput = { + did: DID; + reason: "postComment" | "commentReply"; + commentId: number; +}; + +export async function unauthed_createNotification({ + did, + reason, + commentId, +}: CreateNotificationInput) { + await db.insert(schema.Notification).values({ + did, + reason, + commentId, + }); +} diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index cdada358..e118f2c4 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -12,6 +12,7 @@ import { newCommentVoteAggregateTrigger, newPostVoteAggregateTrigger, } from "./triggers"; +import { atUriToString } from "../atproto/uri"; export const getVoteForPost = cache(async (postId: number) => { const user = await getUser(); @@ -71,7 +72,9 @@ export const unauthed_createPostVote = async ({ )[0]; if (!subject) { - throw new Error(`Subject not found with uri: ${vote.subject.uri.value}`); + throw new Error( + `Subject not found with uri: ${atUriToString(vote.subject.uri)}`, + ); } if (subject.authorDid === repo) { @@ -112,7 +115,9 @@ export async function unauthed_createCommentVote({ )[0]; if (!subject) { - throw new Error(`Subject not found with uri: ${vote.subject.uri.value}`); + throw new Error( + `Subject not found with uri: ${atUriToString(vote.subject.uri)}`, + ); } if (subject.authorDid === repo) { diff --git a/packages/frontpage/lib/infinite-list.tsx b/packages/frontpage/lib/infinite-list.tsx new file mode 100644 index 00000000..0d037654 --- /dev/null +++ b/packages/frontpage/lib/infinite-list.tsx @@ -0,0 +1,113 @@ +"use client"; + +import useSWRInfinite, { unstable_serialize } from "swr/infinite"; +import { createContext, Fragment, ReactNode, startTransition } from "react"; +import { useInView } from "react-intersection-observer"; +import { mutate, SWRConfig } from "swr"; + +export type Page = { + content: ReactNode; + nextCursor: TCursor | null; + pageSize: number; +}; + +type Props = { + getMoreItemsAction: (cursor: TCursor | null) => Promise>; + emptyMessage: string; + cacheKey: string; + fallback: Page | Promise>; + revalidateAll?: boolean; +}; + +export function revalidateInfiniteListPage( + cacheKey: string, + cursor: TCursor | null, +) { + return mutate(unstable_serialize(() => [cacheKey, cursor])); +} + +export function InfiniteList({ fallback, ...props }: Props) { + return ( + [props.cacheKey, null])]: [fallback], + }, + }} + > + + + ); +} + +export const InfiniteListContext = createContext({ + revalidatePage: async (): Promise => { + throw new Error( + "Cannot call InfiniteListContext.revalidate when not inside of an InfiniteList", + ); + }, +}); + +function InfinteListInner({ + getMoreItemsAction, + emptyMessage, + cacheKey, + revalidateAll = false, +}: Omit, "fallback">) { + const { data, size, setSize, mutate } = useSWRInfinite( + (_, previousPageData: Page | null) => { + if (previousPageData && !previousPageData.pageSize) return null; // reached the end + return [cacheKey, previousPageData?.nextCursor ?? null]; + }, + ([_, cursor]) => { + return getMoreItemsAction(cursor); + }, + { suspense: true, revalidateOnMount: false, revalidateAll }, + ); + const { ref: inViewRef } = useInView({ + onChange: (inView) => { + if (inView) { + startTransition(() => void setSize(size + 1)); + } + }, + }); + + // Data can't be undefined because we are using suspense. This is likely a bug in the swr types. + const pages = data!; + + return ( +
+ {pages.map((page, indx) => { + return ( + + { + const currentCursor = pages[indx - 1]?.nextCursor; + await mutate(data, { + revalidate: (_data, args) => + !currentCursor || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (args as any)[1] === currentCursor, + }); + }, + }} + > + {page.content} + + + {indx === pages.length - 1 ? ( + page.pageSize === 0 ? ( +

{emptyMessage}

+ ) : ( +

+ Loading... +

+ ) + ) : null} +
+ ); + })} +
+ ); +} diff --git a/packages/frontpage/lib/navigation.ts b/packages/frontpage/lib/navigation.ts new file mode 100644 index 00000000..89b6113c --- /dev/null +++ b/packages/frontpage/lib/navigation.ts @@ -0,0 +1,20 @@ +import { DID } from "./data/atproto/did"; + +type PostInput = { + handleOrDid: string | DID; + rkey: string; +}; + +export function getPostLink({ handleOrDid, rkey }: PostInput) { + return `/post/${handleOrDid}/${rkey}`; +} + +type CommentInput = { + post: PostInput; + handleOrDid: string | DID; + rkey: string; +}; + +export function getCommentLink({ post, handleOrDid, rkey }: CommentInput) { + return `/post/${post.handleOrDid}/${post.rkey}/${handleOrDid}/${rkey}`; +} diff --git a/packages/frontpage/lib/utils.ts b/packages/frontpage/lib/utils.ts index 03384689..8a749342 100644 --- a/packages/frontpage/lib/utils.ts +++ b/packages/frontpage/lib/utils.ts @@ -9,3 +9,16 @@ export type Prettify = { [K in keyof T]: T[K]; // eslint-disable-next-line @typescript-eslint/ban-types } & {}; + +export function invariant( + condition: unknown, + message: string, +): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +export function exhaustiveCheck(value: never): never { + throw new Error(`Unhandled value: ${value}`); +} diff --git a/packages/frontpage/package.json b/packages/frontpage/package.json index 7503bd45..58749f6f 100644 --- a/packages/frontpage/package.json +++ b/packages/frontpage/package.json @@ -49,6 +49,7 @@ "oauth4webapi": "^2.12.1", "react": "19.0.0-rc-f994737d14-20240522", "react-dom": "19.0.0-rc-f994737d14-20240522", + "react-error-boundary": "^4.0.13", "react-intersection-observer": "^9.13.1", "server-only": "^0.0.1", "swr": "2.2.6-beta.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 499113a3..9ad1429f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,9 @@ importers: react-dom: specifier: 19.0.0-rc-f994737d14-20240522 version: 19.0.0-rc-f994737d14-20240522(react@19.0.0-rc-f994737d14-20240522) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@19.0.0-rc-f994737d14-20240522) react-intersection-observer: specifier: ^9.13.1 version: 9.13.1(react-dom@19.0.0-rc-f994737d14-20240522(react@19.0.0-rc-f994737d14-20240522))(react@19.0.0-rc-f994737d14-20240522)