@better-apps/comments 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Craswell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,180 @@
1
- # Temporary Holding Version
1
+ # @better-apps/comments
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Comment threads on any record an app owns, such as an invoice, a ticket or a preview deploy, optionally pinned to a spot
4
+ inside it. Comments store only the record's type and id; the app's subject types say who can see it, and the rules here
5
+ who can comment. Every write is one atomic batch through
6
+ [`@better-apps/database-drizzle`](../../adapters/database-drizzle/README.md), on Cloudflare D1 as on any other SQL
7
+ database.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { elementAnchor } from "@better-apps/anchors"
13
+ import { comments, CommentsError } from "@better-apps/comments"
14
+ import { createBetterApps } from "better-apps"
15
+
16
+ export const app = createBetterApps({
17
+ database,
18
+ identity,
19
+ subjects: {
20
+ invoice: {
21
+ getOrgId: ({ subjectId }) => invoices.orgOf(subjectId),
22
+ canView: ({ actor, subjectId }) => invoices.canRead(actor, subjectId)
23
+ }
24
+ },
25
+ features: [
26
+ comments({
27
+ subjects: {
28
+ invoice: {
29
+ canComment: ({ actor, subjectId }) => invoices.canEdit(actor, subjectId),
30
+ canModerate: ({ actor }) => actor.roles.includes("admin"),
31
+ visibility: { staff: ({ actor }) => actor.roles.includes("staff") }
32
+ }
33
+ },
34
+ anchors: [elementAnchor()],
35
+ hooks: {
36
+ before: {
37
+ resolveThread: ({ actor, thread, input }) => {
38
+ if (thread?.createdBy !== actor?.userId) {
39
+ throw new CommentsError("FORBIDDEN", "Only the thread's creator can resolve it.")
40
+ }
41
+ return input
42
+ }
43
+ }
44
+ }
45
+ })
46
+ ]
47
+ })
48
+
49
+ const { thread } = await app.api.comments.createThread(
50
+ { subjectType: "invoice", subjectId: "inv_42", body: "Is this total right?" },
51
+ { request }
52
+ )
53
+ ```
54
+
55
+ Browsers call the same actions through `app.handler`, at `POST /api/apps/comments/<action>`. Run `better-apps generate`
56
+ to add the `commentThread` and `comment` tables to your schema.
57
+
58
+ ## Actions
59
+
60
+ - Reads: `listThreads` (by last activity or creation, 20 a page), `getThread` (comments oldest first, 50 a page),
61
+ `countThreads` (open and total for up to 50 records in one grouped query) and `suggestMentions`.
62
+ - Writes: `createThread`, `reply`, `editComment`, `deleteComment`, `resolveThread`, `reopenThread`, `updateThread`,
63
+ `updateAnchor` and `deleteThread`.
64
+ - Server only, through `app.api.comments`: `removeSubject`, `moveSubject`, `removeUser` and `recountThreads` for upkeep,
65
+ `canViewThread` for other features' recipient checks, and `toClientEvent` and `toClientEvents` for live updates.
66
+
67
+ Reads return the users they name, from the identity adapter's `getUsers`. Users it can't find are left out, so show them
68
+ as "Former user".
69
+
70
+ ## Access
71
+
72
+ - The app declares each subject type in `createBetterApps`, with `canView`, `getOrgId` and `publicRead` (see
73
+ [Subjects](../../core/better-apps/README.md#subjects)). Comments add `canComment` for each, and optionally
74
+ `canModerate`, extra visibilities such as `staff`, a `metadata` schema and `canMention`.
75
+ - One rule decides whether someone can see a thread: its organization must be its subject's, the subject's `canView`
76
+ must pass, and the actor must pass the thread's visibility check. Reads, mentions and live updates all use it.
77
+ - Threads and comments are subject types too, `comments.thread` and `comments.comment` (`commentSubjectTypes`), so other
78
+ features can attach to them. Whoever can see a thread can see it and its comments, except deleted comments, which
79
+ nobody can see as records any more.
80
+ - `NOT_FOUND` means the caller can't see it, and `FORBIDDEN` that they can see it but can't do this. Signed-out callers
81
+ get `UNAUTHENTICATED`, except signed-out readers of public subjects, who get text with names and no user ids.
82
+ - Reads count the [access grants](../../core/better-apps/README.md#access-grants) the request carries, such as a share
83
+ link to the subject: `listThreads`, `getThread` and `countThreads`. Someone the app's rules refuse then reads the
84
+ subject's default threads as a signed-out reader does, with names and no user ids. Writes never count a grant, so a
85
+ link never lets anyone comment, reply, resolve or delete. A grant to the subject reaches its default threads and their
86
+ comments as `comments.thread` and `comments.comment` subjects too, so their reactions show.
87
+ - Server code that passes neither an actor nor a request is trusted, so it skips access checks. It still needs an author
88
+ to write: a user, or an external or system author with a name and an optional id.
89
+ - Before hooks run after the access check, keyed by action. They may change the input, but not the subject or thread. A
90
+ hook that throws a `CommentsError` returns its code; any other error returns `REJECTED_BY_HOOK`.
91
+
92
+ ## Writes
93
+
94
+ - Ids are ULIDs a client may generate. Repeating a write with the same ids, author and content returns what the first
95
+ attempt wrote. Reusing your own id for different content is a `CONFLICT`, and an id someone else's row holds is
96
+ `INVALID_INPUT`.
97
+ - `commentCount` and `lastActivityAt` change in the same batch as the comments they describe, so they're always exact.
98
+ - Deleting a comment clears its text and keeps its place. Deleting a thread's last comment deletes the thread.
99
+ - Replying to a resolved thread reopens it, running the `reopenThread` hook too.
100
+ - Bodies are capped at 16 KB, anchors and metadata at 4 KB, and mentions at 20 per comment. Mentioned users who can't
101
+ view the thread aren't notified; only moderators are told who was dropped.
102
+
103
+ ## Formats and anchors
104
+
105
+ - The built-in `text` format is plain text with mention tokens such as `@[user:u_123]`, from
106
+ [`@better-apps/mentions`](../mentions/README.md) (`mentionToken(id)` builds one). A literal `@[` is written `\@[`. Add
107
+ others with `defineFormat({ name, schema, sanitize, toText, mentions })`.
108
+ - Anchors come from [`@better-apps/anchors`](../anchors/README.md). `elementAnchor()` pins threads to an element, found
109
+ with a selector that may use only ids, data attributes, tag names and structural positions. Add others with
110
+ `defineAnchor({ kind, schema })`.
111
+
112
+ ## Mentions
113
+
114
+ Mention checks and suggestions come from `@better-apps/mentions`, so every feature that mentions people treats them the
115
+ same. To let each user list the comments that mention them, add the mentions feature with comments as a source:
116
+
117
+ ```ts
118
+ import { commentMentions, comments } from "@better-apps/comments"
119
+ import { mentions } from "@better-apps/mentions"
120
+
121
+ createBetterApps({
122
+ database,
123
+ identity,
124
+ subjects,
125
+ features: [comments(options), mentions({ sources: [commentMentions] })]
126
+ })
127
+ ```
128
+
129
+ Each mention's record is the comment, `comments.comment`, and its group the thread. Edits drop users a comment no longer
130
+ mentions, and deleting a comment or thread removes its mentions.
131
+
132
+ ## In the browser
133
+
134
+ `@better-apps/comments/client` keeps threads, comments, lists and counts in a framework-free store, and
135
+ `@better-apps/comments/react` reads it with hooks. Neither has UI or an editor; apps bring their own composer and pin
136
+ styles.
137
+
138
+ ```tsx
139
+ import { useCreateThread, useThreads } from "@better-apps/comments/react"
140
+ import { BetterAppsProvider } from "@better-apps/react"
141
+
142
+ function InvoiceComments({ invoiceId }: { invoiceId: string }) {
143
+ const { threads, state } = useThreads({ subjectType: "invoice", subjectId: invoiceId })
144
+ const createThread = useCreateThread()
145
+ // Render the threads, and call createThread.mutate({ subjectType, subjectId, body }) from a form.
146
+ }
147
+ ```
148
+
149
+ - Writes are optimistic: the client makes each new thread's and comment's ULID, so a change shows at once under its
150
+ final id, and a retry can't duplicate it. A refused write is rolled back and rethrown with the server's code.
151
+ `isPending(id)` from `useThread` says which items are still waiting.
152
+ - `applyEvent` takes events from `toClientEvent`, such as from a live updates feature, and ignores ones it has applied
153
+ and comments it already has. A thread that gets a new comment moves to the top of lists by activity.
154
+ - `dehydrate()` and `hydrate(snapshot)` carry a server render's loads to the browser, so the first paint has threads.
155
+ Each Better Apps client has one store, so a router that hydrates `createCommentsClient(client)` and hooks under a
156
+ `BetterAppsProvider` with that client share it.
157
+ - The store holds what the signed-in person could see. If someone else signs in without a page load, call `reset()` or
158
+ create a new Better Apps client, so they never see the previous person's threads. `reset()` also cancels calls still
159
+ under way, which reject with `ABORTED`; a mutation hook whose write was cancelled goes back to `idle`.
160
+ - Hooks: `useThreads(subject, { status, order })`, `useThread(id)`, `useThreadCounts(subjectType, subjectIds)`, which
161
+ splits lists over 50, `useSuggestMentions`, and a mutation hook per write, from `useCreateThread()` to
162
+ `useDeleteThread()`, each with `mutate`, `status`, `error` and `reset`.
163
+ - To place a pin, pass a thread to `useAnchorPosition` from `@better-apps/anchors/react`, or `resolveAnchor` from
164
+ `@better-apps/anchors/dom`.
165
+ - Dates in core fields are `Date`s again in the store. Dates in additional fields stay ISO strings.
166
+
167
+ ## Events
168
+
169
+ `thread.created`, `thread.updated`, `comment.created`, `comment.edited` and `comment.deleted` (both with the previous
170
+ body), `comment.mentioned` (one per mention), `thread.resolved`, `thread.reopened`, `thread.anchorUpdated` (with the
171
+ previous anchor), `thread.deleted`, `thread.moved`, `subject.moved`, `subject.removed` and `user.removed`. They're
172
+ written to the core's outbox in the same batch as the change and delivered at least once. They stay on the server;
173
+ `toClientEvent({ event, viewer, request? })` turns one into what a viewer's browser may see. With the viewer's request,
174
+ grants it carries count as they do for reads. `toClientEvents({ event, viewers })` does the same for many viewers, each
175
+ `{ actor, request? }`, in order, with their access checked together, so each granting feature answers for all of them in
176
+ one call.
177
+
178
+ `thread.deleted` and `thread.moved` list the thread's `commentIds`, so features attached to its comments, such as
179
+ reactions, can clean up or move along. `removeSubject` emits `thread.deleted` for each thread it deletes, and
180
+ `moveSubject` emits `thread.moved`, with `fromOrgId`, for each thread it moves.
@@ -0,0 +1,66 @@
1
+ import type { Actor, SubjectArgs, SubjectRef, Subjects } from "better-apps";
2
+ import type { CommentRules } from "./options.js";
3
+ import type { CommentThread } from "./schema.js";
4
+ /** What a caller may do with a subject's threads in one visibility. */
5
+ export interface Access {
6
+ view: boolean;
7
+ comment: boolean;
8
+ moderate: boolean;
9
+ /** Whether the caller is signed out and the subject isn't public, so signing in might help. */
10
+ signInMayHelp: boolean;
11
+ /**
12
+ * Whether only a grant the request carries, such as a share link, lets the caller see the threads. Such a caller sees
13
+ * them as a signed-out reader does, and can't comment.
14
+ */
15
+ granted: boolean;
16
+ }
17
+ /** Who a call acts as. Server code that names no actor is trusted, so access checks pass. */
18
+ export interface Caller {
19
+ actor: Actor | null;
20
+ trusted: boolean;
21
+ }
22
+ /** Access that allows nothing. */
23
+ export declare const nothing: Access;
24
+ /** The comment rules registered for a subject type, or `INVALID_INPUT`. */
25
+ export declare function subjectOf(rules: Readonly<Record<string, CommentRules>>, subjectType: string): CommentRules;
26
+ /** The organization that owns a subject, or `""` when the app has none. */
27
+ export declare function orgOf(subjects: Subjects, caller: Caller, ref: SubjectRef): Promise<string>;
28
+ /** Whether a visibility is `default` or one the subject type declares. */
29
+ export declare function isDeclaredVisibility(rules: CommentRules, visibility: string): boolean;
30
+ /** The visibilities an actor can see on a subject, `default` first. */
31
+ export declare function visibleTo(rules: CommentRules, args: SubjectArgs): Promise<string[]>;
32
+ /**
33
+ * What a caller may do with the threads of a subject in one visibility. Reads pass the request, so a grant it carries,
34
+ * such as a share link, lets the caller read the subject's default threads; writes leave it out.
35
+ */
36
+ export declare function accessTo(rules: CommentRules, subjects: Subjects, caller: Caller, ref: SubjectRef, visibility: string, request?: Request | null): Promise<Access>;
37
+ /** Refuses a read the caller can't make: `UNAUTHENTICATED` when signing in might help, else `NOT_FOUND`. */
38
+ export declare function requireView(access: Access): void;
39
+ /** How someone sees a thread: by the app's rules, only through a grant their request carries, or not at all. */
40
+ export type ThreadView = "rules" | "granted" | null;
41
+ /**
42
+ * The one rule for whether people can see threads: a thread's organization must be its subject's current one, the
43
+ * viewer must be able to see the subject, and must pass the thread's visibility check. Reads, mentions, notifications
44
+ * and live updates all use it. Each subject's `canViewMany` runs once.
45
+ */
46
+ export declare function canViewThreads(rules: Readonly<Record<string, CommentRules>>, subjects: Subjects, pairs: readonly {
47
+ actor: Actor | null;
48
+ thread: CommentThread;
49
+ }[]): Promise<boolean[]>;
50
+ /**
51
+ * How each viewer sees a thread, by the rule `canViewThreads` applies. A pair with the viewer's request may also see a
52
+ * default thread through a grant it carries, such as a share link to its subject, asked once for the threads the
53
+ * app's rules refuse. Only reads about the person who made the request pass it.
54
+ */
55
+ export declare function viewThreads(rules: Readonly<Record<string, CommentRules>>, subjects: Subjects, pairs: readonly {
56
+ actor: Actor | null;
57
+ thread: CommentThread;
58
+ request?: Request | null | undefined;
59
+ }[]): Promise<ThreadView[]>;
60
+ /** Whether someone can see a thread, by the rule `canViewThreads` applies. */
61
+ export declare function canViewThread(rules: Readonly<Record<string, CommentRules>>, subjects: Subjects, actor: Actor | null, thread: CommentThread): Promise<boolean>;
62
+ /**
63
+ * Which of several actors may be mentioned in a thread: `canMention` when the subject type has it, else whether they
64
+ * can view the thread, checked with one `canViewMany` call.
65
+ */
66
+ export declare function mentionable(rules: Readonly<Record<string, CommentRules>>, subjects: Subjects, actors: readonly Actor[], thread: CommentThread): Promise<Actor[]>;
package/dist/access.js ADDED
@@ -0,0 +1,186 @@
1
+ import { CommentsError } from "./errors.js";
2
+ const everything = { view: true, comment: true, moderate: true, signInMayHelp: false, granted: false };
3
+ /** Access that allows nothing. */
4
+ export const nothing = { view: false, comment: false, moderate: false, signInMayHelp: false, granted: false };
5
+ /** Access that lets the caller read a subject's default threads through a grant, and nothing more. */
6
+ const grantedView = { ...nothing, view: true, granted: true };
7
+ /** The comment rules registered for a subject type, or `INVALID_INPUT`. */
8
+ export function subjectOf(rules, subjectType) {
9
+ if (!Object.hasOwn(rules, subjectType)) {
10
+ throw new CommentsError("INVALID_INPUT", `Comments aren't set up for the "${subjectType}" subject type.`);
11
+ }
12
+ return rules[subjectType];
13
+ }
14
+ /** The organization that owns a subject, or `""` when the app has none. */
15
+ export async function orgOf(subjects, caller, ref) {
16
+ return (await subjects.getOrgId(ref, caller.actor)) ?? "";
17
+ }
18
+ /** Whether a visibility is `default` or one the subject type declares. */
19
+ export function isDeclaredVisibility(rules, visibility) {
20
+ return visibility === "default" || (rules.visibility !== undefined && Object.hasOwn(rules.visibility, visibility));
21
+ }
22
+ /** The visibilities an actor can see on a subject, `default` first. */
23
+ export async function visibleTo(rules, args) {
24
+ const declared = Object.entries(rules.visibility ?? {});
25
+ const passed = await Promise.all(declared.map(async ([, check]) => check(args)));
26
+ return ["default", ...declared.flatMap(([name], index) => (passed[index] ? [name] : []))];
27
+ }
28
+ /**
29
+ * What a caller may do with the threads of a subject in one visibility. Reads pass the request, so a grant it carries,
30
+ * such as a share link, lets the caller read the subject's default threads; writes leave it out.
31
+ */
32
+ export async function accessTo(rules, subjects, caller, ref, visibility, request) {
33
+ const access = await accessByRules(rules, subjects, caller, ref, visibility);
34
+ if (access.view || !request || visibility !== "default") {
35
+ return access;
36
+ }
37
+ return (await subjects.canView(caller.actor, ref, { request })) ? grantedView : access;
38
+ }
39
+ async function accessByRules(rules, subjects, caller, ref, visibility) {
40
+ if (caller.trusted) {
41
+ return everything;
42
+ }
43
+ const { actor } = caller;
44
+ if (!actor) {
45
+ const isPublic = await subjects.isPublic(ref);
46
+ return { ...nothing, view: isPublic && visibility === "default", signInMayHelp: !isPublic };
47
+ }
48
+ const args = { actor, ...ref };
49
+ const check = visibility === "default" ? undefined : rules.visibility?.[visibility];
50
+ const [view, visible] = await Promise.all([
51
+ subjects.canView(actor, ref),
52
+ check ? check(args) : visibility === "default"
53
+ ]);
54
+ if (!view || !visible) {
55
+ return nothing;
56
+ }
57
+ const [comment, moderate] = await Promise.all([rules.canComment(args), rules.canModerate?.(args) ?? false]);
58
+ return { view: true, comment, moderate, signInMayHelp: false, granted: false };
59
+ }
60
+ /** Refuses a read the caller can't make: `UNAUTHENTICATED` when signing in might help, else `NOT_FOUND`. */
61
+ export function requireView(access) {
62
+ if (!access.view) {
63
+ throw access.signInMayHelp
64
+ ? new CommentsError("UNAUTHENTICATED", "Sign in to see these comments.")
65
+ : new CommentsError("NOT_FOUND", "That thread doesn't exist, or you can't see it.");
66
+ }
67
+ }
68
+ /**
69
+ * The one rule for whether people can see threads: a thread's organization must be its subject's current one, the
70
+ * viewer must be able to see the subject, and must pass the thread's visibility check. Reads, mentions, notifications
71
+ * and live updates all use it. Each subject's `canViewMany` runs once.
72
+ */
73
+ export async function canViewThreads(rules, subjects, pairs) {
74
+ return (await viewThreads(rules, subjects, pairs)).map((view) => view !== null);
75
+ }
76
+ /**
77
+ * How each viewer sees a thread, by the rule `canViewThreads` applies. A pair with the viewer's request may also see a
78
+ * default thread through a grant it carries, such as a share link to its subject, asked once for the threads the
79
+ * app's rules refuse. Only reads about the person who made the request pass it.
80
+ */
81
+ export async function viewThreads(rules, subjects, pairs) {
82
+ const placed = await inPlace(rules, subjects, pairs);
83
+ const allowed = await viewThreadsByRules(rules, subjects, pairs, placed);
84
+ const views = allowed.map((seen) => (seen ? "rules" : null));
85
+ const candidates = pairs.flatMap(({ thread, request }, index) => !allowed[index] && request && placed[index] && thread.visibility === "default" ? [index] : []);
86
+ const granted = await canViewDistinct(subjects, candidates.map((index) => {
87
+ const { actor, thread, request } = pairs[index];
88
+ return { actor, subjectType: thread.subjectType, subjectId: thread.subjectId, request };
89
+ }));
90
+ for (const [position, index] of candidates.entries()) {
91
+ if (granted[position]) {
92
+ views[index] = "granted";
93
+ }
94
+ }
95
+ return views;
96
+ }
97
+ /**
98
+ * Whether each thread is on a subject type comments are set up for, and in its subject's current organization, with
99
+ * one organization lookup per subject type for the threads' distinct subjects.
100
+ */
101
+ async function inPlace(rules, subjects, pairs) {
102
+ // Threads on a subject type comments are no longer set up for can't be seen.
103
+ const known = pairs.map(({ thread }) => Object.hasOwn(rules, thread.subjectType) && subjects.has(thread.subjectType));
104
+ const keyOf = ({ subjectType, subjectId }) => JSON.stringify([subjectType, subjectId]);
105
+ const refs = new Map();
106
+ for (const [index, { thread }] of pairs.entries()) {
107
+ if (known[index]) {
108
+ refs.set(keyOf(thread), { subjectType: thread.subjectType, subjectId: thread.subjectId });
109
+ }
110
+ }
111
+ const signedIn = pairs.find(({ actor }) => actor !== null)?.actor ?? null;
112
+ const found = await subjects.getOrgIds([...refs.values()], signedIn);
113
+ const orgs = new Map([...refs.keys()].map((key, index) => [key, found[index] ?? ""]));
114
+ return pairs.map(({ thread }, index) => known[index] === true && orgs.get(keyOf(thread)) === thread.orgId);
115
+ }
116
+ /**
117
+ * `canViewMany` for checks that may repeat, such as one per comment on the same record, asking about each distinct
118
+ * actor, subject and request once.
119
+ */
120
+ async function canViewDistinct(subjects, checks) {
121
+ if (checks.length === 0) {
122
+ return [];
123
+ }
124
+ const ids = new Map();
125
+ const idOf = (value) => {
126
+ if (!value) {
127
+ return null;
128
+ }
129
+ if (!ids.has(value)) {
130
+ ids.set(value, ids.size);
131
+ }
132
+ return ids.get(value) ?? null;
133
+ };
134
+ const distinct = new Map();
135
+ const keys = checks.map((check) => {
136
+ const key = JSON.stringify([idOf(check.actor), idOf(check.request), check.subjectType, check.subjectId]);
137
+ distinct.set(key, check);
138
+ return key;
139
+ });
140
+ const answers = await subjects.canViewMany([...distinct.values()]);
141
+ const byKey = new Map([...distinct.keys()].map((key, index) => [key, answers[index] === true]));
142
+ return keys.map((key) => byKey.get(key) === true);
143
+ }
144
+ async function viewThreadsByRules(rules, subjects, pairs, placed) {
145
+ const candidates = pairs.flatMap((_, index) => (placed[index] ? [index] : []));
146
+ const viewers = await canViewDistinct(subjects, candidates.map((index) => {
147
+ const { actor, thread } = pairs[index];
148
+ return { actor, subjectType: thread.subjectType, subjectId: thread.subjectId };
149
+ }));
150
+ const allowed = pairs.map(() => false);
151
+ await Promise.all(candidates.map(async (index, position) => {
152
+ const { actor, thread } = pairs[index];
153
+ if (!viewers[position]) {
154
+ return;
155
+ }
156
+ if (thread.visibility === "default") {
157
+ allowed[index] = true;
158
+ return;
159
+ }
160
+ const declared = rules[thread.subjectType]?.visibility ?? {};
161
+ const check = Object.hasOwn(declared, thread.visibility) ? declared[thread.visibility] : undefined;
162
+ allowed[index] =
163
+ actor !== null &&
164
+ check !== undefined &&
165
+ Boolean(await check({ actor, subjectType: thread.subjectType, subjectId: thread.subjectId }));
166
+ }));
167
+ return allowed;
168
+ }
169
+ /** Whether someone can see a thread, by the rule `canViewThreads` applies. */
170
+ export async function canViewThread(rules, subjects, actor, thread) {
171
+ const [allowed] = await canViewThreads(rules, subjects, [{ actor, thread }]);
172
+ return allowed === true;
173
+ }
174
+ /**
175
+ * Which of several actors may be mentioned in a thread: `canMention` when the subject type has it, else whether they
176
+ * can view the thread, checked with one `canViewMany` call.
177
+ */
178
+ export async function mentionable(rules, subjects, actors, thread) {
179
+ const { canMention } = subjectOf(rules, thread.subjectType);
180
+ if (canMention) {
181
+ const allowed = await Promise.all(actors.map(async (actor) => canMention({ actor, thread })));
182
+ return actors.filter((_, index) => allowed[index]);
183
+ }
184
+ const allowed = await canViewThreads(rules, subjects, actors.map((actor) => ({ actor, thread })));
185
+ return actors.filter((_, index) => allowed[index]);
186
+ }
@@ -0,0 +1,140 @@
1
+ import type { BetterApp, IdentityUser } from "better-apps";
2
+ import { type BetterAppsClient, type CallOptions, type Serialized, type Store } from "better-apps/client";
3
+ import type { SuggestMentionsInput, ThreadCounts } from "./feature.js";
4
+ import type { CommentTarget, CommentsOptions, CommentsTypes, CreateThreadInput, EditCommentInput, ReplyInput, ThreadTarget, TypesOf, UpdateAnchorInput, UpdateThreadInput } from "./options.js";
5
+ import type { ClientEvent } from "./reads.js";
6
+ import type { Comment, CommentThread } from "./schema.js";
7
+ export { textFormat } from "./formats.js";
8
+ export { isUlid, ulid } from "./ulid.js";
9
+ type CommentsFeatureOf<App> = App extends BetterApp<infer Features> ? Extract<Features[number], {
10
+ id: "comments";
11
+ }> : never;
12
+ /** The comments types of an app, from `typeof app`. */
13
+ export type AppCommentsTypes<App> = CommentsFeatureOf<App> extends {
14
+ options: infer Options extends CommentsOptions;
15
+ } ? TypesOf<Options> : CommentsTypes;
16
+ /** Which record a list of threads is about. */
17
+ export interface Subject<SubjectType extends string = string> {
18
+ subjectType: SubjectType;
19
+ subjectId: string;
20
+ }
21
+ /** How a list of threads is filtered and ordered. */
22
+ export interface ListOptions {
23
+ status?: "open" | "resolved" | undefined;
24
+ order?: "activity" | "created" | undefined;
25
+ }
26
+ /** Where a load stands. `error` holds the client error when it failed. */
27
+ export type LoadState = "idle" | "loading" | "ready" | "error";
28
+ /** A loaded list of a subject's threads, as ids into `threads`. */
29
+ export interface ThreadList {
30
+ subjectType: string;
31
+ subjectId: string;
32
+ status: "open" | "resolved" | null;
33
+ order: "activity" | "created";
34
+ ids: string[];
35
+ cursor: string | null;
36
+ state: LoadState;
37
+ error: Error | null;
38
+ }
39
+ /** A thread's loaded comments, oldest first, as ids into `comments`. */
40
+ export interface ThreadComments {
41
+ ids: string[];
42
+ cursor: string | null;
43
+ state: LoadState;
44
+ error: Error | null;
45
+ }
46
+ /** Everything the store holds. Dates are `Date`s again, except in additional fields. */
47
+ export interface CommentsState<Thread = CommentThread, Message = Comment> {
48
+ threads: Readonly<Record<string, Thread>>;
49
+ comments: Readonly<Record<string, Message>>;
50
+ threadComments: Readonly<Record<string, ThreadComments>>;
51
+ lists: Readonly<Record<string, ThreadList>>;
52
+ counts: Readonly<Record<string, ThreadCounts & {
53
+ state: LoadState;
54
+ }>>;
55
+ /** Names and avatars by user id, from reads. */
56
+ users: Readonly<Record<string, IdentityUser>>;
57
+ /** Ids of threads and comments written here and not yet confirmed by the server. */
58
+ pending: Readonly<Record<string, true>>;
59
+ }
60
+ /** A store snapshot as JSON carries it, for server rendering. */
61
+ export type DehydratedComments = Serialized<CommentsState>;
62
+ /** Keys a list by its subject and options. */
63
+ export declare function listKey(subject: Subject, options?: ListOptions): string;
64
+ /** Keys a subject's counts. */
65
+ export declare function countKey(subjectType: string, subjectId: string): string;
66
+ /** The comments client: a store plus the actions that fill it. */
67
+ export type CommentsClient<Types extends CommentsTypes = CommentsTypes> = ReturnType<typeof createCommentsClientFor<Types>>;
68
+ /**
69
+ * Creates the comments store for a Better Apps client. Pass `typeof app` as the client's type to get the app's subject
70
+ * types, anchors, formats and additional fields. Each Better Apps client has one store, so calling this again with the
71
+ * same client, as `useComments` does, returns the store already made, with whatever a server render hydrated into it.
72
+ *
73
+ * The store holds what the signed-in person could see. When someone else signs in without a page load, call `reset()`
74
+ * or create a new Better Apps client, so they never see the previous person's threads.
75
+ */
76
+ export declare function createCommentsClient<App = BetterApp>(client: BetterAppsClient<App>): CommentsClient<AppCommentsTypes<App>>;
77
+ declare function createCommentsClientFor<Types extends CommentsTypes>(call: (feature: string, action: string, input: unknown, options: CallOptions) => Promise<unknown>): {
78
+ store: Store<CommentsState<CommentThread & Types["threadFields"], Comment & Types["commentFields"]>>;
79
+ /** Loads the first page of a subject's threads. */
80
+ loadThreads(subject: Subject<Types["subjectType"]>, options?: ListOptions): Promise<void>;
81
+ /** Loads the next page of a list loaded with `loadThreads`. */
82
+ loadMoreThreads(subject: Subject<Types["subjectType"]>, options?: ListOptions): Promise<void>;
83
+ /** Loads a thread and the first page of its comments. */
84
+ loadThread(threadId: string): Promise<void>;
85
+ /** Loads the next page of a thread's comments. */
86
+ loadMoreComments(threadId: string): Promise<void>;
87
+ /** Loads open and total counts for many subjects, 50 per call. */
88
+ loadCounts(subjectType: Types["subjectType"], subjectIds: readonly string[]): Promise<void>;
89
+ /** Starts a thread. It shows at once under its final id, and is rolled back if the server refuses it. */
90
+ createThread(input: CreateThreadInput<Types>): Promise<{
91
+ thread: CommentThread & Types["threadFields"];
92
+ comment: Comment & Types["commentFields"];
93
+ droppedMentions: string[];
94
+ }>;
95
+ /** Replies to a thread, reopening it if it was resolved. */
96
+ reply(input: ReplyInput<Types>): Promise<{
97
+ thread: CommentThread & Types["threadFields"];
98
+ comment: Comment & Types["commentFields"];
99
+ droppedMentions: string[];
100
+ }>;
101
+ editComment(input: EditCommentInput<Types>): Promise<{
102
+ comment: Comment & Types["commentFields"];
103
+ droppedMentions: string[];
104
+ }>;
105
+ deleteComment(input: CommentTarget): Promise<{
106
+ comment: Comment & Types["commentFields"];
107
+ threadDeleted: boolean;
108
+ }>;
109
+ resolveThread: (input: ThreadTarget) => Promise<CommentThread & Types["threadFields"]>;
110
+ reopenThread: (input: ThreadTarget) => Promise<CommentThread & Types["threadFields"]>;
111
+ updateThread(input: UpdateThreadInput<Types>): Promise<CommentThread & Types["threadFields"]>;
112
+ updateAnchor(input: UpdateAnchorInput<Types>): Promise<CommentThread & Types["threadFields"]>;
113
+ deleteThread(input: ThreadTarget): Promise<{
114
+ threadId: string;
115
+ }>;
116
+ /**
117
+ * Suggests users to mention in a thread, or on a subject before its thread exists. A search that a `reset()`
118
+ * overtakes suggests nobody, since it ran as the previous person.
119
+ */
120
+ suggestMentions(input: SuggestMentionsInput<Types["subjectType"]>): Promise<IdentityUser[]>;
121
+ /**
122
+ * Applies a live update from `toClientEvent`. Events it has already applied, and comments it already has, are
123
+ * ignored, so an event that arrives before the server's own answer doesn't duplicate a comment.
124
+ */
125
+ applyEvent(event: ClientEvent | Serialized<ClientEvent>): void;
126
+ /** The store's state as JSON can carry it, to send a server render's loads to the browser. */
127
+ dehydrate(): DehydratedComments;
128
+ /**
129
+ * Empties the store, such as when the signed-in person changes without a page load. Lists and threads that are on
130
+ * screen load again for the new person. Calls still under way are cancelled and reject with an `ABORTED` error.
131
+ */
132
+ reset(): void;
133
+ /**
134
+ * Calls `listener` after each `reset()`, such as to clear state kept outside the store that the previous person could
135
+ * see. Returns a function that stops it.
136
+ */
137
+ onReset(listener: () => void): () => void;
138
+ /** Merges a snapshot from `dehydrate`, such as one a server render loaded. */
139
+ hydrate(snapshot: DehydratedComments): void;
140
+ };