Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/frontpage-data-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Generally, the Next.js app should only speak to this layer, and not directly to

As this layer is designed to speak XRPC, it uses AT protocol types and concepts where possible. It borrows many conventions from the Bsky API eg. Using AT URIs to identify resources. Unlike the Bsky API it also offers write operations, not just read operations. This is because Frontpage chooses option 2 in [Paul's Leaflet](https://pfrazee.leaflet.pub/3m5hwua4sh22v) while Bsky chooses option 1. The main reason for this is to allow Frontpage to read it's own writes, Bsky is able to do this via specific logic that it injects into the PDS itself - we don't have that option.

#### A note on AT URI types

In the API layer we generally use generic `AtUri` types from `@atproto/syntax` to represent resources. This matches the bsky API and also allows us to be generic about the specific collection being used (important in the case of posts/comments/votes that can exist in the old or current lexicons).

As data flows deeper into the Frontpage app we're less concerned with following conventions in other atproto apps, and more concerned with type safety and clarity. Therefore in the DB layer we convert these generic `AtUri` types into more specific types that represent exactly which collection is being used. Another difference is that the `AtUri` type allows for the actor (or `host`, in AT terms) to be either a DID or a handle, while in the DB layer we require this to always be a DID. This is because the database only stores DIDs (handles are mutable), so we need to resolve handles to DIDs before we can interact with the database.

### DB Layer

Code for this layer is in `packages/frontpage/lib/data/db`. This layer is responsible for interacting with the Frontpage database. It's structured like a traditional database access layer, with functions for creating, reading, updating, and deleting records in the database.
Expand Down
20 changes: 14 additions & 6 deletions packages/frontpage/app/(app)/_components/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { ShareDropdownButton } from "./share-button";
import { createVote, deleteVote } from "@/lib/api/vote";
import { deletePost } from "@/lib/api/post";
import { invariant } from "@/lib/utils";
import { nsids } from "@/lib/data/atproto/repo";
import { nsids, type PostCollectionType } from "@/lib/data/atproto/repo";
import { AtUri } from "@atproto/syntax";

type PostProps = {
id: number;
Expand All @@ -27,6 +28,7 @@ type PostProps = {
createdAt: Date;
commentCount: number;
rkey: string;
collection: PostCollectionType;
cid: string | null;
isUpvoted: boolean;
};
Expand All @@ -40,6 +42,7 @@ export async function PostCard({
createdAt,
commentCount,
rkey,
collection,
cid,
isUpvoted,
}: PostProps) {
Expand Down Expand Up @@ -126,12 +129,13 @@ export async function PostCard({
rkey,
cid,
author,
collection,
})}
/>
{/* TODO: there's a bug here where delete shows on deleted posts */}
{user?.did === author ? (
<DeleteButton
deleteAction={deletePostAction.bind(null, rkey)}
deleteAction={deletePostAction.bind(null, collection, rkey)}
/>
) : null}
</EllipsisDropdown>
Expand All @@ -142,10 +146,13 @@ export async function PostCard({
);
}

export async function deletePostAction(rkey: string) {
export async function deletePostAction(
collection: PostCollectionType,
rkey: string,
) {
"use server";
const user = await ensureUser();
await deletePost({ authorDid: user.did, rkey });
await deletePost(new AtUri(`at://${user.did}/${collection}/${rkey}`));

revalidatePath("/");
}
Expand All @@ -155,6 +162,7 @@ export async function reportPostAction(
rkey: string;
cid: string | null;
author: DID;
collection: PostCollectionType;
},
formData: FormData,
) {
Expand All @@ -168,9 +176,9 @@ export async function reportPostAction(

await createReport({
...formResult.data,
subjectUri: `at://${input.author}/${nsids.FyiUnravelFrontpagePost}/${input.rkey}`,
subjectUri: `at://${input.author}/${input.collection}/${input.rkey}`,
subjectDid: input.author,
subjectCollection: nsids.FyiUnravelFrontpagePost,
subjectCollection: input.collection,
subjectRkey: input.rkey,
subjectCid: input.cid ?? undefined,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,22 @@ async function performModerationAction(
switch (report.subjectCollection) {
case nsids.FyiUnravelFrontpagePost:
Comment on lines 58 to 59
return await moderatePost({
rkey: report.subjectRkey!,
authorDid: report.subjectDid as DID,
uri: {
actor: report.subjectDid,
collection: report.subjectCollection,
rkey: report.subjectRkey!,
},
cid: report.subjectCid!,
hide: input.status === "accepted",
});

case nsids.FyiUnravelFrontpageComment:
return await moderateComment({
rkey: report.subjectRkey!,
authorDid: report.subjectDid as DID,
uri: {
actor: report.subjectDid,
collection: report.subjectCollection,
rkey: report.subjectRkey!,
},
cid: report.subjectCid!,
hide: input.status === "accepted",
});
Expand Down
1 change: 1 addition & 0 deletions packages/frontpage/app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ async function getMorePostsAction(cursor: number | null) {
cid={post.cid}
rkey={post.rkey}
isUpvoted={post.userHasVoted}
collection={post.collection}
/>
))}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function CommentClientWrapperWithToolbar({
postAuthorDid={postAuthorDid}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onActionDone={() => {
onDoneAction={() => {
startTransition(() => {
setShowNewComment(false);
});
Expand Down Expand Up @@ -226,13 +226,13 @@ export function NewComment({
postAuthorDid,
extraButton,
textAreaRef,
onActionDone,
onDoneAction,
}: {
parent?: { did: DID; rkey: string };
postRkey: string;
postAuthorDid: DID;
autoFocus?: boolean;
onActionDone?: () => void;
onDoneAction?: () => void;
extraButton?: React.ReactNode;
textAreaRef?: React.RefObject<HTMLTextAreaElement | null>;
}) {
Expand All @@ -252,7 +252,7 @@ export function NewComment({
event.preventDefault();
startTransition(() => {
action(new FormData(event.currentTarget));
onActionDone?.();
onDoneAction?.();
setInput("");
});
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "server-only";
import { getDidFromHandleOrDid } from "@/lib/data/atproto/identity";
import { getPost } from "@/lib/data/db/post";
import { notFound } from "next/navigation";
import { nsids } from "@/lib/data/atproto/repo";

export type PostPageParams = Awaited<
PageProps<"/post/[postAuthor]/[postRkey]">["params"]
Expand All @@ -12,7 +13,22 @@ export async function getPostPageData(params: PostPageParams) {
if (!authorDid) {
notFound();
}
const post = await getPost(authorDid, params.postRkey);
const [unravelPost, frontpagePost] = await Promise.all([
getPost({
actor: authorDid,
collection: nsids.FyiUnravelFrontpagePost,
rkey: params.postRkey,
}),
getPost({
actor: authorDid,
collection: nsids.FyiFrontpageFeedPost,
rkey: params.postRkey,
}),
]);

// Choosing frontpagePost over unravelPost if both exist
// This shouldn't happen in regular usage, only if a user creates a post on purpose with an existing rkey
const post = frontpagePost ?? unravelPost;
if (!post) {
notFound();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { getUser } from "@/lib/data/user";
import { notFound } from "next/navigation";
import { PostCard } from "../../../_components/post-card";
import { getPost } from "@/lib/data/db/post";
import { getDidFromHandleOrDid } from "@/lib/data/atproto/identity";
import { Alert, AlertTitle, AlertDescription } from "@/lib/components/ui/alert";
import { Spinner } from "@/lib/components/ui/spinner";
import { NewComment } from "./_lib/comment-client";
import { SuperHackyScrollToTop } from "./scroller";
import { getPostPageData } from "./_lib/page-data";

export default async function PostLayout(
props: LayoutProps<"/post/[postAuthor]/[postRkey]">,
Expand All @@ -20,7 +20,7 @@ export default async function PostLayout(
if (!didParam) {
notFound();
}
const post = await getPost(didParam, params.postRkey);
const { post } = await getPostPageData(params);
if (!post) {
notFound();
}
Expand All @@ -40,6 +40,7 @@ export default async function PostLayout(
rkey={post.rkey}
cid={post.cid}
isUpvoted={post.userHasVoted}
collection={post.collection}
/>
{post.status === "pending" ? (
// TODO: This should have a spinner and refresh on an interval
Expand Down
2 changes: 1 addition & 1 deletion packages/frontpage/app/(app)/post/new/_action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function newPostAction(_prevState: unknown, formData: FormData) {

try {
const [{ rkey }, handle] = await Promise.all([
createPost({ authorDid: user.did, title, url }),
createPost({ actor: user.did, title, url }),
getVerifiedHandle(user.did),
]);

Expand Down
53 changes: 31 additions & 22 deletions packages/frontpage/lib/api/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,33 @@ import "server-only";
import * as db from "../data/db/post";
import { ensureUser } from "../data/user";
import { DataLayerError } from "../data/error";
import { invariant } from "../utils";
import { exhaustiveCheck, invariant } from "../utils";
import { TID } from "@atproto/common-web";
import { type DID } from "../data/atproto/did";
import { after } from "next/server";
import { getAtprotoClient, nsids } from "../data/atproto/repo";
import { type AtUri } from "@atproto/syntax";

export type ApiCreatePostInput = {
authorDid: DID;
actor: DID;
title: string;
url: string;
};

export async function createPost({
authorDid,
title,
url,
}: ApiCreatePostInput) {
export async function createPost({ actor, title, url }: ApiCreatePostInput) {
const user = await ensureUser();

if (user.did !== authorDid) {
if (user.did !== actor) {
throw new DataLayerError("You can only create posts for yourself");
}

const rkey = TID.next().toString();
const uri = { actor, collection: nsids.FyiUnravelFrontpagePost, rkey };
try {
const dbCreatedPost = await db.createPost({
post: { title, url, createdAt: new Date() },
rkey,
authorDid: user.did,
uri,
status: "pending",
collection: nsids.FyiUnravelFrontpagePost,
});
invariant(dbCreatedPost, "Failed to insert post in database");

Expand All @@ -53,28 +49,41 @@ export async function createPost({

return { rkey };
} catch (e) {
await db.deletePost({ authorDid: user.did, rkey });
await db.deletePost(uri);
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new DataLayerError(`Failed to create post: ${e}`);
}
}

export async function deletePost({ authorDid, rkey }: db.DeletePostInput) {
export async function deletePost(uri: AtUri) {
const user = await ensureUser();
const postUri = await db.resolvePostUri(uri);

if (authorDid !== user.did) {
if (postUri.actor !== user.did) {
throw new DataLayerError("You can only delete your own posts");
}

try {
const atproto = getAtprotoClient();
after(() =>
atproto.fyi.unravel.frontpage.post.delete({
repo: authorDid,
rkey,
}),
);
await db.deletePost({ authorDid: user.did, rkey });
after(async () => {
const atproto = getAtprotoClient();
if (postUri.collection === nsids.FyiUnravelFrontpagePost) {
await atproto.fyi.unravel.frontpage.post.delete({
repo: user.did,
rkey: postUri.rkey,
});
} else if (postUri.collection === nsids.FyiFrontpageFeedPost) {
await atproto.fyi.frontpage.feed.post.delete({
repo: user.did,
rkey: postUri.rkey,
});
} else {
exhaustiveCheck(
postUri.collection,
"Cannot delete post. Unknown post collection",
);
}
});
await db.deletePost(postUri);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new DataLayerError(`Failed to delete post: ${e}`);
Expand Down
6 changes: 6 additions & 0 deletions packages/frontpage/lib/data/atproto/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { getUser } from "../user";
import { fetchAuthenticatedAtproto } from "@/lib/auth";
import { cache } from "react";
import { type AtUri } from "@atproto/syntax";

export { ids as nsids } from "@repo/frontpage-atproto-client/lexicons";

Expand Down Expand Up @@ -48,3 +49,8 @@ export type CommentCollectionType =
export type VoteCollectionType =
| FyiUnravelFrontpageVote.Record["$type"]
| FyiFrontpageFeedVote.Record["$type"];

export type StrongRef = {
uri: AtUri;
cid: string;
};
Loading
Loading