-
{topic.title}
-
+
{topic.publishedAt ? (
@@ -250,10 +272,13 @@ const ForumPage = ({
return (
+
{t("new_topic")}
-
+
}
description={container.metadata.description}
title={
@@ -300,12 +325,12 @@ const ForumPage = ({
{t("empty_forum.desc")}
-
{t("new_topic")}
-
+
) : (
@@ -326,20 +351,20 @@ const ForumPage = ({
className="flex flex-wrap items-center gap-2"
>
{isFirstPage ? null : (
-
{t("newest")}
-
+
)}
{nextCursor === null ? null : (
-
{t("older")}
-
+
)}
)}
diff --git a/plugins/forum/src/pages/forum-queries.test.ts b/plugins/forum/src/pages/forum-queries.test.ts
new file mode 100644
index 000000000..9409150bf
--- /dev/null
+++ b/plugins/forum/src/pages/forum-queries.test.ts
@@ -0,0 +1,50 @@
+import { createVitNodeQueryClient } from "@vitnode/core/lib/query-client";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ forumStatsQueryOptions,
+ invalidateForumComments,
+ isSharedResponse,
+ threadQueryOptions,
+} from "./forum-queries";
+
+describe("isSharedResponse", () => {
+ it.each<[null | string, boolean]>([
+ [null, true],
+ ["public, max-age=60", true],
+ ["private, no-store", false],
+ ["no-store", false],
+ ["Private", false],
+ ])("Cache-Control %s is shared: %s", (cacheControl, shared) => {
+ const headers = new Headers();
+ if (cacheControl !== null) headers.set("Cache-Control", cacheControl);
+
+ expect(isSharedResponse(new Response(null, { headers }))).toBe(shared);
+ });
+});
+
+describe("invalidateForumComments", () => {
+ it("makes the next visit refetch the thread and the counts, not another topic's thread", async () => {
+ const queryClient = createVitNodeQueryClient();
+ const thread = vi.fn(async () => await Promise.resolve(null));
+ const otherThread = vi.fn(async () => await Promise.resolve(null));
+ const stats = vi.fn(async () => await Promise.resolve([]));
+ const load = async () => {
+ await queryClient.query({ ...threadQueryOptions(7), queryFn: thread });
+ await queryClient.query({
+ ...threadQueryOptions(8),
+ queryFn: otherThread,
+ });
+ await queryClient.query({ ...forumStatsQueryOptions(), queryFn: stats });
+ };
+
+ await load();
+ await load();
+ await invalidateForumComments(queryClient, 7);
+ await load();
+
+ expect(thread).toHaveBeenCalledTimes(2);
+ expect(stats).toHaveBeenCalledTimes(2);
+ expect(otherThread).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/plugins/forum/src/pages/forum-queries.ts b/plugins/forum/src/pages/forum-queries.ts
new file mode 100644
index 000000000..c94d79e08
--- /dev/null
+++ b/plugins/forum/src/pages/forum-queries.ts
@@ -0,0 +1,122 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { queryOptions } from "@tanstack/react-query";
+import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client";
+import {
+ OPERATIONAL_STALE_TIME,
+ RECORD_STALE_TIME,
+} from "@vitnode/core/lib/query-freshness";
+import { fetcher } from "@vitnode/core/tanstack/fetcher";
+
+import { CONFIG_PLUGIN } from "@/const";
+
+import type { ForumSearch } from "./forum-links";
+
+import { fetchForums, fetchForumStats } from "./forum-data";
+import { fetchThreadPage } from "./thread-data";
+
+const FORUM_TREE_STALE_TIME = RECORD_STALE_TIME;
+
+export const TOPIC_STALE_TIME = RECORD_STALE_TIME;
+
+export const FORUM_ACTIVITY_STALE_TIME = OPERATIONAL_STALE_TIME;
+
+export const PENDING_STALE_TIME = OPERATIONAL_STALE_TIME;
+
+const all = [CONFIG_PLUGIN.pluginId] as const;
+const commentLinks = [...all, "comment-link"] as const;
+const submissions = [...all, "pending"] as const;
+const topicDetails = [...all, "topic"] as const;
+const topicLists = [...all, "topic-list"] as const;
+
+export const forumQueryKeys = {
+ all,
+ commentLink: (slug: string) => [...commentLinks, slug] as const,
+ commentLinks: () => commentLinks,
+ forum: (idOrSlug: string) => [...all, "forum", idOrSlug] as const,
+ forums: () => [...all, "forums"] as const,
+ forumStats: () => [...all, "forum-stats"] as const,
+ pending: (userId: number, page: number) =>
+ [...submissions, userId, { page }] as const,
+ submissions: () => submissions,
+ thread: (topicId: number) => [...all, "thread", topicId] as const,
+ topic: (slug: string) => [...topicDetails, slug] as const,
+ topicDetails: () => topicDetails,
+ topicList: (request: ForumSearch & { forumId: number }) =>
+ [...topicLists, request] as const,
+ topicLists: () => topicLists,
+};
+
+export const isSharedResponse = (res: Response): boolean =>
+ !/\b(?:no-store|private)\b/i.test(res.headers.get("cache-control") ?? "");
+
+export const forumsQueryOptions = () =>
+ queryOptions({
+ meta: LOADER_DATA_QUERY_META,
+ queryFn: fetchForums,
+ queryKey: forumQueryKeys.forums(),
+ staleTime: FORUM_TREE_STALE_TIME,
+ });
+
+export const forumStatsQueryOptions = () =>
+ queryOptions({
+ meta: LOADER_DATA_QUERY_META,
+ queryFn: fetchForumStats,
+ queryKey: forumQueryKeys.forumStats(),
+ staleTime: FORUM_ACTIVITY_STALE_TIME,
+ });
+
+export const forumQueryOptions = (idOrSlug: string) =>
+ queryOptions({
+ meta: LOADER_DATA_QUERY_META,
+ queryFn: async () => {
+ const res = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "content/forums",
+ path: "/{idOrSlug}",
+ args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } },
+ });
+
+ if (res.status === 404) return null;
+ if (res.status !== 200) throw new Error("Loading the forum failed.");
+
+ return await res.json();
+ },
+ queryKey: forumQueryKeys.forum(idOrSlug),
+ staleTime: ({ state }) => (state.data ? FORUM_TREE_STALE_TIME : 0),
+ });
+
+export const threadQueryOptions = (topicId: number) =>
+ queryOptions({
+ meta: LOADER_DATA_QUERY_META,
+ queryFn: async () => await fetchThreadPage({ topicId }),
+ queryKey: forumQueryKeys.thread(topicId),
+ staleTime: FORUM_ACTIVITY_STALE_TIME,
+ });
+
+export const invalidateForumComments = async (
+ queryClient: QueryClient,
+ topicId: number,
+): Promise => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.submissions() }),
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.thread(topicId) }),
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.topicLists() }),
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.forumStats() }),
+ ]);
+};
+
+export const invalidateForumTopics = async (
+ queryClient: QueryClient,
+): Promise => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.commentLinks() }),
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.submissions() }),
+ queryClient.invalidateQueries({
+ queryKey: forumQueryKeys.topicDetails(),
+ }),
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.topicLists() }),
+ queryClient.invalidateQueries({ queryKey: forumQueryKeys.forumStats() }),
+ ]);
+};
diff --git a/plugins/forum/src/pages/forums-page.tsx b/plugins/forum/src/pages/forums-page.tsx
index ae520e944..3565666ec 100644
--- a/plugins/forum/src/pages/forums-page.tsx
+++ b/plugins/forum/src/pages/forums-page.tsx
@@ -1,3 +1,6 @@
+import type { QueryClient } from "@tanstack/react-query";
+import type { PluginRoutePageProps } from "@vitnode/core/routing";
+
import { buttonVariants } from "@vitnode/core/components/ui/button";
import {
Empty,
@@ -8,20 +11,22 @@ import {
EmptyTitle,
} from "@vitnode/core/components/ui/empty";
import { contentContainerTree } from "@vitnode/core/content";
-import {
- definePluginRoute,
- type PluginRoutePageProps,
-} from "@vitnode/core/routing";
+import { HrefLink } from "@vitnode/core/tanstack/link";
+import { defineRoute } from "@vitnode/core/tanstack/plugin-routes";
import { MessagesSquareIcon, PenSquareIcon } from "lucide-react";
import { useTranslations } from "use-intl";
-import { fetchForums, fetchForumStats, indexBy } from "./forum-data";
+import { indexBy } from "./forum-data";
import { topicNewHref } from "./forum-links";
import { ForumList } from "./forum-list";
+import { forumsQueryOptions, forumStatsQueryOptions } from "./forum-queries";
import { ForumShell } from "./forum-ui";
-const loadForums = async () => {
- const [forums, stats] = await Promise.all([fetchForums(), fetchForumStats()]);
+const loadForums = async (queryClient: QueryClient) => {
+ const [forums, stats] = await Promise.all([
+ queryClient.query(forumsQueryOptions()),
+ queryClient.query(forumStatsQueryOptions()),
+ ]);
return { stats, tree: contentContainerTree(forums) };
};
@@ -34,8 +39,8 @@ const ForumsBreadcrumb = () => {
return {t("title")};
};
-export const route = definePluginRoute({
- load: loadForums,
+export const route = defineRoute({
+ load: async ({ context }) => await loadForums(context.queryClient),
head: ({ t }) => ({
description: t("@vitnode/forum.forums.desc"),
@@ -53,10 +58,10 @@ const ForumsPage = ({ loaderData }: PluginRoutePageProps) => {
0 ? (
-
+
{t("new_topic")}
-
+
) : null
}
description={t("desc")}
@@ -72,9 +77,12 @@ const ForumsPage = ({ loaderData }: PluginRoutePageProps) => {
{t("empty.desc")}
-
+
{t("empty.home")}
-
+
) : (
diff --git a/plugins/forum/src/pages/pending-page.tsx b/plugins/forum/src/pages/pending-page.tsx
index db7d10c92..5a51bd5f9 100644
--- a/plugins/forum/src/pages/pending-page.tsx
+++ b/plugins/forum/src/pages/pending-page.tsx
@@ -1,3 +1,6 @@
+import type { PluginRoutePageProps } from "@vitnode/core/routing";
+
+import { queryOptions } from "@tanstack/react-query";
import { Badge } from "@vitnode/core/components/ui/badge";
import { buttonVariants } from "@vitnode/core/components/ui/button";
import {
@@ -8,11 +11,13 @@ import {
EmptyMedia,
EmptyTitle,
} from "@vitnode/core/components/ui/empty";
-import {
- definePluginRoute,
- type PluginRoutePageProps,
-} from "@vitnode/core/routing";
+import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
+import { HrefLink } from "@vitnode/core/tanstack/link";
+import {
+ type AuthenticatedRouteLoadContext,
+ defineAuthenticatedRoute,
+} from "@vitnode/core/tanstack/plugin-routes";
import { InboxIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { z } from "zod";
@@ -29,6 +34,7 @@ import {
pendingHref,
topicEditHref,
} from "./forum-links";
+import { forumQueryKeys, PENDING_STALE_TIME } from "./forum-queries";
import { ForumShell, TimeAgo } from "./forum-ui";
const PAGE_SIZE = 25;
@@ -49,29 +55,49 @@ const zodSubmission = z.object({
type Submission = z.infer;
-const loadPending = async ({ page = 1 }: PendingSearch) => {
- const res = await fetcher({
- plugin: "@vitnode/core",
- method: "get",
- module: "moderation",
- path: "/approvals/mine",
- args: { query: { page, pageSize: PAGE_SIZE } },
+const pendingQueryOptions = ({
+ page,
+ userId,
+}: {
+ page: number;
+ userId: number;
+}) =>
+ queryOptions({
+ meta: LOADER_DATA_QUERY_META,
+ queryFn: async () => {
+ const res = await fetcher({
+ plugin: "@vitnode/core",
+ method: "get",
+ module: "moderation",
+ path: "/approvals/mine",
+ args: { query: { page, pageSize: PAGE_SIZE } },
+ });
+
+ if (res.status !== 200) {
+ throw new Error("Loading your submissions failed.");
+ }
+
+ const data = await res.json();
+
+ return {
+ // The queue is every plugin's; this page is the forum's corner of it.
+ items: z
+ .array(zodSubmission)
+ .parse(data.items)
+ .filter(item => item.pluginId === CONFIG_PLUGIN.pluginId),
+ page: data.page,
+ pageCount: Math.max(1, Math.ceil(data.total / data.pageSize)),
+ };
+ },
+ queryKey: forumQueryKeys.pending(userId, page),
+ staleTime: PENDING_STALE_TIME,
});
- if (res.status !== 200) throw new Error("Loading your submissions failed.");
-
- const data = await res.json();
-
- return {
- // The queue is every plugin's; this page is the forum's corner of it.
- items: z
- .array(zodSubmission)
- .parse(data.items)
- .filter(item => item.pluginId === CONFIG_PLUGIN.pluginId),
- page: data.page,
- pageCount: Math.max(1, Math.ceil(data.total / data.pageSize)),
- };
-};
+const loadPending = async (
+ { auth, queryClient }: AuthenticatedRouteLoadContext,
+ { page = 1 }: PendingSearch,
+) =>
+ await queryClient.query(pendingQueryOptions({ page, userId: auth.user.id }));
type PendingPageData = Awaited>;
@@ -81,8 +107,8 @@ const PendingBreadcrumb = () => {
return {t("title")};
};
-export const route = definePluginRoute({
- load: async ({ search }) => await loadPending(search),
+export const route = defineAuthenticatedRoute({
+ load: async ({ context, search }) => await loadPending(context, search),
head: ({ t }) => ({
description: t("@vitnode/forum.pending.desc"),
@@ -121,12 +147,12 @@ const SubmissionRow = ({ item }: { item: Submission }) => {