@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 +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 +89 -3
package/dist/client.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { createStore } from "better-apps/client";
|
|
2
|
+
import { textFormat } from "./formats.js";
|
|
3
|
+
import { ulid } from "./ulid.js";
|
|
4
|
+
export { textFormat } from "./formats.js";
|
|
5
|
+
export { isUlid, ulid } from "./ulid.js";
|
|
6
|
+
/** Keys a list by its subject and options. */
|
|
7
|
+
export function listKey(subject, options = {}) {
|
|
8
|
+
return JSON.stringify([subject.subjectType, subject.subjectId, options.status ?? null, options.order ?? "activity"]);
|
|
9
|
+
}
|
|
10
|
+
/** Keys a subject's counts. */
|
|
11
|
+
export function countKey(subjectType, subjectId) {
|
|
12
|
+
return JSON.stringify([subjectType, subjectId]);
|
|
13
|
+
}
|
|
14
|
+
const toDate = (value) => (typeof value === "string" ? new Date(value) : value);
|
|
15
|
+
function reviveThread(thread) {
|
|
16
|
+
const row = thread;
|
|
17
|
+
return {
|
|
18
|
+
...row,
|
|
19
|
+
createdAt: toDate(row.createdAt),
|
|
20
|
+
lastActivityAt: toDate(row.lastActivityAt),
|
|
21
|
+
resolvedAt: toDate(row.resolvedAt ?? null)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function reviveComment(comment) {
|
|
25
|
+
const row = comment;
|
|
26
|
+
return {
|
|
27
|
+
...row,
|
|
28
|
+
createdAt: toDate(row.createdAt),
|
|
29
|
+
editedAt: toDate(row.editedAt ?? null),
|
|
30
|
+
deletedAt: toDate(row.deletedAt ?? null)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const empty = {
|
|
34
|
+
threads: {},
|
|
35
|
+
comments: {},
|
|
36
|
+
threadComments: {},
|
|
37
|
+
lists: {},
|
|
38
|
+
counts: {},
|
|
39
|
+
users: {},
|
|
40
|
+
pending: {}
|
|
41
|
+
};
|
|
42
|
+
function without(record, ...keys) {
|
|
43
|
+
const copy = { ...record };
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
delete copy[key];
|
|
46
|
+
}
|
|
47
|
+
return copy;
|
|
48
|
+
}
|
|
49
|
+
const remembered = 1000;
|
|
50
|
+
const clients = new WeakMap();
|
|
51
|
+
/**
|
|
52
|
+
* Creates the comments store for a Better Apps client. Pass `typeof app` as the client's type to get the app's subject
|
|
53
|
+
* types, anchors, formats and additional fields. Each Better Apps client has one store, so calling this again with the
|
|
54
|
+
* same client, as `useComments` does, returns the store already made, with whatever a server render hydrated into it.
|
|
55
|
+
*
|
|
56
|
+
* The store holds what the signed-in person could see. When someone else signs in without a page load, call `reset()`
|
|
57
|
+
* or create a new Better Apps client, so they never see the previous person's threads.
|
|
58
|
+
*/
|
|
59
|
+
export function createCommentsClient(client) {
|
|
60
|
+
let comments = clients.get(client);
|
|
61
|
+
if (!comments) {
|
|
62
|
+
comments = createCommentsClientFor(client.$call);
|
|
63
|
+
clients.set(client, comments);
|
|
64
|
+
}
|
|
65
|
+
return comments;
|
|
66
|
+
}
|
|
67
|
+
function createCommentsClientFor(call) {
|
|
68
|
+
const store = createStore(empty);
|
|
69
|
+
// reset() cancels calls still under way, so one waiting on its headers never goes out as the next person.
|
|
70
|
+
let calls = new AbortController();
|
|
71
|
+
const run = (action, input) => call("comments", action, input, { signal: calls.signal });
|
|
72
|
+
const applied = [];
|
|
73
|
+
const resetListeners = new Set();
|
|
74
|
+
const update = (change) => store.setState(change);
|
|
75
|
+
// reset() starts a new generation. Answers to requests made before it belong to the previous person, so an
|
|
76
|
+
// operation applies its changes only while its generation is current.
|
|
77
|
+
let generation = 0;
|
|
78
|
+
const scoped = () => {
|
|
79
|
+
const started = generation;
|
|
80
|
+
return (change) => {
|
|
81
|
+
if (started === generation) {
|
|
82
|
+
update(change);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
const mergeUsers = (state, users) => users && Object.keys(users).length > 0 ? { ...state, users: { ...state.users, ...users } } : state;
|
|
87
|
+
/**
|
|
88
|
+
* Keeps a thread's place in its subject's lists: a new thread goes on top, a new comment moves its thread to the top
|
|
89
|
+
* of lists by activity, and a thread leaves lists whose status it no longer has. A thread a list hasn't loaded is
|
|
90
|
+
* only added when it's new or just had activity, since its place is otherwise unknown.
|
|
91
|
+
*/
|
|
92
|
+
const placeThread = (state, thread, change) => {
|
|
93
|
+
const lists = { ...state.lists };
|
|
94
|
+
for (const [key, list] of Object.entries(state.lists)) {
|
|
95
|
+
if (list.subjectType !== thread.subjectType || list.subjectId !== thread.subjectId) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const present = list.ids.includes(thread.id);
|
|
99
|
+
const rest = list.ids.filter((id) => id !== thread.id);
|
|
100
|
+
const toTop = change.isNew === true || (change.bump === true && list.order === "activity");
|
|
101
|
+
if (list.status !== null && list.status !== thread.status) {
|
|
102
|
+
lists[key] = present ? { ...list, ids: rest } : list;
|
|
103
|
+
}
|
|
104
|
+
else if (toTop) {
|
|
105
|
+
lists[key] = { ...list, ids: [thread.id, ...rest] };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ...state, lists };
|
|
109
|
+
};
|
|
110
|
+
const countChange = (state, thread, total, open) => {
|
|
111
|
+
const key = countKey(thread.subjectType, thread.subjectId);
|
|
112
|
+
const counts = state.counts[key];
|
|
113
|
+
// Counts still loading arrive from the server with the change in them, or without it, and replace these anyway.
|
|
114
|
+
if (counts?.state !== "ready") {
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...state,
|
|
119
|
+
counts: { ...state.counts, [key]: { ...counts, total: counts.total + total, open: counts.open + open } }
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const putThread = (state, thread) => ({
|
|
123
|
+
...state,
|
|
124
|
+
threads: { ...state.threads, [thread.id]: thread }
|
|
125
|
+
});
|
|
126
|
+
const putComment = (state, comment, append) => {
|
|
127
|
+
const loaded = state.threadComments[comment.threadId];
|
|
128
|
+
const threadComments = append && loaded && !loaded.ids.includes(comment.id)
|
|
129
|
+
? { ...state.threadComments, [comment.threadId]: { ...loaded, ids: [...loaded.ids, comment.id] } }
|
|
130
|
+
: state.threadComments;
|
|
131
|
+
return { ...state, comments: { ...state.comments, [comment.id]: comment }, threadComments };
|
|
132
|
+
};
|
|
133
|
+
const removeThread = (state, threadId) => {
|
|
134
|
+
const thread = state.threads[threadId];
|
|
135
|
+
const commentIds = state.threadComments[threadId]?.ids ?? [];
|
|
136
|
+
let next = {
|
|
137
|
+
...state,
|
|
138
|
+
threads: without(state.threads, threadId),
|
|
139
|
+
comments: without(state.comments, ...commentIds),
|
|
140
|
+
threadComments: without(state.threadComments, threadId),
|
|
141
|
+
lists: Object.fromEntries(Object.entries(state.lists).map(([key, list]) => [
|
|
142
|
+
key,
|
|
143
|
+
{ ...list, ids: list.ids.filter((id) => id !== threadId) }
|
|
144
|
+
]))
|
|
145
|
+
};
|
|
146
|
+
if (thread) {
|
|
147
|
+
next = countChange(next, thread, -1, thread.status === "open" ? -1 : 0);
|
|
148
|
+
}
|
|
149
|
+
return next;
|
|
150
|
+
};
|
|
151
|
+
/** Applies a change now and returns a function that undoes it, for when the server refuses the write. */
|
|
152
|
+
const optimistic = (change, ids) => {
|
|
153
|
+
const before = store.getSnapshot();
|
|
154
|
+
const apply = scoped();
|
|
155
|
+
apply((state) => {
|
|
156
|
+
const next = change(state);
|
|
157
|
+
return { ...next, pending: { ...next.pending, ...Object.fromEntries(ids.map((id) => [id, true])) } };
|
|
158
|
+
});
|
|
159
|
+
const settle = () => apply((state) => ({ ...state, pending: without(state.pending, ...ids) }));
|
|
160
|
+
const undo = () => apply((state) => ({
|
|
161
|
+
...before,
|
|
162
|
+
// Keep what was loaded meanwhile; only the optimistic change is rolled back.
|
|
163
|
+
users: state.users,
|
|
164
|
+
pending: without(state.pending, ...ids)
|
|
165
|
+
}));
|
|
166
|
+
return { settle, undo };
|
|
167
|
+
};
|
|
168
|
+
async function write(action, input, change, ids, confirm) {
|
|
169
|
+
const apply = scoped();
|
|
170
|
+
const { settle, undo } = optimistic(change, ids);
|
|
171
|
+
let output;
|
|
172
|
+
try {
|
|
173
|
+
output = (await run(action, input));
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
undo();
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
apply((state) => confirm(state, output));
|
|
180
|
+
settle();
|
|
181
|
+
return output;
|
|
182
|
+
}
|
|
183
|
+
const existing = (threadId) => {
|
|
184
|
+
const thread = store.getSnapshot().threads[threadId];
|
|
185
|
+
if (!thread) {
|
|
186
|
+
throw new Error(`Load thread ${threadId} before changing it.`);
|
|
187
|
+
}
|
|
188
|
+
return thread;
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
store,
|
|
192
|
+
/** Loads the first page of a subject's threads. */
|
|
193
|
+
async loadThreads(subject, options = {}) {
|
|
194
|
+
const key = listKey(subject, options);
|
|
195
|
+
const blank = {
|
|
196
|
+
subjectType: subject.subjectType,
|
|
197
|
+
subjectId: subject.subjectId,
|
|
198
|
+
status: options.status ?? null,
|
|
199
|
+
order: options.order ?? "activity",
|
|
200
|
+
ids: [],
|
|
201
|
+
cursor: null,
|
|
202
|
+
state: "loading",
|
|
203
|
+
error: null
|
|
204
|
+
};
|
|
205
|
+
update((state) => ({
|
|
206
|
+
...state,
|
|
207
|
+
lists: { ...state.lists, [key]: { ...(state.lists[key] ?? blank), state: "loading", error: null } }
|
|
208
|
+
}));
|
|
209
|
+
await loadPage(key, { ...subject, ...options }, false);
|
|
210
|
+
},
|
|
211
|
+
/** Loads the next page of a list loaded with `loadThreads`. */
|
|
212
|
+
async loadMoreThreads(subject, options = {}) {
|
|
213
|
+
const key = listKey(subject, options);
|
|
214
|
+
const list = store.getSnapshot().lists[key];
|
|
215
|
+
if (!list?.cursor || list.state === "loading") {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
update((state) => ({ ...state, lists: { ...state.lists, [key]: { ...list, state: "loading" } } }));
|
|
219
|
+
await loadPage(key, { ...subject, ...options, cursor: list.cursor }, true);
|
|
220
|
+
},
|
|
221
|
+
/** Loads a thread and the first page of its comments. */
|
|
222
|
+
async loadThread(threadId) {
|
|
223
|
+
await loadComments({ threadId }, false);
|
|
224
|
+
},
|
|
225
|
+
/** Loads the next page of a thread's comments. */
|
|
226
|
+
async loadMoreComments(threadId) {
|
|
227
|
+
const loaded = store.getSnapshot().threadComments[threadId];
|
|
228
|
+
if (loaded?.cursor && loaded.state !== "loading") {
|
|
229
|
+
await loadComments({ threadId, cursor: loaded.cursor }, true);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
/** Loads open and total counts for many subjects, 50 per call. */
|
|
233
|
+
async loadCounts(subjectType, subjectIds) {
|
|
234
|
+
const apply = scoped();
|
|
235
|
+
const unique = [...new Set(subjectIds)];
|
|
236
|
+
const pages = [];
|
|
237
|
+
for (let start = 0; start < unique.length; start += 50) {
|
|
238
|
+
pages.push(unique.slice(start, start + 50));
|
|
239
|
+
}
|
|
240
|
+
// Counts not loaded yet are marked as loading, so a reset meanwhile shows they're missing again.
|
|
241
|
+
const placeholders = (state, loadState) => Object.fromEntries(unique.flatMap((subjectId) => {
|
|
242
|
+
const key = countKey(subjectType, subjectId);
|
|
243
|
+
const current = state[key];
|
|
244
|
+
return current?.state === "ready" ? [] : [[key, { open: 0, total: 0, state: loadState }]];
|
|
245
|
+
}));
|
|
246
|
+
apply((state) => ({ ...state, counts: { ...state.counts, ...placeholders(state.counts, "loading") } }));
|
|
247
|
+
try {
|
|
248
|
+
await Promise.all(pages.map(async (page) => {
|
|
249
|
+
const input = { subjectType, subjectIds: page };
|
|
250
|
+
const counts = (await run("countThreads", input));
|
|
251
|
+
apply((state) => ({
|
|
252
|
+
...state,
|
|
253
|
+
counts: {
|
|
254
|
+
...state.counts,
|
|
255
|
+
...Object.fromEntries(page.map((subjectId) => [
|
|
256
|
+
countKey(subjectType, subjectId),
|
|
257
|
+
{ ...(counts[subjectId] ?? { open: 0, total: 0 }), state: "ready" }
|
|
258
|
+
]))
|
|
259
|
+
}
|
|
260
|
+
}));
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
apply((state) => ({ ...state, counts: { ...state.counts, ...placeholders(state.counts, "error") } }));
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
/** Starts a thread. It shows at once under its final id, and is rolled back if the server refuses it. */
|
|
269
|
+
async createThread(input) {
|
|
270
|
+
const id = input.id ?? ulid();
|
|
271
|
+
const commentId = input.commentId ?? ulid();
|
|
272
|
+
const now = new Date();
|
|
273
|
+
const anchor = input.anchor ?? null;
|
|
274
|
+
const thread = {
|
|
275
|
+
id,
|
|
276
|
+
orgId: "",
|
|
277
|
+
subjectType: input.subjectType,
|
|
278
|
+
subjectId: input.subjectId,
|
|
279
|
+
visibility: input.visibility ?? "default",
|
|
280
|
+
anchorKind: anchor?.kind ?? null,
|
|
281
|
+
anchor: anchor?.value ?? null,
|
|
282
|
+
anchorVersion: anchor?.version ?? null,
|
|
283
|
+
status: "open",
|
|
284
|
+
resolvedAt: null,
|
|
285
|
+
resolvedBy: null,
|
|
286
|
+
createdBy: "",
|
|
287
|
+
createdAt: now,
|
|
288
|
+
lastActivityAt: now,
|
|
289
|
+
commentCount: 1,
|
|
290
|
+
metadata: input.metadata ?? null,
|
|
291
|
+
...input.fields
|
|
292
|
+
};
|
|
293
|
+
const comment = draftComment(commentId, thread, input, now, input.commentFields);
|
|
294
|
+
const output = await write("createThread", { ...input, id, commentId }, (state) => {
|
|
295
|
+
let next = putComment(putThread(state, thread), comment, false);
|
|
296
|
+
next = {
|
|
297
|
+
...next,
|
|
298
|
+
threadComments: {
|
|
299
|
+
...next.threadComments,
|
|
300
|
+
[id]: { ids: [commentId], cursor: null, state: "ready", error: null }
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
return countChange(placeThread(next, thread, { isNew: true }), thread, 1, 1);
|
|
304
|
+
}, [id, commentId], (state, result) => putComment(putThread(state, reviveThread(result.thread)), reviveComment(result.comment), false));
|
|
305
|
+
return {
|
|
306
|
+
thread: reviveThread(output.thread),
|
|
307
|
+
comment: reviveComment(output.comment),
|
|
308
|
+
droppedMentions: output.droppedMentions
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
/** Replies to a thread, reopening it if it was resolved. */
|
|
312
|
+
async reply(input) {
|
|
313
|
+
const thread = existing(input.threadId);
|
|
314
|
+
const id = input.id ?? ulid();
|
|
315
|
+
const now = new Date();
|
|
316
|
+
const comment = draftComment(id, thread, input, now, input.fields);
|
|
317
|
+
const bumped = {
|
|
318
|
+
...thread,
|
|
319
|
+
status: "open",
|
|
320
|
+
resolvedAt: null,
|
|
321
|
+
resolvedBy: null,
|
|
322
|
+
commentCount: thread.commentCount + 1,
|
|
323
|
+
lastActivityAt: now
|
|
324
|
+
};
|
|
325
|
+
const output = await write("reply", { ...input, id }, (state) => {
|
|
326
|
+
const reopened = thread.status === "resolved" ? 1 : 0;
|
|
327
|
+
return countChange(placeThread(putComment(putThread(state, bumped), comment, true), bumped, { bump: true }), bumped, 0, reopened);
|
|
328
|
+
}, [id], (state, result) => putComment(putThread(state, reviveThread(result.thread)), reviveComment(result.comment), true));
|
|
329
|
+
return {
|
|
330
|
+
thread: reviveThread(output.thread),
|
|
331
|
+
comment: reviveComment(output.comment),
|
|
332
|
+
droppedMentions: output.droppedMentions
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
async editComment(input) {
|
|
336
|
+
const comment = store.getSnapshot().comments[input.commentId];
|
|
337
|
+
const format = input.format ?? "text";
|
|
338
|
+
const edited = comment && {
|
|
339
|
+
...comment,
|
|
340
|
+
format,
|
|
341
|
+
body: input.body,
|
|
342
|
+
bodyText: format === "text" ? textFormat.toText(String(input.body)) : comment.bodyText,
|
|
343
|
+
editedAt: new Date()
|
|
344
|
+
};
|
|
345
|
+
const output = await write("editComment", input, (state) => (edited ? putComment(state, edited, false) : state), [input.commentId], (state, result) => putComment(state, reviveComment(result.comment), false));
|
|
346
|
+
return { comment: reviveComment(output.comment), droppedMentions: output.droppedMentions };
|
|
347
|
+
},
|
|
348
|
+
async deleteComment(input) {
|
|
349
|
+
const comment = store.getSnapshot().comments[input.commentId];
|
|
350
|
+
const thread = comment && store.getSnapshot().threads[comment.threadId];
|
|
351
|
+
const output = await write("deleteComment", input, (state) => {
|
|
352
|
+
if (!comment || !thread || comment.deletedAt) {
|
|
353
|
+
return state;
|
|
354
|
+
}
|
|
355
|
+
const deleted = { ...comment, body: null, bodyText: "", deletedAt: new Date() };
|
|
356
|
+
return putThread(putComment(state, deleted, false), { ...thread, commentCount: thread.commentCount - 1 });
|
|
357
|
+
}, [input.commentId], (state, result) => {
|
|
358
|
+
const deleted = reviveComment(result.comment);
|
|
359
|
+
return result.threadDeleted ? removeThread(state, deleted.threadId) : putComment(state, deleted, false);
|
|
360
|
+
});
|
|
361
|
+
return { comment: reviveComment(output.comment), threadDeleted: output.threadDeleted };
|
|
362
|
+
},
|
|
363
|
+
resolveThread: (input) => setStatus("resolveThread", input, "resolved"),
|
|
364
|
+
reopenThread: (input) => setStatus("reopenThread", input, "open"),
|
|
365
|
+
async updateThread(input) {
|
|
366
|
+
const thread = existing(input.threadId);
|
|
367
|
+
const changed = {
|
|
368
|
+
...thread,
|
|
369
|
+
...input.fields,
|
|
370
|
+
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
|
371
|
+
...(input.visibility === undefined ? {} : { visibility: input.visibility })
|
|
372
|
+
};
|
|
373
|
+
return confirmThread("updateThread", input, changed);
|
|
374
|
+
},
|
|
375
|
+
async updateAnchor(input) {
|
|
376
|
+
const thread = existing(input.threadId);
|
|
377
|
+
const changed = {
|
|
378
|
+
...thread,
|
|
379
|
+
anchorKind: input.anchor?.kind ?? null,
|
|
380
|
+
anchor: input.anchor?.value ?? null,
|
|
381
|
+
anchorVersion: input.anchor?.version ?? null
|
|
382
|
+
};
|
|
383
|
+
return confirmThread("updateAnchor", input, changed);
|
|
384
|
+
},
|
|
385
|
+
async deleteThread(input) {
|
|
386
|
+
return write("deleteThread", input, (state) => removeThread(state, input.threadId), [input.threadId], (state) => state);
|
|
387
|
+
},
|
|
388
|
+
/**
|
|
389
|
+
* Suggests users to mention in a thread, or on a subject before its thread exists. A search that a `reset()`
|
|
390
|
+
* overtakes suggests nobody, since it ran as the previous person.
|
|
391
|
+
*/
|
|
392
|
+
async suggestMentions(input) {
|
|
393
|
+
const started = generation;
|
|
394
|
+
try {
|
|
395
|
+
const users = (await run("suggestMentions", input));
|
|
396
|
+
return started === generation ? users : [];
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (started !== generation) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
/**
|
|
406
|
+
* Applies a live update from `toClientEvent`. Events it has already applied, and comments it already has, are
|
|
407
|
+
* ignored, so an event that arrives before the server's own answer doesn't duplicate a comment.
|
|
408
|
+
*/
|
|
409
|
+
applyEvent(event) {
|
|
410
|
+
if (applied.includes(event.id)) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
applied.push(event.id);
|
|
414
|
+
applied.splice(0, Math.max(0, applied.length - remembered));
|
|
415
|
+
const thread = reviveThread(event.thread);
|
|
416
|
+
update((state) => {
|
|
417
|
+
if (event.type === "thread.deleted") {
|
|
418
|
+
return removeThread(state, thread.id);
|
|
419
|
+
}
|
|
420
|
+
const known = state.threads[thread.id];
|
|
421
|
+
let next = putThread(state, thread);
|
|
422
|
+
if ("comment" in event) {
|
|
423
|
+
const comment = reviveComment(event.comment);
|
|
424
|
+
next = putComment(next, comment, true);
|
|
425
|
+
}
|
|
426
|
+
const isNew = event.type === "thread.created" && !known;
|
|
427
|
+
if (isNew) {
|
|
428
|
+
next = countChange(next, thread, 1, thread.status === "open" ? 1 : 0);
|
|
429
|
+
}
|
|
430
|
+
else if (known && known.status !== thread.status) {
|
|
431
|
+
next = countChange(next, thread, 0, thread.status === "open" ? 1 : -1);
|
|
432
|
+
}
|
|
433
|
+
return placeThread(next, thread, { isNew, bump: event.type === "comment.created" });
|
|
434
|
+
});
|
|
435
|
+
},
|
|
436
|
+
/** The store's state as JSON can carry it, to send a server render's loads to the browser. */
|
|
437
|
+
dehydrate() {
|
|
438
|
+
// Through JSON on purpose, so dates become the strings a server render sends. Only loaded counts go, since the
|
|
439
|
+
// browser loads counts it doesn't have and would keep ones marked loading or failed.
|
|
440
|
+
const state = store.getSnapshot();
|
|
441
|
+
const counts = Object.fromEntries(Object.entries(state.counts).filter(([, count]) => count.state === "ready"));
|
|
442
|
+
const json = JSON.stringify({ ...state, counts, pending: {} });
|
|
443
|
+
return JSON.parse(json);
|
|
444
|
+
},
|
|
445
|
+
/**
|
|
446
|
+
* Empties the store, such as when the signed-in person changes without a page load. Lists and threads that are on
|
|
447
|
+
* screen load again for the new person. Calls still under way are cancelled and reject with an `ABORTED` error.
|
|
448
|
+
*/
|
|
449
|
+
reset() {
|
|
450
|
+
generation += 1;
|
|
451
|
+
calls.abort();
|
|
452
|
+
calls = new AbortController();
|
|
453
|
+
applied.length = 0;
|
|
454
|
+
store.setState(() => empty);
|
|
455
|
+
for (const listener of Array.from(resetListeners)) {
|
|
456
|
+
listener();
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
/**
|
|
460
|
+
* Calls `listener` after each `reset()`, such as to clear state kept outside the store that the previous person could
|
|
461
|
+
* see. Returns a function that stops it.
|
|
462
|
+
*/
|
|
463
|
+
onReset(listener) {
|
|
464
|
+
resetListeners.add(listener);
|
|
465
|
+
return () => {
|
|
466
|
+
resetListeners.delete(listener);
|
|
467
|
+
};
|
|
468
|
+
},
|
|
469
|
+
/** Merges a snapshot from `dehydrate`, such as one a server render loaded. */
|
|
470
|
+
hydrate(snapshot) {
|
|
471
|
+
update((state) => ({
|
|
472
|
+
threads: {
|
|
473
|
+
...state.threads,
|
|
474
|
+
...Object.fromEntries(Object.entries(snapshot.threads).map(([id, thread]) => [id, reviveThread(thread)]))
|
|
475
|
+
},
|
|
476
|
+
comments: {
|
|
477
|
+
...state.comments,
|
|
478
|
+
...Object.fromEntries(Object.entries(snapshot.comments).map(([id, comment]) => [id, reviveComment(comment)]))
|
|
479
|
+
},
|
|
480
|
+
threadComments: { ...state.threadComments, ...snapshot.threadComments },
|
|
481
|
+
lists: { ...state.lists, ...snapshot.lists },
|
|
482
|
+
counts: {
|
|
483
|
+
...state.counts,
|
|
484
|
+
...Object.fromEntries(Object.entries(snapshot.counts).filter(([, count]) => count.state === "ready"))
|
|
485
|
+
},
|
|
486
|
+
users: { ...state.users, ...snapshot.users },
|
|
487
|
+
pending: state.pending
|
|
488
|
+
}));
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
function draftComment(id, thread, input, now, fields) {
|
|
492
|
+
const format = input.format ?? "text";
|
|
493
|
+
return {
|
|
494
|
+
id,
|
|
495
|
+
threadId: thread.id,
|
|
496
|
+
orgId: thread.orgId,
|
|
497
|
+
authorType: "user",
|
|
498
|
+
authorId: "",
|
|
499
|
+
authorName: null,
|
|
500
|
+
format,
|
|
501
|
+
body: input.body,
|
|
502
|
+
bodyText: format === "text" ? textFormat.toText(String(input.body)) : "",
|
|
503
|
+
createdAt: now,
|
|
504
|
+
editedAt: null,
|
|
505
|
+
deletedAt: null,
|
|
506
|
+
metadata: null,
|
|
507
|
+
...fields
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
async function loadPage(key, input, append) {
|
|
511
|
+
const apply = scoped();
|
|
512
|
+
try {
|
|
513
|
+
const page = (await run("listThreads", input));
|
|
514
|
+
const threads = page.threads.map((thread) => reviveThread(thread));
|
|
515
|
+
apply((state) => {
|
|
516
|
+
const list = state.lists[key];
|
|
517
|
+
const ids = threads.map((thread) => thread.id);
|
|
518
|
+
// Threads that moved up through an event while paging are already listed, so they aren't repeated.
|
|
519
|
+
const merged = append ? [...list.ids, ...ids.filter((id) => !list.ids.includes(id))] : ids;
|
|
520
|
+
return mergeUsers({
|
|
521
|
+
...state,
|
|
522
|
+
threads: { ...state.threads, ...Object.fromEntries(threads.map((thread) => [thread.id, thread])) },
|
|
523
|
+
lists: { ...state.lists, [key]: { ...list, ids: merged, cursor: page.cursor, state: "ready", error: null } }
|
|
524
|
+
}, page.users);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
apply((state) => ({
|
|
529
|
+
...state,
|
|
530
|
+
lists: { ...state.lists, [key]: { ...state.lists[key], state: "error", error: error } }
|
|
531
|
+
}));
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function loadComments(input, append) {
|
|
536
|
+
const { threadId } = input;
|
|
537
|
+
const apply = scoped();
|
|
538
|
+
apply((state) => ({
|
|
539
|
+
...state,
|
|
540
|
+
threadComments: {
|
|
541
|
+
...state.threadComments,
|
|
542
|
+
[threadId]: { ...(state.threadComments[threadId] ?? { ids: [], cursor: null }), state: "loading", error: null }
|
|
543
|
+
}
|
|
544
|
+
}));
|
|
545
|
+
try {
|
|
546
|
+
const page = (await run("getThread", input));
|
|
547
|
+
const comments = page.comments.map((comment) => reviveComment(comment));
|
|
548
|
+
apply((state) => {
|
|
549
|
+
const loaded = state.threadComments[threadId];
|
|
550
|
+
const ids = comments.map((comment) => comment.id);
|
|
551
|
+
return mergeUsers({
|
|
552
|
+
...putThread(state, reviveThread(page.thread)),
|
|
553
|
+
comments: { ...state.comments, ...Object.fromEntries(comments.map((comment) => [comment.id, comment])) },
|
|
554
|
+
threadComments: {
|
|
555
|
+
...state.threadComments,
|
|
556
|
+
[threadId]: {
|
|
557
|
+
ids: append ? [...loaded.ids, ...ids.filter((id) => !loaded.ids.includes(id))] : ids,
|
|
558
|
+
cursor: page.cursor,
|
|
559
|
+
state: "ready",
|
|
560
|
+
error: null
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}, page.users);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
apply((state) => ({
|
|
568
|
+
...state,
|
|
569
|
+
threadComments: {
|
|
570
|
+
...state.threadComments,
|
|
571
|
+
[threadId]: { ...state.threadComments[threadId], state: "error", error: error }
|
|
572
|
+
}
|
|
573
|
+
}));
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function setStatus(action, input, status) {
|
|
578
|
+
const thread = existing(input.threadId);
|
|
579
|
+
const changed = {
|
|
580
|
+
...thread,
|
|
581
|
+
status,
|
|
582
|
+
resolvedAt: status === "resolved" ? new Date() : null,
|
|
583
|
+
resolvedBy: status === "resolved" ? thread.resolvedBy : null
|
|
584
|
+
};
|
|
585
|
+
return confirmThread(action, input, changed);
|
|
586
|
+
}
|
|
587
|
+
async function confirmThread(action, input, changed) {
|
|
588
|
+
const previous = store.getSnapshot().threads[changed.id];
|
|
589
|
+
const output = await write(action, input, (state) => {
|
|
590
|
+
let next = putThread(state, changed);
|
|
591
|
+
if (previous && previous.status !== changed.status) {
|
|
592
|
+
next = countChange(next, changed, 0, changed.status === "open" ? 1 : -1);
|
|
593
|
+
}
|
|
594
|
+
return placeThread(next, changed, {});
|
|
595
|
+
}, [changed.id], (state, result) => putThread(state, reviveThread(result)));
|
|
596
|
+
return reviveThread(output);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AnchorKind } from "@better-apps/anchors";
|
|
2
|
+
import type { FieldAttribute, StandardSchemaV1 } from "better-apps";
|
|
3
|
+
import type { BodyFormat } from "./formats.js";
|
|
4
|
+
/** Size and count limits. Queries that pass lists of ids stay under D1's 100 bound parameters. */
|
|
5
|
+
export declare const limits: {
|
|
6
|
+
readonly bodyBytes: number;
|
|
7
|
+
readonly anchorBytes: number;
|
|
8
|
+
readonly metadataBytes: number;
|
|
9
|
+
readonly mentions: 20;
|
|
10
|
+
readonly threadsPerPage: 20;
|
|
11
|
+
readonly maxThreadsPerPage: 50;
|
|
12
|
+
readonly commentsPerPage: 50;
|
|
13
|
+
readonly maxCommentsPerPage: 100;
|
|
14
|
+
readonly subjectsPerCount: 50;
|
|
15
|
+
readonly suggestions: 10;
|
|
16
|
+
};
|
|
17
|
+
/** The size of a value as JSON, in UTF-8 bytes. */
|
|
18
|
+
export declare function jsonBytes(value: unknown): number;
|
|
19
|
+
/** JSON with object keys sorted, so equal values compare equal however they were built. */
|
|
20
|
+
export declare function stableJson(value: unknown): string;
|
|
21
|
+
/** A comment body after its format checked and sanitized it, with its text and mentions. */
|
|
22
|
+
export interface CheckedContent {
|
|
23
|
+
format: string;
|
|
24
|
+
body: unknown;
|
|
25
|
+
bodyText: string;
|
|
26
|
+
mentions: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Checks, sanitizes and measures a body in a registered format. */
|
|
29
|
+
export declare function checkContent(formats: ReadonlyMap<string, BodyFormat>, formatName: string | undefined, body: unknown): Promise<CheckedContent>;
|
|
30
|
+
/** A thread's anchor as stored. */
|
|
31
|
+
export interface CheckedAnchor {
|
|
32
|
+
anchorKind: string | null;
|
|
33
|
+
anchor: unknown;
|
|
34
|
+
anchorVersion: string | null;
|
|
35
|
+
}
|
|
36
|
+
/** Checks an anchor against its registered kind. `null` puts the thread on the whole subject. */
|
|
37
|
+
export declare function checkAnchor(anchors: ReadonlyMap<string, AnchorKind>, anchor: {
|
|
38
|
+
kind: string;
|
|
39
|
+
value: unknown;
|
|
40
|
+
version?: string | null | undefined;
|
|
41
|
+
} | null | undefined): Promise<CheckedAnchor>;
|
|
42
|
+
/** Checks metadata against a subject type's schema, when it has one, and its size. */
|
|
43
|
+
export declare function checkMetadata(schema: StandardSchemaV1<unknown, Record<string, unknown>> | undefined, metadata: Record<string, unknown> | null): Promise<Record<string, unknown> | null>;
|
|
44
|
+
/** Checks values for an app's additional fields. Fields it didn't declare are refused. */
|
|
45
|
+
export declare function checkFields(declared: Readonly<Record<string, FieldAttribute>>, values: Readonly<Record<string, unknown>> | undefined): Record<string, unknown>;
|