From a23e102b4f9e7918c9fa8d283d173b491761e2d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:58:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E2=9C=A8=20live=20refresh=20f?= =?UTF-8?q?or=20AdminCP=20lists=20and=20comment=20threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content Engine changes now reach open screens over the existing shared WebSocket as small, typed hints; HTTP stays the source of truth for content, visibility and permissions. - Server: authorized `content/refresh` socket with join acknowledgement, per-tab memberships, limits, re-authorization once a minute (including sessions that ended while a socket stayed open) and immediate re-checks when a thread's root changes. Signals are published from `emitContentEvent` after the commit and cache invalidation. - New `GET /{publicApi.path}/thread/branches` route on the shared thread reader, for targeted, permission-aware branch refreshes. - Generated AdminCP lists refresh the page on screen (filters, sort, page and selection kept), with coalescing and a non-blocking failure notice. - `useContentComments` gains `branches` and `live`: in-place branch merges, "Show new comments" for new top-level comments, drafts kept when a comment is removed, structural sharing and scroll anchoring. Blog and Newsroom use it; the blog thread no longer remounts an open reply form on a first reply. - `useContentLiveRefresh` for custom screens. - Tests for the server socket, authorization, publishing order and routing, multi-instance delivery, the branches route, the live thread controller (with a request-cost benchmark) and the subscription hook. - Docs: Live comments, Live refresh, Comments API and AdminCP updates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFbH6Ps6sXRHUdJUAZk --- .../content/docs/content-engine/admincp.mdx | 6 + .../docs/content-engine/comments-api.mdx | 44 +- .../docs/content-engine/live-refresh.mdx | 146 ++++ .../web/content/docs/content-engine/meta.json | 2 + .../threads-live-new-comments.webp | Bin 0 -> 46724 bytes .../docs/content-engine/threads-live.mdx | 122 +++ apps/web/content/docs/dev/websocket/rooms.mdx | 2 +- apps/web/src/locales/@vitnode/core/pl.json | 8 + .../src/api/modules/content/content.module.ts | 3 +- packages/vitnode/src/content/index.ts | 8 + packages/vitnode/src/content/live/refresh.ts | 104 +++ .../src/content/server/editorial-effects.ts | 6 +- packages/vitnode/src/content/server/emit.ts | 29 +- packages/vitnode/src/content/server/index.ts | 17 +- .../src/content/server/public-routes.ts | 231 +++-- .../server/public-visibility-routes.ts | 2 + .../src/content/server/refresh/auth.test.ts | 216 +++++ .../src/content/server/refresh/auth.ts | 81 ++ .../content/server/refresh/instances.test.ts | 98 +++ .../content/server/refresh/publish.test.ts | 302 +++++++ .../src/content/server/refresh/publish.ts | 258 ++++++ .../src/content/server/refresh/server.test.ts | 344 ++++++++ .../src/content/server/refresh/server.ts | 330 ++++++++ .../src/content/server/refresh/socket.ts | 41 + packages/vitnode/src/content/server/routes.ts | 12 +- .../src/content/server/thread-access.ts | 68 ++ .../src/content/server/thread-routes.test.ts | 106 ++- packages/vitnode/src/content/server/thread.ts | 429 ++++++---- .../src/content/server/write-effects.ts | 12 +- packages/vitnode/src/lib/coalesce.test.ts | 105 +++ packages/vitnode/src/lib/coalesce.ts | 75 ++ packages/vitnode/src/locales/en.json | 13 + .../src/tanstack/admin/content/list-live.ts | 75 ++ .../src/tanstack/admin/content/list.tsx | 76 +- .../src/tanstack/comments/composer-form.tsx | 31 +- .../tanstack/comments/content-comments.tsx | 98 ++- .../vitnode/src/tanstack/comments/index.ts | 5 + .../vitnode/src/tanstack/comments/item.tsx | 18 + .../src/tanstack/comments/live.test.ts | 611 ++++++++++++++ .../vitnode/src/tanstack/comments/live.ts | 788 ++++++++++++++++++ .../src/tanstack/comments/thread.test.ts | 32 + .../vitnode/src/tanstack/comments/thread.ts | 82 ++ .../comments/use-content-comments.test.tsx | 96 +++ .../tanstack/comments/use-content-comments.ts | 398 ++++++++- .../vitnode/src/tanstack/content/index.ts | 5 + .../tanstack/content/live-refresh.test.tsx | 292 +++++++ .../src/tanstack/content/live-refresh.ts | 226 +++++ packages/vitnode/src/ws/provider.tsx | 7 + packages/vitnode/vitest.config.ts | 3 + plugins/blog/src/pages/post-comments-data.ts | 17 +- plugins/blog/src/pages/post-comments.tsx | 57 +- .../newsroom/src/pages/article-comments.tsx | 14 + 52 files changed, 5814 insertions(+), 337 deletions(-) create mode 100644 apps/web/content/docs/content-engine/live-refresh.mdx create mode 100644 apps/web/content/docs/content-engine/threads-live-new-comments.webp create mode 100644 apps/web/content/docs/content-engine/threads-live.mdx create mode 100644 packages/vitnode/src/content/live/refresh.ts create mode 100644 packages/vitnode/src/content/server/refresh/auth.test.ts create mode 100644 packages/vitnode/src/content/server/refresh/auth.ts create mode 100644 packages/vitnode/src/content/server/refresh/instances.test.ts create mode 100644 packages/vitnode/src/content/server/refresh/publish.test.ts create mode 100644 packages/vitnode/src/content/server/refresh/publish.ts create mode 100644 packages/vitnode/src/content/server/refresh/server.test.ts create mode 100644 packages/vitnode/src/content/server/refresh/server.ts create mode 100644 packages/vitnode/src/content/server/refresh/socket.ts create mode 100644 packages/vitnode/src/content/server/thread-access.ts create mode 100644 packages/vitnode/src/lib/coalesce.test.ts create mode 100644 packages/vitnode/src/lib/coalesce.ts create mode 100644 packages/vitnode/src/tanstack/admin/content/list-live.ts create mode 100644 packages/vitnode/src/tanstack/comments/live.test.ts create mode 100644 packages/vitnode/src/tanstack/comments/live.ts create mode 100644 packages/vitnode/src/tanstack/content/live-refresh.test.tsx create mode 100644 packages/vitnode/src/tanstack/content/live-refresh.ts diff --git a/apps/web/content/docs/content-engine/admincp.mdx b/apps/web/content/docs/content-engine/admincp.mdx index 93169a884..0678f2c82 100644 --- a/apps/web/content/docs/content-engine/admincp.mdx +++ b/apps/web/content/docs/content-engine/admincp.mdx @@ -103,6 +103,12 @@ The **More actions** menu of each row collects the less frequent actions. Two of Each action asks for confirmation, refreshes the list and shows a toast. On the edit page, `ContentFormActions` adds a **Hide** or **Unhide** button next to **Save changes**, and `ContentFormStatus` shows the same **Hidden** badge with a short explanation. +## Live updates + +Every generated list stays current while it's open, with no configuration. When a record of the same type changes in another tab or session, such as another admin's edit, a member's post or a scheduled publish, the page on screen reads again with its filters, search, sort and page. Selected rows that are still on the page stay ticked, and a burst of changes costs one read. If that read fails, the list keeps its rows and shows **Could not refresh this list** with **Try again**. + +Live updates need the app to serve the [WebSocket](/docs/dev/websocket/setup). [Live refresh](/docs/content-engine/live-refresh) explains who receives what. + ## Group form fields into sections By default the form shows every field except `field.blocks()`, one after another, in a dialog: diff --git a/apps/web/content/docs/content-engine/comments-api.mdx b/apps/web/content/docs/content-engine/comments-api.mdx index 822dc5241..77137ca5e 100644 --- a/apps/web/content/docs/content-engine/comments-api.mdx +++ b/apps/web/content/docs/content-engine/comments-api.mdx @@ -33,6 +33,8 @@ const comments = useContentComments({ queryKey, thread, ...actions }) | `record` | `ZodType` | No | Validates every loaded comment's public record and gives it a type. The thread route types records loosely, so pass it when your own rendering reads their fields | | `field` | `string` | No | The rich-text field with the comment's text. Defaults to `"body"` | | `onChanged` | `(queryClient) => void \| Promise` | No | Refreshes your other queries after a successful write, such as a comment count elsewhere on the page | +| `branches` | `(ids) => Promise` | No | [`GET /thread/branches`](#get-threadbranches) with `root`, plus `ids` joined by commas only when there are any. Writes then re-read only the branch they touched instead of every loaded page. `live` needs it | +| `live` | `{ contentTypeId, rootId }` | No | The comment content type's `id` and the root record's id. Keeps the thread current over the WebSocket while it is mounted; ignored without `branches`. See [Live comments](/docs/content-engine/threads-live) | ### Callback contract @@ -48,16 +50,20 @@ const comments = useContentComments({ queryKey, thread, ...actions }) | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `tree` | The loaded comments as a tree: `ContentCommentTreeNode[]`, replies in `children` | | `items` | The same entries, flat and depth first | -| `status` | `"pending"` before the first page, `"error"` when it failed, then `"success"` | +| `status` | `"pending"` before the first page, `"error"` when it failed, then `"success"` - also after a failed background refresh, which `refreshError` reports | | `hasMore`, `loadMore()`, `isLoadingMore` | The next page of top-level comments. `loadMore` resolves to `false` when it failed | -| `isRefreshing`, `retry()` | A refetch over data on screen, and retrying a failed first load | +| `isRefreshing`, `retry()` | A refetch over data on screen (not **Load more**), and retrying a failed first load | +| `refreshError`, `retryRefresh()` | A background read of the comments on screen failed, and they stayed as they were. `retryRefresh()` asks again for what it missed and resolves when done | +| `hasNewComments`, `showNewComments()` | With `live`: comments appeared after the last loaded one, at the end of the thread. `showNewComments()` reads only those and adds them below; it resolves to `false` when that failed | +| `liveStatus` | `"off"` (no `live`, or no open WebSocket), `"joining"`, `"live"`, or `"denied"` when the server refused or revoked the subscription | +| `trackDraft(id, open)` | Call with `true` while an edit or reply form is open on `id` and `false` when it closes. A live update that removes that comment keeps it on screen with `removed: true` and its record until then. `useContentCommentItem` calls it for you | | `replyCount`, `truncated` | Every public comment at any depth, and whether a page held more replies than one answer carries | | `permissionsOf(id)` | `{ canDelete, canEdit, canHide, canReport }`, all `false` until known or after a failed batch | | `canComment`, `canReply(node)` | Whether the composer and **Reply** show: signed in, `create` given, under `maxDepth`, not hidden | | `features` | Which actions you wired up | | `busy` | The action running right now, `{ action, id }` or `null`. A second action waits for it | -| `remove(id)`, `hide(id, reason)`, `unhide(id)` | Run an action and refresh after success. Resolve to `{ ok: true }` or `{ ok: false, status }`; `status` is `409` when another action was still running and `0` for a network failure | -| `refresh()` | Refreshes the thread, its permissions and `onChanged`'s queries | +| `remove(id)`, `hide(id, reason)`, `unhide(id)` | Run an action and refresh after success - only that comment's branch with `branches`. Resolve to `{ ok: true }` or `{ ok: false, status }`; `status` is `409` when another action was still running and `0` for a network failure | +| `refresh()` | Re-reads every loaded page, its permissions and `onChanged`'s queries | | `userId` | The signed-in visitor's id, or `null` | ### Thread entries @@ -158,9 +164,10 @@ const MyComment = ({ node }) => { | Export | What it is | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ContentCommentsRoot` | Provides `comments` to the components below; takes a `fallback` shown while the messages load | -| `useContentCommentItem(node)` | One comment's state: `permissions` (the server's answers limited to wired actions), `canReply`, `editing`, `startEditing()`, `stopEditing()`, `isReplying`, `setIsReplying`, `isBusy` | +| `useContentCommentItem(node)` | One comment's state: `permissions` (the server's answers limited to wired actions), `canReply`, `editing`, `startEditing()`, `stopEditing()`, `isReplying`, `setIsReplying`, `isBusy`, and `isRemoved` when a live update removed the comment under an open form | | `ContentCommentActions` | The **More actions** menu with the hide-reason prompt. `children` adds your own menu items | -| `ContentCommentComposer` | The comment form, lazy-loaded: `mode="create"` with `parentId`, or `mode="edit"` with `id` and `editable` | +| `ContentCommentComposer` | The comment form, lazy-loaded: `mode="create"` with `parentId`, or `mode="edit"` with `id` and `editable`. `unavailable` (a translated reason) keeps the text but hides the submit button | +| `ContentCommentsLiveStatus` | **Show new comments** and the refresh-failed notice, with a polite screen reader announcement. Renders nothing while there is nothing to say | | `ContentCommentItem` | One comment as the default section draws it | | `ContentCommentPlaceholder` | The anonymous stand-in for a removed comment | | `ContentCommentsComposer`, `ContentCommentsLoadMore` | The new-comment card and the load-more button | @@ -173,7 +180,30 @@ The Blog plugin's `plugins/blog/src/pages/post-comments.tsx` is a complete custo - **Writes.** The composer posts through [`useContentPublicWrite`](/docs/content-engine/public-write-form-api): a refused write keeps the text and shows the reason, and with `editorial` a stale edit shows the version-conflict alert. A success reads the answer's `status` and `approval`: **Comment posted** only when it is public, **saved as a draft** or **waiting for a moderator** otherwise. - **Actions.** Delete, hide and unhide show **Comment deleted**, **Comment hidden** or **Comment visible again** after the server accepts them. When it refuses, the section shows one message for the cause: signed out, not allowed, gone, busy or unknown. -- **Loading.** A failed first load shows **Try again**; a failed **Load more** keeps the loaded comments and says it failed. +- **Loading.** A failed first load shows **Try again**; a failed **Load more** keeps the loaded comments and says it failed. A failed background refresh keeps them too; `ContentCommentsLiveStatus` offers **Try again**. + +## `GET /thread/branches` + +Every content type with `thread` also gets `GET /{publicApi.path}/thread/branches`. It re-reads a few top-level comments already on screen, each with every reply below it, using the same root check, visibility, placeholders, depth and truncation as `/thread`. `useContentComments` calls it through `branches`. + +```txt +GET /api/newsroom/content/article-comments/thread/branches?root=1&ids=4,9 +``` + +| Query | Required | Description | +| ------ | -------- | -------------------------------------------------------------------------------------------- | +| `root` | Yes | The root record's id | +| `ids` | No | Up to 10 top-level comment ids, joined by commas. Duplicates are ignored. Without it, only the counts are read | + +| Response field | Description | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `branches` | One entry per requested id, in the order asked: `{ id, status: "ok", items }` or `{ id, status: "gone" }` | +| `items` | The top-level comment first, then every reply below it, as [thread entries](#thread-entries) | +| `pageInfo.totalCount`, `replyCount`, `truncated` | The thread's current counts, as on `/thread` | + +`gone` means this reader no longer sees the branch: deleted, hidden or unpublished with no visible reply left, not a top-level comment, or under another root. An unreadable root answers `404`, as on `/thread`; more than 10 or malformed ids answer `400`. + +Guests and readers who can't see hidden comments get the same answer, served from the [public read cache](/docs/content-engine/caching) with `Cache-Control: no-cache`. Readers who may see hidden comments, and readers of a hidden root, get a `private, no-store` answer. ## Related diff --git a/apps/web/content/docs/content-engine/live-refresh.mdx b/apps/web/content/docs/content-engine/live-refresh.mdx new file mode 100644 index 000000000..ebc915edf --- /dev/null +++ b/apps/web/content/docs/content-engine/live-refresh.mdx @@ -0,0 +1,146 @@ +--- +title: Live refresh +description: How VitNode Content Engine keeps AdminCP lists and comment threads current over the shared WebSocket. Covers which writes send a signal, who may subscribe, reconnects, several server instances and custom write routes. +icon: RefreshCw +--- + +Live refresh keeps screens that show Content Engine records current without a reload. Every generated AdminCP list takes part on its own, and a comment section joins in with [Live comments](/docs/content-engine/threads-live). Both use the app's one [WebSocket](/docs/dev/websocket) connection; nothing opens a second one. + +The WebSocket only carries small hints, such as "this list changed" or "this branch of this thread changed". Each open screen then reads the data again over HTTP with the viewer's own session. That costs one extra request per change, and buys a simple rule: what a reader sees always comes from the same routes and checks as a first load. + +## AdminCP lists + +Every generated list joins its content type's room while it's on screen, with no configuration. When a record of that type changes in another tab or session (created, edited, published, hidden, deleted, duplicated, scheduled, or one of its translations changed), the list: + +- reads the page on screen again, with its filters, search, sorting and page number, and keeps the old rows on screen meanwhile; +- forgets the cached pages nobody is looking at, so opening one later reads it fresh; +- keeps selected rows that are still on the page and drops the ones that left it; +- collects a burst of changes into one read, 0.4 seconds after the last change and never more than 2 seconds after the first; +- shows **Could not refresh this list** with **Try again** when that read fails, and keeps the rows. + +A delete can push the current page past the end. The list route answers with the last page instead, so the list doesn't end up empty. + +## What sends a signal + +Signals come from `emitContentEvent`, which every generated route and service effect calls once a write has committed. It works in this order: + +1. The transaction commits. A write that rolls back never gets further. +2. The content event is emitted, and the content type's cached pages and public reads are expired. +3. The signals are published. + +So whoever reads again because of a signal gets the new state, never a cached copy. + +| Room | Who may join | Sent for | +| --- | --- | --- | +| AdminCP list of one content type | An AdminCP session with the content type's `can_view`, the permission of the list route | Every content event of that type | +| Thread of one comment type under one root record | Anyone who may read that thread: guests for a public article; authors, editors and moderators for a hidden one | Changes to comments that were or are public, naming the top-level branch they sit in | +| The same thread room | The same | The root record being hidden, unhidden, published, unpublished, restored or deleted | + +A thread signal never carries text, authors or reasons. It is sent only for a comment that was or is public, so a draft, a comment waiting for a moderator, or an edit of a hidden comment sends nothing to the thread. The AdminCP list still hears about it. + +### Custom write routes + +A custom route that writes through `model.service(c)` or `model.editorialService(c)` emits nothing by itself, so open screens don't hear about it. After the write commits, call `contentEditorialEffects` for an editorial write, or `emitContentEvent` for a plain one, as the generated routes do. Pass the row whenever you have it. For a delete of a threaded record it's required, because the deleted record can't be read back to find its thread: + +```ts title="plugins/newsroom/src/api/modules/comments/routes/remove.route.ts (excerpt)" +import { + contentPublicStateOf, + emitContentEvent, +} from "@vitnode/core/content/server"; + +import { commentContentType } from "@/content/comment"; +import { commentContent } from "@/database/comments"; +import { manifest } from "@/manifest.gen"; + +// Inside your route handler, after the write: +const row = await commentContent.service(c).delete(id); +if (row) { + await emitContentEvent( + c, + commentContentType, + "deleted", + { contentId: row.id }, + { + pluginId: manifest.id, + publicState: contentPublicStateOf(commentContentType, row, null), + row, + }, + ); +} +``` + +For a change that has no content event, `publishContentRefresh(c, { action, contentId, definition, publicState, row })` from the same import sends only the signals. Call it after the caches are expired. + +## Who stays subscribed + +A room name is only an address. The server checks every join with the rules above and answers `FORBIDDEN` or `NOT_FOUND` without adding the connection. After that: + +- **A revoked session** (signing out a device, a password change or reset, an admin revoke) closes the user's WebSocket connections on every instance. **Signing out or in** within a tab reconnects the shared connection under the new identity. Either way, the tabs join their rooms again and the server checks them again. +- **A lost permission or an ended session**, including an AdminCP session that expired or signed out, is caught within about 80 seconds: the server checks every subscription again once it's a minute old. A thread subscription opened while signed in fails that check once its session is gone, even on a public article. +- **A thread root that becomes hidden, unpublished or deleted** is checked at once. Readers who lost access are removed from the room, and their open thread reads again, gets `404` and clears. + +Tabs of one browser share the connection, but each tab joins for itself and acts only on its own confirmed joins. A tab with the comments closed doesn't react to a signal another tab asked for. + +## Reconnects and missed changes + +Delivery is best effort and nothing is replayed. A signal sent while a tab was disconnected is lost, so every join is followed by one read that covers the gap: + +- an AdminCP list reads its page once; +- a comment section reads its loaded pages once. + +After a reconnect, that read waits a random moment of up to 1.5 seconds, so a server restart doesn't make every open tab read at the same time. + +## Several server instances + +With `REDIS_URL` set, a signal published on one instance reaches subscribers on every instance, and a root change re-checks their rooms there too. Without Redis, only tabs connected to the instance that handled the write hear about it, which is fine for a single server. See [Rooms on several instances](/docs/dev/websocket/rooms#rooms-on-several-instances). + +## Cost of a busy thread + +A popular article can have hundreds of readers with its thread open, and every signal reaches all of them, so each one has to stay cheap. Per open section: + +| Event | Requests | +| --- | --- | +| A change in a loaded branch | One `GET /thread/branches` for that branch | +| Five quick changes to the same branch | One, within 2 seconds | +| Changes to several loaded branches at once | One per 10 branches, at most 2 running at once | +| A new top-level comment | None until **Show new comments**, then one `GET /thread` for what follows the last loaded comment | +| A change in a branch that isn't loaded | None | +| A join or reconnect | One read of every loaded page | + +Guests and readers without hidden access all get the same branch answer, so the API serves it from its [public read cache](/docs/content-engine/caching), which the write expired before the signal went out. Readers who may see hidden comments get a private answer that is never shared. + +Limits: one connection holds at most 64 subscriptions across all of its tabs and may send 120 joins a minute, and `/thread/branches` reads at most 10 branches per request. These are limits in the code, not benchmark results. + +## Follow a scope from your own screen + +`useContentLiveRefresh` from `@vitnode/core/tanstack/content` is the hook behind both features. Use it when your own screen shows Content Engine data, such as a custom admin page. Pass a scope and re-read on every signal and every join: + +```tsx title="plugins/newsroom/src/views/story-board.tsx (excerpt)" +import { useContentLiveRefresh } from "@vitnode/core/tanstack/content"; + +import { storyContentType } from "@/content/story"; + +// Inside the component, next to the query that loads the board: +const status = useContentLiveRefresh( + { contentTypeId: storyContentType.id, kind: "admin" }, + { + onSignal: () => void refetch(), + onSubscribed: () => void refetch(), + }, +); +``` + +`refetch` is the refetch of the screen's own TanStack Query. A scope is `{ contentTypeId, kind: "admin" }` or `{ contentTypeId, kind: "thread", rootId }`, with the same join rules as the table above. Pass `null` to stay off. The hook returns `"off"` (no scope or no open connection), `"joining"`, `"live"` or `"denied"` (refused or revoked), and calls `onDenied` when the server refuses or revokes the subscription. + +## Limitations + +- Signals are hints, not a durable log. A reader who was offline catches up on the next join, not change by change. +- Moderators don't get live updates for changes the public can't see, such as a new comment waiting for approval. They see them on the next read. +- A subscription the server refused or revoked stays off until the connection reconnects or the screen is opened again. +- The Forum plugin draws its own topic thread and doesn't use live comments yet. + +## Related + +- [Live comments](/docs/content-engine/threads-live): turn it on for a comment section. +- [Events](/docs/content-engine/events): the content events behind the signals. +- [Reconnects](/docs/dev/websocket/reconnects): what a WebSocket reconnect keeps and loses. diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 13ec5f095..ab1294791 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -23,6 +23,7 @@ "threads", "threads-editing", "threads-frontend", + "threads-live", "notifications", "---Organization---", "relations", @@ -52,6 +53,7 @@ "admincp", "services", "events", + "live-refresh", "---Deployment---", "database", "caching", diff --git a/apps/web/content/docs/content-engine/threads-live-new-comments.webp b/apps/web/content/docs/content-engine/threads-live-new-comments.webp new file mode 100644 index 0000000000000000000000000000000000000000..616af7e7df8f9c4674e41bbe73e44cc58620a800 GIT binary patch literal 46724 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 + + + +### Read single branches + +Add `branches` to the `useContentComments` call in `src/pages/article-comments.tsx`. It calls `/thread/branches`, which the Content Engine adds to every content type with `thread`, so there is nothing to change on the comment type. This is an excerpt: + +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" + const comments = useContentComments({ + ...articleCommentsThread(articleId), + branches: async ids => // [!code ++:13] + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/article-comments", + path: "/thread/branches", + args: { + query: { + root: String(articleId), + ...(ids.length > 0 ? { ids: ids.join(",") } : {}), + }, + }, + }), +``` + +A branch is one top-level comment with every reply below it. `branches` already makes the reader's own writes cheaper: a reply, edit, hide or delete re-reads only its branch instead of every loaded page. + + + + + +### Subscribe to the thread + +Add `live` to the same call, with the comment type's id and the article's id. Import `commentContentType` unless [Comment actions](/docs/content-engine/threads-editing) already did for reports: + +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" +import { commentContentType } from "@/content/comment"; // [!code ++] + + live: { contentTypeId: commentContentType.id, rootId: articleId }, // [!code ++] +``` + +That's the whole change. `live` needs `branches` and is ignored without it. `ContentComments` shows **Show new comments** and refresh errors on its own. A custom layout renders `ContentCommentsLiveStatus` inside its `ContentCommentsRoot`, as the Blog plugin does; see [Comments API](/docs/content-engine/comments-api#your-own-layout). + + + + + +## Check the result + +Use two browser windows, one of them private, so they hold different sessions. Sign in as a different member in each, and open the same article with at most 20 top-level comments, so its only page is loaded: + +1. In window A, reply to a comment. Window B shows the reply within a second or two, without a reload. +2. In window B, click **Reply** under a comment and type a few words without sending. In window A, reply to the same comment. Window B shows the new reply and keeps your unsent text. +3. In window A, post a new top-level comment. Window B shows **New comments were posted.** at the end of the thread. Click **Show new comments** to add it below the others. + +A signed-out window gets the same updates; it just can't reply. + + + +## What arrives live + +| Change in another session | What an open section does | +| --- | --- | +| A reply, edit, hide, unhide or delete in a loaded branch | Re-reads that branch and puts it back in place | +| A burst of changes to one branch | Re-reads it once, 0.4 seconds after the last change and never more than 2 seconds after the first | +| A new top-level comment, with every page loaded | Shows **Show new comments**; clicking it reads only what follows the last loaded comment | +| A new top-level comment, with more pages on the server | Nothing yet. Comments are oldest first, so **Load more comments** reaches it | +| A change in a branch that isn't loaded | Nothing; it's read fresh when its page loads | +| A comment that is a draft or waits for a moderator | Nothing. It isn't public, so other readers aren't told | +| The article is hidden, unpublished or deleted | Readers who lost access see the thread disappear | + +Each reader's own session decides what they see, exactly as on the first load. A hidden comment still shows to its author and moderators, and becomes a placeholder for everybody else when it has replies. + +### Open forms keep their text + +If a comment is removed while someone edits it or replies to it, the form stays open with the text. It says "This comment was removed while you were editing it" or "The comment you are replying to was removed", and hides the submit button so the text can be copied elsewhere. Closing the form re-reads the branch, and the comment leaves the screen or turns into a placeholder. + +### Failed refreshes + +When a live refresh fails, the comments on screen stay and the section says "Could not refresh the comments, so some may be out of date." with **Try again**. A comment that was hidden, unpublished or deleted still leaves the screen, or turns into a placeholder when it has replies. When the article itself becomes unreadable, the thread is cleared rather than left on screen. + +## Related + +- [Live refresh](/docs/content-engine/live-refresh): which writes send a signal, who may subscribe, reconnects, several instances and custom write routes. +- [Comments API](/docs/content-engine/comments-api): every `useContentComments` option and controller field. diff --git a/apps/web/content/docs/dev/websocket/rooms.mdx b/apps/web/content/docs/dev/websocket/rooms.mdx index e562b18db..c3b264bbf 100644 --- a/apps/web/content/docs/dev/websocket/rooms.mdx +++ b/apps/web/content/docs/dev/websocket/rooms.mdx @@ -207,4 +207,4 @@ Both listeners return a function that removes them. Without Redis, all of this w ## Related - [Reconnects and missed messages](/docs/dev/websocket/reconnects) -- [WebSocket](/docs/dev/websocket) +- [Live refresh](/docs/content-engine/live-refresh): Content Engine's authorized rooms for AdminCP lists and comment threads, built on these primitives. diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index f9eceaeb6..9337fb3f9 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -3377,6 +3377,14 @@ "name": "[usunięty]", "by_author": "Komentarz usunięty przez użytkownika", "by_moderator": "Komentarz usunięty przez moderatora" + }, + "live": { + "new_comments": "Pojawiły się nowe komentarze.", + "show_new": "Pokaż nowe komentarze", + "refresh_failed": "Nie udało się odświeżyć komentarzy, więc część z nich może być nieaktualna.", + "retry": "Spróbuj ponownie", + "edit_removed": "Ten komentarz został usunięty w trakcie edycji. Twój tekst nadal tu jest, ale nie da się go już zapisać.", + "reply_removed": "Komentarz, na który odpowiadasz, został usunięty. Twój tekst nadal tu jest, ale nie da się go już opublikować." } } }, diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts index f141e7c15..710695906 100644 --- a/packages/vitnode/src/api/modules/content/content.module.ts +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/builders"; import { contentLiveWebSocket } from "@/content/server/live/socket"; +import { contentRefreshWebSocket } from "@/content/server/refresh/socket"; import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron"; import { contentNotificationsQueueTask } from "./tasks/content-notifications.task"; @@ -17,5 +18,5 @@ export const contentModule = buildModule({ contentSearchDependentsQueueTask, contentNotificationsQueueTask, ], - webSockets: [contentLiveWebSocket], + webSockets: [contentLiveWebSocket, contentRefreshWebSocket], }); diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index e8f62ade8..ce6c22ef2 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -419,6 +419,14 @@ export { resolveContentTranslationIndexes, toSnakeCase, } from "./indexes"; +export type { + ContentRefreshClientMessage, + ContentRefreshErrorCode, + ContentRefreshScope, + ContentRefreshServerMessage, + ContentRefreshSignal, +} from "./live/refresh"; +export { contentRefreshChannel, contentRefreshRoom } from "./live/refresh"; export { contentLocalesMatch, isContentLocaleShaped, diff --git a/packages/vitnode/src/content/live/refresh.ts b/packages/vitnode/src/content/live/refresh.ts new file mode 100644 index 000000000..063d5856d --- /dev/null +++ b/packages/vitnode/src/content/live/refresh.ts @@ -0,0 +1,104 @@ +import type { ContentId } from "@/content/ids"; + +import { contentIdKey } from "@/content/ids"; +import { createWebSocketChannel } from "@/ws/types"; + +/** + * What one live refresh subscription watches: + * + * - `admin`: the generated AdminCP list of one content type. Joining needs an + * AdminCP session with the content type's `can_view`. + * - `thread`: the public thread of one threaded content type under one root + * record. Joining needs the same access the `GET /thread` route asks for. + * + * Browser-safe. + */ +export type ContentRefreshScope = + | { contentTypeId: string; kind: "admin" } + | { contentTypeId: string; kind: "thread"; rootId: ContentId }; + +/** + * A hint that something a subscriber shows changed. It never carries values: + * HTTP stays the source of truth, and the receiver reads it again. + * + * - `list`: an admin list of this content type changed. + * - `branch`: a public change inside one top-level branch of a thread. + * `branchId` is the top-level comment's id - public, or shown as a + * placeholder, before or after the change. `removedId` is set when a + * comment that was public stopped being so, so a receiver whose refetch + * fails can still take it off screen. + * - `thread`: the thread's root record changed in a way that can move who may + * read it. Read the thread again, and drop it when that is refused. + */ +export type ContentRefreshSignal = + | { branchId: ContentId; kind: "branch"; removedId?: ContentId } + | { kind: "list" } + | { kind: "thread" }; + +export interface ContentRefreshClientMessage { + clientId: string; + scope: ContentRefreshScope; + type: "join" | "leave"; +} + +export type ContentRefreshErrorCode = + | "FORBIDDEN" + | "INVALID_MESSAGE" + | "NOT_FOUND" + | "RATE_LIMITED" + | "TOO_MANY_SUBSCRIPTIONS"; + +export type ContentRefreshServerMessage = + | { + clientId: string; + code: "FORBIDDEN" | "NOT_FOUND"; + room: string; + type: "revoked"; + } + | { + clientId: string; + code: ContentRefreshErrorCode; + room?: string; + type: "error"; + } + | { clientId: string; room: string; type: "joined" } + | { room: string; signal: ContentRefreshSignal; type: "changed" }; + +/** Core's channel for Content Engine live refresh, on the shared WebSocket. */ +export const contentRefreshChannel = createWebSocketChannel< + ContentRefreshClientMessage, + ContentRefreshServerMessage +>({ pluginId: "@vitnode/core", module: "content", id: "refresh" }); + +export const CONTENT_REFRESH_ROOM_PREFIX = "content-refresh:"; + +/** + * The room one scope maps to - a routing key, never a permission. Content type + * ids hold no `:`, and ids are compared as decimal or UUID strings, so a + * bigint id is never rounded through a `number`. + */ +export const contentRefreshRoom = (scope: ContentRefreshScope): string => + scope.kind === "admin" + ? `${CONTENT_REFRESH_ROOM_PREFIX}admin:${scope.contentTypeId}` + : `${CONTENT_REFRESH_ROOM_PREFIX}thread:${scope.contentTypeId}:${contentIdKey(scope.rootId)}`; + +/** Subscriptions one connection may hold at once, over every tab sharing it. */ +export const CONTENT_REFRESH_MAX_SUBSCRIPTIONS = 64; + +/** Join requests one connection may send per minute. */ +export const CONTENT_REFRESH_MAX_JOINS_PER_MINUTE = 120; + +/** How long a successful join is trusted before the server checks it again. */ +export const CONTENT_REFRESH_REAUTH_MS = 60_000; + +/** How often the server looks for subscriptions due for that check. */ +export const CONTENT_REFRESH_SWEEP_MS = 20_000; + +/** A burst of signals is applied this long after the last one... */ +export const CONTENT_REFRESH_DEBOUNCE_MS = 400; + +/** ...but never later than this after the first, so traffic cannot starve it. */ +export const CONTENT_REFRESH_MAX_WAIT_MS = 2_000; + +/** Top-level branches one `GET /thread/branches` request reads. */ +export const CONTENT_THREAD_BRANCHES_MAX = 10; diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index 007d97996..a5b615383 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -167,7 +167,11 @@ export const contentEditorialEffects = async ( // be handling the request. Passed on every path, interactive and // scheduled, so the envelope's owner is a property of the event rather // than of how it was triggered. - { pluginId, publicState: outcome.publicState }, + { + pluginId, + publicState: outcome.publicState, + row: outcome.row, + }, ); // Logged here rather than left to each caller. The scheduled path diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index dfa4ee7c5..8c23ec9d5 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -29,6 +29,7 @@ import type { ContentPublicState } from "./page-cache"; import { contentEventName } from "../events"; import { invalidateContentPages } from "./page-cache"; +import { publishContentRefresh } from "./refresh/publish"; type ContentPayload = | ContentCreatedPayload @@ -56,10 +57,15 @@ interface ContentEventEmitter { } /** - * Emits `content.{definition.id}.{action}` on the request's event bus and then - * invalidates the cached pages that show the content type. Call it after the - * write's transaction has committed. The generated routes already emit every - * built-in event; use this from custom write paths. Server-only. + * Emits `content.{definition.id}.{action}` on the request's event bus, + * invalidates the cached pages and public reads that show the content type, + * and only then sends the live refresh signals (`publishContentRefresh`) to + * open AdminCP lists and threads - so a client that reacts reads the new + * state. Call it after the write's transaction has committed, never inside + * it: a rolled-back write must not be announced. The generated routes already + * emit every built-in event; use this from custom write paths. A plain + * `model.service(c)` or `model.editorialService(c)` write emits nothing on + * its own. Server-only. * * @returns The event bus result, with a page-cache invalidation failure, if * any, appended to `failures` rather than thrown. @@ -77,6 +83,12 @@ export const emitContentEvent = async ( * the content type's cached lists. */ publicState?: ContentPublicState; + /** + * The record after the write, or as it was just before a delete. Live + * refresh routes a threaded record to its thread with it; a deleted one + * cannot be read back, so pass it on every delete of a threaded type. + */ + row?: null | Record; }, ): Promise => { const name = contentEventName(definition.id, action) as VitNodeEventName; @@ -99,6 +111,15 @@ export const emitContentEvent = async ( ), ]); + // After the caches above, so whoever reads again on this signal misses them. + await publishContentRefresh(c, { + action, + contentId: payload.contentId, + definition, + publicState: options?.publicState, + row: options?.row, + }); + return pageCacheFailure === null ? result : { ...result, failures: [...result.failures, pageCacheFailure] }; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 9279aa799..eab8f2574 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -214,8 +214,10 @@ export type { } from "./notifications/registry"; export { contentListPageCacheTags, + contentPublicStateOf, contentRecordPageCacheTags, } from "./page-cache"; +export type { ContentPublicState } from "./page-cache"; export { assertContentPreviewIsServable, contentPreviewSecret, @@ -303,6 +305,11 @@ export { } from "./query"; export { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; export type { ReferenceTarget } from "./references"; +export { + onContentRefreshRootChanged, + publishContentRefresh, +} from "./refresh/publish"; +export type { ContentRefreshChange } from "./refresh/publish"; export { contentLocaleHeaders, resolveContentRequestLocale, @@ -396,8 +403,16 @@ export { assertContentThreadPlacement, ContentThreadPlacementError, readContentThread, + readContentThreadBranches, +} from "./thread"; +export type { + ContentThreadBranch, + ContentThreadBranches, + ContentThreadNode, + ContentThreadPage, } from "./thread"; -export type { ContentThreadNode, ContentThreadPage } from "./thread"; +export { resolveContentThreadRoot } from "./thread-access"; +export type { ContentThreadRootAccess } from "./thread-access"; export { createContentTranslationEditorialService } from "./translation-editorial-service"; export type { ContentRevisionDetailForLocale, diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index b39639c76..127c11ec2 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -10,7 +10,7 @@ import { z } from "@hono/zod-openapi"; import { and, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; -import type { ContentIdOf } from "../ids"; +import type { ContentId, ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, ContentPublicFilterInput, @@ -19,6 +19,7 @@ import type { import type { ContentModel } from "./model"; import type { ContentPreviewTokenPayload } from "./preview-token"; import type { ContentPublicService } from "./public-service"; +import type { ContentThreadNode } from "./thread"; import { setPageCacheTags } from "../../api/lib/page-cache"; import { buildRoute } from "../../api/lib/route"; @@ -37,6 +38,7 @@ import { contentContainerFieldOf } from "../containers/association"; import { createContentContainerService } from "../containers/server/service"; import { ContentEngineError } from "../errors"; import { parseContentId } from "../ids"; +import { CONTENT_THREAD_BRANCHES_MAX } from "../live/refresh"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, @@ -47,7 +49,6 @@ import { publicOrderableColumns } from "../registry"; import { buildContentDeliveryRoutes } from "./delivery-routes"; import { resolveContentPublicRowFiles } from "./files"; import { - canViewHiddenContentRecord, contentListHiddenAccess, contentThreadHiddenAccess, findViewableHiddenBySlug, @@ -79,7 +80,8 @@ import { contentSnapshotRow, projectTranslationRevisionSnapshot, } from "./revision-snapshot"; -import { readContentThread } from "./thread"; +import { readContentThread, readContentThreadBranches } from "./thread"; +import { resolveContentThreadRoot } from "./thread-access"; /** * The public routes of one content type: list and detail, plus preview, @@ -524,6 +526,38 @@ export const buildContentPublicRoutes = < }, }); + /** Rows for a thread read: public ones, plus hidden ones this reader may see. */ + const threadProjector = + (c: Context, hiddenParents: boolean) => + async (ids: ContentId[], { hiddenIds }: { hiddenIds: ContentId[] }) => { + const read = service(c).findManyByIds; + const rows = read + ? await read(ids as never, { + hidden: hiddenIds.length > 0, + hiddenParents, + }) + : []; + + return new Map( + rows.map(row => [String((row as unknown as { id: unknown }).id), row]), + ); + }; + + const serializeThreadNode = < + T extends { deleted: ContentThreadNode["deleted"] }, + >({ + deleted, + ...item + }: T) => ({ + ...item, + deleted: deleted + ? { + byAuthor: deleted.byAuthor, + createdAt: deleted.createdAt.toISOString(), + } + : null, + }); + const threadNode = z.object({ deleted: z .object({ byAuthor: z.boolean(), createdAt: z.string() }) @@ -578,42 +612,11 @@ export const buildContentPublicRoutes = < }, handler: async c => { const { after, first, root } = c.req.valid("query"); - const rootField = definition.fields[definition.thread.rootField]; - const target = - rootField?.kind === "relation" ? rootField.target() : undefined; - const rootModel = (c.get("core")?.contentModels ?? []).find( - entry => entry.model.definition.id === target?.id, - )?.model; - const rootId = target ? parseContentId(target.idStrategy, root) : null; - // An existence check: `select: []` reads the same published, visible row - // without hydrating a single field of it. - const findReadableRoot = async () => { - const rootService = rootModel?.publicService?.(c); - if (rootId === null || !target || !rootService) return null; - - if (await rootService.findById(rootId, { select: [] })) { - return { hidden: false }; - } - - const hidden = await rootService.findById(rootId, { - hidden: true, - select: [], - }); - - return hidden && - (await canViewHiddenContentRecord(c, { - contentTypeId: target.id, - itemId: rootId, - })) - ? { hidden: true } - : null; - }; - const readable = await findReadableRoot(); - if (!readable || rootId === null) { + const readable = await resolveContentThreadRoot(c, definition, root); + if (!readable) { throw new HTTPException(404, { message: "Thread not found." }); } - const read = service(c).findManyByIds; const hiddenAccess = await contentThreadHiddenAccess(c, { model, pluginId, @@ -622,35 +625,13 @@ export const buildContentPublicRoutes = < after, first, hiddenAccess, - project: async (ids, { hiddenIds }) => { - const rows = read - ? await read(ids as never, { - hidden: hiddenIds.length > 0, - hiddenParents: readable.hidden, - }) - : []; - - return new Map( - rows.map(row => [ - String((row as unknown as { id: unknown }).id), - row, - ]), - ); - }, - root: rootId, + project: threadProjector(c, readable.hidden), + root: readable.rootId, }); const body = { ...page, - items: page.items.map(({ deleted, ...item }) => ({ - ...item, - deleted: deleted - ? { - byAuthor: deleted.byAuthor, - createdAt: deleted.createdAt.toISOString(), - } - : null, - })), + items: page.items.map(serializeThreadNode), }; if (hiddenAccess || readable.hidden) { @@ -659,13 +640,137 @@ export const buildContentPublicRoutes = < setPageCacheTags(c, [ ...contentListPageCacheTags(definition), - ...(target ? contentListPageCacheTags(target) : []), + ...contentListPageCacheTags(readable.target), ]); return c.json(body, 200); }, }); + const threadBranch = z.discriminatedUnion("status", [ + z.object({ + id: schemas.params.shape.id, + items: z.array(threadNode), + status: z.literal("ok"), + }), + z.object({ id: schemas.params.shape.id, status: z.literal("gone") }), + ]); + + /** + * The current state of a few top-level branches already on a reader's + * screen, so a live update re-reads one branch instead of every page. The + * same root check, visibility, placeholders, depth and truncation as + * `/thread`. Guests and readers with no hidden access get the same answer, + * so it is read through the public read cache that every comment write + * expires before it is announced; anyone else's answer is private. + */ + const threadBranches = buildRoute({ + pluginId, + route: { + method: "get", + path: "/thread/branches", + description: `Re-read some top-level ${name} branches of one thread, each with every answer below it`, + request: { + query: z.object({ + root: z.string().min(1).max(64), + ids: z + .string() + .max(CONTENT_THREAD_BRANCHES_MAX * 65) + .optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + branches: z.array(threadBranch), + pageInfo: z.object({ totalCount: z.number() }), + replyCount: z.number(), + truncated: z.boolean(), + }), + }, + }, + description: + "Each requested branch, in the order asked, or `gone` when the reader no longer sees it. Without `ids`, only the counts", + }, + 400: { description: "Malformed or too many ids" }, + 404: { description: "The root record does not exist or is not public" }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + const unique = new Map(); + for (const raw of (query.ids ?? "").split(",")) { + const trimmed = raw.trim(); + if (trimmed === "") continue; + const id = parseContentId(definition.idStrategy, trimmed); + if (id === null) { + throw new HTTPException(400, { message: "Invalid branch id." }); + } + if (!unique.has(String(id))) unique.set(String(id), id); + } + const ids = [...unique.values()]; + if (ids.length > CONTENT_THREAD_BRANCHES_MAX) { + throw new HTTPException(400, { + message: `Ask for at most ${CONTENT_THREAD_BRANCHES_MAX} branches at once.`, + }); + } + + const readable = await resolveContentThreadRoot( + c, + definition, + query.root, + ); + if (!readable) { + throw new HTTPException(404, { message: "Thread not found." }); + } + + const hiddenAccess = await contentThreadHiddenAccess(c, { + model, + pluginId, + }); + const load = async () => { + const result = await readContentThreadBranches(c, model, { + hiddenAccess, + ids, + project: threadProjector(c, readable.hidden), + root: readable.rootId, + }); + + return { + ...result, + branches: result.branches.map(branch => + branch.status === "ok" + ? { ...branch, items: branch.items.map(serializeThreadNode) } + : branch, + ), + }; + }; + + if (hiddenAccess || readable.hidden) { + return c.json(await load(), 200, { + "Cache-Control": "private, no-store", + }); + } + + const body = await rememberContentPublicRead(c, { + contentTypeId: definition.id, + key: { + ids: ids.map(String), + root: String(readable.rootId), + route: "thread-branches", + }, + load, + }); + + // Every viewer of a busy thread asks for the same branch after one + // signal: the read cache above answers them, and no shared HTTP cache + // may hold a copy that outlives the next write. + return c.json(body, 200, { "Cache-Control": "no-cache" }); + }, + }); + const detail = buildRoute({ pluginId, route: { @@ -755,7 +860,7 @@ export const buildContentPublicRoutes = < ...(definition.delivery.enabled ? buildContentDeliveryRoutes(model, { pluginId }) : []), - ...(definition.thread.enabled ? [thread] : []), + ...(definition.thread.enabled ? [thread, threadBranches] : []), ...(definition.publicWrite.enabled || definition.reports.enabled || definition.visibility.enabled diff --git a/packages/vitnode/src/content/server/public-visibility-routes.ts b/packages/vitnode/src/content/server/public-visibility-routes.ts index b9ed4c1d6..56355e6f2 100644 --- a/packages/vitnode/src/content/server/public-visibility-routes.ts +++ b/packages/vitnode/src/content/server/public-visibility-routes.ts @@ -286,6 +286,7 @@ export const contentPublicDeleteRoute = < { pluginId, publicState: contentPublicStateOf(definition, deleted, null), + row: deleted, }, ); await syncContentSearch(c, definition, { @@ -309,6 +310,7 @@ export const contentPublicDeleteRoute = < { pluginId, publicState: contentPublicStateOf(definition, row, null), + row, }, ); await syncContentSearch(c, definition, { diff --git a/packages/vitnode/src/content/server/refresh/auth.test.ts b/packages/vitnode/src/content/server/refresh/auth.test.ts new file mode 100644 index 000000000..a0ee87476 --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/auth.test.ts @@ -0,0 +1,216 @@ +import type { Context, Next } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { ContentRefreshScope } from "@/content/live/refresh"; + +import { SessionModel } from "@/api/models/session"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { core_content_revisions } from "@/database/content"; +import { core_content_containers } from "@/database/content-containers"; +import { core_content_submissions } from "@/database/content-submissions"; +import { + BOARD, + POLICY_LIVE, + POLICY_PLUGIN_ID, + policyBoardRows, + policyReplies, + policyTopicRow, + policyTopics, +} from "@/tests/content-policies"; +import { + createSessionWorld, + fakeSession, + sessionCookies, +} from "@/tests/sessions"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { authorizeContentRefresh } from "./auth"; + +const TOPIC = { DRAFT: 2, HIDDEN: 3, LIVE: 1 } as const; +const REPLY_TYPE = policyReplies.definition.id; +const TOPIC_TYPE = policyTopics.definition.id; + +const DEVICE = { id: 3, publicId: "laptop" }; +const admin = fakeSession(7, DEVICE); +const member = fakeSession(8, DEVICE, ["user"]); +const author = fakeSession(9, DEVICE, ["user"]); + +const CAN_VIEW_TOPICS = { + module: policyTopics.definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: POLICY_PLUGIN_ID, +}; + +const setup = async () => { + const world = await createSessionWorld({ + extraTables: [ + [core_content_containers, policyBoardRows()], + [ + policyTopics.table, + [ + policyTopicRow(TOPIC.LIVE, BOARD.OPEN), + policyTopicRow(TOPIC.DRAFT, BOARD.OPEN, { + publishedAt: null, + status: "draft", + }), + policyTopicRow(TOPIC.HIDDEN, BOARD.OPEN, { + author: author.userId, + hiddenAt: POLICY_LIVE, + hiddenBy: 99, + }), + ], + ], + [policyReplies.table, []], + [core_content_revisions, []], + [core_content_submissions, []], + ], + sessions: [admin, member, author], + }); + + world.app.use("*", async (c: Context, next: Next) => { + c.set("core", { + ...c.get("core"), + contentModels: [ + { model: policyTopics, pluginId: POLICY_PLUGIN_ID }, + { model: policyReplies, pluginId: POLICY_PLUGIN_ID }, + ], + contentTypes: [ + { definition: policyTopics.definition, pluginId: POLICY_PLUGIN_ID }, + { definition: policyReplies.definition, pluginId: POLICY_PLUGIN_ID }, + ], + moderation: { scopes: [], targets: [] }, + permissionStaff: [], + } as unknown as EnvVariablesVitNode["core"]); + await next(); + }); + world.app.post("/authorize", async c => + c.json( + await authorizeContentRefresh(c, await c.req.json()), + ), + ); + // An open WebSocket keeps the user it was opened with, even after sign-out. + world.app.post("/authorize-after-sign-out", async c => { + await new SessionModel(c).deleteSession(); + + return c.json( + await authorizeContentRefresh(c, await c.req.json()), + ); + }); + + const authorize = async ( + scope: ContentRefreshScope, + { + as, + kind = "admin", + signedOut = false, + }: { + as?: typeof admin; + kind?: "admin" | "user"; + signedOut?: boolean; + } = {}, + ) => { + const response = await world.app.request( + signedOut ? "/authorize-after-sign-out" : "/authorize", + { + body: JSON.stringify(scope), + headers: as ? { cookie: sessionCookies(as, kind) } : {}, + method: "POST", + }, + ); + + return (await response.json()) as Awaited< + ReturnType + >; + }; + + return { authorize, world }; +}; + +const thread = (rootId: number | string): ContentRefreshScope => ({ + contentTypeId: REPLY_TYPE, + kind: "thread", + rootId, +}); + +const NOT_FOUND = { code: "NOT_FOUND", ok: false }; +const FORBIDDEN = { code: "FORBIDDEN", ok: false }; + +describe("authorizeContentRefresh for a thread", () => { + it("lets a guest follow the thread of a public root", async () => { + const { authorize } = await setup(); + + expect(await authorize(thread(TOPIC.LIVE))).toStrictEqual({ ok: true }); + }); + + it.each([ + ["a draft", thread(TOPIC.DRAFT)], + ["a hidden", thread(TOPIC.HIDDEN)], + ["a missing", thread(999)], + ["a malformed", thread("abc")], + [ + "a content type without threads, for", + { contentTypeId: TOPIC_TYPE, kind: "thread", rootId: TOPIC.LIVE }, + ], + [ + "an unknown content type, for", + { contentTypeId: "test.nope", kind: "thread", rootId: TOPIC.LIVE }, + ], + ] satisfies [string, ContentRefreshScope][])( + "answers NOT_FOUND for %s root", + async (_label, scope) => { + const { authorize } = await setup(); + + expect(await authorize(scope)).toStrictEqual(NOT_FOUND); + }, + ); + + it("lets a signed-in reader follow a public thread only while their session exists", async () => { + const { authorize, world } = await setup(); + const reader = { as: member, kind: "user" } as const; + + expect(await authorize(thread(TOPIC.LIVE), reader)).toStrictEqual({ + ok: true, + }); + expect( + await authorize(thread(TOPIC.LIVE), { ...reader, signedOut: true }), + ).toStrictEqual(FORBIDDEN); + expect(world.rowsFor(member).user).toBe(0); + }); + + it("lets the author of a hidden root follow its thread", async () => { + const { authorize } = await setup(); + + expect( + await authorize(thread(TOPIC.HIDDEN), { as: author, kind: "user" }), + ).toStrictEqual({ ok: true }); + }); +}); + +describe("authorizeContentRefresh for an AdminCP list", () => { + const list: ContentRefreshScope = { + contentTypeId: TOPIC_TYPE, + kind: "admin", + }; + + it.each([ + ["without a session", undefined, "admin"], + ["with only a member session", member, "user"], + ["with an admin session lacking can_view", admin, "admin"], + ] as const)("refuses %s", async (_label, as, kind) => { + const { authorize } = await setup(); + + expect(await authorize(list, { as, kind })).toStrictEqual(FORBIDDEN); + }); + + it("accepts an AdminCP session with the content type's can_view", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [CAN_VIEW_TOPICS], + userId: admin.userId, + }); + + expect(await authorize(list, { as: admin })).toStrictEqual({ ok: true }); + }); +}); diff --git a/packages/vitnode/src/content/server/refresh/auth.ts b/packages/vitnode/src/content/server/refresh/auth.ts new file mode 100644 index 000000000..d8670ed76 --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/auth.ts @@ -0,0 +1,81 @@ +import type { Context } from "hono"; + +import type { ContentRefreshScope } from "@/content/live/refresh"; + +import { checkStaffPermissionOfUser } from "@/api/lib/check-staff-permission"; +import { SessionModel } from "@/api/models/session"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentRefreshRoom } from "@/content/live/refresh"; + +import { resolveContentThreadRoot } from "../thread-access"; + +export type ContentRefreshAuthorization = + | { code: "FORBIDDEN" | "NOT_FOUND"; ok: false } + | { ok: true }; + +/** + * Whether the connection behind `c` may follow `scope`, by the same rules as + * the HTTP route it mirrors: + * + * - `admin`: an AdminCP session (read fresh, so a signed-out or expired one + * fails) whose user holds the content type's `can_view` - the permission + * the generated `GET /` list route requires. + * - `thread`: `resolveContentThreadRoot`, the rule behind `GET /thread`. A + * guest passes for a public root; a connection opened signed in fails once + * that session is gone. + */ +export const authorizeContentRefresh = async ( + c: Context, + scope: ContentRefreshScope, +): Promise => { + const entry = (c.get("core")?.contentTypes ?? []).find( + candidate => candidate.definition.id === scope.contentTypeId, + ); + if (!entry) return { code: "NOT_FOUND", ok: false }; + + if (scope.kind === "thread") { + const { definition } = entry; + if (!definition.thread.enabled || !definition.publicApi.enabled) { + return { code: "NOT_FOUND", ok: false }; + } + + // The connection keeps the user it was opened with. Signed out since, it + // follows nothing until the client reconnects as whoever it is now. + const user = c.get("user"); + if (user && (await new SessionModel(c).getUser())?.id !== user.id) { + return { code: "FORBIDDEN", ok: false }; + } + + return (await resolveContentThreadRoot(c, definition, scope.rootId)) + ? { ok: true } + : { code: "NOT_FOUND", ok: false }; + } + + const session = await new SessionAdminModel(c).getSession({ extend: false }); + if (!session) return { code: "FORBIDDEN", ok: false }; + + const allowed = await checkStaffPermissionOfUser(c, session.user, { + module: entry.definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: entry.pluginId, + type: "admin", + }); + + return allowed ? { ok: true } : { code: "FORBIDDEN", ok: false }; +}; + +/** + * Connections that are sure to get the same answer for `scope`, so a re-check + * of a busy room asks once per group: a thread's answer depends only on who is + * signed in. `null` for an admin scope, which reads each connection's own + * AdminCP session. + */ +export const contentRefreshShareKey = ( + c: Context, + scope: ContentRefreshScope, +): null | string => { + if (scope.kind === "admin") return null; + + return `${contentRefreshRoom(scope)}|${c.get("user")?.id ?? "guest"}`; +}; diff --git a/packages/vitnode/src/content/server/refresh/instances.test.ts b/packages/vitnode/src/content/server/refresh/instances.test.ts new file mode 100644 index 000000000..e13eea202 --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/instances.test.ts @@ -0,0 +1,98 @@ +import type { WSContext } from "hono/ws"; + +import { describe, expect, it } from "vitest"; + +import type { CacheClient } from "@/api/lib/cache"; + +import { contentRefreshRoom } from "@/content/live/refresh"; +import { + BOARD, + createPolicyWorld, + policyReplies, + policyTopicRow, + USER, +} from "@/tests/content-policies"; +import { + initRealtimePubSub, + markWebSocketEnabled, + wsRegistry, +} from "@/ws/registry"; + +/** One Redis channel between this instance and another one. */ +const bus = () => { + const published: string[] = []; + let deliver: ((raw: string) => void) | undefined; + const client = { + duplicate: () => { + const subscriber = { + connect: async () => await Promise.resolve(), + on: () => subscriber, + subscribe: async ( + _channel: string, + listener: (raw: string) => void, + ) => { + deliver = listener; + + return await Promise.resolve(); + }, + }; + + return subscriber; + }, + publish: async (_channel: string, raw: string) => { + published.push(raw); + + return await Promise.resolve(1); + }, + }; + + return { + client: client as unknown as CacheClient, + /** Hands a message to this process as if another instance sent it. */ + fromOtherInstance: (message: Record) => { + deliver?.(JSON.stringify({ ...message, origin: "other-instance" })); + }, + published: () => + published.map(raw => JSON.parse(raw) as Record), + }; +}; + +describe("content refresh across instances", () => { + it("relays a root change to a thread followed only on another instance", async () => { + const pubsub = bus(); + initRealtimePubSub(pubsub.client); + markWebSocketEnabled(); + await Promise.resolve(); + const room = contentRefreshRoom({ + contentTypeId: policyReplies.definition.id, + kind: "thread", + rootId: 10, + }); + const { send } = await createPolicyWorld({ + topics: [policyTopicRow(10, BOARD.OPEN)], + }); + + await send("POST", "/topics/10/hide", { body: {}, user: USER.ROOT }); + + const message = pubsub.published().find(entry => entry.room === room); + expect(message).toMatchObject({ + data: { room, signal: { kind: "thread" }, type: "changed" }, + type: "room", + }); + + const received: unknown[] = []; + const ws = { + send: (raw: string) => { + received.push((JSON.parse(raw) as { data: unknown }).data); + }, + } as unknown as WSContext; + wsRegistry.add(ws, null); + wsRegistry.join(ws, room, "tab"); + pubsub.fromOtherInstance(message ?? {}); + wsRegistry.remove(ws); + + expect(received).toStrictEqual([ + { room, signal: { kind: "thread" }, type: "changed" }, + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/refresh/publish.test.ts b/packages/vitnode/src/content/server/refresh/publish.test.ts new file mode 100644 index 000000000..0e81c811f --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/publish.test.ts @@ -0,0 +1,302 @@ +import type { WSContext } from "hono/ws"; + +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { + ContentRefreshScope, + ContentRefreshServerMessage, +} from "@/content/live/refresh"; + +import { CacheModel } from "@/api/lib/cache"; +import { contentRefreshRoom } from "@/content/live/refresh"; +import { + BOARD, + createPolicyWorld, + POLICY_LIVE, + policyReplies, + policyReplyRow, + policyTopicRow, + USER, +} from "@/tests/content-policies"; +import { markWebSocketEnabled, realtime, wsRegistry } from "@/ws/registry"; + +import { onContentRefreshRootChanged } from "./publish"; + +const REPLY_TYPE = policyReplies.definition.id; +const TOPIC = { APPROVE: 30, LIVE: 10 } as const; + +const threadOf = (rootId: number): ContentRefreshScope => ({ + contentTypeId: REPLY_TYPE, + kind: "thread", + rootId, +}); +const REPLIES_LIST: ContentRefreshScope = { + contentTypeId: REPLY_TYPE, + kind: "admin", +}; + +/** + * Topic 10: + * + * 100 ─ 101 ─ 102 + * └ 103 (hidden) + * 110 + */ +const world = async () => + await createPolicyWorld({ + replies: [ + policyReplyRow(100, TOPIC.LIVE), + policyReplyRow(101, TOPIC.LIVE, { parent: 100 }), + policyReplyRow(102, TOPIC.LIVE, { parent: 101 }), + policyReplyRow(103, TOPIC.LIVE, { + hiddenAt: POLICY_LIVE, + hiddenBy: USER.ROOT, + parent: 101, + }), + policyReplyRow(110, TOPIC.LIVE), + ], + topics: [ + policyTopicRow(TOPIC.LIVE, BOARD.OPEN), + policyTopicRow(TOPIC.APPROVE, BOARD.APPROVE), + ], + }); + +const sockets: WSContext[] = []; + +/** A browser tab following `scope`, as a successful join leaves it. */ +const listen = (scope: ContentRefreshScope) => { + const received: ContentRefreshServerMessage[] = []; + const ws = { + send: (raw: string) => { + received.push( + (JSON.parse(raw) as { data: ContentRefreshServerMessage }).data, + ); + }, + } as unknown as WSContext; + wsRegistry.add(ws, null); + wsRegistry.join(ws, contentRefreshRoom(scope), "tab"); + sockets.push(ws); + + return { received, ws }; +}; + +const unique = (messages: T[]): T[] => [ + ...new Map( + messages.map(message => [JSON.stringify(message), message]), + ).values(), +]; + +const signalsOf = (messages: ContentRefreshServerMessage[]) => + messages.flatMap(message => + message.type === "changed" ? [message.signal] : [], + ); + +beforeAll(() => { + markWebSocketEnabled(); +}); + +afterEach(() => { + sockets.splice(0).forEach(ws => { + wsRegistry.remove(ws); + }); + vi.restoreAllMocks(); +}); + +describe("publishing content refresh signals", () => { + it.each([ + { + expected: { branchId: 100, kind: "branch" }, + name: "a new reply deep in a branch", + request: [ + "POST", + "/replies", + { values: { body: "New", parent: 102, topic: TOPIC.LIVE } }, + ], + user: USER.MEMBER, + }, + { + expected: { branchId: 100, kind: "branch" }, + name: "an edit", + request: ["PATCH", "/replies/102", { values: { body: "Edited" } }], + user: USER.MOD_OPEN, + }, + { + expected: { branchId: 100, kind: "branch", removedId: 102 }, + name: "a delete", + request: ["POST", "/replies/102/delete"], + user: USER.ROOT, + }, + { + expected: { branchId: 100, kind: "branch", removedId: 102 }, + name: "a hide", + request: ["POST", "/replies/102/hide", {}], + user: USER.ROOT, + }, + { + expected: { branchId: 100, kind: "branch" }, + name: "an unhide", + request: ["POST", "/replies/103/unhide"], + user: USER.ROOT, + }, + { + expected: { branchId: 110, kind: "branch", removedId: 110 }, + name: "a top-level delete", + request: ["POST", "/replies/110/delete"], + user: USER.ROOT, + }, + ] as const)( + "sends $name to the thread with its top-level branch, and the list to the AdminCP", + async ({ expected, request: [method, path, body], user }) => { + const { send } = await world(); + const thread = listen(threadOf(TOPIC.LIVE)); + const list = listen(REPLIES_LIST); + + const response = await send(method, path, { body, user }); + + expect(response.status).toBeLessThan(300); + // A create announces `created` and `published`: one hint per event. + expect(unique(thread.received)).toStrictEqual([ + { + room: contentRefreshRoom(threadOf(TOPIC.LIVE)), + signal: expected, + type: "changed", + }, + ]); + expect(unique(signalsOf(list.received))).toStrictEqual([ + { kind: "list" }, + ]); + }, + ); + + it("keeps a reply that waits for a moderator off the thread, but not off the AdminCP list", async () => { + const { send } = await world(); + const thread = listen(threadOf(TOPIC.APPROVE)); + const list = listen(REPLIES_LIST); + + const response = await send("POST", "/replies", { + body: { values: { body: "Pending", topic: TOPIC.APPROVE } }, + user: USER.MEMBER, + }); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ status: "draft" }); + expect(thread.received).toStrictEqual([]); + expect(unique(signalsOf(list.received))).toStrictEqual([{ kind: "list" }]); + }); + + it("sends nothing to a thread room nobody on this instance follows", async () => { + const { send } = await world(); + const toRoom = vi.spyOn(realtime, "toRoom"); + + await send("POST", "/replies", { + body: { values: { body: "Unheard", topic: TOPIC.LIVE } }, + user: USER.MEMBER, + }); + + expect(unique(toRoom.mock.calls.map(([room]) => room))).toStrictEqual([ + contentRefreshRoom(REPLIES_LIST), + ]); + }); + + it("tells a thread its root was hidden, and re-checks the room's members", async () => { + const { send } = await world(); + const thread = listen(threadOf(TOPIC.LIVE)); + const rootChanged = vi.fn(); + const stop = onContentRefreshRootChanged(rootChanged); + + const response = await send("POST", `/topics/${TOPIC.LIVE}/hide`, { + body: {}, + user: USER.ROOT, + }); + stop(); + + expect(response.status).toBe(200); + expect(signalsOf(thread.received)).toStrictEqual([{ kind: "thread" }]); + expect(rootChanged).toHaveBeenCalledExactlyOnceWith( + contentRefreshRoom(threadOf(TOPIC.LIVE)), + ); + }); + + it("signals only after the event went out and the cached public reads expired", async () => { + const { emitted, send } = await world(); + const setSystem = vi.spyOn(CacheModel.prototype, "setSystem"); + const seen: { bumped: boolean; events: string[] }[] = []; + const thread = listen(threadOf(TOPIC.LIVE)); + const { send: deliver } = thread.ws as unknown as { + send: (raw: string) => void; + }; + Object.assign(thread.ws, { + send: (raw: string) => { + seen.push({ + bumped: setSystem.mock.calls.some( + ([key]) => key === `content:gen:${REPLY_TYPE}`, + ), + events: emitted.map(event => event.name), + }); + deliver(raw); + }, + }); + + await send("POST", "/replies", { + body: { values: { body: "Ordered", parent: 100, topic: TOPIC.LIVE } }, + user: USER.MEMBER, + }); + + expect(seen).toStrictEqual([ + { bumped: true, events: [`content.${REPLY_TYPE}.created`] }, + { + bumped: true, + events: [ + `content.${REPLY_TYPE}.created`, + `content.${REPLY_TYPE}.published`, + ], + }, + ]); + }); + + it.each([ + ["a missing reply", "PATCH", "/replies/999", { body: "Nope" }, 404], + [ + "a parent from another topic", + "POST", + "/replies", + { body: "Nope", parent: 100, topic: TOPIC.APPROVE }, + 400, + ], + ] as const)( + "publishes nothing for a refused write to %s", + async (_label, method, path, values, status) => { + const { send } = await world(); + const thread = listen(threadOf(TOPIC.LIVE)); + const list = listen(REPLIES_LIST); + + const response = await send(method, path, { + body: { values }, + user: USER.MEMBER, + }); + + expect(response.status).toBe(status); + expect([...thread.received, ...list.received]).toStrictEqual([]); + }, + ); + + it("never fails a committed write when sending a signal throws", async () => { + const { events, send } = await world(); + listen(threadOf(TOPIC.LIVE)); + vi.spyOn(realtime, "toRoom").mockImplementation(() => { + throw new Error("Socket layer down."); + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const response = await send("POST", "/replies", { + body: { values: { body: "Still saved", topic: TOPIC.LIVE } }, + user: USER.MEMBER, + }); + + expect(response.status).toBe(201); + expect(events()).toStrictEqual([ + `content.${REPLY_TYPE}.created`, + `content.${REPLY_TYPE}.published`, + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/refresh/publish.ts b/packages/vitnode/src/content/server/refresh/publish.ts new file mode 100644 index 000000000..069b5262a --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/publish.ts @@ -0,0 +1,258 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { ContentEventAction } from "@/content/events"; +import type { ContentId } from "@/content/ids"; +import type { + ContentRefreshScope, + ContentRefreshServerMessage, + ContentRefreshSignal, +} from "@/content/live/refresh"; +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { parseContentId } from "@/content/ids"; +import { + contentRefreshChannel, + contentRefreshRoom, +} from "@/content/live/refresh"; +import { + isRealtimePubSubEnabled, + isWebSocketEnabled, + realtime, + wsRegistry, +} from "@/ws/registry"; + +import type { ContentPublicState } from "../page-cache"; + +import { findContentModel } from "../model"; +import { contentDependentDefinitions } from "../parent-visibility"; +import { contentThreadTarget } from "../thread-access"; +import { isContentBaseRowPublic } from "../visibility"; + +/** One committed content change, as `publishContentRefresh` reads it. */ +export interface ContentRefreshChange { + action: ContentEventAction; + contentId: ContentId; + definition: AnyContentTypeDefinition; + /** Whether the record was public before and after; derived from `row` when absent. */ + publicState?: ContentPublicState; + /** + * The record after the write - or, for a delete, as it was just before it. + * A threaded record needs its root and parent to be routed, and a deleted + * one can no longer be read back, so pass it on every delete. + */ + row?: null | Record; +} + +/** Actions after which a thread's root may be readable by different people. */ +const ROOT_ACCESS_ACTIONS: ReadonlySet = new Set([ + "deleted", + "hidden", + "published", + "restored", + "unhidden", + "unpublished", +]); + +/** Actions that can only take a record off the public site. */ +const REMOVING_ACTIONS: ReadonlySet = new Set([ + "deleted", + "hidden", + "unpublished", +]); + +type RootChangedListener = (room: string) => Promise | void; + +const rootChangedListeners = new Set(); + +/** + * Runs `listener` with the room of every thread whose root changed in a way + * that can move access, on this instance. The refresh socket re-checks that + * room's members with it. Returns an unsubscribe function. + */ +export const onContentRefreshRootChanged = ( + listener: RootChangedListener, +): (() => void) => { + rootChangedListeners.add(listener); + + return () => { + rootChangedListeners.delete(listener); + }; +}; + +const send = (scope: ContentRefreshScope, signal: ContentRefreshSignal) => { + const room = contentRefreshRoom(scope); + realtime.toRoom(room, contentRefreshChannel, { + room, + signal, + type: "changed", + } satisfies ContentRefreshServerMessage); + + return room; +}; + +/** Without Redis, nobody outside this instance can be listening. */ +const hasListeners = (scope: ContentRefreshScope): boolean => + isRealtimePubSubEnabled() || + wsRegistry.membersOf(contentRefreshRoom(scope)).length > 0; + +const publicStateOf = ({ + action, + publicState, + row, +}: ContentRefreshChange): ContentPublicState => { + if (publicState) return publicState; + // Unknown: assume a record that can only have left the site was on it, and + // judge everything else by the row. A draft's edit stays unannounced. + const rowIsPublic = row ? isContentBaseRowPublic(row) : false; + + return { + isPublic: !REMOVING_ACTIONS.has(action) && rowIsPublic, + wasPublic: REMOVING_ACTIONS.has(action) || rowIsPublic, + }; +}; + +const MAX_PARENT_HOPS = 64; + +/** + * Where one threaded record sits: its root and the top-level reply its branch + * hangs from. Reads the record when `row` lacks the thread columns, then walks + * up its parents - each one a primary-key read, at most `MAX_PARENT_HOPS`. + */ +const threadLocation = async ( + c: Context, + definition: AnyContentTypeDefinition, + contentId: ContentId, + row: null | Record | undefined, +): Promise => { + const target = contentThreadTarget(definition); + const model = findContentModel( + c.get("core")?.contentModels ?? [], + definition.id, + )?.model; + if (!target || !model) return null; + + const { parentField, rootField } = definition.thread; + const table: PgTable = model.table; + const columns = model.columns as Record; + const db = c.get("db"); + const read = async (id: unknown) => { + const [found] = await db + .select({ parent: columns[parentField], root: columns[rootField] }) + .from(table) + .where(eq(columns.id, id as never)) + .limit(1); + + return found as undefined | { parent: unknown; root: unknown }; + }; + + const own = + row && Object.hasOwn(row, rootField) && Object.hasOwn(row, parentField) + ? { parent: row[parentField], root: row[rootField] } + : await read(contentId); + if (!own) return null; + + const rootId = parseContentId(target.idStrategy, own.root); + if ( + rootId === null || + !hasListeners({ contentTypeId: definition.id, kind: "thread", rootId }) + ) { + return null; + } + + let branchId: ContentId = contentId; + let parent = own.parent; + for (let hop = 0; parent !== null && parent !== undefined; hop++) { + const parentId = parseContentId(definition.idStrategy, parent); + if (parentId === null || hop >= MAX_PARENT_HOPS) return null; + branchId = parentId; + parent = (await read(parentId))?.parent; + } + + return { branchId, rootId }; +}; + +/** + * Announces one committed content change to the live refresh rooms that show + * it, over the shared WebSocket (and Redis, across instances): + * + * - every change to the content type's AdminCP list room, as `list`; + * - a change that was or is public, for a threaded type, to its thread's + * room, as `branch` with the top-level branch it sits in; + * - a change that can move who reads a record, to the thread rooms hanging + * off it, as `thread` - and this instance re-checks those rooms' members. + * + * A signal names no values, no author and no private record: a branch id is + * only sent when that branch was or is on the public thread. Call it after the + * transaction committed and the caches were expired, so a receiver that reads + * again gets the new state; `emitContentEvent` does, for every write that goes + * through it. Best effort - never throws, and nothing is replayed to a client + * that was not connected. Server-only. + */ +export const publishContentRefresh = async ( + c: Context, + change: ContentRefreshChange, +): Promise => { + if (!isWebSocketEnabled()) return; + + try { + const { action, contentId, definition, row } = change; + send({ contentTypeId: definition.id, kind: "admin" }, { kind: "list" }); + + const state = publicStateOf(change); + const touchesPublic = state.wasPublic || state.isPublic; + + if (definition.thread.enabled && touchesPublic) { + const location = await threadLocation(c, definition, contentId, row); + if (location) { + send( + { + contentTypeId: definition.id, + kind: "thread", + rootId: location.rootId, + }, + { + branchId: location.branchId, + kind: "branch", + ...(state.wasPublic && !state.isPublic + ? { removedId: contentId } + : {}), + }, + ); + } + } + + if ( + touchesPublic && + (ROOT_ACCESS_ACTIONS.has(action) || state.wasPublic !== state.isPublic) + ) { + for (const dependent of contentDependentDefinitions(c, definition)) { + if (contentThreadTarget(dependent)?.id !== definition.id) continue; + const scope: ContentRefreshScope = { + contentTypeId: dependent.id, + kind: "thread", + rootId: contentId, + }; + if (!hasListeners(scope)) continue; + const room = send(scope, { kind: "thread" }); + await Promise.all( + [...rootChangedListeners].map(async listener => listener(room)), + ); + } + } + } catch (error) { + // The write has committed; a missed hint costs freshness, never data. + try { + await c + .get("log") + .error( + `[content-refresh] ${change.definition.id}: live signal failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } catch { + // oxlint-disable-next-line no-console + console.error("[VitNode] Content live refresh signal failed:", error); + } + } +}; diff --git a/packages/vitnode/src/content/server/refresh/server.test.ts b/packages/vitnode/src/content/server/refresh/server.test.ts new file mode 100644 index 000000000..1e77760ca --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/server.test.ts @@ -0,0 +1,344 @@ +import type { Context } from "hono"; +import type { WSContext } from "hono/ws"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + ContentRefreshScope, + ContentRefreshServerMessage, +} from "@/content/live/refresh"; + +import { + CONTENT_REFRESH_MAX_JOINS_PER_MINUTE, + CONTENT_REFRESH_MAX_SUBSCRIPTIONS, + contentRefreshRoom, +} from "@/content/live/refresh"; +import { wsRegistry } from "@/ws/registry"; + +import type { ContentRefreshAuthorization } from "./auth"; + +import { createContentRefreshServer } from "./server"; + +const REAUTH_MS = 60_000; + +const ADMIN: ContentRefreshScope = { + contentTypeId: "blog.post", + kind: "admin", +}; +const THREAD: ContentRefreshScope = { + contentTypeId: "blog.comment", + kind: "thread", + rootId: 7, +}; + +const cleanups: (() => void)[] = []; + +afterEach(() => { + cleanups.splice(0).forEach(cleanup => cleanup()); +}); + +const setup = ({ + shareKey = () => null, +}: { + shareKey?: (c: Context, scope: ContentRefreshScope) => null | string; +} = {}) => { + let at = 1_000_000; + const denied = new Map(); + const authorize = vi.fn( + async (c: Context): Promise => + await Promise.resolve(denied.get(c) ?? { ok: true }), + ); + const server = createContentRefreshServer({ + authorize, + now: () => at, + reauthMs: REAUTH_MS, + shareKey, + }); + const stopClose = wsRegistry.onConnectionClose(server.onConnectionClose); + const sockets: WSContext[] = []; + cleanups.push(() => { + server.stop(); + stopClose(); + sockets.forEach(ws => wsRegistry.remove(ws)); + }); + + const socket = () => { + const received: ContentRefreshServerMessage[] = []; + const ws = { + readyState: 1, + send: (raw: string) => { + received.push( + (JSON.parse(raw) as { data: ContentRefreshServerMessage }).data, + ); + }, + } as unknown as WSContext; + const c = {} as Context; + wsRegistry.add(ws, null); + sockets.push(ws); + + const send = async (data: unknown) => { + await server.handle({ c, data, ws }); + }; + + return { + c, + join: async (scope: ContentRefreshScope, clientId = "tab-a") => { + await send({ clientId, scope, type: "join" }); + }, + leave: async (scope: ContentRefreshScope, clientId = "tab-a") => { + await send({ clientId, scope, type: "leave" }); + }, + received, + send, + ws, + }; + }; + + const members = (scope: ContentRefreshScope) => + wsRegistry + .membersOf(contentRefreshRoom(scope)) + .map(({ memberId }) => memberId); + + return { + advance: (ms: number) => { + at += ms; + }, + authorize, + deny: ( + target: { c: Context }, + code: "FORBIDDEN" | "NOT_FOUND" = "FORBIDDEN", + ) => { + denied.set(target.c, { code, ok: false }); + }, + members, + server, + socket, + }; +}; + +describe("content refresh joins", () => { + it.each([ + ["an admin list", ADMIN, "content-refresh:admin:blog.post"], + [ + "a thread, with a bigint root kept as its exact string", + { + contentTypeId: "blog.comment", + kind: "thread", + rootId: "9223372036854775807", + } satisfies ContentRefreshScope, + "content-refresh:thread:blog.comment:9223372036854775807", + ], + ])("acks an authorized join to %s", async (_label, scope, room) => { + const { members, server, socket } = setup(); + const tab = socket(); + + await tab.join(scope); + + expect(tab.received).toStrictEqual([ + { clientId: "tab-a", room, type: "joined" }, + ]); + expect(members(scope)).toStrictEqual(["tab-a"]); + expect(server.memberCount()).toBe(1); + }); + + it.each(["FORBIDDEN", "NOT_FOUND"] as const)( + "answers a refused join with %s and no membership", + async code => { + const { deny, members, server, socket } = setup(); + const tab = socket(); + deny(tab, code); + + await tab.join(THREAD); + + expect(tab.received).toStrictEqual([ + { + clientId: "tab-a", + code, + room: contentRefreshRoom(THREAD), + type: "error", + }, + ]); + expect(members(THREAD)).toStrictEqual([]); + expect(server.memberCount()).toBe(0); + }, + ); + + it.each([ + [ + "a scope without a content type", + { clientId: "tab-a", scope: { kind: "admin" }, type: "join" }, + "tab-a", + ], + [ + "an unknown field", + { clientId: "tab-a", extra: 1, scope: ADMIN, type: "join" }, + "tab-a", + ], + [ + "an unknown type", + { clientId: "tab-a", scope: ADMIN, type: "subscribe" }, + "tab-a", + ], + ["no object at all", "join", ""], + ])("answers %s with INVALID_MESSAGE", async (_label, data, clientId) => { + const { server, socket } = setup(); + const tab = socket(); + + await tab.send(data); + + expect(tab.received).toStrictEqual([ + { clientId, code: "INVALID_MESSAGE", type: "error" }, + ]); + expect(server.memberCount()).toBe(0); + }); + + it("keeps tabs sharing a socket apart: one leaving keeps the other", async () => { + const { members, server, socket } = setup(); + const tab = socket(); + + await tab.join(THREAD, "tab-a"); + await tab.join(THREAD, "tab-b"); + await tab.leave(THREAD, "tab-a"); + + expect(members(THREAD)).toStrictEqual(["tab-b"]); + expect(server.memberCount()).toBe(1); + + await tab.leave(THREAD, "tab-b"); + + expect(members(THREAD)).toStrictEqual([]); + expect(server.memberCount()).toBe(0); + }); + + it("refuses a subscription over the limit with TOO_MANY_SUBSCRIPTIONS", async () => { + const { authorize, server, socket } = setup(); + const tab = socket(); + for (let index = 0; index < CONTENT_REFRESH_MAX_SUBSCRIPTIONS; index++) { + await tab.join({ contentTypeId: `type-${index}`, kind: "admin" }); + } + authorize.mockClear(); + + await tab.join({ contentTypeId: "one-more", kind: "admin" }); + + expect(tab.received.at(-1)).toMatchObject({ + code: "TOO_MANY_SUBSCRIPTIONS", + type: "error", + }); + expect(authorize).not.toHaveBeenCalled(); + expect(server.memberCount()).toBe(CONTENT_REFRESH_MAX_SUBSCRIPTIONS); + }); + + it("refuses joins over the per-minute limit with RATE_LIMITED, until the minute passes", async () => { + const { advance, socket } = setup(); + const tab = socket(); + for (let index = 0; index < CONTENT_REFRESH_MAX_JOINS_PER_MINUTE; index++) { + await tab.join(ADMIN); + } + + await tab.join(ADMIN); + + expect(tab.received.at(-1)).toMatchObject({ + code: "RATE_LIMITED", + type: "error", + }); + + advance(60_000); + await tab.join(ADMIN); + + expect(tab.received.at(-1)).toMatchObject({ type: "joined" }); + }); +}); + +describe("content refresh re-checks", () => { + it("revokes a membership that lost access once it is due, and keeps the others", async () => { + const { advance, authorize, deny, members, socket, server } = setup(); + const kept = socket(); + const lost = socket(); + await kept.join(THREAD); + await lost.join(THREAD, "tab-b"); + authorize.mockClear(); + deny(lost, "NOT_FOUND"); + + advance(REAUTH_MS - 1); + await server.sweep(); + + expect(authorize).not.toHaveBeenCalled(); + + advance(1); + await server.sweep(); + + expect(lost.received.at(-1)).toStrictEqual({ + clientId: "tab-b", + code: "NOT_FOUND", + room: contentRefreshRoom(THREAD), + type: "revoked", + }); + expect(members(THREAD)).toStrictEqual(["tab-a"]); + expect(kept.received.at(-1)).toMatchObject({ type: "joined" }); + }); + + it("re-checks one room at once, asking once per share key", async () => { + const shareKey = vi.fn( + (c: Context) => (c as unknown as { key?: string }).key ?? null, + ); + const { authorize, deny, members, server, socket } = setup({ shareKey }); + const guests = [socket(), socket()]; + const member = socket(); + const elsewhere = socket(); + for (const guest of guests) { + Object.assign(guest.c, { key: "guest" }); + await guest.join(THREAD); + } + Object.assign(member.c, { key: "user-1" }); + await member.join(THREAD); + await elsewhere.join(ADMIN); + authorize.mockClear(); + guests.forEach(guest => { + deny(guest); + }); + + await server.revalidateRoom(contentRefreshRoom(THREAD)); + + expect(authorize).toHaveBeenCalledTimes(2); + expect(authorize.mock.calls.map(([c]) => c)).not.toContain(elsewhere.c); + expect(members(THREAD)).toHaveLength(1); + expect(members(ADMIN)).toStrictEqual(["tab-a"]); + }); + + it("forgets every membership of a closed socket", async () => { + const { authorize, server, socket } = setup(); + const tab = socket(); + await tab.join(THREAD); + authorize.mockClear(); + + wsRegistry.remove(tab.ws); + await server.revalidateRoom(contentRefreshRoom(THREAD)); + + expect(server.memberCount()).toBe(0); + expect(authorize).not.toHaveBeenCalled(); + }); + + it("does not register a socket that closed while its join was checked", async () => { + const { authorize, members, server, socket } = setup(); + const tab = socket(); + let answer: (value: ContentRefreshAuthorization) => void = () => undefined; + authorize.mockImplementationOnce( + async () => + await new Promise(resolve => { + answer = resolve; + }), + ); + + const joining = tab.join(THREAD); + await vi.waitFor(() => { + expect(authorize).toHaveBeenCalledOnce(); + }); + Object.assign(tab.ws, { readyState: 3 }); + wsRegistry.remove(tab.ws); + answer({ ok: true }); + await joining; + + expect(tab.received).toStrictEqual([]); + expect(members(THREAD)).toStrictEqual([]); + expect(server.memberCount()).toBe(0); + }); +}); diff --git a/packages/vitnode/src/content/server/refresh/server.ts b/packages/vitnode/src/content/server/refresh/server.ts new file mode 100644 index 000000000..30c89ac0d --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/server.ts @@ -0,0 +1,330 @@ +import type { Context } from "hono"; +import type { WSContext } from "hono/ws"; + +import { z } from "zod"; + +import type { + ContentRefreshClientMessage, + ContentRefreshErrorCode, + ContentRefreshScope, + ContentRefreshServerMessage, +} from "@/content/live/refresh"; + +import { contentAnyIdSchema } from "@/content/ids"; +import { + CONTENT_REFRESH_MAX_JOINS_PER_MINUTE, + CONTENT_REFRESH_MAX_SUBSCRIPTIONS, + CONTENT_REFRESH_REAUTH_MS, + CONTENT_REFRESH_SWEEP_MS, + contentRefreshChannel, + contentRefreshRoom, +} from "@/content/live/refresh"; +import { wsRegistry } from "@/ws/registry"; + +import type { ContentRefreshAuthorization } from "./auth"; + +import { authorizeContentRefresh, contentRefreshShareKey } from "./auth"; + +const contentTypeId = z.string().min(1).max(100); + +const scopeSchema = z.discriminatedUnion("kind", [ + z.strictObject({ contentTypeId, kind: z.literal("admin") }), + z.strictObject({ + contentTypeId, + kind: z.literal("thread"), + rootId: contentAnyIdSchema, + }), +]) satisfies z.ZodType; + +const clientId = z.string().min(1).max(64); + +const clientMessageSchema = z.strictObject({ + clientId, + scope: scopeSchema, + type: z.enum(["join", "leave"]), +}) satisfies z.ZodType; + +const JOIN_WINDOW_MS = 60_000; + +interface Membership { + c: Context; + checkedAt: number; + clientIds: Set; + room: string; + scope: ContentRefreshScope; + ws: WSContext; +} + +const logError = (message: string, error: unknown): void => { + // oxlint-disable-next-line no-console + console.error(message, error); +}; + +const sendTo = (ws: WSContext, data: ContentRefreshServerMessage): void => { + try { + ws.send(JSON.stringify({ data, id: contentRefreshChannel.id })); + } catch { + // The socket closed meanwhile; its close handling cleans up. + } +}; + +const clientIdOf = (data: unknown): string => { + if (typeof data !== "object" || data === null || !("clientId" in data)) { + return ""; + } + const parsed = clientId.safeParse(data.clientId); + + return parsed.success ? parsed.data : ""; +}; + +/** + * The server half of Content Engine live refresh: who follows which scope on + * this instance. A room name is only a routing key - every join is + * authorized, each membership is checked again every + * `CONTENT_REFRESH_REAUTH_MS` (so a signed-out, expired or demoted viewer stops + * receiving signals) and at once when its thread's root changes. A failed + * check answers `revoked` and leaves the room. Memberships are per tab + * (`clientId`), so tabs sharing one connection never ride on each other's + * joins. + */ +export const createContentRefreshServer = ({ + authorize = authorizeContentRefresh, + now = Date.now, + reauthMs = CONTENT_REFRESH_REAUTH_MS, + shareKey = contentRefreshShareKey, +}: { + authorize?: ( + c: Context, + scope: ContentRefreshScope, + ) => Promise; + now?: () => number; + reauthMs?: number; + shareKey?: (c: Context, scope: ContentRefreshScope) => null | string; +} = {}) => { + const bySocket = new Map>(); + const byRoom = new Map>(); + const joins = new WeakMap(); + const queues = new WeakMap>(); + let sweeper: ReturnType | undefined; + + const countOf = (ws: WSContext): number => + [...(bySocket.get(ws)?.values() ?? [])].reduce( + (total, membership) => total + membership.clientIds.size, + 0, + ); + + const removeFromRoom = (membership: Membership): void => { + const members = byRoom.get(membership.room); + members?.delete(membership); + if (members?.size === 0) byRoom.delete(membership.room); + }; + + const drop = (membership: Membership, id: string): void => { + wsRegistry.leave(membership.ws, membership.room, id); + membership.clientIds.delete(id); + if (membership.clientIds.size > 0) return; + + const rooms = bySocket.get(membership.ws); + rooms?.delete(membership.room); + if (rooms?.size === 0) bySocket.delete(membership.ws); + removeFromRoom(membership); + }; + + const revoke = ( + membership: Membership, + code: "FORBIDDEN" | "NOT_FOUND", + ): void => { + for (const id of [...membership.clientIds]) { + drop(membership, id); + sendTo(membership.ws, { + clientId: id, + code, + room: membership.room, + type: "revoked", + }); + } + }; + + /** Re-checks memberships, asking once per group of identical answers. */ + const recheck = async (memberships: Membership[]): Promise => { + const shared = new Map>(); + + await Promise.all( + memberships.map(async membership => { + const key = shareKey(membership.c, membership.scope); + let pending = key === null ? undefined : shared.get(key); + if (!pending) { + pending = authorize(membership.c, membership.scope).catch( + (error: unknown): ContentRefreshAuthorization => { + logError("Content refresh authorization error:", error); + + return { code: "FORBIDDEN", ok: false }; + }, + ); + if (key !== null) shared.set(key, pending); + } + const result = await pending; + if (result.ok) membership.checkedAt = now(); + else revoke(membership, result.code); + }), + ); + }; + + const sweep = async (): Promise => { + const due = [...bySocket.values()] + .flatMap(rooms => [...rooms.values()]) + .filter(membership => membership.checkedAt + reauthMs <= now()); + if (due.length > 0) await recheck(due); + }; + + const startSweeper = (): void => { + if (sweeper) return; + sweeper = setInterval(() => { + sweep().catch((error: unknown) => { + logError("Content refresh sweep error:", error); + }); + }, CONTENT_REFRESH_SWEEP_MS); + sweeper.unref?.(); + }; + + const allowJoin = (ws: WSContext): boolean => { + const recent = (joins.get(ws) ?? []).filter( + at => at > now() - JOIN_WINDOW_MS, + ); + const allowed = recent.length < CONTENT_REFRESH_MAX_JOINS_PER_MINUTE; + if (allowed) recent.push(now()); + joins.set(ws, recent); + + return allowed; + }; + + const handleMessage = async ( + c: Context, + ws: WSContext, + message: ContentRefreshClientMessage, + ): Promise => { + const room = contentRefreshRoom(message.scope); + const existing = bySocket.get(ws)?.get(room); + + if (message.type === "leave") { + if (existing?.clientIds.has(message.clientId)) { + drop(existing, message.clientId); + } + + return; + } + + const fail = (code: ContentRefreshErrorCode): void => { + sendTo(ws, { clientId: message.clientId, code, room, type: "error" }); + }; + + if (!allowJoin(ws)) { + fail("RATE_LIMITED"); + + return; + } + if ( + !existing?.clientIds.has(message.clientId) && + countOf(ws) >= CONTENT_REFRESH_MAX_SUBSCRIPTIONS + ) { + fail("TOO_MANY_SUBSCRIPTIONS"); + + return; + } + + const result = await authorize(c, message.scope); + // Closing or closed while authorizing: its close cleanup already ran. + if (ws.readyState >= 2) return; + if (!result.ok) { + const current = bySocket.get(ws)?.get(room); + if (current?.clientIds.has(message.clientId)) { + drop(current, message.clientId); + } + fail(result.code); + + return; + } + + const rooms = bySocket.get(ws) ?? new Map(); + bySocket.set(ws, rooms); + let membership = rooms.get(room); + if (!membership) { + membership = { + c, + checkedAt: now(), + clientIds: new Set(), + room, + scope: message.scope, + ws, + }; + rooms.set(room, membership); + byRoom.set( + room, + (byRoom.get(room) ?? new Set()).add(membership), + ); + } + membership.checkedAt = now(); + membership.clientIds.add(message.clientId); + wsRegistry.join(ws, room, message.clientId); + + sendTo(ws, { clientId: message.clientId, room, type: "joined" }); + startSweeper(); + }; + + return { + handle: async ({ + c, + data, + ws, + }: { + c: Context; + data: unknown; + ws: WSContext; + }): Promise => { + const parsed = clientMessageSchema.safeParse(data); + if (!parsed.success) { + sendTo(ws, { + clientId: clientIdOf(data), + code: "INVALID_MESSAGE", + type: "error", + }); + + return; + } + + // One message at a time per connection, so a leave never overtakes + // the join it undoes. + const previous = queues.get(ws) ?? Promise.resolve(); + const settled = previous + .then(async () => { + await handleMessage(c, ws, parsed.data); + }) + .catch((error: unknown) => { + logError("Content refresh message error:", error); + }); + queues.set(ws, settled); + await settled; + }, + memberCount: (): number => + [...bySocket.keys()].reduce((total, ws) => total + countOf(ws), 0), + onConnectionClose: (ws: WSContext): void => { + const rooms = bySocket.get(ws); + if (!rooms) return; + bySocket.delete(ws); + for (const membership of rooms.values()) removeFromRoom(membership); + }, + /** + * Checks every local member of `room` again now - after its thread's + * root changed - and revokes the ones that lost access. + */ + revalidateRoom: async (room: string): Promise => { + const members = [...(byRoom.get(room) ?? [])]; + if (members.length > 0) await recheck(members); + }, + stop: (): void => { + clearInterval(sweeper); + sweeper = undefined; + }, + sweep, + }; +}; diff --git a/packages/vitnode/src/content/server/refresh/socket.ts b/packages/vitnode/src/content/server/refresh/socket.ts new file mode 100644 index 000000000..4ddeb61de --- /dev/null +++ b/packages/vitnode/src/content/server/refresh/socket.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { buildWebSocket } from "@/api/lib/websocket"; +import { + CONTENT_REFRESH_ROOM_PREFIX, + contentRefreshChannel, +} from "@/content/live/refresh"; +import { onRemoteRoomMessage, wsRegistry } from "@/ws/registry"; + +import { onContentRefreshRootChanged } from "./publish"; +import { createContentRefreshServer } from "./server"; + +const contentRefreshServer = createContentRefreshServer(); + +const threadChangedMessage = z.object({ + signal: z.object({ kind: z.literal("thread") }), +}); + +wsRegistry.onConnectionClose(contentRefreshServer.onConnectionClose); +onContentRefreshRootChanged(contentRefreshServer.revalidateRoom); + +// A root changed on another instance: its members here are checked again too. +onRemoteRoomMessage((room, id, data) => { + if ( + id === contentRefreshChannel.id && + room.startsWith(CONTENT_REFRESH_ROOM_PREFIX) && + threadChangedMessage.safeParse(data).success + ) { + contentRefreshServer.revalidateRoom(room).catch((error: unknown) => { + // oxlint-disable-next-line no-console + console.error("Content refresh revalidation error:", error); + }); + } +}); + +export const contentRefreshWebSocket = buildWebSocket({ + description: + "Content Engine live refresh: authorized subscriptions to AdminCP lists and public threads, answered with small change signals.", + id: "refresh", + onMessage: contentRefreshServer.handle, +}); diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 0e45eb2cf..39ef89736 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -914,7 +914,11 @@ export const buildContentRoutes = < definition, "created", { contentId: row.id }, - { pluginId, publicState: contentPublicStateOf(definition, null, row) }, + { + pluginId, + publicState: contentPublicStateOf(definition, null, row), + row, + }, ); // A new record is a draft, so this normally indexes a private document - @@ -2046,7 +2050,11 @@ export const buildContentRoutes = < definition, "deleted", { contentId: row.id }, - { pluginId, publicState: contentPublicStateOf(definition, row, null) }, + { + pluginId, + publicState: contentPublicStateOf(definition, row, null), + row, + }, ); // Drafts are indexed as private documents, so every delete removes one - a diff --git a/packages/vitnode/src/content/server/thread-access.ts b/packages/vitnode/src/content/server/thread-access.ts new file mode 100644 index 000000000..f23a91717 --- /dev/null +++ b/packages/vitnode/src/content/server/thread-access.ts @@ -0,0 +1,68 @@ +import type { Context } from "hono"; + +import type { ContentId } from "../ids"; +import type { AnyContentTypeDefinition } from "../types"; + +import { parseContentId } from "../ids"; +import { canViewHiddenContentRecord } from "./hidden-access"; + +/** A thread root the current reader may open, from {@link resolveContentThreadRoot}. */ +export interface ContentThreadRootAccess { + /** `true` when the root is hidden and readable only to this reader. */ + hidden: boolean; + rootId: ContentId; + /** The content type the thread hangs off. */ + target: AnyContentTypeDefinition; +} + +/** The content type a thread's `root` relation points at, if it has one. */ +export const contentThreadTarget = ( + definition: AnyContentTypeDefinition, +): AnyContentTypeDefinition | undefined => { + if (!definition.thread.enabled) return undefined; + const rootField = definition.fields[definition.thread.rootField]; + + return rootField?.kind === "relation" ? rootField.target() : undefined; +}; + +/** + * Whether the current reader may open the thread of `definition` under + * `root`: the root is published and visible, or hidden and readable to them + * (an author, an editor or a moderator). The single rule behind `GET /thread`, + * `GET /thread/branches` and live thread subscriptions. `null` for a missing, + * malformed, unpublished or unreadable root - callers answer all of them the + * same way, so none confirms that a draft exists. Server-only. + */ +export const resolveContentThreadRoot = async ( + c: Context, + definition: AnyContentTypeDefinition, + root: unknown, +): Promise => { + const target = contentThreadTarget(definition); + if (!target) return null; + + const rootId = parseContentId(target.idStrategy, root); + const rootService = (c.get("core")?.contentModels ?? []) + .find(entry => entry.model.definition.id === target.id) + ?.model.publicService?.(c); + if (rootId === null || !rootService) return null; + + // An existence check: `select: []` reads the same published, visible row + // without hydrating a single field of it. + if (await rootService.findById(rootId, { select: [] })) { + return { hidden: false, rootId, target }; + } + + const hidden = await rootService.findById(rootId, { + hidden: true, + select: [], + }); + + return hidden && + (await canViewHiddenContentRecord(c, { + contentTypeId: target.id, + itemId: rootId, + })) + ? { hidden: true, rootId, target } + : null; +}; diff --git a/packages/vitnode/src/content/server/thread-routes.test.ts b/packages/vitnode/src/content/server/thread-routes.test.ts index f888deb18..73f291409 100644 --- a/packages/vitnode/src/content/server/thread-routes.test.ts +++ b/packages/vitnode/src/content/server/thread-routes.test.ts @@ -1,16 +1,18 @@ import { describe, expect, it } from "vitest"; +import { CONTENT_THREAD_BRANCHES_MAX } from "@/content/live/refresh"; import { BOARD, createPolicyWorld, POLICY_LIVE, policyReplyRow, policyTopicRow, + USER, } from "@/tests/content-policies"; -import type { ContentThreadPage } from "./thread"; +import type { ContentThreadBranches, ContentThreadPage } from "./thread"; -const TOPIC = { DRAFT: 12, HIDDEN: 13, LIVE: 10 } as const; +const TOPIC = { DRAFT: 12, HIDDEN: 13, LIVE: 10, OTHER: 11 } as const; const draft = { publishedAt: null, status: "draft" }; @@ -21,6 +23,8 @@ const draft = { publishedAt: null, status: "draft" }; * 103 (draft) ─ 104 * 105 (hidden, no answers) * 106 ─ 107 (draft) + * + * Topic 11: 200 */ const { send } = await createPolicyWorld({ replies: [ @@ -32,9 +36,11 @@ const { send } = await createPolicyWorld({ policyReplyRow(105, TOPIC.LIVE, { hiddenAt: POLICY_LIVE }), policyReplyRow(106, TOPIC.LIVE), policyReplyRow(107, TOPIC.LIVE, { parent: 106, ...draft }), + policyReplyRow(200, TOPIC.OTHER), ], topics: [ policyTopicRow(TOPIC.LIVE, BOARD.OPEN), + policyTopicRow(TOPIC.OTHER, BOARD.OPEN), policyTopicRow(TOPIC.DRAFT, BOARD.OPEN, draft), policyTopicRow(TOPIC.HIDDEN, BOARD.OPEN, { hiddenAt: POLICY_LIVE }), ], @@ -110,3 +116,99 @@ describe("GET /replies/thread", () => { ]); }); }); + +type Branches = ContentThreadBranches<{ body: string; id: number }>; + +const readBranches = async (ids: string, user?: number) => { + const response = await send( + "GET", + `/replies/thread/branches?root=${TOPIC.LIVE}${ids === "" ? "" : `&ids=${ids}`}`, + { user }, + ); + expect(response.status).toBe(200); + + return { + body: (await response.json()) as Branches, + cacheControl: response.headers.get("cache-control"), + }; +}; + +describe("GET /replies/thread/branches", () => { + it("answers each branch with the same entries /thread places on a page", async () => { + const page = await read(`root=${TOPIC.LIVE}`); + const { body } = await readBranches("106,100,103"); + + const slice = (from: number, to: number) => + page.items.slice( + page.items.findIndex(item => item.id === from), + to === -1 ? undefined : page.items.findIndex(item => item.id === to), + ); + expect(body.branches).toStrictEqual([ + { id: 106, items: slice(106, -1), status: "ok" }, + { id: 100, items: slice(100, 103), status: "ok" }, + { id: 103, items: slice(103, 106), status: "ok" }, + ]); + expect(body.replyCount).toBe(page.replyCount); + expect(body.pageInfo.totalCount).toBe(page.pageInfo.totalCount); + }); + + it("answers gone for a reply, another root's reply, a hidden reply without answers and a missing id", async () => { + const { body } = await readBranches("101,200,105,999"); + + expect(body.branches).toStrictEqual([ + { id: 101, status: "gone" }, + { id: 200, status: "gone" }, + { id: 105, status: "gone" }, + { id: 999, status: "gone" }, + ]); + }); + + it("answers only the counts without ids", async () => { + const { body } = await readBranches(""); + + expect(body).toStrictEqual({ + branches: [], + pageInfo: { totalCount: 2 }, + replyCount: 5, + truncated: false, + }); + }); + + it.each([ + ["a draft", String(TOPIC.DRAFT)], + ["a hidden", String(TOPIC.HIDDEN)], + ["a missing", "999"], + ])("answers 404 for %s root", async (_label, root) => { + expect( + (await send("GET", `/replies/thread/branches?root=${root}&ids=100`)) + .status, + ).toBe(404); + }); + + it.each([ + [ + "too many ids", + Array.from( + { length: CONTENT_THREAD_BRANCHES_MAX + 1 }, + (_, index) => 100 + index, + ).join(","), + ], + ["a malformed id", "100,abc"], + ])("answers 400 for %s", async (_label, ids) => { + expect( + ( + await send( + "GET", + `/replies/thread/branches?root=${TOPIC.LIVE}&ids=${ids}`, + ) + ).status, + ).toBe(400); + }); + + it.each([ + ["a guest", undefined, "no-cache"], + ["a reader who sees hidden replies", USER.ROOT, "private, no-store"], + ])("lets %s cache it as %s", async (_label, user, cacheControl) => { + expect((await readBranches("100", user)).cacheControl).toBe(cacheControl); + }); +}); diff --git a/packages/vitnode/src/content/server/thread.ts b/packages/vitnode/src/content/server/thread.ts index 60a9a72f4..7c8b8c571 100644 --- a/packages/vitnode/src/content/server/thread.ts +++ b/packages/vitnode/src/content/server/thread.ts @@ -233,43 +233,37 @@ interface NodeRow extends Record { status: string; } +interface ThreadReadOptions { + /** + * Hidden replies this reader may see, from `contentThreadHiddenAccess`. + * They are kept with `hidden: true`; omit it for the public thread. + */ + hiddenAccess?: ContentPublicHiddenAccess; + /** + * Rows for these ids, in any order: public ones, plus the hidden ones in + * `hiddenIds` the reader may see. + */ + project: ( + ids: ContentId[], + options: { hiddenIds: ContentId[] }, + ) => Promise>; + root: ContentId; +} + /** - * Pages a thread oldest first: a page is a run of top-level replies plus every - * answer below them, flattened depth first. A reply that is not public is left - * out - unless a public answer sits below it, in which case it is returned as - * `removed` with no values, so the tree keeps its shape. + * Everything a thread read needs, built once per request: the top-level + * selection, the counts and the tree below a set of top-level rows. Shared by + * the page read and the branch read so both answer with the same visibility, + * placeholders, depth and truncation. */ -export const readContentThread = async < +const createThreadReader = < TDefinition extends AnyContentTypeDefinition, TRecord, >( c: Context, model: ContentModel, - { - first, - after, - hiddenAccess, - project, - root, - }: { - after?: string; - first: number; - /** - * Hidden replies this reader may see, from `contentThreadHiddenAccess`. - * They are kept with `hidden: true`; omit it for the public thread. - */ - hiddenAccess?: ContentPublicHiddenAccess; - /** - * Rows for these ids, in any order: public ones, plus the hidden ones in - * `hiddenIds` the reader may see. - */ - project: ( - ids: ContentId[], - options: { hiddenIds: ContentId[] }, - ) => Promise>; - root: ContentId; - }, -): Promise> => { + { hiddenAccess, project, root }: ThreadReadOptions, +) => { const { definition } = model; const { maxDepth, parentField, rootField } = definition.thread; const columns = model.columns as Record; @@ -325,12 +319,30 @@ export const readContentThread = async < }; /** - * A top-level reply that is not public earns a place on the page only when - * something answers it - otherwise it would be pruned and leave a page short, - * or empty with `hasNextPage` still set. Skipped ones are read past in a few - * bounded rounds. + * A top-level reply that is not public earns a place only when something + * answers it - otherwise it would be pruned and leave a page short. */ - const readTops = async (): Promise => { + const keepTops = async (batch: NodeRow[]): Promise => { + const hidden = batch.filter(row => !isReadable(row)).map(row => row.id); + const answered = new Set( + hidden.length === 0 + ? [] + : ( + await db + .select({ parent: parentColumn }) + .from(table) + .where(inArray(parentColumn, hidden)) + ).map(row => String(row.parent)), + ); + + return batch.filter(row => isReadable(row) || answered.has(String(row.id))); + }; + + /** Skipped top-level replies are read past in a few bounded rounds. */ + const readTops = async ( + after: string | undefined, + first: number, + ): Promise => { const collected: NodeRow[] = []; let position = decodeCursor(after); @@ -343,20 +355,7 @@ export const readContentThread = async < .limit(first + 1)) as NodeRow[]; if (batch.length === 0) break; - const hidden = batch.filter(row => !isReadable(row)).map(row => row.id); - const answered = new Set( - hidden.length === 0 - ? [] - : ( - await db - .select({ parent: parentColumn }) - .from(table) - .where(inArray(parentColumn, hidden)) - ).map(row => String(row.parent)), - ); - collected.push( - ...batch.filter(row => isReadable(row) || answered.has(String(row.id))), - ); + collected.push(...(await keepTops(batch))); const last = batch.at(-1); if (!last || batch.length <= first) break; @@ -366,122 +365,177 @@ export const readContentThread = async < return collected; }; - const [tops, [topTotal], [replyTotal]] = await Promise.all([ - readTops(), - db - .select({ value: count() }) - .from(table) - .where(and(topLevel, publicCondition)), - db - .select({ value: count() }) - .from(table) - .where(and(eq(rootColumn, root), publicCondition)), - ]); - - const hasNextPage = tops.length > first; - const page = tops.slice(0, first); + /** The given top-level replies of this root, oldest first. */ + const readBranchTops = async (ids: ContentId[]): Promise => + ids.length === 0 + ? [] + : await keepTops( + (await db + .select(nodeSelection) + .from(table) + .where(and(topLevel, inArray(columns.id, ids))) + .orderBy(asc(columns.createdAt), asc(columns.id))) as NodeRow[], + ); + + const counts = async (): Promise<{ + replyCount: number; + totalCount: number; + }> => { + const [[topTotal], [replyTotal]] = await Promise.all([ + db + .select({ value: count() }) + .from(table) + .where(and(topLevel, publicCondition)), + db + .select({ value: count() }) + .from(table) + .where(and(eq(rootColumn, root), publicCondition)), + ]); - const nodes = new Map(); - const children = new Map(); - for (const row of page) nodes.set(String(row.id), row); + return { + replyCount: replyTotal?.value ?? 0, + totalCount: topTotal?.value ?? 0, + }; + }; - let frontier = page.map(row => row.id); - let truncated = false; - for (let level = 2; level <= maxDepth && frontier.length > 0; level++) { - if (nodes.size >= CONTENT_THREAD_MAX_NODES) { - truncated = true; - break; - } - const rows = (await db - .select(nodeSelection) - .from(table) - .where(and(eq(rootColumn, root), inArray(parentColumn, frontier))) - .orderBy(asc(columns.createdAt), asc(columns.id)) - .limit(CONTENT_THREAD_MAX_NODES - nodes.size + 1)) as NodeRow[]; - if (rows.length > CONTENT_THREAD_MAX_NODES - nodes.size) { - truncated = true; - rows.length = CONTENT_THREAD_MAX_NODES - nodes.size; + /** Every reader-visible entry below `page`, flattened depth first. */ + const build = async ( + page: NodeRow[], + ): Promise<{ items: ContentThreadNode[]; truncated: boolean }> => { + const nodes = new Map(); + const children = new Map(); + for (const row of page) nodes.set(String(row.id), row); + + let frontier = page.map(row => row.id); + let truncated = false; + for (let level = 2; level <= maxDepth && frontier.length > 0; level++) { + if (nodes.size >= CONTENT_THREAD_MAX_NODES) { + truncated = true; + break; + } + const rows = (await db + .select(nodeSelection) + .from(table) + .where(and(eq(rootColumn, root), inArray(parentColumn, frontier))) + .orderBy(asc(columns.createdAt), asc(columns.id)) + .limit(CONTENT_THREAD_MAX_NODES - nodes.size + 1)) as NodeRow[]; + if (rows.length > CONTENT_THREAD_MAX_NODES - nodes.size) { + truncated = true; + rows.length = CONTENT_THREAD_MAX_NODES - nodes.size; + } + + for (const row of rows) { + nodes.set(String(row.id), row); + const key = String(row.parent); + children.set(key, [...(children.get(key) ?? []), row]); + } + frontier = rows.map(row => row.id); } - for (const row of rows) { - nodes.set(String(row.id), row); - const key = String(row.parent); - children.set(key, [...(children.get(key) ?? []), row]); - } - frontier = rows.map(row => row.id); - } + const kept = new Set(); + const keep = (row: NodeRow): boolean => { + let any = false; + for (const child of children.get(String(row.id)) ?? []) { + if (keep(child)) any = true; + } + if (any || isReadable(row)) { + kept.add(String(row.id)); - const kept = new Set(); - const keep = (row: NodeRow): boolean => { - let any = false; - for (const child of children.get(String(row.id)) ?? []) { - if (keep(child)) any = true; - } - if (any || isReadable(row)) { - kept.add(String(row.id)); + return true; + } - return true; - } + return false; + }; + for (const row of page) keep(row); + + const readable = [...kept] + .map(key => nodes.get(key)) + .filter((row): row is NodeRow => row !== undefined) + .filter(row => isReadable(row)); + const records = await project( + readable.map(row => row.id), + { + hiddenIds: readable + .filter(row => !isContentBaseRowPublic(row)) + .map(row => row.id), + }, + ); - return false; - }; - for (const row of page) keep(row); - - const readable = [...kept] - .map(key => nodes.get(key)) - .filter((row): row is NodeRow => row !== undefined) - .filter(row => isReadable(row)); - const records = await project( - readable.map(row => row.id), - { - hiddenIds: readable - .filter(row => !isContentBaseRowPublic(row)) - .map(row => row.id), - }, - ); - - const placeholderOf = ( - row: NodeRow, - owner: null | number, - ): ContentThreadNode["deleted"] => { - const removedBy = - row.deletedAt instanceof Date - ? row.deletedBy - : row.hiddenAt instanceof Date - ? row.hiddenBy - : undefined; - if (removedBy === undefined) return null; + const placeholderOf = ( + row: NodeRow, + owner: null | number, + ): ContentThreadNode["deleted"] => { + const removedBy = + row.deletedAt instanceof Date + ? row.deletedBy + : row.hiddenAt instanceof Date + ? row.hiddenBy + : undefined; + if (removedBy === undefined) return null; + + return { + byAuthor: owner !== null && removedBy === owner, + createdAt: row.createdAt, + }; + }; - return { - byAuthor: owner !== null && removedBy === owner, - createdAt: row.createdAt, + const items: ContentThreadNode[] = []; + const walk = (row: NodeRow, depth: number): void => { + const key = String(row.id); + if (!kept.has(key)) return; + const record = records.get(key) ?? null; + const owner = typeof row.owner === "number" ? row.owner : null; + const hidden = record !== null && isViewableHidden(row); + items.push({ + deleted: record === null ? placeholderOf(row, owner) : null, + depth, + hidden, + hiddenReason: + hidden && typeof row.hiddenReason === "string" + ? row.hiddenReason + : null, + id: row.id, + parentId: row.parent, + record, + removed: record === null, + }); + for (const child of children.get(key) ?? []) walk(child, depth + 1); }; - }; + for (const row of page) walk(row, 1); - const items: ContentThreadNode[] = []; - const walk = (row: NodeRow, depth: number): void => { - const key = String(row.id); - if (!kept.has(key)) return; - const record = records.get(key) ?? null; - const owner = typeof row.owner === "number" ? row.owner : null; - const hidden = record !== null && isViewableHidden(row); - items.push({ - deleted: record === null ? placeholderOf(row, owner) : null, - depth, - hidden, - hiddenReason: - hidden && typeof row.hiddenReason === "string" - ? row.hiddenReason - : null, - id: row.id, - parentId: row.parent, - record, - removed: record === null, - }); - for (const child of children.get(key) ?? []) walk(child, depth + 1); + return { items, truncated }; }; - for (const row of page) walk(row, 1); + return { build, counts, readBranchTops, readTops }; +}; + +/** + * Pages a thread oldest first: a page is a run of top-level replies plus every + * answer below them, flattened depth first. A reply that is not public is left + * out - unless a public answer sits below it, in which case it is returned as + * `removed` with no values, so the tree keeps its shape. + */ +export const readContentThread = async < + TDefinition extends AnyContentTypeDefinition, + TRecord, +>( + c: Context, + model: ContentModel, + { + first, + after, + ...options + }: ThreadReadOptions & { after?: string; first: number }, +): Promise> => { + const reader = createThreadReader(c, model, options); + const [tops, { replyCount, totalCount }] = await Promise.all([ + reader.readTops(after, first), + reader.counts(), + ]); + + const hasNextPage = tops.length > first; + const page = tops.slice(0, first); + const { items, truncated } = await reader.build(page); const last = page.at(-1); return { @@ -491,9 +545,74 @@ export const readContentThread = async < ? encodeCursor({ createdAt: last.createdAtText, id: last.id }) : null, hasNextPage, - totalCount: topTotal?.value ?? 0, + totalCount, }, - replyCount: replyTotal?.value ?? 0, + replyCount, + truncated, + }; +}; + +/** + * One top-level branch as {@link readContentThreadBranches} answers it: + * `items` holds the top-level reply first, then every answer below it, exactly + * as `readContentThread` would place them on a page. `gone` when this reader + * no longer sees the branch at all - deleted, hidden or unpublished with no + * public answer left, not top-level, or not under this root. + */ +export type ContentThreadBranch = + | { id: ContentId; items: ContentThreadNode[]; status: "ok" } + | { id: ContentId; status: "gone" }; + +export interface ContentThreadBranches { + branches: ContentThreadBranch[]; + pageInfo: { totalCount: number }; + replyCount: number; + truncated: boolean; +} + +/** + * Reads the current state of some top-level branches of one thread, with the + * same visibility, placeholder, depth and truncation rules as + * {@link readContentThread} - for refreshing branches already on screen + * without reading every page again. Answers every requested id, in the order + * given; one that is not a top-level reply of `root` is `gone`, whether it + * exists elsewhere or not. Server-only. + */ +export const readContentThreadBranches = async < + TDefinition extends AnyContentTypeDefinition, + TRecord, +>( + c: Context, + model: ContentModel, + { ids, ...options }: ThreadReadOptions & { ids: ContentId[] }, +): Promise> => { + const reader = createThreadReader(c, model, options); + const [tops, { replyCount, totalCount }] = await Promise.all([ + reader.readBranchTops(ids), + reader.counts(), + ]); + const { items, truncated } = await reader.build(tops); + + const byBranch = new Map[]>(); + let current: ContentThreadNode[] | undefined; + for (const item of items) { + if (item.depth === 1) { + current = []; + byBranch.set(String(item.id), current); + } + current?.push(item); + } + + return { + branches: ids.map(id => { + const branch = byBranch.get(String(id)); + + return branch + ? { id: branch[0]?.id ?? id, items: branch, status: "ok" as const } + : { id, status: "gone" as const }; + }), + pageInfo: { totalCount }, + replyCount, truncated, }; }; diff --git a/packages/vitnode/src/content/server/write-effects.ts b/packages/vitnode/src/content/server/write-effects.ts index 0b61e9d6e..881359da9 100644 --- a/packages/vitnode/src/content/server/write-effects.ts +++ b/packages/vitnode/src/content/server/write-effects.ts @@ -43,7 +43,11 @@ export const contentPlainWriteEffects = async < definition, "created", { contentId: id }, - { pluginId, publicState: contentPublicStateOf(definition, null, row) }, + { + pluginId, + publicState: contentPublicStateOf(definition, null, row), + row, + }, ); } else if (operation === "update" && changedFields.length > 0) { await emitContentEvent( @@ -52,7 +56,11 @@ export const contentPlainWriteEffects = async < "updated", { changedFields, contentId: id }, // An edit cannot move publication, so the row after it says both. - { pluginId, publicState: contentPublicStateOf(definition, row, row) }, + { + pluginId, + publicState: contentPublicStateOf(definition, row, row), + row, + }, ); } diff --git a/packages/vitnode/src/lib/coalesce.test.ts b/packages/vitnode/src/lib/coalesce.test.ts new file mode 100644 index 000000000..e78dc9e83 --- /dev/null +++ b/packages/vitnode/src/lib/coalesce.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCoalescer } from "./coalesce"; + +const DEBOUNCE_MS = 400; +const MAX_WAIT_MS = 2_000; + +const setup = () => { + const finish: (() => void)[] = []; + const run = vi.fn( + async () => + await new Promise(resolve => { + finish.push(resolve); + }), + ); + const coalescer = createCoalescer({ + debounceMs: DEBOUNCE_MS, + maxWaitMs: MAX_WAIT_MS, + run, + }); + const settle = async () => { + finish.splice(0).forEach(resolve => resolve()); + await vi.advanceTimersByTimeAsync(0); + }; + + return { coalescer, run, settle }; +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createCoalescer", () => { + it("runs once, after a burst went quiet for debounceMs", async () => { + const { coalescer, run } = setup(); + + coalescer.schedule(); + await vi.advanceTimersByTimeAsync(300); + coalescer.schedule(); + await vi.advanceTimersByTimeAsync(300); + coalescer.schedule(); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS - 1); + + expect(run).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(run).toHaveBeenCalledOnce(); + }); + + it("runs by maxWaitMs while calls keep coming", async () => { + const { coalescer, run } = setup(); + + for (let elapsed = 0; elapsed < 1_800; elapsed += 300) { + coalescer.schedule(); + await vi.advanceTimersByTimeAsync(300); + } + coalescer.schedule(); + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS - 1_800 - 1); + + expect(run).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(run).toHaveBeenCalledOnce(); + }); + + it("runs one at a time, and once more after a run that got new calls", async () => { + const { coalescer, run, settle } = setup(); + + coalescer.flush(); + expect(run).toHaveBeenCalledOnce(); + + coalescer.schedule(); + coalescer.schedule(); + coalescer.flush(); + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS); + + expect(run).toHaveBeenCalledOnce(); + + await settle(); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + + expect(run).toHaveBeenCalledTimes(2); + + await settle(); + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS); + + expect(run).toHaveBeenCalledTimes(2); + }); + + it("drops a scheduled run on cancel", async () => { + const { coalescer, run } = setup(); + + coalescer.schedule(); + coalescer.cancel(); + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS); + + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/lib/coalesce.ts b/packages/vitnode/src/lib/coalesce.ts new file mode 100644 index 000000000..1cae9eeab --- /dev/null +++ b/packages/vitnode/src/lib/coalesce.ts @@ -0,0 +1,75 @@ +/** A task that runs at most once per burst of requests. */ +export interface Coalescer { + /** Drops a scheduled run; a run already going finishes. */ + cancel: () => void; + /** Runs now if nothing runs yet; otherwise right after the current run. */ + flush: () => void; + /** Asks for a run: after `debounceMs` of quiet, but within `maxWaitMs`. */ + schedule: () => void; +} + +/** + * Coalesces bursts of `schedule()` calls into single runs of `run`: it waits + * for `debounceMs` without a new call, yet never longer than `maxWaitMs` after + * the first call of a burst, so continuous traffic cannot postpone it forever. + * One run at a time: a call that lands during a run schedules exactly one + * more, after it. A failed run is not retried. Universal. + */ +export const createCoalescer = ({ + debounceMs, + maxWaitMs, + run, +}: { + debounceMs: number; + maxWaitMs: number; + run: () => Promise; +}): Coalescer => { + let timer: ReturnType | undefined; + let firstAt: null | number = null; + let running = false; + let again = false; + + const start = (): void => { + clearTimeout(timer); + timer = undefined; + firstAt = null; + if (running) { + again = true; + + return; + } + running = true; + void run() + .catch(() => { + // The caller surfaces its own failures; a hint is not retried. + }) + .finally(() => { + running = false; + if (again) { + again = false; + schedule(); + } + }); + }; + + const schedule = (): void => { + const now = Date.now(); + firstAt ??= now; + clearTimeout(timer); + timer = setTimeout( + start, + Math.max(0, Math.min(debounceMs, firstAt + maxWaitMs - now)), + ); + }; + + return { + cancel: () => { + clearTimeout(timer); + timer = undefined; + firstAt = null; + again = false; + }, + flush: start, + schedule, + }; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 0a663cb3e..b37ddb54a 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1735,6 +1735,14 @@ "name": "[deleted]", "by_author": "Comment deleted by user", "by_moderator": "Comment removed by a moderator" + }, + "live": { + "new_comments": "New comments were posted.", + "show_new": "Show new comments", + "refresh_failed": "Could not refresh the comments, so some may be out of date.", + "retry": "Try again", + "edit_removed": "This comment was removed while you were editing it. Your text is still here, but it can no longer be saved.", + "reply_removed": "The comment you are replying to was removed. Your text is still here, but it can no longer be posted." } }, "content": { @@ -2109,6 +2117,11 @@ "desc": "The form is back to the saved version." } } + }, + "list_refresh": { + "title": "Could not refresh this list", + "desc": "Someone changed these records, but the latest rows did not load. You are looking at the rows loaded before.", + "retry": "Try again" } }, "containers": { diff --git a/packages/vitnode/src/tanstack/admin/content/list-live.ts b/packages/vitnode/src/tanstack/admin/content/list-live.ts new file mode 100644 index 000000000..2e39731f7 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/content/list-live.ts @@ -0,0 +1,75 @@ +import { useQueryClient } from "@tanstack/react-query"; +import React from "react"; + +import type { ContentLiveRefreshStatus } from "@/tanstack/content/live-refresh"; + +import { + CONTENT_REFRESH_DEBOUNCE_MS, + CONTENT_REFRESH_MAX_WAIT_MS, +} from "@/content/live/refresh"; +import { createCoalescer } from "@/lib/coalesce"; +import { useContentLiveRefresh } from "@/tanstack/content/live-refresh"; +import { invalidateContentList } from "@/views/admin/views/content/lib/invalidate"; + +/** Spreads the reads of many open lists after a server restart. */ +const RECONNECT_JITTER_MS = 1_500; + +/** + * Keeps one generated AdminCP list current while it is mounted: joins the + * content type's admin refresh room and, on a signal, refetches the list page + * on screen - same filters, search, sort and page, rows kept meanwhile - and + * drops the cached pages nobody looks at. Bursts collapse into one read + * (`CONTENT_REFRESH_DEBOUNCE_MS`, at most `CONTENT_REFRESH_MAX_WAIT_MS` late) + * and a join or rejoin reads once to cover what was missed. Used by + * `ContentListScreen`; every generated list gets it. Browser-only. + */ +export const useContentListLiveRefresh = ( + contentTypeId: string, +): ContentLiveRefreshStatus => { + const queryClient = useQueryClient(); + const coalescer = React.useMemo( + () => + createCoalescer({ + debounceMs: CONTENT_REFRESH_DEBOUNCE_MS, + maxWaitMs: CONTENT_REFRESH_MAX_WAIT_MS, + run: async () => { + await invalidateContentList(queryClient, contentTypeId); + }, + }), + [contentTypeId, queryClient], + ); + const jitterRef = React.useRef | undefined>( + undefined, + ); + + React.useEffect( + () => () => { + clearTimeout(jitterRef.current); + coalescer.cancel(); + }, + [coalescer], + ); + + const scope = React.useMemo( + () => ({ contentTypeId, kind: "admin" as const }), + [contentTypeId], + ); + + return useContentLiveRefresh(scope, { + onSignal: () => { + coalescer.schedule(); + }, + onSubscribed: ({ reconnect }) => { + if (!reconnect) { + coalescer.schedule(); + + return; + } + clearTimeout(jitterRef.current); + jitterRef.current = setTimeout( + coalescer.schedule, + Math.random() * RECONNECT_JITTER_MS, + ); + }, + }); +}; diff --git a/packages/vitnode/src/tanstack/admin/content/list.tsx b/packages/vitnode/src/tanstack/admin/content/list.tsx index 1cfec675b..a2f06d8a0 100644 --- a/packages/vitnode/src/tanstack/admin/content/list.tsx +++ b/packages/vitnode/src/tanstack/admin/content/list.tsx @@ -1,5 +1,6 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; +import { RotateCwIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; @@ -16,6 +17,8 @@ import type { ContentRowData } from "@/views/admin/views/content/table/cells"; import { ContentDataTable } from "@/components/table/content"; import { DataTableSkeleton } from "@/components/table/data-table-content"; import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; import { buildContentColumnSpec, CONTENT_PERMISSIONS, @@ -43,6 +46,7 @@ import { useLocale } from "../../i18n/locale"; import { useAdminPermission } from "../permissions"; import { ContentBulkActions } from "./bulk-actions"; import { ContentCreateAction } from "./create-action"; +import { useContentListLiveRefresh } from "./list-live"; import { contentListPageQuery } from "./query"; import { contentListRouteParams, @@ -87,9 +91,10 @@ const ContentListTable = ({ const { definition, pluginId, registration } = entry; const t = useTranslations("core.content"); const locale = useLocale(); - const { data } = useSuspenseQuery( + const { data, isFetching, isRefetchError, refetch } = useSuspenseQuery( contentListPageQuery({ definition, locale, params, pluginId }), ); + useContentListLiveRefresh(definition.id); const canDelete = useAdminPermission({ module: definition.permissionModule, permission: CONTENT_PERMISSIONS.delete, @@ -210,30 +215,51 @@ const ContentListTable = ({ return ( - - bulkActions={ - bulkActions.length > 0 ? ( - - ) : undefined - } - columns={columns} - customNoResults={ - isNarrowed - ? undefined - : { description: t("empty.desc"), title: t("empty.title") } - } - edges={data.edges} - filters={filters.length > 0 ? filters : undefined} - id={`content-${definition.id}`} - order={contentTableOrder(definition)} - pageInfo={data.pageInfo} - search={contentTableSearchEnabled(definition)} - /> +
+ {isRefetchError ? ( + + {t("list_refresh.title")} + {t("list_refresh.desc")} +
+ +
+
+ ) : null} + + bulkActions={ + bulkActions.length > 0 ? ( + + ) : undefined + } + columns={columns} + customNoResults={ + isNarrowed + ? undefined + : { description: t("empty.desc"), title: t("empty.title") } + } + edges={data.edges} + filters={filters.length > 0 ? filters : undefined} + id={`content-${definition.id}`} + order={contentTableOrder(definition)} + pageInfo={data.pageInfo} + search={contentTableSearchEnabled(definition)} + /> +
{duplicated && formDialog ? ( void; /** Called after the server accepted the comment, with what it became. */ onDone?: (outcome: ContentCommentWriteOutcome) => void; + /** + * Why this form can no longer be sent, already translated - e.g. the + * comment it edits or answers was removed meanwhile. Keeps the editor and + * its text, shows the reason and hides the submit button. + */ + unavailable?: string; } & ( | { editable: ContentCommentEditable; id: ContentId; mode: "edit" } | { mode: "create"; parentId: ContentId | null } @@ -59,7 +65,7 @@ const useBodySchema = (defaultBody: RichTextDocument) => { * shows the version-conflict alert. Lazy-loaded by `ContentCommentComposer`. */ const ContentCommentComposerForm = (props: ContentCommentComposerFormProps) => { - const { autoFocus, label, onCancel, onDone } = props; + const { autoFocus, label, onCancel, onDone, unavailable } = props; const t = useTranslations("core.comments"); const { comments } = useContentCommentsContext(); const editId = props.mode === "edit" ? props.id : null; @@ -112,6 +118,11 @@ const ContentCommentComposerForm = (props: ContentCommentComposerFormProps) => { return (
+ {unavailable ? ( + + {unavailable} + + ) : null} {write.conflict} {props.mode === "edit" && props.editable.proposed ? ( @@ -146,14 +157,16 @@ const ContentCommentComposerForm = (props: ContentCommentComposerFormProps) => { onSubmit={onSubmit} >
- - {props.mode === "edit" ? ( - + {unavailable ? null : ( + + {props.mode === "edit" ? ( + + )} {onCancel ? (
) : null} @@ -426,6 +428,97 @@ export const ContentCommentsLoadMore = ({ ); }; +/** + * The live state of the thread, for a section that renders its own layout: + * **Show new comments** when comments appeared after the loaded ones, and a + * notice with **Try again** when a background refresh failed - the comments + * on screen stay. Both are announced politely to screen readers. Renders + * nothing while there is nothing to say. + */ +export const ContentCommentsLiveStatus = ({ + className, +}: { + className?: string; +}) => { + const t = useTranslations("core.comments"); + const { comments } = useContentCommentsContext(); + const [isShowing, setIsShowing] = React.useState(false); + const [isRetrying, setIsRetrying] = React.useState(false); + const announcement = [ + comments.hasNewComments ? t("live.new_comments") : null, + comments.refreshError ? t("live.refresh_failed") : null, + ] + .filter(Boolean) + .join(" "); + + return ( + <> + {/* Always mounted, so a screen reader hears what is added to it. */} +

+ {announcement} +

+ {comments.hasNewComments ? ( +
+ + +
+ ) : null} + {comments.refreshError ? ( +
+ + +
+ ) : null} + + ); +}; + /** * The new-comment card: the composer for a signed-in visitor, a sign-in link * for a guest that returns here, or nothing when the thread has no `create`. @@ -598,6 +691,7 @@ const ContentCommentsSection = ({ {t("truncated")}

) : null} + diff --git a/packages/vitnode/src/tanstack/comments/index.ts b/packages/vitnode/src/tanstack/comments/index.ts index d2674e2d4..653717765 100644 --- a/packages/vitnode/src/tanstack/comments/index.ts +++ b/packages/vitnode/src/tanstack/comments/index.ts @@ -6,6 +6,7 @@ export { ContentCommentPlaceholder, ContentComments, ContentCommentsComposer, + ContentCommentsLiveStatus, ContentCommentsLoadMore, } from "./content-comments"; export type { ContentCommentItemState } from "./item"; @@ -17,6 +18,7 @@ export { preloadContentCommentComposer, useContentCommentItem, } from "./item"; +export { ContentCommentsRequestError } from "./live"; export { CONTENT_COMMENTS_NAMESPACES, ContentCommentsRoot, @@ -25,6 +27,8 @@ export { } from "./root"; export type { ContentCommentNode, + ContentCommentsBranch, + ContentCommentsBranches, ContentCommentsThreadPage, ContentCommentTreeNode, ContentCommentWriteOutcome, @@ -33,6 +37,7 @@ export { buildContentCommentTree, contentCommentWriteOutcome, countContentCommentReplies, + shareContentCommentTree, } from "./thread"; export type { ContentCommentActionResult, diff --git a/packages/vitnode/src/tanstack/comments/item.tsx b/packages/vitnode/src/tanstack/comments/item.tsx index 135fd3ce2..c29def897 100644 --- a/packages/vitnode/src/tanstack/comments/item.tsx +++ b/packages/vitnode/src/tanstack/comments/item.tsx @@ -107,6 +107,18 @@ export const useContentCommentItem = (node: ContentCommentNode) => { const [isReplying, setIsReplying] = React.useState(false); const permissions = comments.permissionsOf(node.id); const isBusy = comments.busy !== null && comments.busy.id === node.id; + const hasDraft = editing !== null || isReplying; + const { trackDraft } = comments; + + // A live update must not unmount a form the reader is typing in. + React.useEffect(() => { + if (!hasDraft) return; + trackDraft(node.id, true); + + return () => { + trackDraft(node.id, false); + }; + }, [hasDraft, node.id, trackDraft]); return { /** Whether **Reply** shows: the visitor may write, under `maxDepth`, not hidden. */ @@ -118,6 +130,12 @@ export const useContentCommentItem = (node: ContentCommentNode) => { /** `true` while an action runs on this comment. */ isBusy, isReplying, + /** + * The comment was removed, or is no longer visible to this reader, while + * a form was open on it: it stays only so the typed text is not lost, and + * can no longer be saved or answered. + */ + isRemoved: node.removed && node.record !== null, /** The server's answers, limited to the actions the plugin wired up. */ permissions: { canDelete: comments.features.delete && permissions.canDelete, diff --git a/packages/vitnode/src/tanstack/comments/live.test.ts b/packages/vitnode/src/tanstack/comments/live.test.ts new file mode 100644 index 000000000..f18411662 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/live.test.ts @@ -0,0 +1,611 @@ +import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ContentCommentsData } from "./live"; +import type { + ContentCommentNode, + ContentCommentsBranches, + ContentCommentsThreadPage, +} from "./thread"; + +import { + appendContentCommentsTail, + ContentCommentsRequestError, + createContentCommentsLive, + mergeContentCommentBranches, +} from "./live"; + +interface Rec { + text: string; +} + +type Node = ContentCommentNode; +type Page = ContentCommentsThreadPage; +type Data = ContentCommentsData; + +const node = ( + id: number, + parentId: null | number = null, + overrides: Partial = {}, +): Node => ({ + deleted: null, + depth: parentId === null ? 1 : 2, + hidden: false, + hiddenReason: null, + id, + parentId, + record: { text: `comment ${id}` }, + removed: false, + ...overrides, +}); + +const page = ( + items: Node[], + endCursor: null | string, + hasNextPage = false, +): Page => ({ + items, + pageInfo: { endCursor, hasNextPage, totalCount: 3 }, + replyCount: 5, + truncated: false, +}); + +const ids = (items: Node[]) => items.map(item => item.id); + +const answer = ( + branches: ContentCommentsBranches["branches"], + truncated = false, +): ContentCommentsBranches => ({ + branches, + pageInfo: { totalCount: 9 }, + replyCount: 20, + truncated, +}); + +describe("mergeContentCommentBranches", () => { + const data = (): Data => ({ + pageParams: [null, "c1"], + pages: [ + page([node(1), node(2, 1), node(3)], "c1", true), + page([node(4), node(5, 4)], "c2"), + ], + }); + + it("replaces a branch in place and keeps every other page and page param", () => { + const before = data(); + + const merged = mergeContentCommentBranches( + before, + answer([{ id: 1, items: [node(1), node(6, 1)], status: "ok" }], true), + ); + + expect(ids(merged.pages[0].items)).toStrictEqual([1, 6, 3]); + expect(merged.pages[0].items[2]).toBe(before.pages[0].items[2]); + expect(merged.pages[1].items).toBe(before.pages[1].items); + expect(merged.pageParams).toBe(before.pageParams); + expect(merged.pages.map(item => item.pageInfo)).toStrictEqual([ + { endCursor: "c1", hasNextPage: true, totalCount: 9 }, + { endCursor: "c2", hasNextPage: false, totalCount: 9 }, + ]); + expect(merged.pages.map(item => item.replyCount)).toStrictEqual([20, 20]); + expect(merged.pages.map(item => item.truncated)).toStrictEqual([ + true, + false, + ]); + }); + + it("drops a gone branch with its replies", () => { + const merged = mergeContentCommentBranches( + data(), + answer([{ id: 4, status: "gone" }]), + ); + + expect(ids(merged.pages[1].items)).toStrictEqual([]); + expect(ids(merged.pages[0].items)).toStrictEqual([1, 2, 3]); + }); + + it("leaves a branch alone when skip says a newer answer replaced it", () => { + const before = data(); + + const merged = mergeContentCommentBranches( + before, + answer([{ id: 1, status: "gone" }]), + { skip: key => key === "1" }, + ); + + expect(merged.pages[0].items).toBe(before.pages[0].items); + }); + + it("keeps a comment with an open editor as removed, with its text", () => { + const merged = mergeContentCommentBranches( + data(), + answer([{ id: 1, items: [node(1)], status: "ok" }]), + { drafts: new Set(["2"]) }, + ); + + expect( + merged.pages[0].items.map(({ id, record, removed }) => ({ + id, + record, + removed, + })), + ).toStrictEqual([ + { id: 1, record: { text: "comment 1" }, removed: false }, + { id: 2, record: { text: "comment 2" }, removed: true }, + { id: 3, record: { text: "comment 3" }, removed: false }, + ]); + }); +}); + +describe("appendContentCommentsTail", () => { + const data = (): Data => ({ + pageParams: [null], + pages: [page([node(1), node(2, 1)], "c1")], + }); + + it("appends the comments after the last loaded one, without branches already on screen", () => { + const before = data(); + + const appended = appendContentCommentsTail( + before, + page([node(1), node(7), node(8, 7)], "c2"), + "c1", + ); + + expect(appended.pageParams).toStrictEqual([null, "c1"]); + expect(appended.pages[0].items).toBe(before.pages[0].items); + expect(ids(appended.pages[1].items)).toStrictEqual([7, 8]); + }); + + it("replaces an empty thread's only page", () => { + const tail = page([node(7)], "c1"); + + expect( + appendContentCommentsTail( + { pageParams: [null], pages: [page([], null)] }, + tail, + null, + ), + ).toStrictEqual({ pageParams: [null], pages: [tail] }); + }); + + it("only updates the counts when nothing new is left", () => { + const appended = appendContentCommentsTail( + data(), + { ...page([node(1)], "c2"), replyCount: 8 }, + "c1", + ); + + expect(appended.pages).toHaveLength(1); + expect(appended.pages[0].replyCount).toBe(8); + }); +}); + +/** + * A thread on a fake server: top-level comment `k * 10` with one reply + * `k * 10 + 1` below it, `PAGE_SIZE` top-level comments per page. + */ +const PAGE_SIZE = 2; + +const branchOf = (k: number): Node[] => [ + node(k * 10), + node(k * 10 + 1, k * 10), +]; + +const createServer = (count: number) => { + const state = { + branches: Array.from({ length: count }, (_, index) => branchOf(index + 1)), + denied: null as null | number, + }; + const refuse = () => { + if (state.denied !== null) { + throw new ContentCommentsRequestError(state.denied, "Refused."); + } + }; + const counts = () => ({ + replyCount: state.branches.flat().length, + totalCount: state.branches.length, + }); + + return { + branches: (requested: number[]): ContentCommentsBranches => { + refuse(); + const { replyCount, totalCount } = counts(); + + return structuredClone({ + branches: requested.map(id => { + const found = state.branches.find(branch => branch[0].id === id); + + return found + ? { id, items: found, status: "ok" as const } + : { id, status: "gone" as const }; + }), + pageInfo: { totalCount }, + replyCount, + truncated: false, + }); + }, + page: (after: null | string): Page => { + refuse(); + const start = after === null ? 0 : Number(after.slice(1)); + const tops = state.branches.slice(start, start + PAGE_SIZE); + const end = start + tops.length; + const { replyCount, totalCount } = counts(); + + return structuredClone({ + items: tops.flat(), + pageInfo: { + endCursor: tops.length > 0 ? `c${end}` : null, + hasNextPage: end < state.branches.length, + totalCount, + }, + replyCount, + truncated: false, + }); + }, + state, + }; +}; + +const THREAD_KEY = ["comments", 1, "thread"]; + +const cleanups: (() => void)[] = []; + +const setup = async ({ + branches, + loadedPages, + maxRequests, +}: { + branches: number; + loadedPages: number; + maxRequests?: number; +}) => { + const server = createServer(branches); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false, staleTime: Infinity }, + }, + }); + // `GET /thread` as the infinite query reads it. + const threadReads = vi.fn(async (after: null | string) => { + await Promise.resolve(); + + return server.page(after); + }); + const options = { + getNextPageParam: (last: Page) => + last.pageInfo.hasNextPage ? last.pageInfo.endCursor : null, + initialPageParam: null as null | string, + queryFn: async ({ pageParam }: { pageParam: null | string }) => + await threadReads(pageParam), + queryKey: THREAD_KEY, + }; + await queryClient.infiniteQuery({ ...options, pages: loadedPages }); + const observer = new InfiniteQueryObserver(queryClient, options); + const stopObserving = observer.subscribe(() => undefined); + + const live = createContentCommentsLive({ + maxRequests, + permissionsKey: ["comments", 1, "permissions"], + queryClient, + threadKey: THREAD_KEY, + }); + // `GET /thread/branches` and `GET /thread` as the live controller reads them. + const readBranches = vi.fn(async (requested: number[]) => { + await Promise.resolve(); + + return server.branches(requested); + }); + const readPage = vi.fn(async (after: null | string) => { + await Promise.resolve(); + + return server.page(after); + }); + live.setSource({ readBranches, readPage }); + const detach = live.attach(); + cleanups.push(() => { + detach(); + stopObserving(); + queryClient.clear(); + }); + threadReads.mockClear(); + + const data = () => queryClient.getQueryData(THREAD_KEY); + const loadedIds = () => data()?.pages.flatMap(item => ids(item.items)); + const requests = () => ({ + readBranches: readBranches.mock.calls.length, + readPage: readPage.mock.calls.length, + threadReads: threadReads.mock.calls.length, + }); + + return { + data, + live, + loadedIds, + observer, + readBranches, + readPage, + requests, + server, + threadReads, + }; +}; + +/** A promise the test settles by hand. */ +const gate = () => { + let open: (value: T) => void = () => undefined; + const promise = new Promise(resolve => { + open = resolve; + }); + + return { open, promise }; +}; + +const SETTLE_MS = 5_000; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanups.splice(0).forEach(cleanup => cleanup()); + vi.useRealTimers(); +}); + +describe("createContentCommentsLive", () => { + describe("request cost", () => { + it.each([ + ["a new comment while more pages wait on the server", () => [node(90)]], + [ + "a reply in a branch that is not loaded", + () => [...branchOf(7), node(72, 70)], + ], + ])("%s: 0 requests", async (_label, change) => { + const { live, requests, server } = await setup({ + branches: 8, + loadedPages: 3, + }); + const branch = change(); + const at = server.state.branches.findIndex( + item => item[0].id === branch[0].id, + ); + if (at === -1) server.state.branches.push(branch); + else server.state.branches[at] = branch; + + live.signal({ branchId: branch[0].id, kind: "branch" }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(requests()).toStrictEqual({ + readBranches: 0, + readPage: 0, + threadReads: 0, + }); + expect(live.getSnapshot().hasNew).toBe(false); + }); + + it("a new comment at the end of the thread: 0 requests, then 1 page read on showNew", async () => { + const { data, live, readPage, requests, server } = await setup({ + branches: 6, + loadedPages: 3, + }); + server.state.branches.push([node(90)]); + + live.signal({ branchId: 90, kind: "branch" }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(requests()).toStrictEqual({ + readBranches: 0, + readPage: 0, + threadReads: 0, + }); + expect(live.getSnapshot().hasNew).toBe(true); + + const before = data()?.pages.map(item => item.items) ?? []; + const shown = await live.showNew(); + + expect(shown).toBe(true); + expect(requests()).toStrictEqual({ + readBranches: 0, + readPage: 1, + threadReads: 0, + }); + expect(readPage).toHaveBeenCalledWith("c6"); + const after = data()?.pages ?? []; + expect(after).toHaveLength(4); + before.forEach((items, index) => { + expect(after[index].items).toBe(items); + }); + expect(ids(after[3].items)).toStrictEqual([90]); + expect(live.getSnapshot().hasNew).toBe(false); + }); + + it("five quick signals for one loaded branch: 1 branch read", async () => { + const { live, loadedIds, readBranches, requests, server } = await setup({ + branches: 6, + loadedPages: 3, + }); + server.state.branches[0] = [...branchOf(1), node(12, 10)]; + + for (let signal = 0; signal < 5; signal++) { + live.signal({ branchId: 10, kind: "branch" }); + await vi.advanceTimersByTimeAsync(100); + } + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(requests()).toStrictEqual({ + readBranches: 1, + readPage: 0, + threadReads: 0, + }); + expect(readBranches).toHaveBeenCalledWith([10]); + expect(loadedIds()?.slice(0, 3)).toStrictEqual([10, 11, 12]); + }); + }); + + it("holds a branch answer while the next page loads, and merges it after", async () => { + const { + data, + live, + loadedIds, + observer, + readBranches, + server, + threadReads, + } = await setup({ branches: 4, loadedPages: 1 }); + const branchAnswer = gate>(); + readBranches.mockImplementationOnce(async () => await branchAnswer.promise); + const nextPage = gate(); + threadReads.mockImplementationOnce(async () => await nextPage.promise); + + live.signal({ branchId: 10, kind: "branch" }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + expect(readBranches).toHaveBeenCalledOnce(); + + const loading = observer.fetchNextPage(); + await vi.advanceTimersByTimeAsync(0); + server.state.branches[0] = [node(10, null, { record: { text: "new" } })]; + branchAnswer.open(server.branches([10])); + await vi.advanceTimersByTimeAsync(0); + + expect(data()?.pages[0].items[0].record).toStrictEqual({ + text: "comment 10", + }); + + nextPage.open(server.page("c2")); + await loading; + await vi.advanceTimersByTimeAsync(0); + + expect(loadedIds()).toStrictEqual([10, 20, 21, 30, 31, 40, 41]); + expect(data()?.pages[0].items[0].record).toStrictEqual({ text: "new" }); + }); + + it("drops a branch answer that started before a full refetch, and asks again", async () => { + const { data, live, readBranches, server, threadReads } = await setup({ + branches: 2, + loadedPages: 1, + }); + const stale = gate>(); + readBranches.mockImplementationOnce(async () => await stale.promise); + + live.signal({ branchId: 10, kind: "branch" }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + expect(readBranches).toHaveBeenCalledOnce(); + + const staleAnswer = server.branches([10]); + server.state.branches[0] = [node(10, null, { record: { text: "fresh" } })]; + live.reconcile(); + await vi.advanceTimersByTimeAsync(0); + stale.open(staleAnswer); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(threadReads).toHaveBeenCalledOnce(); + expect(readBranches).toHaveBeenCalledTimes(2); + expect(data()?.pages[0].items[0].record).toStrictEqual({ text: "fresh" }); + }); + + it("keeps the thread when a branch read fails, takes the removed comment off and retries", async () => { + const { live, loadedIds, readBranches, server } = await setup({ + branches: 2, + loadedPages: 1, + }); + readBranches.mockRejectedValueOnce( + new ContentCommentsRequestError(500, "Down."), + ); + server.state.branches[0] = [node(10)]; + + live.signal({ branchId: 10, kind: "branch", removedId: 11 }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(loadedIds()).toStrictEqual([10, 20, 21]); + expect(live.getSnapshot().refreshError).toBe(true); + + const retried = live.retry(); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + await retried; + + expect(readBranches).toHaveBeenCalledTimes(2); + expect(loadedIds()).toStrictEqual([10, 20, 21]); + expect(live.getSnapshot().refreshError).toBe(false); + }); + + it.each([401, 403, 404])( + "drops the thread when a branch read answers %i", + async status => { + const { data, live, server } = await setup({ + branches: 2, + loadedPages: 1, + }); + server.state.denied = status; + + live.signal({ branchId: 10, kind: "branch" }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(data()).toBeUndefined(); + }, + ); + + it("keeps a removed comment with an open editor until the editor closes", async () => { + const { data, live, loadedIds, readBranches, server } = await setup({ + branches: 2, + loadedPages: 1, + }); + live.trackDraft(11, true); + server.state.branches[0] = [node(10)]; + + live.signal({ branchId: 10, kind: "branch", removedId: 11 }); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(data()?.pages[0].items[1]).toMatchObject({ + id: 11, + record: { text: "comment 11" }, + removed: true, + }); + + live.trackDraft(11, false); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(readBranches).toHaveBeenCalledTimes(2); + expect(loadedIds()).toStrictEqual([10, 20, 21]); + }); + + it("sends at most maxRequests branch reads at once", async () => { + const { live, readBranches, server } = await setup({ + branches: 30, + loadedPages: 15, + maxRequests: 2, + }); + let running = 0; + let most = 0; + const waiting: (() => void)[] = []; + readBranches.mockImplementation(async requested => { + running++; + most = Math.max(most, running); + await new Promise(resolve => { + waiting.push(resolve); + }); + running--; + + return server.branches(requested); + }); + + for (const branch of server.state.branches) { + live.signal({ branchId: branch[0].id, kind: "branch" }); + } + await vi.advanceTimersByTimeAsync(SETTLE_MS); + + expect(readBranches).toHaveBeenCalledTimes(2); + + while (waiting.length > 0) { + waiting.splice(0).forEach(resolve => resolve()); + await vi.advanceTimersByTimeAsync(SETTLE_MS); + } + + expect(most).toBe(2); + expect(readBranches).toHaveBeenCalledTimes(3); + expect( + readBranches.mock.calls + .flatMap(([requested]) => requested) + .toSorted((a, b) => a - b), + ).toStrictEqual(server.state.branches.map(branch => branch[0].id)); + }); +}); diff --git a/packages/vitnode/src/tanstack/comments/live.ts b/packages/vitnode/src/tanstack/comments/live.ts new file mode 100644 index 000000000..5cc55e1fb --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/live.ts @@ -0,0 +1,788 @@ +import type { + InfiniteData, + QueryCacheNotifyEvent, + QueryClient, + QueryKey, +} from "@tanstack/react-query"; + +import { hashKey } from "@tanstack/react-query"; + +import type { ContentId } from "@/content/ids"; +import type { ContentRefreshSignal } from "@/content/live/refresh"; + +import { + CONTENT_REFRESH_DEBOUNCE_MS, + CONTENT_REFRESH_MAX_WAIT_MS, + CONTENT_THREAD_BRANCHES_MAX, +} from "@/content/live/refresh"; +import { createCoalescer } from "@/lib/coalesce"; + +import type { + ContentCommentNode, + ContentCommentsBranches, + ContentCommentsThreadPage, +} from "./thread"; + +/** A thread read that did not answer `2xx`, with the status it answered. */ +export class ContentCommentsRequestError extends Error { + constructor(status: number, message: string) { + super(message); + this.name = "ContentCommentsRequestError"; + this.status = status; + } + + readonly status: number; +} + +/** `401`, `403` and `404`: the reader may no longer see this thread at all. */ +export const isContentCommentsAccessError = (error: unknown): boolean => + error instanceof ContentCommentsRequestError && + (error.status === 401 || error.status === 403 || error.status === 404); + +export type ContentCommentsData = InfiniteData< + ContentCommentsThreadPage, + null | string +>; + +/** Concurrent `GET /thread/branches` requests one thread sends at most. */ +export const CONTENT_COMMENTS_MAX_BRANCH_REQUESTS = 2; + +/** Spreads the reads of every viewer of a busy thread after a server restart. */ +const RECONNECT_JITTER_MS = 1_500; + +const keyOf = (id: ContentId): string => String(id); + +/** + * Puts nodes the reader is typing in back into a branch that lost them, as + * `removed` with their old record, so the open editor keeps its text and can + * say why it can no longer be sent. Pure. + */ +const keepDrafts = ( + before: ContentCommentNode[], + after: ContentCommentNode[], + drafts: ReadonlySet, +): ContentCommentNode[] => { + if (drafts.size === 0) return after; + const next = [...after]; + + for (const node of before) { + const key = keyOf(node.id); + if (!drafts.has(key) || node.record === null) continue; + + const index = next.findIndex(item => keyOf(item.id) === key); + const current = index === -1 ? undefined : next[index]; + if (current && current.record !== null) continue; + const kept = { ...node, removed: true }; + if (current) { + next[index] = kept; + continue; + } + const parentKey = node.parentId === null ? null : keyOf(node.parentId); + const parentIndex = + parentKey === null + ? -1 + : next.findIndex(item => keyOf(item.id) === parentKey); + next.splice(parentIndex === -1 ? next.length : parentIndex + 1, 0, kept); + } + + return next; +}; + +/** + * Replaces each answered top-level branch where it sits in the loaded pages + * and drops the `gone` ones, keeping every other page, item and page param + * as it was - so cursors stay valid and nothing moves across a page + * boundary. Branches that are not loaded are left alone; the counts of every + * page take the answer's. Pure. + */ +export const mergeContentCommentBranches = ( + data: ContentCommentsData, + result: ContentCommentsBranches, + { + drafts = new Set(), + skip = () => false, + }: { + /** Ids with an open editor, see `keepDrafts`. */ + drafts?: ReadonlySet; + /** Branches a newer answer already replaced. */ + skip?: (branchKey: string) => boolean; + } = {}, +): ContentCommentsData => { + const answers = new Map( + result.branches + .filter(branch => !skip(keyOf(branch.id))) + .map(branch => [keyOf(branch.id), branch]), + ); + + const pages = data.pages.map(page => { + let changed = false; + const items: ContentCommentNode[] = []; + let index = 0; + while (index < page.items.length) { + const item = page.items[index]; + const answer = item.depth === 1 ? answers.get(keyOf(item.id)) : undefined; + let end = index + 1; + while (end < page.items.length && page.items[end].depth > 1) end++; + + if (answer) { + changed = true; + items.push( + ...keepDrafts( + page.items.slice(index, end), + answer.status === "ok" ? answer.items : [], + drafts, + ), + ); + } else { + items.push(...page.items.slice(index, end)); + } + index = end; + } + + return { + ...page, + items: changed ? items : page.items, + pageInfo: { ...page.pageInfo, totalCount: result.pageInfo.totalCount }, + replyCount: result.replyCount, + truncated: page.truncated || (changed && result.truncated), + }; + }); + + return { ...data, pages }; +}; + +/** Every top-level id on the loaded pages. */ +const loadedBranches = ( + data: ContentCommentsData | undefined, +): Set => + new Set( + (data?.pages ?? []).flatMap(page => + page.items.filter(item => item.depth === 1).map(item => keyOf(item.id)), + ), + ); + +/** + * Appends the comments that came after the last loaded one as a new page, + * leaving out branches already on screen. An empty thread's only page is + * replaced instead. Pure. + */ +export const appendContentCommentsTail = ( + data: ContentCommentsData, + tail: ContentCommentsThreadPage, + after: null | string, +): ContentCommentsData => { + if (after === null) { + return { pageParams: [null], pages: [tail] }; + } + + const counted = data.pages.map(page => ({ + ...page, + pageInfo: { ...page.pageInfo, totalCount: tail.pageInfo.totalCount }, + replyCount: tail.replyCount, + })); + const loaded = loadedBranches(data); + const items: ContentCommentNode[] = []; + let skipping = false; + for (const item of tail.items) { + if (item.depth === 1) skipping = loaded.has(keyOf(item.id)); + if (!skipping) items.push(item); + } + if (items.length === 0 && !tail.pageInfo.hasNextPage) { + return { ...data, pages: counted }; + } + + return { + pageParams: [...data.pageParams, after], + pages: [...counted, { ...tail, items }], + }; +}; + +/** The last cursor of the loaded pages, `null` before any comment. */ +const tailCursor = ( + data: ContentCommentsData, +): null | string => { + for (const page of data.pages.toReversed()) { + if (page.pageInfo.endCursor !== null) return page.pageInfo.endCursor; + } + + return null; +}; + +export interface ContentCommentsLiveSnapshot { + /** A comment appeared after the loaded ones, at the end of the thread. */ + hasNew: boolean; + /** A background refresh failed; what is on screen may be out of date. */ + refreshError: boolean; +} + +/** How a live thread reads, installed with `setSource` once mounted. */ +export interface ContentCommentsLiveSource { + /** Called right before the loaded thread changes, to hold the scroll position. */ + beforeChange?: () => void; + /** `GET /thread/branches` - throws `ContentCommentsRequestError` on a refusal. */ + readBranches: (ids: TId[]) => Promise>; + /** `GET /thread` after a cursor - throws `ContentCommentsRequestError` on a refusal. */ + readPage: ( + after: null | string, + ) => Promise>; +} + +export interface ContentCommentsLiveOptions { + debounceMs?: number; + maxRequests?: number; + maxWaitMs?: number; + /** The permissions cache prefix, refreshed for every branch that changed. */ + permissionsKey: QueryKey; + queryClient: QueryClient; + /** The thread's infinite query key. */ + threadKey: QueryKey; +} + +const DETACHED = async (): Promise => + await Promise.reject( + new ContentCommentsRequestError(0, "The live thread has no source yet."), + ); + +/** + * Keeps a loaded comment thread current from live signals, at a predictable + * cost: + * + * - A change in a loaded branch re-reads only that branch, through + * `/thread/branches`, and merges it in place. Changes to the same branches + * within `debounceMs` (at most `maxWaitMs`) share one request; at most + * `maxRequests` run at once, never two for the same branch. + * - A change in a branch that is not loaded is not fetched. At the end of the + * thread it raises `hasNew`, and `showNew()` reads only what follows the + * last loaded comment; before the end, "Load more" will read it anyway. + * - Nothing is merged while the thread query fetches - an infinite fetch + * writes back the pages it started from - and an answer that started + * before a full refetch is dropped and asked again. + * - `reconcile()` re-reads the loaded pages, for a (re)join, a root change + * and a revoked subscription. A `401`, `403` or `404` from any read resets + * the thread, so nothing the reader lost access to stays on screen. + * + * Framework-free; `useContentComments` drives it. Browser-only in practice. + */ +export const createContentCommentsLive = ({ + debounceMs = CONTENT_REFRESH_DEBOUNCE_MS, + maxRequests = CONTENT_COMMENTS_MAX_BRANCH_REQUESTS, + maxWaitMs = CONTENT_REFRESH_MAX_WAIT_MS, + permissionsKey, + queryClient, + threadKey, +}: ContentCommentsLiveOptions) => { + type Data = ContentCommentsData; + + let source: ContentCommentsLiveSource = { + readBranches: DETACHED, + readPage: DETACHED, + }; + const beforeChange = (): void => { + source.beforeChange?.(); + }; + + const threadHash = hashKey(threadKey); + const ids = new Map(); + const dirty = new Set(); + const inFlight = new Map>(); + const applied = new Map(); + const removals = new Map>(); + const failed = new Set(); + const newBranches = new Set(); + const drafts = new Map(); + const idleWaiters: (() => void)[] = []; + const listeners = new Set<() => void>(); + let snapshot: ContentCommentsLiveSnapshot = { + hasNew: false, + refreshError: false, + }; + let epoch = 0; + let sequence = 0; + let active = 0; + let disposed = false; + let reconcileTimer: ReturnType | undefined; + + const setSnapshot = (next: Partial): void => { + const merged = { ...snapshot, ...next }; + if ( + merged.hasNew === snapshot.hasNew && + merged.refreshError === snapshot.refreshError + ) { + return; + } + snapshot = merged; + listeners.forEach(listener => listener()); + }; + + const query = () => queryClient.getQueryCache().get(threadHash); + const data = (): Data | undefined => + queryClient.getQueryData(threadKey); + const isFetching = (): boolean => query()?.state.fetchStatus === "fetching"; + + /** Resolves once the thread query is not fetching. */ + const idle = async (): Promise => { + if (!isFetching()) return; + await new Promise(resolve => { + idleWaiters.push(resolve); + }); + }; + + const draftKeys = (): Set => new Set(drafts.keys()); + + const findNode = (key: string) => + data() + ?.pages.flatMap(page => page.items) + .find(item => keyOf(item.id) === key); + + /** The top-level id a loaded comment hangs from. */ + const branchOf = (id: ContentId): null | string => { + const items = new Map( + (data()?.pages ?? []).flatMap(page => + page.items.map(item => [keyOf(item.id), item] as const), + ), + ); + let node = items.get(keyOf(id)); + for (let hop = 0; node && hop < 64; hop++) { + if (node.depth === 1 || node.parentId === null) return keyOf(node.id); + node = items.get(keyOf(node.parentId)); + } + + return null; + }; + + const loseAccess = async (): Promise => { + dirty.clear(); + removals.clear(); + failed.clear(); + newBranches.clear(); + setSnapshot({ hasNew: false, refreshError: false }); + beforeChange(); + await queryClient.resetQueries({ exact: true, queryKey: threadKey }); + }; + + /** Takes removed comments off screen when their branch could not be read. */ + const applyRemovals = (keys: string[]): void => { + const gone = new Set(keys.flatMap(key => [...(removals.get(key) ?? [])])); + if (gone.size === 0) return; + beforeChange(); + queryClient.setQueryData(threadKey, old => + old + ? { + ...old, + pages: old.pages.map(page => ({ + ...page, + items: page.items.flatMap(item => { + const key = keyOf(item.id); + if (!gone.has(key)) return [item]; + const hasReplies = page.items.some( + child => + child.parentId !== null && keyOf(child.parentId) === key, + ); + + return hasReplies + ? [ + { + ...item, + deleted: null, + hidden: false, + hiddenReason: null, + record: null, + removed: true, + }, + ] + : []; + }), + })), + } + : old, + ); + }; + + const refreshPermissions = (keys: Set): void => { + if (keys.size === 0) return; + void queryClient.invalidateQueries({ + predicate: candidate => { + const batch = candidate.queryKey.at(-1); + + return ( + typeof batch === "object" && + batch !== null && + "ids" in batch && + Array.isArray(batch.ids) && + batch.ids.some(id => keys.has(keyOf(id as ContentId))) + ); + }, + queryKey: permissionsKey, + }); + }; + + const apply = ( + keys: string[], + result: ContentCommentsBranches, + request: number, + ): void => { + const before = data(); + if (!before) return; + const touched = new Set(); + const answered = new Set(result.branches.map(branch => keyOf(branch.id))); + for (const page of before.pages) { + let branch: null | string = null; + for (const item of page.items) { + if (item.depth === 1) branch = keyOf(item.id); + if (branch !== null && answered.has(branch)) { + touched.add(keyOf(item.id)); + } + } + } + for (const branch of result.branches) { + if (branch.status === "ok") { + branch.items.forEach(item => touched.add(keyOf(item.id))); + } + } + + if (touched.size > 0) beforeChange(); + queryClient.setQueryData(threadKey, old => + old + ? mergeContentCommentBranches(old, result, { + drafts: draftKeys(), + skip: key => (applied.get(key) ?? 0) > request, + }) + : old, + ); + for (const key of keys) { + applied.set(key, request); + removals.delete(key); + failed.delete(key); + } + if (failed.size === 0) setSnapshot({ refreshError: false }); + refreshPermissions(touched); + }; + + /** One `/thread/branches` request; resolves once its answer is merged. */ + const request = async (keys: string[]): Promise => { + const current = ++sequence; + const startedIn = epoch; + active++; + const task = (async () => { + try { + const result = await source.readBranches( + keys.flatMap(key => { + const id = ids.get(key); + + return id === undefined ? [] : [id]; + }), + ); + await idle(); + if (disposed) return; + if (startedIn !== epoch) { + // A full refetch began meanwhile and may have newer rows. + keys.forEach(key => dirty.add(key)); + + return; + } + apply(keys, result, current); + } catch (error) { + if (disposed) return; + if (isContentCommentsAccessError(error)) { + await loseAccess(); + + return; + } + keys.forEach(key => failed.add(key)); + applyRemovals(keys); + setSnapshot({ refreshError: true }); + } + })(); + keys.forEach(key => inFlight.set(key, task)); + try { + await task; + } finally { + active--; + keys.forEach(key => { + if (inFlight.get(key) === task) inFlight.delete(key); + }); + if (dirty.size > 0) coalescer.schedule(); + } + }; + + const pump = (): void => { + if (disposed || isFetching()) return; + while (active < maxRequests) { + const batch = [...dirty] + .filter(key => !inFlight.has(key)) + .slice(0, CONTENT_THREAD_BRANCHES_MAX); + if (batch.length === 0) return; + batch.forEach(key => dirty.delete(key)); + void request(batch); + } + }; + + const coalescer = createCoalescer({ + debounceMs, + maxWaitMs, + run: async () => { + await idle(); + pump(); + }, + }); + + const markDirty = (id: ContentId): void => { + const key = keyOf(id); + ids.set(key, id as TId); + dirty.add(key); + }; + + const runReconcile = async (): Promise => { + // The refetch below reads every loaded branch, so nothing queued before + // it needs its own request; signals that land during it queue again. + dirty.clear(); + await queryClient.refetchQueries({ + exact: true, + queryKey: threadKey, + type: "active", + }); + const state = query()?.state; + if (state?.status === "error") { + if (isContentCommentsAccessError(state.error)) { + await loseAccess(); + } else { + applyRemovals([...removals.keys()]); + setSnapshot({ refreshError: true }); + } + + return; + } + removals.clear(); + failed.clear(); + const loaded = loadedBranches(data()); + for (const key of newBranches) if (loaded.has(key)) newBranches.delete(key); + setSnapshot({ hasNew: newBranches.size > 0, refreshError: false }); + }; + + let reconciling: null | Promise = null; + + /** One re-read of the loaded pages at a time; callers meanwhile share it. */ + const reconcileNow = async (): Promise => { + // Nothing loaded - or access was just lost and the reset reads again. + if (disposed || !data()) return; + reconciling ??= runReconcile().finally(() => { + reconciling = null; + }); + await reconciling; + }; + + const showNew = async (attempt = 0): Promise => { + await idle(); + const current = data(); + if (!current || disposed) return false; + const after = tailCursor(current); + const startedIn = epoch; + + let tail: ContentCommentsThreadPage; + try { + tail = await source.readPage(after); + } catch (error) { + if (isContentCommentsAccessError(error)) await loseAccess(); + + return false; + } + await idle(); + if (disposed) return false; + if (startedIn !== epoch) { + return attempt < 1 ? await showNew(attempt + 1) : false; + } + + beforeChange(); + queryClient.setQueryData(threadKey, old => + old ? appendContentCommentsTail(old, tail, tailCursor(old)) : old, + ); + refreshPermissions(new Set(tail.items.map(item => keyOf(item.id)))); + + const loaded = loadedBranches(data()); + const missing = [...newBranches].filter(key => !loaded.has(key)); + newBranches.clear(); + setSnapshot({ hasNew: false }); + // Published long after it was written, so it sits among loaded ones. + if (missing.length > 0) await reconcileNow(); + + return true; + }; + + const onCacheEvent = (event: QueryCacheNotifyEvent): void => { + if (event.type !== "updated" || event.query.queryHash !== threadHash) { + return; + } + const { action } = event; + if (action.type === "fetch" && !action.meta?.fetchMore) epoch++; + if ( + (action.type === "success" || action.type === "error") && + event.query.state.fetchStatus === "idle" + ) { + idleWaiters.splice(0).forEach(resolve => resolve()); + if (dirty.size > 0) coalescer.schedule(); + } + }; + + return { + /** + * Starts following the thread query; the returned function stops it and + * drops scheduled work. Can be called again after stopping. + */ + attach: (): (() => void) => { + disposed = false; + const unsubscribe = queryClient.getQueryCache().subscribe(onCacheEvent); + + return () => { + disposed = true; + clearTimeout(reconcileTimer); + coalescer.cancel(); + unsubscribe(); + idleWaiters.splice(0).forEach(resolve => resolve()); + }; + }, + getSnapshot: (): ContentCommentsLiveSnapshot => snapshot, + /** Installs how it reads and what to call before it changes the thread. */ + setSource: (next: ContentCommentsLiveSource): void => { + source = next; + }, + /** Re-reads the loaded pages - after a (re)join, a root change or a revoke. */ + reconcile: ({ jitter = false }: { jitter?: boolean } = {}): void => { + clearTimeout(reconcileTimer); + if (!jitter) { + void reconcileNow(); + + return; + } + reconcileTimer = setTimeout( + () => void reconcileNow(), + Math.random() * RECONNECT_JITTER_MS, + ); + }, + /** + * Re-reads the branches of these comments now - after the reader's own + * write - and resolves once they are merged. With no ids, only the counts + * are read. + */ + refresh: async (commentIds: ContentId[]): Promise => { + const keys = [ + ...new Set( + commentIds.flatMap(id => { + const branch = branchOf(id); + if (branch === null) return []; + const node = findNode(branch); + if (node) ids.set(branch, node.id); + + return [branch]; + }), + ), + ]; + keys.forEach(key => dirty.delete(key)); + await Promise.all(keys.map(async key => await inFlight.get(key))); + await idle(); + if (!disposed) await request(keys); + }, + /** Reads what follows the last loaded comment, after the reader's own post. */ + refreshTail: async (): Promise => { + const current = data(); + const last = current?.pages.at(-1); + if (!current || !last) return false; + if (last.pageInfo.hasNextPage) { + await request([]); + + return true; + } + + return await showNew(); + }, + /** + * Asks again for every branch whose last refresh failed - or re-reads the + * loaded pages when that is what failed - and resolves once it is done. + */ + retry: async (): Promise => { + if (failed.size === 0) { + await reconcileNow(); + + return; + } + const keys = [...failed]; + failed.clear(); + keys.forEach(key => dirty.delete(key)); + await idle(); + for ( + let start = 0; + start < keys.length; + start += CONTENT_THREAD_BRANCHES_MAX + ) { + if (disposed) return; + await request(keys.slice(start, start + CONTENT_THREAD_BRANCHES_MAX)); + } + }, + /** One signal from the thread's live room. */ + signal: (signal: ContentRefreshSignal): void => { + if (disposed) return; + if (signal.kind === "thread") { + void reconcileNow(); + + return; + } + if (signal.kind !== "branch") return; + + const key = keyOf(signal.branchId); + const current = data(); + if (loadedBranches(current).has(key)) { + if (signal.removedId !== undefined) { + const removed = removals.get(key) ?? new Set(); + removed.add(keyOf(signal.removedId)); + removals.set(key, removed); + } + markDirty(signal.branchId); + coalescer.schedule(); + + return; + } + // Not on screen: "Load more" reads it fresh when it is reached, and at + // the end of the thread the reader decides when to show it. + if (current && current.pages.at(-1)?.pageInfo.hasNextPage === false) { + newBranches.add(key); + setSnapshot({ hasNew: true }); + } + }, + subscribe: (listener: () => void): (() => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + /** + * Marks a comment with an open edit or reply form: if a refresh removes + * it, it stays on screen as `removed` with its text until the form + * closes, and is refreshed then. + */ + trackDraft: (id: ContentId, open: boolean): void => { + const key = keyOf(id); + const count = (drafts.get(key) ?? 0) + (open ? 1 : -1); + if (count > 0) { + drafts.set(key, count); + + return; + } + drafts.delete(key); + const node = findNode(key); + if (node?.removed && node.record !== null) { + const branch = branchOf(node.id); + if (branch !== null) { + const top = findNode(branch); + if (top) markDirty(top.id); + coalescer.flush(); + } + } + }, + /** Shows the comments that appeared at the end of the thread. */ + showNew: async (): Promise => await showNew(), + }; +}; + +export type ContentCommentsLive = ReturnType< + typeof createContentCommentsLive +>; diff --git a/packages/vitnode/src/tanstack/comments/thread.test.ts b/packages/vitnode/src/tanstack/comments/thread.test.ts index 07a51c6fd..1690491c5 100644 --- a/packages/vitnode/src/tanstack/comments/thread.test.ts +++ b/packages/vitnode/src/tanstack/comments/thread.test.ts @@ -6,6 +6,7 @@ import { buildContentCommentTree, contentCommentWriteOutcome, countContentCommentReplies, + shareContentCommentTree, } from "./thread"; const node = ( @@ -79,6 +80,37 @@ describe("buildContentCommentTree", () => { }); }); +describe("shareContentCommentTree", () => { + // Records keep their identity across reads, as the query cache's + // structural sharing hands them back. + const items = [node(1, null), node(2, 1, 2), node(3, null), node(4, 3, 2)]; + + it("reuses every node of a branch that did not change", () => { + const previous = buildContentCommentTree(items); + const changed = [...items]; + changed[3] = { ...changed[3], record: { text: "edited" } }; + + const shared = shareContentCommentTree( + previous, + buildContentCommentTree(changed), + ); + + expect(shared).not.toBe(previous); + expect(shared[0]).toBe(previous[0]); + expect(shared[0].children[0]).toBe(previous[0].children[0]); + expect(shared[1]).not.toBe(previous[1]); + expect(shared[1].children[0].record).toStrictEqual({ text: "edited" }); + }); + + it("hands back the previous tree when nothing changed", () => { + const previous = buildContentCommentTree(items); + + expect( + shareContentCommentTree(previous, buildContentCommentTree([...items])), + ).toBe(previous); + }); +}); + describe("countContentCommentReplies", () => { it("counts every reply below a node, at any depth", () => { const [first, second] = buildContentCommentTree([ diff --git a/packages/vitnode/src/tanstack/comments/thread.ts b/packages/vitnode/src/tanstack/comments/thread.ts index c851112d1..48a840233 100644 --- a/packages/vitnode/src/tanstack/comments/thread.ts +++ b/packages/vitnode/src/tanstack/comments/thread.ts @@ -1,5 +1,8 @@ import type { ContentId } from "@/content/ids"; +/** The `id` prefix of every comment element, for `#comment-...` links. */ +export const CONTENT_COMMENT_ANCHOR_PREFIX = "comment-"; + /** * One entry of a comment thread, as `GET /{publicApi.path}/thread` answers it: * a comment the reader may see, or a placeholder kept so its replies stay in @@ -50,6 +53,28 @@ export interface ContentCommentsThreadPage< truncated: boolean; } +/** + * One top-level branch as `GET /{publicApi.path}/thread/branches` answers it: + * the top-level comment and every reply below it, or `gone` when the reader no + * longer sees it. + */ +export type ContentCommentsBranch = + | { id: TId; items: ContentCommentNode[]; status: "ok" } + | { id: TId; status: "gone" }; + +/** The answer of `GET /{publicApi.path}/thread/branches`. */ +export interface ContentCommentsBranches< + TRecord, + TId extends ContentId = ContentId, +> { + branches: ContentCommentsBranch[]; + pageInfo: { totalCount: number }; + /** Every public comment in the thread, at any depth. */ + replyCount: number; + /** `true` when the branches held more nested replies than one answer carries. */ + truncated: boolean; +} + /** A thread entry with the replies loaded below it. */ export interface ContentCommentTreeNode< TRecord, @@ -85,6 +110,63 @@ export const buildContentCommentTree = ( return roots; }; +const sameNode = ( + a: ContentCommentNode, + b: ContentCommentNode, +): boolean => + a.id === b.id && + a.depth === b.depth && + a.deleted === b.deleted && + a.hidden === b.hidden && + a.hiddenReason === b.hiddenReason && + a.parentId === b.parentId && + a.record === b.record && + a.removed === b.removed; + +/** + * Reuses the nodes of `previous` wherever a node and everything below it is + * unchanged, so memoized comment components skip re-rendering when one + * branch of a long thread changes. Relies on the query cache's structural + * sharing, which keeps unchanged records the same objects. Pure. + */ +export const shareContentCommentTree = ( + previous: ContentCommentTreeNode[], + next: ContentCommentTreeNode[], +): ContentCommentTreeNode[] => { + if (previous.length === 0) return next; + const before = new Map>(); + const index = (nodes: ContentCommentTreeNode[]) => { + for (const node of nodes) { + before.set(String(node.id), node); + index(node.children); + } + }; + index(previous); + + const share = ( + node: ContentCommentTreeNode, + ): ContentCommentTreeNode => { + const children = node.children.map(share); + const old = before.get(String(node.id)); + if ( + old && + sameNode(old, node) && + old.children.length === children.length && + old.children.every((child, position) => child === children[position]) + ) { + return old; + } + + return { ...node, children }; + }; + const shared = next.map(share); + + return shared.length === previous.length && + shared.every((node, position) => node === previous[position]) + ? previous + : shared; +}; + /** Every reply below `node`, at any depth. Pure. */ export const countContentCommentReplies = ( node: ContentCommentTreeNode, diff --git a/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx b/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx index d0ffcdf76..78ca94350 100644 --- a/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx +++ b/packages/vitnode/src/tanstack/comments/use-content-comments.test.tsx @@ -331,6 +331,102 @@ describe("useContentComments", () => { }, ); + it("keeps the loaded comments and reports refreshError when a background refetch fails", async () => { + const { result, thread } = await mountLoaded(); + thread.mockResolvedValue(respond(500) as ThreadResponse); + + await act(async () => { + await result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.refreshError).toBe(true); + }); + expect(result.current.status).toBe("success"); + expect(result.current.items.map(item => item.id)).toStrictEqual([1, 2]); + }); + + it("is loading more, not refreshing, while the next page loads", async () => { + const { result, thread } = await mountLoaded(); + let answer: () => void = () => undefined; + thread.mockImplementationOnce( + async () => + await new Promise(resolve => { + answer = () => { + resolve(respond(200, PAGES["cursor-1"]) as ThreadResponse); + }; + }), + ); + + let loading: Promise = Promise.resolve(true); + act(() => { + loading = result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.isLoadingMore).toBe(true); + }); + expect(result.current.isRefreshing).toBe(false); + + await act(async () => { + answer(); + await loading; + }); + }); + + it.each([ + { + name: "a delete", + run: async (comments: Comments) => await comments.remove(2), + }, + { + name: "an edit", + run: async (comments: Comments) => { + await comments.requestSave(2, { + body: doc("edited"), + expectedVersion: undefined, + }); + await comments.written({ approval: null, status: "published" }); + }, + }, + ])( + "re-reads only the touched branch after $name when `branches` is wired", + async ({ run }) => { + const branches = vi.fn( + async (ids: number[]) => + await Promise.resolve( + respond(200, { + branches: ids.map(id => ({ + id, + items: [comment(1), comment(2, 1, { record: { text: "new" } })], + status: "ok", + })), + pageInfo: { totalCount: 2 }, + replyCount: 3, + truncated: false, + }), + ), + ); + const save = vi.fn(async () => await Promise.resolve(respond(200))); + const { result, thread } = await mountLoaded({ + branches, + delete: save, + edit: { load: vi.fn(), save }, + }); + + await act(async () => { + await run(result.current); + }); + + await waitFor(() => { + expect(result.current.items[1].record).toStrictEqual({ text: "new" }); + }); + expect(save).toHaveBeenCalledOnce(); + expect(branches).toHaveBeenCalledExactlyOnceWith([1]); + expect(thread).toHaveBeenCalledOnce(); + }, + ); + it("refuses a second action while one runs, without calling it", async () => { let settle: (() => void) | undefined; const remove = vi.fn( diff --git a/packages/vitnode/src/tanstack/comments/use-content-comments.ts b/packages/vitnode/src/tanstack/comments/use-content-comments.ts index ca1e59343..ab509fffa 100644 --- a/packages/vitnode/src/tanstack/comments/use-content-comments.ts +++ b/packages/vitnode/src/tanstack/comments/use-content-comments.ts @@ -1,6 +1,7 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { + hashKey, infiniteQueryOptions, useInfiniteQuery, useQueryClient, @@ -11,6 +12,7 @@ import { z } from "zod"; import type { ContentId } from "@/content/ids"; import type { RichTextDocument } from "@/content/rich-text"; import type { ContentTarget } from "@/content/target"; +import type { ContentLiveRefreshStatus } from "@/tanstack/content/live-refresh"; import { zodRichTextDocument } from "@/content/rich-text"; @@ -18,16 +20,25 @@ import type { ContentPermissions, ContentPermissionsResponse, } from "../content/permissions"; +import type { ContentCommentsLive } from "./live"; import type { ContentCommentNode, + ContentCommentsBranches, ContentCommentsThreadPage, ContentCommentTreeNode, ContentCommentWriteOutcome, } from "./thread"; import { useSessionQuery } from "../auth/session-query"; +import { useContentLiveRefresh } from "../content/live-refresh"; import { useContentPermissions } from "../content/permissions"; -import { buildContentCommentTree, contentCommentWriteOutcome } from "./thread"; +import { ContentCommentsRequestError, createContentCommentsLive } from "./live"; +import { + buildContentCommentTree, + CONTENT_COMMENT_ANCHOR_PREFIX, + contentCommentWriteOutcome, + shareContentCommentTree, +} from "./thread"; /** The response of a typed `fetcher` call, as the comments hook reads it. */ export interface ContentCommentsApiResponse { @@ -85,6 +96,16 @@ export interface ContentCommentsConfig< R extends ContentCommentsApiResponse, TRecord = ContentCommentRecordOf, > { + /** + * Re-reads a few top-level branches - `GET /thread/branches` with `root` + * and `ids` joined by commas (left out when there are none). With it, a + * write refreshes only the branch it touched instead of every loaded page, + * and `live` can keep the thread current. Without it, a write re-reads + * every loaded page. + */ + branches?: ( + ids: ContentCommentIdOf[], + ) => Promise; /** * Opens a new top-level comment or a reply - `POST /` with the root record, * `body` and `parent: parentId`. Without it nobody can comment here. @@ -114,6 +135,19 @@ export interface ContentCommentsConfig< * public record and in `GET /{id}/edit`'s `values`. Defaults to `"body"`. */ field?: string; + /** + * Keeps the thread current while it is on screen, over the shared + * WebSocket: changed branches are re-read with `branches` (required) and + * merged in place, new comments at the end wait behind **Show new + * comments**, and a reconnect re-reads the loaded pages. Leave it out for a + * thread that only changes on reload. + */ + live?: { + /** The comment content type's id, e.g. `blogCommentContentType.id`. */ + contentTypeId: string; + /** The root record the thread hangs off. */ + rootId: ContentId; + }; /** * The deepest level a reply may sit at - the content type's * `thread.maxDepth`. Leave it out for flat comments: **Reply** never shows. @@ -169,6 +203,14 @@ export interface ContentCommentsConfig< const DEFAULT_PAGE_SIZE = 20; +type WriteTarget = { id: TId } | { parentId: null | TId }; + +const noopSubscribe = () => () => undefined; + +const EMPTY_LIVE_SNAPSHOT = { hasNew: false, refreshError: false }; + +const emptySnapshot = () => EMPTY_LIVE_SNAPSHOT; + const zodWrittenRecord = z.object({ approval: z.object({ status: z.string() }).nullable(), status: z.string(), @@ -180,8 +222,8 @@ const zodWrittenRecord = z.object({ * the page's HTML. `useContentComments` reads the same cache entry. * Universal. * - * @throws An `Error` from `queryFn` when the thread route answers anything - * but `2xx`. + * @throws A `ContentCommentsRequestError` from `queryFn`, carrying the + * `status`, when the thread route answers anything but `2xx`. */ export const contentCommentsQueryOptions = < R extends ContentCommentsApiResponse, @@ -200,31 +242,58 @@ export const contentCommentsQueryOptions = < last: ContentCommentsThreadPage>, ) => (last.pageInfo.hasNextPage ? last.pageInfo.endCursor : null), initialPageParam: null as null | string, - queryFn: async ({ pageParam }) => { - const res = await thread({ after: pageParam, first: pageSize }); - if (!res.ok) { - throw new Error(`Loading the comments answered ${res.status}.`); - } - - // The typed response's `2xx` member is the thread route's answer, and - // without `record` its records keep the type the route gives them. - const page = (await res.json()) as ContentCommentsThreadPage< - TRecord, - ContentCommentIdOf - >; - if (!record) return page; - - return { - ...page, - items: page.items.map(item => ({ - ...item, - record: item.record === null ? null : record.parse(item.record), - })), - }; - }, + queryFn: async ({ pageParam }) => + await readContentCommentsPage({ + after: pageParam, + first: pageSize, + record, + thread, + }), queryKey: [...queryKey, "thread"], }); +/** Validates each record of thread nodes with `record`, when given. */ +const parseNodes = ( + items: ContentCommentNode[], + record: undefined | z.ZodType, +): ContentCommentNode[] => + record + ? items.map(item => ({ + ...item, + record: item.record === null ? null : record.parse(item.record), + })) + : items; + +const readContentCommentsPage = async < + R extends ContentCommentsApiResponse, + TRecord, +>({ + after, + first, + record, + thread, +}: Pick, "record" | "thread"> & { + after: null | string; + first: number; +}): Promise>> => { + const res = await thread({ after, first }); + if (!res.ok) { + throw new ContentCommentsRequestError( + res.status, + `Loading the comments answered ${res.status}.`, + ); + } + + // The typed response's `2xx` member is the thread route's answer, and + // without `record` its records keep the type the route gives them. + const page = (await res.json()) as ContentCommentsThreadPage< + TRecord, + ContentCommentIdOf + >; + + return { ...page, items: parseNodes(page.items, record) }; +}; + const readEditable = ( body: unknown, field: string, @@ -267,7 +336,7 @@ export interface ContentCommentsController< busy: ContentCommentBusy | null; /** Whether the visitor may write a new comment here: signed in, with `create` given. */ canComment: boolean; - /** Whether **Reply** shows under `node`: replies allowed, under `maxDepth` and not hidden. */ + /** Whether **Reply** shows under `node`: replies allowed, under `maxDepth`, not hidden or removed. */ canReply: (node: ContentCommentNode) => boolean; /** Which actions the plugin wired up. A button also needs the server's `can*` answer. */ features: { @@ -282,16 +351,23 @@ export interface ContentCommentsController< field: string; /** Whether more top-level comments wait on the server. */ hasMore: boolean; + /** + * Live only: comments appeared after the last loaded one, at the end of the + * thread. They stay off screen until `showNewComments()`. + */ + hasNewComments: boolean; /** Hides one comment, with an optional reason of at most 500 characters. */ hide: (id: TId, reason: null | string) => Promise; /** `true` while the next page loads. */ isLoadingMore: boolean; - /** `true` while a refetch runs over comments already on screen. */ + /** `true` while comments already on screen are read again - not while loading more. */ isRefreshing: boolean; /** `true` while the session is unknown, so the composer can wait for it. */ isSessionPending: boolean; /** Every loaded entry, flat and depth first. */ items: ContentCommentNode[]; + /** Where the live subscription stands; `off` without `live`. */ + liveStatus: ContentLiveRefreshStatus; /** Reads what an edit form opens with: the public text, or the author's pending proposal. */ loadEditable: ( id: TId, @@ -302,8 +378,13 @@ export interface ContentCommentsController< loadMore: () => Promise; /** The visitor's permissions for one comment, all `false` until known or after a failure. */ permissionsOf: (id: TId) => ContentPermissions; - /** Refreshes the thread, its permissions and `onChanged`'s queries. */ + /** Re-reads every loaded page, its permissions and `onChanged`'s queries. */ refresh: () => Promise; + /** + * A background read of comments already on screen failed. They stay, as + * they were; `retryRefresh()` asks again. + */ + refreshError: boolean; /** Deletes one comment. One with replies stays as an anonymous placeholder. */ remove: (id: TId) => Promise; /** Every public comment in the thread, at any depth. */ @@ -322,10 +403,27 @@ export interface ContentCommentsController< ) => Promise; /** Retries the first load after an error. */ retry: () => Promise; + /** Asks again for what a failed background refresh missed; resolves when done. */ + retryRefresh: () => Promise; /** Marks one comment busy while its editor loads, so its menu waits. */ setEditing: (id: null | TId) => void; - /** `pending` before the first page, `error` when it failed, then `success`. */ + /** + * Live only: reads the comments that appeared at the end of the thread and + * appends them below the loaded ones. Resolves to `false` when that failed. + */ + showNewComments: () => Promise; + /** + * `pending` before the first page, `error` when it failed, then `success` - + * also after a failed background refresh, which `refreshError` reports. + */ status: "error" | "pending" | "success"; + /** + * Call with `true` while an edit or reply form is open on `id`, and `false` + * when it closes. If a live update removes that comment meanwhile, it stays + * on screen with `removed: true` and its text, so the form keeps what was + * typed and can say why it can no longer be sent. + */ + trackDraft: (id: TId, open: boolean) => void; /** The loaded comments as a tree, replies under their parents. */ tree: ContentCommentTreeNode[]; /** `true` when a page held more nested replies than one answer carries. */ @@ -342,13 +440,137 @@ export interface ContentCommentsController< written: (record: unknown) => Promise; } +/** + * Keeps what the reader is reading in place when comments above the viewport + * change: `capture` (called right before the change) remembers the first + * comment whose top is on screen; after the next commit the page scrolls by + * however far it moved. Browser scroll anchoring alone misses changes it does + * not attribute to the anchor, and does nothing in some browsers. + */ +const useScrollAnchor = (pages: unknown): (() => void) => { + const anchorRef = React.useRef( + null, + ); + + React.useLayoutEffect(() => { + const captured = anchorRef.current; + anchorRef.current = null; + if (!captured?.element.isConnected) return; + const shift = captured.element.getBoundingClientRect().top - captured.top; + if (Math.abs(shift) >= 1) window.scrollBy(0, shift); + }, [pages]); + + return React.useCallback(() => { + if (typeof document === "undefined") return; + for (const element of document.querySelectorAll( + `[id^="${CONTENT_COMMENT_ANCHOR_PREFIX}"]`, + )) { + const { top } = element.getBoundingClientRect(); + if (top >= 0) { + anchorRef.current = { element, top }; + + return; + } + } + }, []); +}; + +/** + * The live controller of one thread, when `branches` is wired: rebuilt when + * the thread's key changes, following the query cache while mounted, and + * always reading through the latest callbacks. + */ +const useLiveThread = ({ + beforeChange, + branches, + pageSize, + queryClient, + queryKey, + record, + thread, +}: Pick, "branches" | "record" | "thread"> & { + beforeChange: () => void; + pageSize: number; + queryClient: QueryClient; + queryKey: QueryKey; +}): ContentCommentsLive> | null => { + type TId = ContentCommentIdOf; + + const enabled = Boolean(branches); + const key = `${hashKey(queryKey)}|${enabled ? "live" : "off"}`; + const create = () => + enabled + ? createContentCommentsLive({ + permissionsKey: [...queryKey, "permissions"], + queryClient, + threadKey: [...queryKey, "thread"], + }) + : null; + const [state, setState] = React.useState(() => ({ + key, + live: create(), + queryClient, + })); + let { live } = state; + if (state.key !== key || state.queryClient !== queryClient) { + live = create(); + setState({ key, live, queryClient }); + } + + React.useEffect(() => { + live?.setSource({ + beforeChange, + readBranches: async ids => { + if (!branches) { + throw new ContentCommentsRequestError(0, "No `branches`."); + } + const res = await branches(ids); + if (!res.ok) { + throw new ContentCommentsRequestError( + res.status, + `Refreshing the comments answered ${res.status}.`, + ); + } + // The typed response's `2xx` member is the branches route's answer. + const body = (await res.json()) as ContentCommentsBranches< + TRecord, + TId + >; + + return { + ...body, + branches: body.branches.map(branch => + branch.status === "ok" + ? { ...branch, items: parseNodes(branch.items, record) } + : branch, + ), + }; + }, + readPage: async after => + await readContentCommentsPage({ + after, + first: pageSize, + record, + thread, + }), + }); + }); + React.useEffect(() => live?.attach(), [live]); + + return live; +}; + /** * The state and actions of one comment thread under a Content Engine record: * paginated loading, the reply tree, batched permissions for the comments on * screen, and create, edit, delete, hide and restore through the plugin's own * typed callbacks. A write refreshes the thread and its permissions only after - * the server accepted it, and a second action on the same thread waits until - * the first one settles. + * the server accepted it - only the branch it touched when `branches` is + * given - and a second action on the same thread waits until the first one + * settles. With `live`, other readers' changes arrive over the shared + * WebSocket: changed branches are re-read and merged in place, new comments + * wait for `showNewComments()`, and a failed background read keeps the + * comments on screen and sets `refreshError`. * * It renders nothing and shows no messages: hand it to `ContentComments` for * the default section, or to `ContentCommentsRoot` to lay out your own. The @@ -395,9 +617,11 @@ export const useContentComments = < const queryClient = useQueryClient(); const session = useSessionQuery(); const { + branches: requestBranches, create, delete: remove, edit, + live: liveConfig, maxDepth, onChanged, pageSize, @@ -418,7 +642,71 @@ export const useContentComments = < () => pages?.flatMap(page => page.items) ?? [], [pages], ); - const tree = React.useMemo(() => buildContentCommentTree(items), [items]); + // Hands back the previous tree's nodes for unchanged branches, so one live + // update re-renders only the branch it touched. + const [shareTree] = React.useState(() => { + let previous: ContentCommentTreeNode[] = []; + + return (next: ContentCommentTreeNode[]) => { + previous = shareContentCommentTree(previous, next); + + return previous; + }; + }); + const tree = React.useMemo( + () => shareTree(buildContentCommentTree(items)), + [items, shareTree], + ); + + const captureScrollAnchor = useScrollAnchor(pages); + const live = useLiveThread({ + beforeChange: captureScrollAnchor, + branches: requestBranches, + pageSize: pageSize ?? DEFAULT_PAGE_SIZE, + queryClient, + queryKey, + record, + thread, + }); + const liveSnapshot = React.useSyncExternalStore( + live?.subscribe ?? noopSubscribe, + live?.getSnapshot ?? emptySnapshot, + live?.getSnapshot ?? emptySnapshot, + ); + + const liveContentTypeId = liveConfig?.contentTypeId; + const liveRootId = liveConfig?.rootId; + const liveScope = React.useMemo( + () => + live && liveContentTypeId !== undefined && liveRootId !== undefined + ? { + contentTypeId: liveContentTypeId, + kind: "thread" as const, + rootId: liveRootId, + } + : null, + [live, liveContentTypeId, liveRootId], + ); + const liveStatus = useContentLiveRefresh(liveScope, { + onDenied: () => { + live?.reconcile(); + }, + onSignal: signal => { + live?.signal(signal); + }, + onSubscribed: ({ reconnect }) => { + live?.reconcile({ jitter: reconnect }); + }, + }); + const trackDraft = React.useCallback( + (id: TId, open: boolean) => { + live?.trackDraft(id, open); + }, + [live], + ); + + // Where the last write landed, so `written` refreshes only that branch. + const lastWriteRef = React.useRef>(null); const requestPermissions = config.permissions; const permissions = useContentPermissions({ @@ -452,6 +740,23 @@ export const useContentComments = < ]); }; + // After a write: only the branch it touched, when `branches` is wired. + const refreshAfterWrite = async (target: null | WriteTarget) => { + if (!live || !target) { + await refresh(); + + return; + } + await Promise.all([ + "id" in target + ? live.refresh([target.id]) + : target.parentId === null + ? live.refreshTail() + : live.refresh([target.parentId]), + onChanged?.(queryClient), + ]); + }; + const run = async ( id: TId, action: ContentCommentBusy["action"], @@ -463,7 +768,7 @@ export const useContentComments = < const res = await request().catch(() => null); if (!res) return { ok: false, status: 0 }; if (!res.ok) return { ok: false, status: res.status }; - await refresh(); + await refreshAfterWrite({ id }); return { ok: true }; } finally { @@ -481,6 +786,7 @@ export const useContentComments = < signedIn && maxDepth !== undefined && !node.hidden && + !node.removed && node.depth < maxDepth, features: { create: Boolean(create), @@ -492,6 +798,7 @@ export const useContentComments = < }, field, hasMore: comments.hasNextPage, + hasNewComments: liveSnapshot.hasNew, hide: async (id, reason) => visibility ? await run( @@ -501,9 +808,13 @@ export const useContentComments = < ) : missing, isLoadingMore: comments.isFetchingNextPage, - isRefreshing: comments.isFetching && !comments.isPending, + isRefreshing: + comments.isFetching && + !comments.isPending && + !comments.isFetchingNextPage, isSessionPending: session.isPending, items, + liveStatus, loadEditable: async id => { if (!edit) return missing; const res = await edit.load(id).catch(() => null); @@ -520,25 +831,38 @@ export const useContentComments = < }, permissionsOf: permissions.of, refresh, + refreshError: comments.isRefetchError || liveSnapshot.refreshError, remove: async id => remove ? await run(id, "delete", async () => await remove(id)) : missing, replyCount: pages?.at(-1)?.replyCount ?? 0, reportTarget: id => report?.(id) ?? null, requestCreate: async input => { if (!create) throw new Error("This thread has no `create`."); + lastWriteRef.current = { parentId: input.parentId }; return await create(input); }, requestSave: async (id, input) => { if (!edit) throw new Error("This thread has no `edit`."); + lastWriteRef.current = { id }; return await edit.save(id, input); }, retry: async () => await comments.refetch(), + retryRefresh: async () => { + if (comments.isRefetchError) { + await comments.refetch(); + + return; + } + await live?.retry(); + }, setEditing: id => { markBusy(id === null ? null : { action: "edit", id }); }, - status: comments.status, + showNewComments: async () => (live ? await live.showNew() : false), + status: comments.data ? "success" : comments.status, + trackDraft, tree, truncated: pages?.some(page => page.truncated) ?? false, unhide: async id => @@ -547,7 +871,9 @@ export const useContentComments = < : missing, userId: session.data?.user?.id ?? null, written: async record => { - await refresh(); + const target = lastWriteRef.current; + lastWriteRef.current = null; + await refreshAfterWrite(target); const parsed = zodWrittenRecord.safeParse(record); return parsed.success ? contentCommentWriteOutcome(parsed.data) : "draft"; diff --git a/packages/vitnode/src/tanstack/content/index.ts b/packages/vitnode/src/tanstack/content/index.ts index cc464fe84..521a50f45 100644 --- a/packages/vitnode/src/tanstack/content/index.ts +++ b/packages/vitnode/src/tanstack/content/index.ts @@ -28,6 +28,11 @@ export { ContentListResults, useContentListSearchInput, } from "./list-ui"; +export type { + ContentLiveRefreshHandlers, + ContentLiveRefreshStatus, +} from "./live-refresh"; +export { useContentLiveRefresh } from "./live-refresh"; export type { ContentPermissions, ContentPermissionsResponse, diff --git a/packages/vitnode/src/tanstack/content/live-refresh.test.tsx b/packages/vitnode/src/tanstack/content/live-refresh.test.tsx new file mode 100644 index 000000000..3422604a3 --- /dev/null +++ b/packages/vitnode/src/tanstack/content/live-refresh.test.tsx @@ -0,0 +1,292 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ContentRefreshClientMessage, + ContentRefreshScope, + ContentRefreshServerMessage, +} from "@/content/live/refresh"; + +import { + contentRefreshChannel, + contentRefreshRoom, +} from "@/content/live/refresh"; +import { VitNodeWebSocketProvider } from "@/ws/provider"; + +import type { ContentLiveRefreshHandlers } from "./live-refresh"; + +import { useContentLiveRefresh } from "./live-refresh"; + +const SCOPE: ContentRefreshScope = { + contentTypeId: "blog.comment", + kind: "thread", + rootId: 7, +}; +const ROOM = contentRefreshRoom(SCOPE); + +class FakeWebSocket { + constructor(url: string) { + this.url = url; + sockets.push(this); + } + + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = FakeWebSocket.CONNECTING; + sent: ContentRefreshClientMessage[] = []; + readonly url: string; + + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + drop() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + push(data: ContentRefreshServerMessage) { + this.onmessage?.({ + data: JSON.stringify({ data, id: contentRefreshChannel.id }), + }); + } + + send(raw: string) { + const message = JSON.parse(raw) as { + data: ContentRefreshClientMessage; + id: string; + }; + if (message.id === contentRefreshChannel.id) this.sent.push(message.data); + } +} + +let sockets: FakeWebSocket[] = []; + +const Probe = ({ + handlers, + scope = SCOPE, +}: { + handlers: ContentLiveRefreshHandlers; + scope?: ContentRefreshScope | null; +}) => ( + {useContentLiveRefresh(scope, handlers)} +); + +const handlersOf = () => ({ + onDenied: vi.fn(), + onSignal: vi.fn(), + onSubscribed: vi.fn(), +}); + +const status = () => screen.getByTestId("status").textContent; + +const mount = () => { + const handlers = handlersOf(); + const view = render( + + + , + ); + + return { handlers, view }; +}; + +/** Opens the first socket and answers this tab's join. */ +const goLive = () => { + act(() => sockets[0]?.open()); + const clientId = sockets[0]?.sent[0]?.clientId ?? ""; + act(() => sockets[0]?.push({ clientId, room: ROOM, type: "joined" })); + + return clientId; +}; + +describe("useContentLiveRefresh", () => { + beforeEach(() => { + sockets = []; + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("BroadcastChannel", undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("joins its scope once the socket opens", () => { + mount(); + + expect(status()).toBe("off"); + + act(() => sockets[0]?.open()); + + expect(sockets[0]?.sent).toStrictEqual([ + { clientId: expect.any(String), scope: SCOPE, type: "join" }, + ]); + expect(status()).toBe("joining"); + }); + + it("goes live on its own joined answer, never another tab's", () => { + const { handlers } = mount(); + act(() => sockets[0]?.open()); + const clientId = sockets[0]?.sent[0]?.clientId ?? ""; + + act(() => + sockets[0]?.push({ clientId: "another-tab", room: ROOM, type: "joined" }), + ); + + expect(status()).toBe("joining"); + expect(handlers.onSubscribed).not.toHaveBeenCalled(); + + act(() => sockets[0]?.push({ clientId, room: ROOM, type: "joined" })); + + expect(status()).toBe("live"); + expect(handlers.onSubscribed).toHaveBeenCalledExactlyOnceWith({ + reconnect: false, + }); + }); + + it("passes on only its own room's signals", () => { + const { handlers } = mount(); + goLive(); + + act(() => { + sockets[0]?.push({ + room: contentRefreshRoom({ ...SCOPE, rootId: 8 }), + signal: { kind: "thread" }, + type: "changed", + }); + sockets[0]?.push({ + room: ROOM, + signal: { branchId: 3, kind: "branch" }, + type: "changed", + }); + }); + + expect(handlers.onSignal).toHaveBeenCalledExactlyOnceWith({ + branchId: 3, + kind: "branch", + }); + }); + + it("joins again after a reconnect and says it is one", () => { + const { handlers } = mount(); + const clientId = goLive(); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + act(() => sockets[0]?.drop()); + act(() => { + vi.advanceTimersByTime(2_000); + }); + act(() => sockets[1]?.open()); + + // The leave sent while offline is flushed first, so it never undoes the join. + expect(sockets[1]?.sent.at(-1)).toStrictEqual({ + clientId, + scope: SCOPE, + type: "join", + }); + + act(() => sockets[1]?.push({ clientId, room: ROOM, type: "joined" })); + + expect(handlers.onSubscribed).toHaveBeenLastCalledWith({ + reconnect: true, + }); + expect(status()).toBe("live"); + }); + + it.each([ + { + code: "NOT_FOUND", + message: (clientId: string): ContentRefreshServerMessage => ({ + clientId, + code: "NOT_FOUND", + room: ROOM, + type: "revoked", + }), + name: "a revoked subscription", + }, + { + code: "FORBIDDEN", + message: (clientId: string): ContentRefreshServerMessage => ({ + clientId, + code: "FORBIDDEN", + room: ROOM, + type: "error", + }), + name: "a refused join", + }, + ])("reports $name as denied", ({ code, message }) => { + const { handlers } = mount(); + const clientId = goLive(); + + act(() => sockets[0]?.push(message("another-tab"))); + + expect(status()).toBe("live"); + + act(() => sockets[0]?.push(message(clientId))); + + expect(status()).toBe("denied"); + expect(handlers.onDenied).toHaveBeenCalledExactlyOnceWith(code); + }); + + it("asks again later when the server is busy", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = sockets[0]?.sent[0]?.clientId ?? ""; + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + act(() => + sockets[0]?.push({ + clientId, + code: "RATE_LIMITED", + room: ROOM, + type: "error", + }), + ); + + expect(status()).toBe("off"); + + act(() => { + vi.advanceTimersByTime(2_000); + }); + + expect(sockets[0]?.sent).toStrictEqual([ + { clientId, scope: SCOPE, type: "join" }, + { clientId, scope: SCOPE, type: "join" }, + ]); + }); + + it("leaves when it unmounts", () => { + const { view } = mount(); + const clientId = goLive(); + + view.rerender({null}); + + expect(sockets[0]?.sent.at(-1)).toStrictEqual({ + clientId, + scope: SCOPE, + type: "leave", + }); + }); + + it("stays off without a WebSocket provider", () => { + render(); + + expect(status()).toBe("off"); + expect(sockets).toHaveLength(0); + }); +}); diff --git a/packages/vitnode/src/tanstack/content/live-refresh.ts b/packages/vitnode/src/tanstack/content/live-refresh.ts new file mode 100644 index 000000000..1570850a7 --- /dev/null +++ b/packages/vitnode/src/tanstack/content/live-refresh.ts @@ -0,0 +1,226 @@ +import React from "react"; + +import type { + ContentRefreshClientMessage, + ContentRefreshScope, + ContentRefreshServerMessage, + ContentRefreshSignal, +} from "@/content/live/refresh"; + +import { + contentRefreshChannel, + contentRefreshRoom, +} from "@/content/live/refresh"; +import { useOptionalVitNodeWebSocketContext } from "@/ws/provider"; + +/** + * Where one live refresh subscription stands: + * + * - `off`: no scope, or no open connection - nothing arrives live. + * - `joining`: the join was sent and waits for the server's answer. + * - `live`: joined; signals arrive. + * - `denied`: the server refused or revoked it. The data on screen needs a + * fresh read to find out what the viewer may still see. + */ +export type ContentLiveRefreshStatus = "denied" | "joining" | "live" | "off"; + +export interface ContentLiveRefreshHandlers { + /** + * The server refused the join, or revoked it later - signed out, a lost + * permission or a root that is no longer readable. Read the data again + * and drop whatever that read refuses. + */ + onDenied?: (code: "FORBIDDEN" | "NOT_FOUND") => void; + /** One change signal for this subscription's scope. */ + onSignal: (signal: ContentRefreshSignal) => void; + /** + * The server acknowledged the join. Nothing is replayed, so read the data + * again here to cover what changed before the join - `reconnect` is `true` + * when this subscription was live before on an earlier connection. + */ + onSubscribed?: (info: { reconnect: boolean }) => void; +} + +const newClientId = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + +const RETRY_DELAYS_MS = [2_000, 10_000, 30_000]; + +/** + * Follows one Content Engine live refresh scope - an AdminCP list or a public + * thread - over the shared WebSocket, for as long as the component is + * mounted with that scope. Joins when the connection opens and again after + * every reconnect, leaves on unmount or when the scope changes, and passes on + * only the signals of its own acknowledged join: tabs share one connection, + * and every tab sees every message, so a tab never acts on a room another + * tab joined. Pass `null` to stay off. Browser-only; stays `off` outside + * `` or when the app serves no WebSocket. + * + * Signals are hints, not data: re-read through HTTP, which stays the source + * of truth for content and permissions. Delivery is best effort; reconcile in + * `onSubscribed`. + * + * @example + * ```tsx + * import { useContentLiveRefresh } from "@vitnode/core/tanstack/content"; + * + * useContentLiveRefresh( + * { contentTypeId: "blog.post", kind: "admin" }, + * { + * onSignal: () => void refetch(), + * onSubscribed: () => void refetch(), + * }, + * ); + * ``` + */ +export const useContentLiveRefresh = ( + scope: ContentRefreshScope | null, + handlers: ContentLiveRefreshHandlers, +): ContentLiveRefreshStatus => { + const [clientId] = React.useState(newClientId); + const room = scope ? contentRefreshRoom(scope) : null; + // The answer to the latest join, for the connection it was sent on. + const [status, setStatus] = React.useState<{ + connectionId: null | string; + room: null | string; + status: ContentLiveRefreshStatus; + }>({ connectionId: null, room: null, status: "off" }); + + // Mutated only by the socket's message handler and the join effect. + const joinRef = React.useRef<{ + attempt: number; + joined: boolean; + room: null | string; + wasLive: boolean; + }>({ attempt: 0, joined: false, room: null, wasLive: false }); + const retryRef = React.useRef | undefined>( + undefined, + ); + + const socket = useOptionalVitNodeWebSocketContext(); + const connectionId = + socket?.readyState === 1 ? (socket.connectionId ?? null) : null; + const socketSend = socket?.send; + const subscribe = socket?.subscribe; + const send = React.useCallback( + (data: ContentRefreshClientMessage) => { + socketSend?.(contentRefreshChannel.id, data); + }, + [socketSend], + ); + + const onMessage = (message: ContentRefreshServerMessage) => { + const join = joinRef.current; + if (join.room === null || message.room !== join.room) { + // Errors about a malformed message carry no room. + if ( + message.type === "error" && + message.clientId === clientId && + message.room === undefined + ) { + setStatus({ connectionId, room: join.room, status: "denied" }); + } + + return; + } + + switch (message.type) { + case "changed": + if (join.joined) handlers.onSignal(message.signal); + + return; + case "error": { + if (message.clientId !== clientId) return; + join.joined = false; + if ( + message.code === "RATE_LIMITED" || + message.code === "TOO_MANY_SUBSCRIPTIONS" + ) { + const delay = RETRY_DELAYS_MS[join.attempt]; + setStatus({ connectionId, room: join.room, status: "off" }); + if (delay === undefined || !scope) return; + join.attempt++; + clearTimeout(retryRef.current); + retryRef.current = setTimeout(() => { + if (joinRef.current.room !== room) return; + send({ clientId, scope, type: "join" }); + }, delay); + + return; + } + setStatus({ connectionId, room: join.room, status: "denied" }); + if (message.code === "FORBIDDEN" || message.code === "NOT_FOUND") { + handlers.onDenied?.(message.code); + } + + return; + } + case "joined": { + if (message.clientId !== clientId) return; + const reconnect = join.wasLive; + join.joined = true; + join.wasLive = true; + join.attempt = 0; + setStatus({ connectionId, room: join.room, status: "live" }); + handlers.onSubscribed?.({ reconnect }); + + return; + } + case "revoked": + if (message.clientId !== clientId) return; + join.joined = false; + setStatus({ connectionId, room: join.room, status: "denied" }); + handlers.onDenied?.(message.code); + } + }; + const scopeRef = React.useRef(scope); + const onMessageRef = React.useRef(onMessage); + React.useEffect(() => { + scopeRef.current = scope; + onMessageRef.current = onMessage; + }); + + React.useEffect( + () => + subscribe?.(contentRefreshChannel.id, data => { + onMessageRef.current(data as ContentRefreshServerMessage); + }), + [subscribe], + ); + + // Joining is a message to the server: it follows the connection and the + // scope, and leaves when either goes. + React.useEffect(() => { + const current = scopeRef.current; + if (!connectionId || room === null || !current) return; + + const join = joinRef.current; + if (join.room !== room) { + joinRef.current = { + attempt: 0, + joined: false, + room, + wasLive: false, + }; + } else { + join.joined = false; + join.attempt = 0; + } + send({ clientId, scope: current, type: "join" }); + + return () => { + clearTimeout(retryRef.current); + joinRef.current.joined = false; + send({ clientId, scope: current, type: "leave" }); + }; + }, [clientId, connectionId, room, send]); + + if (!connectionId || room === null) return "off"; + if (status.room !== room || status.connectionId !== connectionId) { + return "joining"; + } + + return status.status; +}; diff --git a/packages/vitnode/src/ws/provider.tsx b/packages/vitnode/src/ws/provider.tsx index 200147081..176e37690 100644 --- a/packages/vitnode/src/ws/provider.tsx +++ b/packages/vitnode/src/ws/provider.tsx @@ -20,6 +20,13 @@ interface VitNodeWebSocketContextValue { const VitNodeWebSocketContext = React.createContext(null); +/** + * The shared connection, or `null` outside `` - for + * features that should simply stay offline without one. + */ +export const useOptionalVitNodeWebSocketContext = + (): null | VitNodeWebSocketContextValue => React.use(VitNodeWebSocketContext); + export const useVitNodeWebSocketContext = (): VitNodeWebSocketContextValue => { const context = React.use(VitNodeWebSocketContext); if (!context) { diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index 2d2dafeba..bdf4a8a79 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -21,6 +21,9 @@ const ISOLATED = [ "src/blocks/registry.test.ts", "src/blocks/validate.test.ts", "src/components/ui/icon-registry.test.ts", + "src/content/server/refresh/instances.test.ts", + "src/content/server/refresh/publish.test.ts", + "src/content/server/refresh/server.test.ts", "src/editor/instance/defaults.test.ts", "src/editor/state/repair.test.ts", "src/tanstack/admin/content/slots.test.ts", diff --git a/plugins/blog/src/pages/post-comments-data.ts b/plugins/blog/src/pages/post-comments-data.ts index b17dc4a2a..32a068687 100644 --- a/plugins/blog/src/pages/post-comments-data.ts +++ b/plugins/blog/src/pages/post-comments-data.ts @@ -39,7 +39,8 @@ export type PostComment = z.infer; /** * The comment thread under one article, wired to the blog's typed comment - * routes: replies, editing with revisions, deleting, hiding and reports. + * routes: replies, editing with revisions, deleting, hiding and reports - and + * kept current live, re-reading only the branches that changed. */ export const usePostComments = (articleId: number) => useContentComments({ @@ -60,6 +61,20 @@ export const usePostComments = (articleId: number) => }, }, }), + branches: async ids => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/thread/branches", + args: { + query: { + root: String(articleId), + ...(ids.length > 0 ? { ids: ids.join(",") } : {}), + }, + }, + }), + live: { contentTypeId: blogCommentContentType.id, rootId: articleId }, permissions: async ids => await fetcher({ plugin: manifest.id, diff --git a/plugins/blog/src/pages/post-comments.tsx b/plugins/blog/src/pages/post-comments.tsx index c2467842d..61e94b995 100644 --- a/plugins/blog/src/pages/post-comments.tsx +++ b/plugins/blog/src/pages/post-comments.tsx @@ -26,6 +26,7 @@ import { ContentCommentComposer, ContentCommentPlaceholder, ContentCommentsComposer, + ContentCommentsLiveStatus, ContentCommentsLoadMore, ContentCommentsRoot, type ContentCommentTreeNode, @@ -197,6 +198,7 @@ const CommentEntry = ({ mode="edit" onCancel={item.stopEditing} onDone={item.stopEditing} + unavailable={item.isRemoved ? tc("live.edit_removed") : undefined} /> ) : (
) : null} @@ -473,35 +476,37 @@ const CommentBranch = ({ ); - if (!hasReplies) return row; - + // Always the same element tree, so a comment that gets its first reply + // while the reader types an answer keeps its form - and what was typed. return ( {row} - -
    - {node.children.map(child => ( - - ))} -
-
+
    + {node.children.map(child => ( + + ))} +
+ + ) : null}
); }; @@ -782,6 +787,8 @@ const PostCommentsThread = ({

) : null} + +
diff --git a/plugins/newsroom/src/pages/article-comments.tsx b/plugins/newsroom/src/pages/article-comments.tsx index bb1c3abed..9a08a6159 100644 --- a/plugins/newsroom/src/pages/article-comments.tsx +++ b/plugins/newsroom/src/pages/article-comments.tsx @@ -32,6 +32,20 @@ export const ArticleComments = ({ articleId }: { articleId: number }) => { const comments = useContentComments({ ...articleCommentsThread(articleId), maxDepth: COMMENT_MAX_DEPTH, + branches: async ids => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/article-comments", + path: "/thread/branches", + args: { + query: { + root: String(articleId), + ...(ids.length > 0 ? { ids: ids.join(",") } : {}), + }, + }, + }), + live: { contentTypeId: commentContentType.id, rootId: articleId }, permissions: async ids => await fetcher({ plugin: manifest.id,