diff --git a/CHANGES.md b/CHANGES.md
index 389d5729..39e07f78 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -235,6 +235,17 @@ To be released.
are removed; UnoCSS emits a single _src/public/uno.css_ whose
URL is cache-busted by file mtime.
+ - Added public reaction list pages anchored to each local post:
+ `/@:handle/:id/likes` lists the accounts that liked the post,
+ `/@:handle/:id/shares` lists the accounts that boosted it,
+ `/@:handle/:id/reactions/:emoji` lists the accounts that reacted with
+ a specific emoji, and `/@:handle/:id/quotes` lists the posts that
+ quote it. Each page features the original post above the list. On
+ the profile feed and post permalink page, the per-post like, share,
+ quote, and reaction-emoji indicators now link into these pages for
+ local posts; remote posts continue to display the counts as plain
+ text. [[#490]]
+
- Added avatar and header image upload to the admin account creation and
editing forms, with drag-and-drop support and in-page image preview.
Files are stored using the same storage backend as the Mastodon-compatible
@@ -371,6 +382,7 @@ To be released.
[#487]: https://github.com/fedify-dev/hollo/pull/487
[#488]: https://github.com/fedify-dev/hollo/issues/488
[#489]: https://github.com/fedify-dev/hollo/issues/489
+[#490]: https://github.com/fedify-dev/hollo/pull/490
Version 0.8.4
diff --git a/src/components/Post.tsx b/src/components/Post.tsx
index 44e60170..90e9f3fb 100644
--- a/src/components/Post.tsx
+++ b/src/components/Post.tsx
@@ -4,6 +4,7 @@ import { proxyUrl } from "../media-proxy";
import type { PreviewCard } from "../previewcard";
import type {
Account,
+ AccountOwner,
Medium as DbMedium,
Poll as DbPoll,
Post as DbPost,
@@ -11,41 +12,45 @@ import type {
Reaction,
} from "../schema";
+export type PostAccount = Account & { owner?: AccountOwner | null };
+
+export type PostForView = DbPost & {
+ account: PostAccount;
+ media: DbMedium[];
+ poll: (DbPoll & { options: PollOption[] }) | null;
+ sharing:
+ | (DbPost & {
+ account: PostAccount;
+ media: DbMedium[];
+ poll: (DbPoll & { options: PollOption[] }) | null;
+ replyTarget: (DbPost & { account: PostAccount }) | null;
+ quoteTarget:
+ | (DbPost & {
+ account: PostAccount;
+ media: DbMedium[];
+ poll: (DbPoll & { options: PollOption[] }) | null;
+ replyTarget: (DbPost & { account: PostAccount }) | null;
+ reactions: Reaction[];
+ })
+ | null;
+ reactions: Reaction[];
+ })
+ | null;
+ replyTarget: (DbPost & { account: PostAccount }) | null;
+ quoteTarget:
+ | (DbPost & {
+ account: PostAccount;
+ media: DbMedium[];
+ poll: (DbPoll & { options: PollOption[] }) | null;
+ replyTarget: (DbPost & { account: PostAccount }) | null;
+ reactions: Reaction[];
+ })
+ | null;
+ reactions: Reaction[];
+};
+
export interface PostProps {
- readonly post: DbPost & {
- account: Account;
- media: DbMedium[];
- poll: (DbPoll & { options: PollOption[] }) | null;
- sharing:
- | (DbPost & {
- account: Account;
- media: DbMedium[];
- poll: (DbPoll & { options: PollOption[] }) | null;
- replyTarget: (DbPost & { account: Account }) | null;
- quoteTarget:
- | (DbPost & {
- account: Account;
- media: DbMedium[];
- poll: (DbPoll & { options: PollOption[] }) | null;
- replyTarget: (DbPost & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })
- | null;
- replyTarget: (DbPost & { account: Account }) | null;
- quoteTarget:
- | (DbPost & {
- account: Account;
- media: DbMedium[];
- poll: (DbPoll & { options: PollOption[] }) | null;
- replyTarget: (DbPost & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- };
+ readonly post: PostForView;
readonly shared?: Date;
readonly pinned?: boolean;
readonly quoted?: boolean;
@@ -85,6 +90,8 @@ export function Post({
);
const authorUrl = account.url ?? account.iri;
const avatar = proxyUrl(account.avatarUrl, baseUrl);
+ const localPermalink =
+ account.owner == null ? null : `/@${account.owner.handle}/${post.id}`;
const wrapperClass = quoted
? "rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-900/60"
: featured
@@ -188,19 +195,37 @@ export function Post({
{post.likesCount != null && post.likesCount > 0 && (
<>
·
-
-
- {numberFormatter.format(post.likesCount)}
-
+
>
)}
{post.sharesCount != null && post.sharesCount > 0 && (
<>
·
-
-
- {numberFormatter.format(post.sharesCount)}
-
+
+ >
+ )}
+ {post.quotesCount != null && post.quotesCount > 0 && (
+ <>
+ ·
+
>
)}
{post.reactions.length > 0 && (
@@ -208,17 +233,30 @@ export function Post({
·
{Object.entries(groupByEmojis(post.reactions, baseUrl)).map(
- ([emoji, { src, count }]) =>
- src == null ? (
- {emoji}
+ ([emoji, { src, count }]) => {
+ const inner =
+ src == null ? (
+ {emoji}
+ ) : (
+
+ );
+ const title = `${emoji} × ${count}`;
+ return localPermalink == null ? (
+ {inner}
) : (
-
- ),
+
+ {inner}
+
+ );
+ },
)}
>
@@ -228,6 +266,41 @@ export function Post({
);
}
+interface CountLinkProps {
+ readonly localPermalink: string | null;
+ readonly path: string;
+ readonly icon: string;
+ readonly count: number;
+ readonly label: string;
+}
+
+function CountLink({
+ localPermalink,
+ path,
+ icon,
+ count,
+ label,
+}: CountLinkProps) {
+ const inner = (
+ <>
+
+ {numberFormatter.format(count)}
+ >
+ );
+ if (localPermalink == null) {
+ return {inner};
+ }
+ return (
+
+ {inner}
+
+ );
+}
+
function groupByEmojis(
reactions: Reaction[],
baseUrl: URL | string,
@@ -255,10 +328,10 @@ interface PostContentProps {
poll: (DbPoll & { options: PollOption[] }) | null;
quoteTarget:
| (DbPost & {
- account: Account;
+ account: PostAccount;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
- replyTarget: (DbPost & { account: Account }) | null;
+ replyTarget: (DbPost & { account: PostAccount }) | null;
reactions: Reaction[];
})
| null;
diff --git a/src/components/PublicAccountList.tsx b/src/components/PublicAccountList.tsx
new file mode 100644
index 00000000..39e959f3
--- /dev/null
+++ b/src/components/PublicAccountList.tsx
@@ -0,0 +1,79 @@
+import { escape } from "es-toolkit";
+
+import { renderCustomEmojis } from "../custom-emoji";
+import { proxyUrl } from "../media-proxy";
+import type { Account } from "../schema";
+import { sanitizeHtml } from "../xss";
+
+export interface PublicAccountListProps {
+ readonly accounts: readonly Account[];
+ readonly baseUrl: URL | string;
+}
+
+export function PublicAccountList({
+ accounts,
+ baseUrl,
+}: PublicAccountListProps) {
+ return (
+
+ {accounts.map((account) => (
+ -
+
+
+ ))}
+
+ );
+}
+
+interface PublicAccountItemProps {
+ readonly account: Account;
+ readonly baseUrl: URL | string;
+}
+
+function PublicAccountItem({ account, baseUrl }: PublicAccountItemProps) {
+ const nameHtml = renderCustomEmojis(
+ escape(account.name),
+ account.emojis,
+ baseUrl,
+ );
+ const bioHtml = renderCustomEmojis(
+ sanitizeHtml(account.bioHtml ?? ""),
+ account.emojis,
+ baseUrl,
+ );
+ const href = account.url ?? account.iri;
+ const avatar = proxyUrl(account.avatarUrl, baseUrl);
+ return (
+
+
+ {avatar ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {account.handle}
+
+ {bioHtml && (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx
index 3f3ca99b..910b8de6 100644
--- a/src/pages/profile/index.tsx
+++ b/src/pages/profile/index.tsx
@@ -3,25 +3,23 @@ import { Hono } from "hono";
import xss from "xss";
import { Layout } from "../../components/Layout.tsx";
-import { Post as PostView } from "../../components/Post.tsx";
+import { type PostForView, Post as PostView } from "../../components/Post.tsx";
import { Profile } from "../../components/Profile.tsx";
import { db } from "../../db.ts";
import {
type Account,
type AccountOwner,
type FeaturedTag,
- type Medium,
- type Poll,
- type PollOption,
- type Post,
posts,
- type Reaction,
} from "../../schema.ts";
import { isUuid } from "../../uuid.ts";
+import postReactions from "./postReactions.tsx";
+import { postViewRelations } from "./postRelations.ts";
import profilePost from "./profilePost.tsx";
const profile = new Hono();
+profile.route("/:id{[-a-f0-9]+}", postReactions);
profile.route("/:id{[-a-f0-9]+}", profilePost);
const PAGE_SIZE = 30;
@@ -73,84 +71,14 @@ profile.get<"/:handle">(async (c) => {
orderBy: (posts, { desc }) => [desc(posts.id)],
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- sharing: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
+ with: postViewRelations,
});
const pinnedPostList =
cont == null
? await db.query.pinnedPosts.findMany({
where: { accountId: { eq: owner.id } },
orderBy: (pinnedPosts, { desc }) => [desc(pinnedPosts.index)],
- with: {
- post: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- sharing: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- },
+ with: { post: { with: postViewRelations } },
})
: [];
const featuredTagList = await db.query.featuredTags.findMany({
@@ -229,40 +157,7 @@ profile.get("/tagged/:tag", async (c) => {
orderBy: (posts, { desc }) => [desc(posts.id)],
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- sharing: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
+ with: postViewRelations,
});
const featuredTagList = await db.query.featuredTags.findMany({
where: { accountOwnerId: { eq: owner.id } },
@@ -287,74 +182,8 @@ profile.get("/tagged/:tag", async (c) => {
interface ProfilePageProps {
readonly accountOwner: AccountOwner & { account: Account };
readonly tag?: string;
- readonly posts: (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- sharing:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })
- | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })[];
- readonly pinnedPosts: (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- sharing:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })
- | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })[];
+ readonly posts: PostForView[];
+ readonly pinnedPosts: PostForView[];
readonly featuredTags: FeaturedTag[];
readonly atomUrl?: string;
readonly olderUrl?: string;
diff --git a/src/pages/profile/postReactions.tsx b/src/pages/profile/postReactions.tsx
new file mode 100644
index 00000000..1ad1867b
--- /dev/null
+++ b/src/pages/profile/postReactions.tsx
@@ -0,0 +1,364 @@
+import { and, count, eq, isNull, or } from "drizzle-orm";
+import { type Context, Hono } from "hono";
+
+import { Layout } from "../../components/Layout.tsx";
+import { type PostForView, Post as PostView } from "../../components/Post.tsx";
+import { PublicAccountList } from "../../components/PublicAccountList.tsx";
+import db from "../../db.ts";
+import { proxyUrl } from "../../media-proxy.ts";
+import { type AccountOwner, likes, posts, reactions } from "../../schema.ts";
+import { isUuid } from "../../uuid.ts";
+import { postViewRelations } from "./postRelations.ts";
+import { summarizePostForTitle } from "./summary.ts";
+
+const PAGE_SIZE = 100;
+
+const postReactions = new Hono();
+
+async function loadLocalPost(c: Context): Promise<{
+ accountOwner: AccountOwner;
+ post: PostForView;
+} | null> {
+ let handle = c.req.param("handle");
+ const postId = c.req.param("id");
+ if (handle == null || postId == null) return null;
+ if (!isUuid(postId)) return null;
+ if (handle.startsWith("@")) handle = handle.substring(1);
+ const accountOwner = await db.query.accountOwners.findFirst({
+ where: { handle: { eq: handle } },
+ });
+ if (accountOwner == null) return null;
+ const post = await db.query.posts.findFirst({
+ where: {
+ RAW: (posts, { and, eq, or }) =>
+ and(
+ eq(posts.accountId, accountOwner.id),
+ eq(posts.id, postId),
+ or(eq(posts.visibility, "public"), eq(posts.visibility, "unlisted")),
+ )!,
+ },
+ with: postViewRelations,
+ });
+ if (post == null) return null;
+ return { accountOwner, post };
+}
+
+function parsePage(c: Context): number | null {
+ const pageStr = c.req.query("page");
+ if (pageStr === undefined) return 1;
+ const parsed = Number.parseInt(pageStr, 10);
+ if (Number.isNaN(parsed) || parsed < 1) return null;
+ return parsed;
+}
+
+function paginationUrls(
+ page: number,
+ hasNext: boolean,
+): { newerUrl?: string; olderUrl?: string } {
+ return {
+ newerUrl: page > 1 ? `?page=${page - 1}` : undefined,
+ olderUrl: hasNext ? `?page=${page + 1}` : undefined,
+ };
+}
+
+const numberFormatter = new Intl.NumberFormat("en-US");
+
+postReactions.get("/likes", async (c) => {
+ const loaded = await loadLocalPost(c);
+ if (loaded == null) return c.notFound();
+ const { accountOwner, post } = loaded;
+ const page = parsePage(c);
+ if (page == null) return c.notFound();
+
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(likes)
+ .where(eq(likes.postId, post.id));
+ const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ if (page > maxPage) return c.notFound();
+
+ const rows = await db.query.likes.findMany({
+ where: { postId: { eq: post.id } },
+ orderBy: (likes, { desc }) => [desc(likes.created)],
+ limit: PAGE_SIZE,
+ offset: (page - 1) * PAGE_SIZE,
+ with: { account: true },
+ });
+
+ const { newerUrl, olderUrl } = paginationUrls(page, page * PAGE_SIZE < total);
+
+ return c.html(
+
+ r.account)}
+ baseUrl={c.req.url}
+ />
+ ,
+ );
+});
+
+postReactions.get("/shares", async (c) => {
+ const loaded = await loadLocalPost(c);
+ if (loaded == null) return c.notFound();
+ const { accountOwner, post } = loaded;
+ const page = parsePage(c);
+ if (page == null) return c.notFound();
+
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(posts)
+ .where(
+ and(
+ eq(posts.sharingId, post.id),
+ or(eq(posts.visibility, "public"), eq(posts.visibility, "unlisted")),
+ ),
+ );
+ const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ if (page > maxPage) return c.notFound();
+
+ const rows = await db.query.posts.findMany({
+ where: {
+ RAW: (posts, { and, eq, or }) =>
+ and(
+ eq(posts.sharingId, post.id),
+ or(eq(posts.visibility, "public"), eq(posts.visibility, "unlisted")),
+ )!,
+ },
+ orderBy: (posts, { desc }) => [desc(posts.id)],
+ limit: PAGE_SIZE,
+ offset: (page - 1) * PAGE_SIZE,
+ with: { account: true },
+ });
+
+ const { newerUrl, olderUrl } = paginationUrls(page, page * PAGE_SIZE < total);
+
+ return c.html(
+
+ r.account)}
+ baseUrl={c.req.url}
+ />
+ ,
+ );
+});
+
+postReactions.get("/reactions/:emoji", async (c) => {
+ const emoji = c.req.param("emoji");
+ if (emoji == null || emoji === "") return c.notFound();
+ const loaded = await loadLocalPost(c);
+ if (loaded == null) return c.notFound();
+ const { accountOwner, post } = loaded;
+ const page = parsePage(c);
+ if (page == null) return c.notFound();
+
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(reactions)
+ .where(and(eq(reactions.postId, post.id), eq(reactions.emoji, emoji)));
+ if (total < 1) return c.notFound();
+ const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ if (page > maxPage) return c.notFound();
+
+ const rows = await db.query.reactions.findMany({
+ where: { postId: { eq: post.id }, emoji: { eq: emoji } },
+ orderBy: (reactions, { desc }) => [desc(reactions.created)],
+ limit: PAGE_SIZE,
+ offset: (page - 1) * PAGE_SIZE,
+ with: { account: true },
+ });
+
+ const { newerUrl, olderUrl } = paginationUrls(page, page * PAGE_SIZE < total);
+ const customEmojiUrl = post.reactions.find(
+ (r) => r.emoji === emoji && r.customEmoji != null,
+ )?.customEmoji;
+ const proxiedEmojiUrl =
+ customEmojiUrl == null ? null : proxyUrl(customEmojiUrl, c.req.url);
+ const emojiNode =
+ proxiedEmojiUrl == null ? (
+ {emoji}
+ ) : (
+
+ );
+ const headingPrefix =
+ total === 1
+ ? "1 reaction with "
+ : `${numberFormatter.format(total)} reactions with `;
+
+ return c.html(
+
+ {headingPrefix}
+ {emojiNode}
+ >
+ }
+ baseUrl={c.req.url}
+ newerUrl={newerUrl}
+ olderUrl={olderUrl}
+ >
+ r.account)}
+ baseUrl={c.req.url}
+ />
+ ,
+ );
+});
+
+postReactions.get("/quotes", async (c) => {
+ const loaded = await loadLocalPost(c);
+ if (loaded == null) return c.notFound();
+ const { accountOwner, post } = loaded;
+ const page = parsePage(c);
+ if (page == null) return c.notFound();
+
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(posts)
+ .where(
+ and(
+ eq(posts.quoteTargetId, post.id),
+ or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)),
+ isNull(posts.sharingId),
+ or(eq(posts.visibility, "public"), eq(posts.visibility, "unlisted")),
+ ),
+ );
+ const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ if (page > maxPage) return c.notFound();
+
+ const quoteRows = await db.query.posts.findMany({
+ where: {
+ RAW: (posts, { and, eq, isNull, or }) =>
+ and(
+ eq(posts.quoteTargetId, post.id),
+ or(eq(posts.quoteState, "accepted"), isNull(posts.quoteState)),
+ isNull(posts.sharingId),
+ or(eq(posts.visibility, "public"), eq(posts.visibility, "unlisted")),
+ )!,
+ },
+ orderBy: (posts, { desc }) => [desc(posts.id)],
+ limit: PAGE_SIZE,
+ offset: (page - 1) * PAGE_SIZE,
+ with: postViewRelations,
+ });
+
+ const { newerUrl, olderUrl } = paginationUrls(page, page * PAGE_SIZE < total);
+
+ return c.html(
+
+
+ {quoteRows.map((q) => (
+
+ ))}
+
+ ,
+ );
+});
+
+interface ReactionListPageProps {
+ readonly accountOwner: AccountOwner;
+ readonly post: PostForView;
+ readonly pageTitle: string;
+ readonly heading: unknown;
+ readonly baseUrl: URL | string;
+ readonly newerUrl?: string;
+ readonly olderUrl?: string;
+ readonly children?: unknown;
+}
+
+function ReactionListPage({
+ accountOwner,
+ post,
+ pageTitle,
+ heading,
+ baseUrl,
+ newerUrl,
+ olderUrl,
+ children,
+}: ReactionListPageProps) {
+ const summary = summarizePostForTitle(post);
+ return (
+
+
+
+
+
+ {heading}
+
+ {children}
+
+ {(newerUrl || olderUrl) && (
+
+ )}
+
+
+ );
+}
+
+export default postReactions;
diff --git a/src/pages/profile/postRelations.ts b/src/pages/profile/postRelations.ts
new file mode 100644
index 00000000..4dc79a26
--- /dev/null
+++ b/src/pages/profile/postRelations.ts
@@ -0,0 +1,45 @@
+// Drizzle relational query `with` clause that hydrates a post with every
+// nested relation the Post component needs in order to render: the author's
+// account (and its owner row, used to detect local accounts and emit
+// permalink-based reaction list links), media, polls, reply targets, quote
+// targets, the sharing relation when the post is a boost, and the reactions
+// rendered in the post footer.
+//
+// Reuse this constant for every query whose results are passed to
+// PostView so the four SSR queries (profile feed, post permalink, hashtag
+// feed, and the reaction list pages) stay in sync when the post relation
+// graph changes.
+export const postViewRelations = {
+ account: { with: { owner: true } },
+ media: true,
+ poll: { with: { options: true } },
+ sharing: {
+ with: {
+ account: { with: { owner: true } },
+ media: true,
+ poll: { with: { options: true } },
+ replyTarget: { with: { account: { with: { owner: true } } } },
+ quoteTarget: {
+ with: {
+ account: { with: { owner: true } },
+ media: true,
+ poll: { with: { options: true } },
+ replyTarget: { with: { account: { with: { owner: true } } } },
+ reactions: true,
+ },
+ },
+ reactions: true,
+ },
+ },
+ replyTarget: { with: { account: { with: { owner: true } } } },
+ quoteTarget: {
+ with: {
+ account: { with: { owner: true } },
+ media: true,
+ poll: { with: { options: true } },
+ replyTarget: { with: { account: { with: { owner: true } } } },
+ reactions: true,
+ },
+ },
+ reactions: true,
+} as const;
diff --git a/src/pages/profile/profilePost.tsx b/src/pages/profile/profilePost.tsx
index 3f5435aa..973ab94e 100644
--- a/src/pages/profile/profilePost.tsx
+++ b/src/pages/profile/profilePost.tsx
@@ -1,18 +1,12 @@
import { Hono } from "hono";
import { Layout } from "../../components/Layout.tsx";
-import { Post as PostView } from "../../components/Post.tsx";
+import { type PostForView, Post as PostView } from "../../components/Post.tsx";
import db from "../../db.ts";
-import {
- type Account,
- type AccountOwner,
- type Medium,
- type Poll,
- type PollOption,
- type Post,
- type Reaction,
-} from "../../schema.ts";
+import { type AccountOwner } from "../../schema.ts";
import { isUuid } from "../../uuid.ts";
+import { postViewRelations } from "./postRelations.ts";
+import { summarizePostForTitle } from "./summary.ts";
const profilePost = new Hono();
@@ -35,77 +29,13 @@ profilePost.get<"/:handle{@[^/]+}/:id{[-a-f0-9]+}">(async (c) => {
)!,
},
with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- sharing: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
+ ...postViewRelations,
replies: {
where: { visibility: { in: ["public", "unlisted"] } },
orderBy: (posts, { desc }) => [desc(posts.published)],
limit: 20,
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- sharing: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
+ with: postViewRelations,
},
- reactions: true,
},
});
if (post == null) return c.notFound();
@@ -116,88 +46,17 @@ profilePost.get<"/:handle{@[^/]+}/:id{[-a-f0-9]+}">(async (c) => {
interface PostPageProps {
readonly accountOwner: AccountOwner;
- readonly post: Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- sharing:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })
- | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- replies: (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- sharing:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })
- | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })[];
- reactions: Reaction[];
- };
+ readonly post: PostForView & { replies: PostForView[] };
readonly baseUrl: URL | string;
}
function PostPage({ post, accountOwner, baseUrl }: PostPageProps) {
- const summary =
- post.summary ??
- ((post.content ?? "").length > 30
- ? `${(post.content ?? "").substring(0, 30)}…`
- : (post.content ?? ""));
+ const summary = summarizePostForTitle(post);
return (
,
+): string {
+ if (post.summary) return post.summary;
+ const content = post.content ?? "";
+ if (content.length > SUMMARY_MAX_LENGTH) {
+ return `${content.substring(0, SUMMARY_MAX_LENGTH)}…`;
+ }
+ return content;
+}
diff --git a/src/pages/tags/index.tsx b/src/pages/tags/index.tsx
index 2beff44a..b4a55d00 100644
--- a/src/pages/tags/index.tsx
+++ b/src/pages/tags/index.tsx
@@ -1,17 +1,10 @@
import { Hono } from "hono";
import { Layout } from "../../components/Layout.tsx";
-import { Post as PostView } from "../../components/Post.tsx";
+import { type PostForView, Post as PostView } from "../../components/Post.tsx";
import { db } from "../../db.ts";
-import {
- type Account,
- accountOwners,
- type Medium,
- type Poll,
- type PollOption,
- type Post,
- type Reaction,
-} from "../../schema.ts";
+import { accountOwners } from "../../schema.ts";
+import { postViewRelations } from "../profile/postRelations.ts";
const tags = new Hono().basePath("/:tag");
@@ -37,80 +30,14 @@ tags.get(async (c) => {
)!,
},
orderBy: (posts, { desc }) => [desc(posts.id)],
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- sharing: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
- },
- replyTarget: { with: { account: true } },
- quoteTarget: {
- with: {
- account: true,
- media: true,
- poll: { with: { options: true } },
- replyTarget: { with: { account: true } },
- reactions: true,
- },
- },
- reactions: true,
- },
+ with: postViewRelations,
});
return c.html();
});
interface TagPageProps {
readonly tag: string;
- readonly posts: (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- sharing:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })
- | null;
- replyTarget: (Post & { account: Account }) | null;
- quoteTarget:
- | (Post & {
- account: Account;
- media: Medium[];
- poll: (Poll & { options: PollOption[] }) | null;
- replyTarget: (Post & { account: Account }) | null;
- reactions: Reaction[];
- })
- | null;
- reactions: Reaction[];
- })[];
+ readonly posts: PostForView[];
readonly baseUrl: URL | string;
}