@better-apps/comments 0.0.0-stage → 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/access.d.ts +66 -0
- package/dist/access.js +186 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.js +598 -0
- package/dist/content.d.ts +45 -0
- package/dist/content.js +136 -0
- package/dist/cursor.d.ts +7 -0
- package/dist/cursor.js +23 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +11 -0
- package/dist/events.d.ts +74 -0
- package/dist/events.js +21 -0
- package/dist/feature.d.ts +270 -0
- package/dist/feature.js +97 -0
- package/dist/formats.d.ts +26 -0
- package/dist/formats.js +25 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/inputs.d.ts +108 -0
- package/dist/inputs.js +69 -0
- package/dist/mentions.d.ts +5 -0
- package/dist/mentions.js +48 -0
- package/dist/options.d.ts +161 -0
- package/dist/options.js +1 -0
- package/dist/react.d.ts +122 -0
- package/dist/react.js +179 -0
- package/dist/reads.d.ts +54 -0
- package/dist/reads.js +297 -0
- package/dist/runtime.d.ts +52 -0
- package/dist/runtime.js +110 -0
- package/dist/schema.d.ts +62 -0
- package/dist/schema.js +53 -0
- package/dist/subjects.d.ts +17 -0
- package/dist/subjects.js +93 -0
- package/dist/ulid.d.ts +4 -0
- package/dist/ulid.js +24 -0
- package/dist/upkeep.d.ts +27 -0
- package/dist/upkeep.js +189 -0
- package/dist/writes.d.ts +27 -0
- package/dist/writes.js +463 -0
- package/package.json +91 -4
package/dist/react.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// React hooks over the comments store, with no UI: apps bring their own composer, thread list and pin styles.
|
|
2
|
+
import { useBetterApps, useStore } from "@better-apps/react";
|
|
3
|
+
import { BetterAppsClientError } from "better-apps/client";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { countKey, createCommentsClient, listKey } from "./client.js";
|
|
6
|
+
export * from "./client.js";
|
|
7
|
+
/**
|
|
8
|
+
* The comments client for the nearest `BetterAppsProvider`. Each Better Apps client has one, so every hook shares one
|
|
9
|
+
* store, and so does code that called `createCommentsClient` with the same client, such as a router hydrating a server
|
|
10
|
+
* render's loads. Pass `typeof app` for the app's types.
|
|
11
|
+
*/
|
|
12
|
+
export function useComments() {
|
|
13
|
+
return createCommentsClient(useBetterApps());
|
|
14
|
+
}
|
|
15
|
+
function useCommentsState(select, isEqual) {
|
|
16
|
+
const comments = useComments();
|
|
17
|
+
return useStore(comments.store, select, isEqual);
|
|
18
|
+
}
|
|
19
|
+
const sameItems = (a, b) => a.length === b.length && a.every((item, index) => Object.is(item, b[index]));
|
|
20
|
+
/** A subject's threads, loaded on first use. A list a server render already loaded isn't fetched again. */
|
|
21
|
+
export function useThreads(subject, options = {}) {
|
|
22
|
+
const comments = useComments();
|
|
23
|
+
const { subjectType, subjectId } = subject;
|
|
24
|
+
const { status, order } = options;
|
|
25
|
+
const key = listKey({ subjectType, subjectId }, { status, order });
|
|
26
|
+
const list = useCommentsState((state) => state.lists[key]);
|
|
27
|
+
const threads = useCommentsState((state) => (state.lists[key]?.ids ?? []).flatMap((id) => (state.threads[id] ? [state.threads[id]] : [])), sameItems);
|
|
28
|
+
const users = useCommentsState((state) => state.users);
|
|
29
|
+
const listState = list?.state;
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (listState === undefined) {
|
|
32
|
+
comments.loadThreads({ subjectType, subjectId }, { status, order }).catch(() => undefined);
|
|
33
|
+
}
|
|
34
|
+
}, [comments, subjectType, subjectId, status, order, listState]);
|
|
35
|
+
const loadMore = useCallback(() => comments.loadMoreThreads({ subjectType, subjectId }, { status, order }), [comments, subjectType, subjectId, status, order]);
|
|
36
|
+
const refresh = useCallback(() => comments.loadThreads({ subjectType, subjectId }, { status, order }), [comments, subjectType, subjectId, status, order]);
|
|
37
|
+
return {
|
|
38
|
+
threads,
|
|
39
|
+
state: list?.state ?? "loading",
|
|
40
|
+
error: list?.error ?? null,
|
|
41
|
+
hasMore: Boolean(list?.cursor),
|
|
42
|
+
loadMore,
|
|
43
|
+
refresh,
|
|
44
|
+
users
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A thread with its comments, oldest first, loaded on first use. */
|
|
48
|
+
export function useThread(threadId) {
|
|
49
|
+
const comments = useComments();
|
|
50
|
+
const thread = useCommentsState((state) => (threadId ? state.threads[threadId] : undefined));
|
|
51
|
+
const loaded = useCommentsState((state) => (threadId ? state.threadComments[threadId] : undefined));
|
|
52
|
+
const messages = useCommentsState((state) => ((threadId && state.threadComments[threadId]?.ids) || []).flatMap((id) => state.comments[id] ? [state.comments[id]] : []), sameItems);
|
|
53
|
+
const users = useCommentsState((state) => state.users);
|
|
54
|
+
const pending = useCommentsState((state) => state.pending);
|
|
55
|
+
const loadedState = loaded?.state;
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (threadId && loadedState === undefined) {
|
|
58
|
+
comments.loadThread(threadId).catch(() => undefined);
|
|
59
|
+
}
|
|
60
|
+
}, [comments, threadId, loadedState]);
|
|
61
|
+
const loadMore = useCallback(async () => {
|
|
62
|
+
if (threadId) {
|
|
63
|
+
await comments.loadMoreComments(threadId);
|
|
64
|
+
}
|
|
65
|
+
}, [comments, threadId]);
|
|
66
|
+
return {
|
|
67
|
+
thread,
|
|
68
|
+
comments: messages,
|
|
69
|
+
state: threadId ? (loaded?.state ?? "loading") : "idle",
|
|
70
|
+
error: loaded?.error ?? null,
|
|
71
|
+
hasMore: Boolean(loaded?.cursor),
|
|
72
|
+
loadMore,
|
|
73
|
+
users,
|
|
74
|
+
/** Whether a thread or comment is still waiting for the server. */
|
|
75
|
+
isPending: (id) => pending[id] === true
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Open and total thread counts for many records, such as badges in a table. Lists over 50 are split into calls. */
|
|
79
|
+
export function useThreadCounts(subjectType, subjectIds) {
|
|
80
|
+
const comments = useComments();
|
|
81
|
+
const idsKey = subjectIds.join("\u0000");
|
|
82
|
+
// Ids with no counts at all, not even loading ones. A reset empties the store, so its ids show up here again.
|
|
83
|
+
const missing = useCommentsState((state) => subjectIds.filter((subjectId) => !state.counts[countKey(subjectType, subjectId)]), sameItems);
|
|
84
|
+
const counts = useCommentsState((state) => Object.fromEntries(subjectIds.flatMap((subjectId) => {
|
|
85
|
+
const count = state.counts[countKey(subjectType, subjectId)];
|
|
86
|
+
return count?.state === "ready" ? [[subjectId, { open: count.open, total: count.total }]] : [];
|
|
87
|
+
})), (a, b) => JSON.stringify(a) === JSON.stringify(b));
|
|
88
|
+
const missingKey = missing.join("\u0000");
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (missingKey !== "") {
|
|
91
|
+
comments.loadCounts(subjectType, missingKey.split("\u0000")).catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
}, [comments, subjectType, idsKey, missingKey]);
|
|
94
|
+
// Counts that failed load again once when the list mounts or changes, rather than after every failure, so a failing
|
|
95
|
+
// server isn't asked in a loop.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const state = comments.store.getSnapshot();
|
|
98
|
+
const ids = idsKey === "" ? [] : idsKey.split("\u0000");
|
|
99
|
+
const failed = ids.filter((subjectId) => state.counts[countKey(subjectType, subjectId)]?.state === "error");
|
|
100
|
+
if (failed.length > 0) {
|
|
101
|
+
comments.loadCounts(subjectType, failed).catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
}, [comments, subjectType, idsKey]);
|
|
104
|
+
return counts;
|
|
105
|
+
}
|
|
106
|
+
function useMutation(run) {
|
|
107
|
+
const [status, setStatus] = useState("idle");
|
|
108
|
+
const [error, setError] = useState(null);
|
|
109
|
+
// Only the latest run describes the mutation, so one that settles after a newer run or a reset changes nothing.
|
|
110
|
+
const latest = useRef(0);
|
|
111
|
+
const mutate = useCallback(async (input) => {
|
|
112
|
+
const current = ++latest.current;
|
|
113
|
+
setStatus("pending");
|
|
114
|
+
setError(null);
|
|
115
|
+
try {
|
|
116
|
+
const output = await run(input);
|
|
117
|
+
if (current === latest.current) {
|
|
118
|
+
setStatus("success");
|
|
119
|
+
}
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
122
|
+
catch (caught) {
|
|
123
|
+
if (current === latest.current) {
|
|
124
|
+
// A write the store's reset() cancelled belonged to the previous person, so it shows as never having run.
|
|
125
|
+
const cancelled = caught instanceof BetterAppsClientError && caught.code === "ABORTED";
|
|
126
|
+
setStatus(cancelled ? "idle" : "error");
|
|
127
|
+
setError(cancelled ? null : caught);
|
|
128
|
+
}
|
|
129
|
+
throw caught;
|
|
130
|
+
}
|
|
131
|
+
}, [run]);
|
|
132
|
+
const reset = useCallback(() => {
|
|
133
|
+
latest.current += 1;
|
|
134
|
+
setStatus("idle");
|
|
135
|
+
setError(null);
|
|
136
|
+
}, []);
|
|
137
|
+
return { mutate, status, error, reset };
|
|
138
|
+
}
|
|
139
|
+
function useAction(name) {
|
|
140
|
+
const comments = useComments();
|
|
141
|
+
const run = useCallback((input) => comments[name](input), [comments, name]);
|
|
142
|
+
return useMutation(run);
|
|
143
|
+
}
|
|
144
|
+
/** Starts a thread, shown at once and rolled back if the server refuses it. */
|
|
145
|
+
export const useCreateThread = () => useAction("createThread");
|
|
146
|
+
/** Replies to a thread, reopening it if it was resolved. */
|
|
147
|
+
export const useReply = () => useAction("reply");
|
|
148
|
+
export const useEditComment = () => useAction("editComment");
|
|
149
|
+
export const useDeleteComment = () => useAction("deleteComment");
|
|
150
|
+
export const useResolveThread = () => useAction("resolveThread");
|
|
151
|
+
export const useReopenThread = () => useAction("reopenThread");
|
|
152
|
+
export const useUpdateThread = () => useAction("updateThread");
|
|
153
|
+
export const useUpdateAnchor = () => useAction("updateAnchor");
|
|
154
|
+
export const useDeleteThread = () => useAction("deleteThread");
|
|
155
|
+
/** Mention suggestions for a thread, or for a subject before its thread exists. `search` asks for a query's matches. */
|
|
156
|
+
export function useSuggestMentions(target) {
|
|
157
|
+
const comments = useComments();
|
|
158
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
159
|
+
const [error, setError] = useState(null);
|
|
160
|
+
const targetKey = JSON.stringify(target);
|
|
161
|
+
// Suggestions are what the signed-in person could see, so they go when the store is reset for someone else.
|
|
162
|
+
useEffect(() => comments.onReset(() => {
|
|
163
|
+
setSuggestions([]);
|
|
164
|
+
setError(null);
|
|
165
|
+
}), [comments]);
|
|
166
|
+
const search = useCallback(async (query) => {
|
|
167
|
+
try {
|
|
168
|
+
const found = await comments.suggestMentions({ ...JSON.parse(targetKey), query });
|
|
169
|
+
setSuggestions(found);
|
|
170
|
+
setError(null);
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
catch (caught) {
|
|
174
|
+
setError(caught);
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}, [comments, targetKey]);
|
|
178
|
+
return { suggestions, error, search };
|
|
179
|
+
}
|
package/dist/reads.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Actor, IdentityUser } from "better-apps";
|
|
2
|
+
import { type Config, type Context } from "./runtime.js";
|
|
3
|
+
import { type Comment, type CommentThread } from "./schema.js";
|
|
4
|
+
/** A page of threads, with the users they name. */
|
|
5
|
+
export interface ThreadPage<Thread = CommentThread> {
|
|
6
|
+
threads: Thread[];
|
|
7
|
+
/** The cursor for the next page, or `null` on the last one. */
|
|
8
|
+
cursor: string | null;
|
|
9
|
+
/** Names and avatars by user id. Users the identity adapter can't find are missing; show them as "Former user". */
|
|
10
|
+
users: Record<string, IdentityUser>;
|
|
11
|
+
}
|
|
12
|
+
/** A thread with a page of its comments, oldest first. */
|
|
13
|
+
export interface ThreadWithComments<Thread = CommentThread, Message = Comment> {
|
|
14
|
+
thread: Thread;
|
|
15
|
+
comments: Message[];
|
|
16
|
+
cursor: string | null;
|
|
17
|
+
users: Record<string, IdentityUser>;
|
|
18
|
+
}
|
|
19
|
+
export declare function listThreads(config: Config, context: Context): Promise<ThreadPage>;
|
|
20
|
+
export declare function getThread(config: Config, context: Context): Promise<ThreadWithComments>;
|
|
21
|
+
export declare function countThreads(config: Config, context: Context): Promise<Record<string, {
|
|
22
|
+
open: number;
|
|
23
|
+
total: number;
|
|
24
|
+
}>>;
|
|
25
|
+
export declare function suggestMentions(config: Config, context: Context): Promise<IdentityUser[]>;
|
|
26
|
+
/** Whether someone can see a thread, for other features' recipient checks, such as notifications. */
|
|
27
|
+
export declare function canViewThread(config: Config, context: Context): Promise<boolean>;
|
|
28
|
+
/** An event a browser may see, from `toClientEvent`. */
|
|
29
|
+
export type ClientEvent = {
|
|
30
|
+
id: string;
|
|
31
|
+
type: "thread.created" | "comment.created" | "comment.edited" | "comment.deleted" | "comment.mentioned";
|
|
32
|
+
thread: CommentThread;
|
|
33
|
+
comment: Comment;
|
|
34
|
+
} | {
|
|
35
|
+
id: string;
|
|
36
|
+
type: "thread.updated" | "thread.resolved" | "thread.reopened" | "thread.anchorUpdated" | "thread.deleted";
|
|
37
|
+
thread: CommentThread;
|
|
38
|
+
};
|
|
39
|
+
/** Someone a live updates feature sends events to, with the request they connected with. */
|
|
40
|
+
export interface LiveViewer {
|
|
41
|
+
/** Who's connected: a signed-in user, or `null` for a signed-out visitor. */
|
|
42
|
+
actor: Actor | null;
|
|
43
|
+
/** Their request, so a grant it carries, such as a share link, counts as it does for reads. */
|
|
44
|
+
request?: Request | null | undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Turns a server event into one a viewer's browser may see, for a live updates feature. It returns `null` when the
|
|
48
|
+
* viewer can't see the thread, and leaves out previous bodies and anchors. Threads and comments keep the fields reads
|
|
49
|
+
* return, so applying an event never blanks what a read showed. `comment.mentioned` only reaches the user it mentions.
|
|
50
|
+
* With the viewer's `request`, a grant it carries, such as a share link, counts as it does for reads.
|
|
51
|
+
*/
|
|
52
|
+
export declare function toClientEvent(config: Config, context: Context): Promise<ClientEvent | null>;
|
|
53
|
+
/** `toClientEvent` for many viewers, in order, with their access checked together, such as by one grant call. */
|
|
54
|
+
export declare function toClientEvents(config: Config, context: Context): Promise<(ClientEvent | null)[]>;
|
package/dist/reads.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { suggestMentions as suggestUsers } from "@better-apps/mentions";
|
|
2
|
+
import { accessTo, canViewThread as canView, orgOf, requireView, subjectOf, viewThreads, visibleTo } from "./access.js";
|
|
3
|
+
import { limits } from "./content.js";
|
|
4
|
+
import { decodeCursor, encodeCursor } from "./cursor.js";
|
|
5
|
+
import { CommentsError } from "./errors.js";
|
|
6
|
+
import { callerOf, forbid, loadThread, lookupUsers, mentionScope, threadAccess } from "./runtime.js";
|
|
7
|
+
import { commentModel, threadModel } from "./schema.js";
|
|
8
|
+
/** Signed-out readers get no user ids or metadata. */
|
|
9
|
+
function publicThread(thread) {
|
|
10
|
+
return { ...thread, createdBy: "", resolvedBy: null, metadata: null };
|
|
11
|
+
}
|
|
12
|
+
/** Signed-out readers get each comment as text with names filled in, and no user ids. */
|
|
13
|
+
function publicComment(config, comment, users) {
|
|
14
|
+
const format = config.formats.get(comment.format);
|
|
15
|
+
const names = (userId) => users[userId]?.name ?? undefined;
|
|
16
|
+
const bodyText = comment.body === null || !format ? comment.bodyText : format.toText(comment.body, names);
|
|
17
|
+
const authorName = comment.authorType === "user" ? (users[comment.authorId]?.name ?? null) : comment.authorName;
|
|
18
|
+
return { ...comment, authorId: "", authorName, body: null, bodyText, metadata: null };
|
|
19
|
+
}
|
|
20
|
+
/** The users a page of comments names: user authors, and everyone mentioned. */
|
|
21
|
+
function namedUsers(config, comments) {
|
|
22
|
+
return comments.flatMap((comment) => {
|
|
23
|
+
const format = config.formats.get(comment.format);
|
|
24
|
+
const mentioned = comment.body === null || !format ? [] : format.mentions(comment.body);
|
|
25
|
+
return comment.authorType === "user" ? [comment.authorId, ...mentioned] : mentioned;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function pageSize(requested, fallback) {
|
|
29
|
+
return requested ?? fallback;
|
|
30
|
+
}
|
|
31
|
+
export async function listThreads(config, context) {
|
|
32
|
+
const input = context.input;
|
|
33
|
+
const { subjectType, subjectId } = input;
|
|
34
|
+
const rules = subjectOf(config.subjects, subjectType);
|
|
35
|
+
const caller = callerOf(context);
|
|
36
|
+
const ref = { subjectType, subjectId };
|
|
37
|
+
const access = await accessTo(rules, context.subjects, caller, ref, "default", context.request);
|
|
38
|
+
let visibilities = ["default"];
|
|
39
|
+
if (caller.trusted) {
|
|
40
|
+
visibilities = ["default", ...Object.keys(rules.visibility ?? {})];
|
|
41
|
+
}
|
|
42
|
+
else if (caller.actor && access.view && !access.granted) {
|
|
43
|
+
visibilities = await visibleTo(rules, { actor: caller.actor, subjectType, subjectId });
|
|
44
|
+
}
|
|
45
|
+
requireView(access);
|
|
46
|
+
const orgId = await orgOf(context.subjects, caller, ref);
|
|
47
|
+
const where = [
|
|
48
|
+
{ field: "orgId", value: orgId },
|
|
49
|
+
{ field: "subjectType", value: subjectType },
|
|
50
|
+
{ field: "subjectId", value: subjectId },
|
|
51
|
+
{ field: "visibility", operator: "in", value: visibilities }
|
|
52
|
+
];
|
|
53
|
+
if (input.status) {
|
|
54
|
+
where.push({ field: "status", value: input.status });
|
|
55
|
+
}
|
|
56
|
+
if (input.anchorKind !== undefined) {
|
|
57
|
+
where.push({ field: "anchorKind", value: input.anchorKind });
|
|
58
|
+
}
|
|
59
|
+
for (const [field, value] of Object.entries(input.where ?? {})) {
|
|
60
|
+
if (!Object.hasOwn(config.threadFields, field) || !config.threadFields[field]?.index) {
|
|
61
|
+
throw new CommentsError("INVALID_INPUT", `Threads can't be filtered by "${field}"; only indexed additional fields.`);
|
|
62
|
+
}
|
|
63
|
+
where.push({ field, value });
|
|
64
|
+
}
|
|
65
|
+
const orderField = input.order === "created" ? "createdAt" : "lastActivityAt";
|
|
66
|
+
const limit = pageSize(input.limit, limits.threadsPerPage);
|
|
67
|
+
const after = input.cursor === undefined ? undefined : decodeCursor(input.cursor);
|
|
68
|
+
const rows = await context.db.findMany({
|
|
69
|
+
model: threadModel,
|
|
70
|
+
where,
|
|
71
|
+
sortBy: [
|
|
72
|
+
{ field: orderField, direction: "desc" },
|
|
73
|
+
{ field: "id", direction: "desc" }
|
|
74
|
+
],
|
|
75
|
+
after: after && { [orderField]: after.time, id: after.id },
|
|
76
|
+
limit: limit + 1
|
|
77
|
+
});
|
|
78
|
+
const threads = rows.slice(0, limit);
|
|
79
|
+
const last = threads.at(-1);
|
|
80
|
+
const cursor = rows.length > limit && last ? encodeCursor(last[orderField], last.id) : null;
|
|
81
|
+
// Someone who sees the threads only through a grant, such as a share link, sees them as a signed-out reader does.
|
|
82
|
+
if ((!caller.actor && !caller.trusted) || access.granted) {
|
|
83
|
+
return { threads: threads.map(publicThread), cursor, users: {} };
|
|
84
|
+
}
|
|
85
|
+
const users = await lookupUsers(context, threads.flatMap((thread) => [thread.createdBy, thread.resolvedBy ?? ""]));
|
|
86
|
+
return { threads, cursor, users };
|
|
87
|
+
}
|
|
88
|
+
export async function getThread(config, context) {
|
|
89
|
+
const input = context.input;
|
|
90
|
+
const thread = await loadThread(context, input.threadId);
|
|
91
|
+
const { caller, access } = await threadAccess(config, context, thread, context.request);
|
|
92
|
+
requireView(access);
|
|
93
|
+
const limit = pageSize(input.limit, limits.commentsPerPage);
|
|
94
|
+
const after = input.cursor === undefined ? undefined : decodeCursor(input.cursor);
|
|
95
|
+
const rows = await context.db.findMany({
|
|
96
|
+
model: commentModel,
|
|
97
|
+
where: [{ field: "threadId", value: thread.id }],
|
|
98
|
+
sortBy: [
|
|
99
|
+
{ field: "createdAt", direction: "asc" },
|
|
100
|
+
{ field: "id", direction: "asc" }
|
|
101
|
+
],
|
|
102
|
+
after: after && { createdAt: after.time, id: after.id },
|
|
103
|
+
limit: limit + 1
|
|
104
|
+
});
|
|
105
|
+
const comments = rows.slice(0, limit);
|
|
106
|
+
const last = comments.at(-1);
|
|
107
|
+
const cursor = rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null;
|
|
108
|
+
const users = await lookupUsers(context, [thread.createdBy, thread.resolvedBy ?? "", ...namedUsers(config, comments)]);
|
|
109
|
+
if ((!caller.actor && !caller.trusted) || access.granted) {
|
|
110
|
+
return {
|
|
111
|
+
thread: publicThread(thread),
|
|
112
|
+
comments: comments.map((comment) => publicComment(config, comment, users)),
|
|
113
|
+
cursor,
|
|
114
|
+
users: {}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { thread, comments, cursor, users };
|
|
118
|
+
}
|
|
119
|
+
export async function countThreads(config, context) {
|
|
120
|
+
const input = context.input;
|
|
121
|
+
const { subjectType } = input;
|
|
122
|
+
const rules = subjectOf(config.subjects, subjectType);
|
|
123
|
+
const caller = callerOf(context);
|
|
124
|
+
const subjectIds = [...new Set(input.subjectIds)];
|
|
125
|
+
const { actor } = caller;
|
|
126
|
+
let allowed = subjectIds.map(() => true);
|
|
127
|
+
// Subjects seen only through a grant the request carries, such as a share link, count their default threads.
|
|
128
|
+
const granted = new Set();
|
|
129
|
+
if (!caller.trusted) {
|
|
130
|
+
allowed = await context.subjects.canViewMany(subjectIds.map((subjectId) => ({ actor, subjectType, subjectId })));
|
|
131
|
+
const refused = subjectIds.filter((_, index) => !allowed[index]);
|
|
132
|
+
const { request } = context;
|
|
133
|
+
if (request && refused.length > 0) {
|
|
134
|
+
const reached = await context.subjects.canViewMany(refused.map((subjectId) => ({ actor, subjectType, subjectId, request })));
|
|
135
|
+
for (const [index, subjectId] of refused.entries()) {
|
|
136
|
+
if (reached[index]) {
|
|
137
|
+
granted.add(subjectId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
allowed = subjectIds.map((subjectId, index) => allowed[index] === true || granted.has(subjectId));
|
|
141
|
+
}
|
|
142
|
+
if (!actor && !allowed.includes(true)) {
|
|
143
|
+
throw new CommentsError("UNAUTHENTICATED", "Sign in to see these comments.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const viewable = subjectIds.filter((_, index) => allowed[index]);
|
|
147
|
+
const allVisibilities = ["default", ...Object.keys(rules.visibility ?? {})];
|
|
148
|
+
const visibleOn = async (subjectId) => {
|
|
149
|
+
if (caller.trusted) {
|
|
150
|
+
return allVisibilities;
|
|
151
|
+
}
|
|
152
|
+
return actor && !granted.has(subjectId) ? visibleTo(rules, { actor, subjectType, subjectId }) : ["default"];
|
|
153
|
+
};
|
|
154
|
+
const details = await Promise.all(viewable.map(async (subjectId) => ({
|
|
155
|
+
subjectId,
|
|
156
|
+
orgId: await orgOf(context.subjects, caller, { subjectType, subjectId }),
|
|
157
|
+
visible: new Set(await visibleOn(subjectId))
|
|
158
|
+
})));
|
|
159
|
+
const counts = {};
|
|
160
|
+
const bySubject = new Map(details.map((detail) => [detail.subjectId, detail]));
|
|
161
|
+
for (const detail of details) {
|
|
162
|
+
counts[detail.subjectId] = { open: 0, total: 0 };
|
|
163
|
+
}
|
|
164
|
+
if (viewable.length === 0) {
|
|
165
|
+
return counts;
|
|
166
|
+
}
|
|
167
|
+
const where = [
|
|
168
|
+
{ field: "subjectType", value: subjectType },
|
|
169
|
+
{ field: "subjectId", operator: "in", value: viewable }
|
|
170
|
+
];
|
|
171
|
+
if (input.status) {
|
|
172
|
+
where.push({ field: "status", value: input.status });
|
|
173
|
+
}
|
|
174
|
+
const groups = await context.db.countGroups({
|
|
175
|
+
model: threadModel,
|
|
176
|
+
where,
|
|
177
|
+
groupBy: ["orgId", "subjectId", "visibility", "status"]
|
|
178
|
+
});
|
|
179
|
+
for (const { group, count } of groups) {
|
|
180
|
+
const detail = bySubject.get(String(group.subjectId));
|
|
181
|
+
const tally = counts[String(group.subjectId)];
|
|
182
|
+
if (detail && tally && group.orgId === detail.orgId && detail.visible.has(String(group.visibility))) {
|
|
183
|
+
tally.total += count;
|
|
184
|
+
tally.open += group.status === "open" ? count : 0;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return counts;
|
|
188
|
+
}
|
|
189
|
+
export async function suggestMentions(config, context) {
|
|
190
|
+
const input = context.input;
|
|
191
|
+
let thread;
|
|
192
|
+
if (input.threadId !== undefined) {
|
|
193
|
+
thread = await loadThread(context, input.threadId);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const subjectType = input.subjectType;
|
|
197
|
+
const subjectId = input.subjectId;
|
|
198
|
+
subjectOf(config.subjects, subjectType);
|
|
199
|
+
const now = context.now;
|
|
200
|
+
// Suggestions for a new thread are checked as if it were a default thread on the subject.
|
|
201
|
+
thread = {
|
|
202
|
+
id: "",
|
|
203
|
+
orgId: await orgOf(context.subjects, callerOf(context), { subjectType, subjectId }),
|
|
204
|
+
subjectType,
|
|
205
|
+
subjectId,
|
|
206
|
+
visibility: "default",
|
|
207
|
+
anchorKind: null,
|
|
208
|
+
anchor: null,
|
|
209
|
+
anchorVersion: null,
|
|
210
|
+
status: "open",
|
|
211
|
+
resolvedAt: null,
|
|
212
|
+
resolvedBy: null,
|
|
213
|
+
createdBy: "",
|
|
214
|
+
createdAt: now,
|
|
215
|
+
lastActivityAt: now,
|
|
216
|
+
commentCount: 0,
|
|
217
|
+
metadata: null
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const { access } = await threadAccess(config, context, thread);
|
|
221
|
+
requireView(access);
|
|
222
|
+
forbid(access.comment, "You can't comment here.");
|
|
223
|
+
return suggestUsers(mentionScope(config, context, thread), input.query, limits.suggestions);
|
|
224
|
+
}
|
|
225
|
+
/** Whether someone can see a thread, for other features' recipient checks, such as notifications. */
|
|
226
|
+
export async function canViewThread(config, context) {
|
|
227
|
+
const input = context.input;
|
|
228
|
+
const thread = input.thread ??
|
|
229
|
+
(await context.db.findOne({
|
|
230
|
+
model: threadModel,
|
|
231
|
+
where: [{ field: "id", value: input.threadId ?? "" }]
|
|
232
|
+
}));
|
|
233
|
+
return thread ? canView(config.subjects, context.subjects, input.actor, thread) : false;
|
|
234
|
+
}
|
|
235
|
+
const commentEventTypes = new Set([
|
|
236
|
+
"thread.created",
|
|
237
|
+
"comment.created",
|
|
238
|
+
"comment.edited",
|
|
239
|
+
"comment.deleted",
|
|
240
|
+
"comment.mentioned"
|
|
241
|
+
]);
|
|
242
|
+
const threadEventTypes = new Set([
|
|
243
|
+
"thread.updated",
|
|
244
|
+
"thread.resolved",
|
|
245
|
+
"thread.reopened",
|
|
246
|
+
"thread.anchorUpdated",
|
|
247
|
+
"thread.deleted"
|
|
248
|
+
]);
|
|
249
|
+
/**
|
|
250
|
+
* Turns a server event into one a viewer's browser may see, for a live updates feature. It returns `null` when the
|
|
251
|
+
* viewer can't see the thread, and leaves out previous bodies and anchors. Threads and comments keep the fields reads
|
|
252
|
+
* return, so applying an event never blanks what a read showed. `comment.mentioned` only reaches the user it mentions.
|
|
253
|
+
* With the viewer's `request`, a grant it carries, such as a share link, counts as it does for reads.
|
|
254
|
+
*/
|
|
255
|
+
export async function toClientEvent(config, context) {
|
|
256
|
+
const { event, viewer, request } = context.input;
|
|
257
|
+
const [shown] = await clientEvents(config, context, event, [{ actor: viewer, request }]);
|
|
258
|
+
return shown ?? null;
|
|
259
|
+
}
|
|
260
|
+
/** `toClientEvent` for many viewers, in order, with their access checked together, such as by one grant call. */
|
|
261
|
+
export async function toClientEvents(config, context) {
|
|
262
|
+
const { event, viewers } = context.input;
|
|
263
|
+
return clientEvents(config, context, event, viewers);
|
|
264
|
+
}
|
|
265
|
+
async function clientEvents(config, context, event, viewers) {
|
|
266
|
+
const none = viewers.map(() => null);
|
|
267
|
+
if (event.feature !== "comments" || (!commentEventTypes.has(event.type) && !threadEventTypes.has(event.type))) {
|
|
268
|
+
return none;
|
|
269
|
+
}
|
|
270
|
+
const data = event.data;
|
|
271
|
+
const reached = viewers.map(({ actor }) => event.type !== "comment.mentioned" || (actor !== null && actor.userId === data.userId));
|
|
272
|
+
const asked = viewers.flatMap((viewer, index) => (reached[index] ? [index] : []));
|
|
273
|
+
const views = await viewThreads(config.subjects, context.subjects, asked.map((index) => ({
|
|
274
|
+
actor: viewers[index]?.actor ?? null,
|
|
275
|
+
thread: data.thread,
|
|
276
|
+
request: viewers[index]?.request
|
|
277
|
+
})));
|
|
278
|
+
const viewOf = new Map(asked.map((index, position) => [index, views[position] ?? null]));
|
|
279
|
+
// Someone who sees the thread only through a grant sees it as a signed-out reader does.
|
|
280
|
+
const hiddenFor = (index) => viewers[index]?.actor === null || viewOf.get(index) === "granted";
|
|
281
|
+
let hiddenComment = data.comment;
|
|
282
|
+
if (data.comment && asked.some((index) => viewOf.get(index) && hiddenFor(index))) {
|
|
283
|
+
const users = await lookupUsers(context, namedUsers(config, [data.comment]));
|
|
284
|
+
hiddenComment = publicComment(config, data.comment, users);
|
|
285
|
+
}
|
|
286
|
+
return viewers.map((_, index) => {
|
|
287
|
+
if (!viewOf.get(index)) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const hidden = hiddenFor(index);
|
|
291
|
+
const thread = hidden ? publicThread(data.thread) : data.thread;
|
|
292
|
+
const comment = hidden ? hiddenComment : data.comment;
|
|
293
|
+
return comment && commentEventTypes.has(event.type)
|
|
294
|
+
? { id: event.id, type: event.type, thread, comment }
|
|
295
|
+
: { id: event.id, type: event.type, thread };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AnchorKind } from "@better-apps/anchors";
|
|
2
|
+
import type { MentionScope } from "@better-apps/mentions";
|
|
3
|
+
import { type ActionContext, type Author, type FieldAttribute, type IdentityUser, type StandardSchemaV1 } from "better-apps";
|
|
4
|
+
import { type Access, type Caller } from "./access.js";
|
|
5
|
+
import type { BodyFormat } from "./formats.js";
|
|
6
|
+
import type { BeforeHooks, CommentRules, HookArgs } from "./options.js";
|
|
7
|
+
import { type Comment, type CommentThread } from "./schema.js";
|
|
8
|
+
/** The options, resolved once when the feature is created. */
|
|
9
|
+
export interface Config {
|
|
10
|
+
subjects: Readonly<Record<string, CommentRules>>;
|
|
11
|
+
anchors: ReadonlyMap<string, AnchorKind>;
|
|
12
|
+
formats: ReadonlyMap<string, BodyFormat>;
|
|
13
|
+
hooks: BeforeHooks;
|
|
14
|
+
threadFields: Readonly<Record<string, FieldAttribute>>;
|
|
15
|
+
commentFields: Readonly<Record<string, FieldAttribute>>;
|
|
16
|
+
}
|
|
17
|
+
export type Context = ActionContext<unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* Who a call acts as. Server code that passes neither an actor nor a request is trusted, so its reads and writes skip
|
|
20
|
+
* access checks; server code acting for a request or an actor is checked as that user.
|
|
21
|
+
*/
|
|
22
|
+
export declare function callerOf(context: Context): Caller;
|
|
23
|
+
/** The author of a write, or `UNAUTHENTICATED` without one. */
|
|
24
|
+
export declare function requireAuthor(context: Context): Author;
|
|
25
|
+
/** The id stored for an author: a user id, or the id server code gave an external or system author. */
|
|
26
|
+
export declare function authorIdOf(author: Author): string;
|
|
27
|
+
/** Whether an author wrote a comment. */
|
|
28
|
+
export declare function wrote(author: Author, comment: Comment): boolean;
|
|
29
|
+
/** A thread by id, or `NOT_FOUND`. */
|
|
30
|
+
export declare function loadThread(context: Context, threadId: string): Promise<CommentThread>;
|
|
31
|
+
/**
|
|
32
|
+
* What the caller may do with a thread. A thread outside its subject's current organization can't be seen. Reads pass
|
|
33
|
+
* the request, so a grant it carries counts; writes leave it out.
|
|
34
|
+
*/
|
|
35
|
+
export declare function threadAccess(config: Config, context: Context, thread: CommentThread, request?: Request | null): Promise<{
|
|
36
|
+
rules: CommentRules;
|
|
37
|
+
caller: Caller;
|
|
38
|
+
access: Access;
|
|
39
|
+
}>;
|
|
40
|
+
/** Refuses an action the caller can view but can't take. */
|
|
41
|
+
export declare function forbid(allowed: boolean, message: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Runs a before hook and checks what it returns with the action's schema. A `BetterAppsError`, such as a
|
|
44
|
+
* `CommentsError`, keeps its code; any other error becomes `REJECTED_BY_HOOK`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function runHook<Input>(hook: ((args: HookArgs<Input>) => Input | Promise<Input>) | undefined, args: HookArgs<Input>, schema: StandardSchemaV1): Promise<Input>;
|
|
47
|
+
/** Splits ids into lists of at most 90, under D1's limit of 100 bound parameters. */
|
|
48
|
+
export declare function chunks<Item>(items: readonly Item[], size?: number): Item[][];
|
|
49
|
+
/** Looks up users' names and avatars, keyed by id. Users the identity adapter can't find are left out. */
|
|
50
|
+
export declare function lookupUsers(context: Context, ids: Iterable<string>): Promise<Record<string, IdentityUser>>;
|
|
51
|
+
/** Where a thread's mentions are checked: its organization, and who may be mentioned there. */
|
|
52
|
+
export declare function mentionScope(config: Config, context: Context, thread: CommentThread): MentionScope;
|