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 (
-
-
-
-
-
- Toggle theme
-
-
-
- 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
+ <>
+
+
+
+
-
-
- {isAdmin().then((isAdmin) =>
- isAdmin ? (
-
-
- Moderation
-
-
- ) : null,
+
+
+
+
+ {did ? (
+
+ ) : (
+ {handle}
)}
-
-
-
-
-
+
+ {isAdmin().then((isAdmin) =>
+ isAdmin ? (
+
+
+ Moderation
+
+
+ ) : null,
+ )}
+
+
+
+
+
+
+ >
);
}
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 (
+
+ );
+}
+
+export function MarkAllAsReadButton() {
+ const [isPending, startTransition] = useTransition();
+ return (
+
+ );
+}
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)