g-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 +31 -0
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/dist/chunk-6E3M3PQN.js +3056 -0
- package/dist/chunk-6E3M3PQN.js.map +1 -0
- package/dist/chunk-X6RAR374.cjs +3077 -0
- package/dist/chunk-X6RAR374.cjs.map +1 -0
- package/dist/element.d.ts +770 -0
- package/dist/element.js +1226 -0
- package/dist/element.js.map +1 -0
- package/dist/index.cjs +68 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +37 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/messages-BlBkT6Kr.d.cts +649 -0
- package/dist/messages-BlBkT6Kr.d.ts +649 -0
- package/dist/react.cjs +2557 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +320 -0
- package/dist/react.d.ts +320 -0
- package/dist/react.js +2549 -0
- package/dist/react.js.map +1 -0
- package/package.json +89 -3
- package/styles/styles.css +1145 -0
|
@@ -0,0 +1,3056 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var PACKAGE_NAME = "g-comments";
|
|
3
|
+
var AUTH_MESSAGE_TYPE = "g-comments:auth";
|
|
4
|
+
var REDIRECT_CODE_PARAM = "gc_code";
|
|
5
|
+
var REDIRECT_NONCE_PARAM = "gc_nonce";
|
|
6
|
+
var STORAGE_PREFIX = "gc";
|
|
7
|
+
var COUNTS_BATCH_SIZE = 50;
|
|
8
|
+
var REFETCH_BATCH_SIZE = 50;
|
|
9
|
+
var CLIENT_ID_FORMAT = /^[\w-]{8,64}$/;
|
|
10
|
+
|
|
11
|
+
// src/errors.ts
|
|
12
|
+
var SERVER_TYPES = /* @__PURE__ */ new Set([
|
|
13
|
+
"invalid_key",
|
|
14
|
+
"not_logged_in",
|
|
15
|
+
"invalid_access",
|
|
16
|
+
"invalid_parameters",
|
|
17
|
+
"not_found",
|
|
18
|
+
"rate_limit",
|
|
19
|
+
"thread_conflict",
|
|
20
|
+
"page_not_allowed",
|
|
21
|
+
"invalid_url",
|
|
22
|
+
"no_category",
|
|
23
|
+
"thread_not_created",
|
|
24
|
+
"invalid_grant",
|
|
25
|
+
"user_not_allowed",
|
|
26
|
+
"submission_rejected",
|
|
27
|
+
"not_a_root_comment",
|
|
28
|
+
"pin_limit_reached"
|
|
29
|
+
]);
|
|
30
|
+
var TYPE_BY_STATUS = {
|
|
31
|
+
400: "invalid_parameters",
|
|
32
|
+
401: "invalid_key",
|
|
33
|
+
403: "invalid_access",
|
|
34
|
+
404: "not_found",
|
|
35
|
+
409: "thread_conflict",
|
|
36
|
+
422: "invalid_parameters",
|
|
37
|
+
429: "rate_limit"
|
|
38
|
+
};
|
|
39
|
+
var CommentsError = class extends Error {
|
|
40
|
+
name = "CommentsError";
|
|
41
|
+
type;
|
|
42
|
+
/** HTTP status, or 0 when no response was received. */
|
|
43
|
+
status;
|
|
44
|
+
/** Human-readable messages from the server, safe to show inline. */
|
|
45
|
+
errors;
|
|
46
|
+
extras;
|
|
47
|
+
/** Seconds until a rate-limited action may be retried. */
|
|
48
|
+
waitSeconds;
|
|
49
|
+
/** Developer-facing explanation of the probable cause (setup problems). */
|
|
50
|
+
hint;
|
|
51
|
+
/** The raw `error_type` from the server, if it sent one. */
|
|
52
|
+
serverType;
|
|
53
|
+
constructor(type, message, options = {}) {
|
|
54
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
55
|
+
this.type = type;
|
|
56
|
+
this.status = options.status ?? 0;
|
|
57
|
+
this.errors = options.errors ?? (message ? [message] : []);
|
|
58
|
+
this.extras = options.extras;
|
|
59
|
+
this.waitSeconds = options.waitSeconds;
|
|
60
|
+
this.hint = options.hint;
|
|
61
|
+
this.serverType = options.serverType;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
function isCommentsError(error) {
|
|
65
|
+
return error instanceof CommentsError;
|
|
66
|
+
}
|
|
67
|
+
function errorFromResponse(status, body, headers) {
|
|
68
|
+
const serverType = typeof body?.error_type === "string" ? body.error_type : void 0;
|
|
69
|
+
const type = serverType && SERVER_TYPES.has(serverType) ? serverType : TYPE_BY_STATUS[status] ?? "unknown";
|
|
70
|
+
const errors = Array.isArray(body?.errors) ? body.errors.filter((e) => typeof e === "string") : [];
|
|
71
|
+
const extras = body?.extras && typeof body.extras === "object" ? body.extras : void 0;
|
|
72
|
+
return new CommentsError(type, errors[0] ?? `Request failed with status ${status}`, {
|
|
73
|
+
status,
|
|
74
|
+
errors,
|
|
75
|
+
extras,
|
|
76
|
+
serverType,
|
|
77
|
+
waitSeconds: type === "rate_limit" ? waitSecondsFrom(extras, headers) : void 0
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function waitSecondsFrom(extras, headers) {
|
|
81
|
+
return seconds(extras?.wait_seconds) ?? seconds(headers?.get("Retry-After"));
|
|
82
|
+
}
|
|
83
|
+
function seconds(value) {
|
|
84
|
+
if (typeof value === "number") {
|
|
85
|
+
return Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === "string" && /^\s*\d+(\.\d+)?\s*$/.test(value)) {
|
|
88
|
+
return Number(value);
|
|
89
|
+
}
|
|
90
|
+
return void 0;
|
|
91
|
+
}
|
|
92
|
+
function unreachableError(forum, origin, cause) {
|
|
93
|
+
const from = origin ? `this page's origin (${origin})` : "this page's origin";
|
|
94
|
+
const hint = [
|
|
95
|
+
`${PACKAGE_NAME} could not reach ${forum}. Probable causes:`,
|
|
96
|
+
`- ${from} is not allowed: add the host under Admin \u2192 Customize \u2192 Embedding on the forum, or to the discourse_comments_allowed_origins setting;`,
|
|
97
|
+
"- the forum is behind HTTP basic auth, which rejects CORS preflight (OPTIONS) requests: let OPTIONS through without credentials;",
|
|
98
|
+
"- the discourse-comments plugin is disabled (discourse_comments_enabled);",
|
|
99
|
+
"- the forum URL is wrong or the forum is offline."
|
|
100
|
+
].join("\n");
|
|
101
|
+
return new CommentsError("unreachable", `Could not reach ${forum}`, {
|
|
102
|
+
status: 0,
|
|
103
|
+
errors: [],
|
|
104
|
+
hint,
|
|
105
|
+
cause
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/core/storage.ts
|
|
110
|
+
function memoryStorage() {
|
|
111
|
+
const items = /* @__PURE__ */ new Map();
|
|
112
|
+
return {
|
|
113
|
+
durable: false,
|
|
114
|
+
getItem: (key) => items.get(key) ?? null,
|
|
115
|
+
setItem: (key, value) => void items.set(key, value),
|
|
116
|
+
removeItem: (key) => void items.delete(key)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function signInStorage(storage) {
|
|
120
|
+
if (storage.durable !== false) {
|
|
121
|
+
return storage;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const session = globalThis.sessionStorage;
|
|
125
|
+
if (session) {
|
|
126
|
+
const probe = "gc:probe";
|
|
127
|
+
session.setItem(probe, probe);
|
|
128
|
+
const readable = session.getItem(probe) === probe;
|
|
129
|
+
session.removeItem(probe);
|
|
130
|
+
if (readable) {
|
|
131
|
+
return session;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function browserStorage() {
|
|
139
|
+
try {
|
|
140
|
+
const storage = globalThis.localStorage;
|
|
141
|
+
if (storage) {
|
|
142
|
+
const probe = "gc:probe";
|
|
143
|
+
storage.setItem(probe, probe);
|
|
144
|
+
storage.removeItem(probe);
|
|
145
|
+
return { storage, isLocalStorage: true };
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
return { storage: memoryStorage(), isLocalStorage: false };
|
|
150
|
+
}
|
|
151
|
+
function isLocalStorage(storage) {
|
|
152
|
+
try {
|
|
153
|
+
return storage === globalThis.localStorage;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function readJson(storage, key) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = storage.getItem(key);
|
|
161
|
+
return raw ? JSON.parse(raw) : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function writeJson(storage, key, value) {
|
|
167
|
+
try {
|
|
168
|
+
storage.setItem(key, JSON.stringify(value));
|
|
169
|
+
} catch {
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function removeItem(storage, key) {
|
|
173
|
+
try {
|
|
174
|
+
storage.removeItem(key);
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/core/env.ts
|
|
180
|
+
function getWindow() {
|
|
181
|
+
return typeof window === "undefined" ? void 0 : window;
|
|
182
|
+
}
|
|
183
|
+
function getDocument() {
|
|
184
|
+
return typeof document === "undefined" ? void 0 : document;
|
|
185
|
+
}
|
|
186
|
+
function pageOrigin() {
|
|
187
|
+
return getWindow()?.location.origin;
|
|
188
|
+
}
|
|
189
|
+
function defaultPageUrl() {
|
|
190
|
+
const canonical = getDocument()?.querySelector('link[rel="canonical"]');
|
|
191
|
+
if (canonical?.href) {
|
|
192
|
+
return canonical.href;
|
|
193
|
+
}
|
|
194
|
+
return getWindow()?.location.href;
|
|
195
|
+
}
|
|
196
|
+
function defaultPageTitle() {
|
|
197
|
+
return getDocument()?.title || void 0;
|
|
198
|
+
}
|
|
199
|
+
function currentUrlWithoutHash() {
|
|
200
|
+
const location = getWindow()?.location;
|
|
201
|
+
if (!location) {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
return location.href.split("#")[0];
|
|
205
|
+
}
|
|
206
|
+
function isDocumentHidden() {
|
|
207
|
+
return getDocument()?.hidden === true;
|
|
208
|
+
}
|
|
209
|
+
function onVisibilityChange(listener) {
|
|
210
|
+
const doc = getDocument();
|
|
211
|
+
if (!doc) {
|
|
212
|
+
return () => {
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
doc.addEventListener("visibilitychange", listener);
|
|
216
|
+
return () => doc.removeEventListener("visibilitychange", listener);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/core/counts.ts
|
|
220
|
+
function encodeRef(ref) {
|
|
221
|
+
if ("topicId" in ref) {
|
|
222
|
+
return `t:${ref.topicId}`;
|
|
223
|
+
}
|
|
224
|
+
if ("identifier" in ref) {
|
|
225
|
+
const host = ref.host ?? getWindow()?.location.host;
|
|
226
|
+
if (!host) {
|
|
227
|
+
throw new TypeError("A count ref with an identifier needs a host outside the browser");
|
|
228
|
+
}
|
|
229
|
+
return `id:${host}/${ref.identifier}`;
|
|
230
|
+
}
|
|
231
|
+
return `url:${ref.url}`;
|
|
232
|
+
}
|
|
233
|
+
var MAX_QUERY_LENGTH = 4e3;
|
|
234
|
+
var CountsBatcher = class {
|
|
235
|
+
#http;
|
|
236
|
+
#waiters = [];
|
|
237
|
+
#scheduled = false;
|
|
238
|
+
constructor(http) {
|
|
239
|
+
this.#http = http;
|
|
240
|
+
}
|
|
241
|
+
request(refs) {
|
|
242
|
+
const keys = refs.map(encodeRef);
|
|
243
|
+
if (keys.length === 0) {
|
|
244
|
+
return Promise.resolve({});
|
|
245
|
+
}
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
this.#waiters.push({ keys, resolve, reject });
|
|
248
|
+
if (!this.#scheduled) {
|
|
249
|
+
this.#scheduled = true;
|
|
250
|
+
setTimeout(() => void this.#flush(), 0);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
async #flush() {
|
|
255
|
+
const waiters = this.#waiters;
|
|
256
|
+
this.#waiters = [];
|
|
257
|
+
this.#scheduled = false;
|
|
258
|
+
const unique = [...new Set(waiters.flatMap((w) => w.keys))];
|
|
259
|
+
const batches = [];
|
|
260
|
+
let current = [];
|
|
261
|
+
let length = 0;
|
|
262
|
+
for (const key of unique) {
|
|
263
|
+
const cost = encodeURIComponent(key).length + "&refs[]=".length;
|
|
264
|
+
if (current.length > 0 && (current.length >= COUNTS_BATCH_SIZE || length + cost > MAX_QUERY_LENGTH)) {
|
|
265
|
+
batches.push(current);
|
|
266
|
+
current = [];
|
|
267
|
+
length = 0;
|
|
268
|
+
}
|
|
269
|
+
current.push(key);
|
|
270
|
+
length += cost;
|
|
271
|
+
}
|
|
272
|
+
if (current.length > 0) {
|
|
273
|
+
batches.push(current);
|
|
274
|
+
}
|
|
275
|
+
const results = /* @__PURE__ */ new Map();
|
|
276
|
+
const failures = /* @__PURE__ */ new Map();
|
|
277
|
+
await Promise.all(
|
|
278
|
+
batches.map(async (batch) => {
|
|
279
|
+
try {
|
|
280
|
+
const response = await this.#http.request("GET", "/counts", {
|
|
281
|
+
query: { refs: batch },
|
|
282
|
+
anonymous: true
|
|
283
|
+
});
|
|
284
|
+
for (const key of batch) {
|
|
285
|
+
results.set(key, Number(response.counts?.[key] ?? 0));
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
for (const key of batch) {
|
|
289
|
+
failures.set(key, error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
for (const waiter of waiters) {
|
|
295
|
+
const failed = waiter.keys.find((key) => failures.has(key));
|
|
296
|
+
if (failed !== void 0) {
|
|
297
|
+
waiter.reject(failures.get(failed));
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const counts = {};
|
|
301
|
+
for (const key of waiter.keys) {
|
|
302
|
+
counts[key] = results.get(key) ?? 0;
|
|
303
|
+
}
|
|
304
|
+
waiter.resolve(counts);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/types.ts
|
|
310
|
+
function isVirtualThread(thread) {
|
|
311
|
+
return thread.topic_id === null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/core/composer.ts
|
|
315
|
+
var TYPING_WINDOW_MS = 100;
|
|
316
|
+
function createComposerTimer(now = Date.now) {
|
|
317
|
+
let openedAt = null;
|
|
318
|
+
let typingMs = 0;
|
|
319
|
+
let lastTypingAt = -Infinity;
|
|
320
|
+
const measure = () => ({
|
|
321
|
+
typingDurationMsecs: typingMs,
|
|
322
|
+
composerOpenDurationMsecs: openedAt === null ? 0 : Math.max(0, now() - openedAt)
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
get started() {
|
|
326
|
+
return openedAt !== null;
|
|
327
|
+
},
|
|
328
|
+
start() {
|
|
329
|
+
openedAt ??= now();
|
|
330
|
+
},
|
|
331
|
+
typing() {
|
|
332
|
+
openedAt ??= now();
|
|
333
|
+
const at = now();
|
|
334
|
+
if (at - lastTypingAt >= TYPING_WINDOW_MS) {
|
|
335
|
+
typingMs += TYPING_WINDOW_MS;
|
|
336
|
+
lastTypingAt = at;
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
measure,
|
|
340
|
+
stop() {
|
|
341
|
+
const timing = measure();
|
|
342
|
+
openedAt = null;
|
|
343
|
+
typingMs = 0;
|
|
344
|
+
lastTypingAt = -Infinity;
|
|
345
|
+
return timing;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/core/emitter.ts
|
|
351
|
+
var Emitter = class {
|
|
352
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
353
|
+
get size() {
|
|
354
|
+
return this.#listeners.size;
|
|
355
|
+
}
|
|
356
|
+
on(listener) {
|
|
357
|
+
this.#listeners.add(listener);
|
|
358
|
+
return () => {
|
|
359
|
+
this.#listeners.delete(listener);
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
emit(value) {
|
|
363
|
+
for (const listener of [...this.#listeners]) {
|
|
364
|
+
try {
|
|
365
|
+
listener(value);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.error(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
clear() {
|
|
372
|
+
this.#listeners.clear();
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// src/core/http.ts
|
|
377
|
+
function normalizeForum(forum) {
|
|
378
|
+
let url;
|
|
379
|
+
try {
|
|
380
|
+
url = new URL(forum);
|
|
381
|
+
} catch {
|
|
382
|
+
throw new TypeError(`${PACKAGE_NAME}: "forum" must be an absolute URL, got "${forum}"`);
|
|
383
|
+
}
|
|
384
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
385
|
+
throw new TypeError(`${PACKAGE_NAME}: "forum" must be an http(s) URL, got "${forum}"`);
|
|
386
|
+
}
|
|
387
|
+
return { origin: url.origin, base: url.origin + url.pathname.replace(/\/+$/, "") };
|
|
388
|
+
}
|
|
389
|
+
function buildQuery(query) {
|
|
390
|
+
if (!query) {
|
|
391
|
+
return "";
|
|
392
|
+
}
|
|
393
|
+
const parts = [];
|
|
394
|
+
for (const [key, value] of Object.entries(query)) {
|
|
395
|
+
if (value === void 0 || value === null) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (Array.isArray(value)) {
|
|
399
|
+
for (const item of value) {
|
|
400
|
+
parts.push(`${encodeURIComponent(key)}[]=${encodeURIComponent(String(item))}`);
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return parts.join("&");
|
|
407
|
+
}
|
|
408
|
+
function threadPath(threadId) {
|
|
409
|
+
return `/threads/${encodeURIComponent(threadId).replace(/%3A/gi, ":")}`;
|
|
410
|
+
}
|
|
411
|
+
function isAbortError(error) {
|
|
412
|
+
return typeof error === "object" && error !== null && error.name === "AbortError";
|
|
413
|
+
}
|
|
414
|
+
var Http = class {
|
|
415
|
+
forum;
|
|
416
|
+
#options;
|
|
417
|
+
#fetch;
|
|
418
|
+
constructor(options) {
|
|
419
|
+
this.forum = options.forum;
|
|
420
|
+
this.#options = options;
|
|
421
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
422
|
+
}
|
|
423
|
+
get fetch() {
|
|
424
|
+
return this.#fetch;
|
|
425
|
+
}
|
|
426
|
+
url(path, query) {
|
|
427
|
+
const qs = buildQuery(query);
|
|
428
|
+
const url = `${this.forum.base}/comments${path}`;
|
|
429
|
+
return qs ? `${url}?${qs}` : url;
|
|
430
|
+
}
|
|
431
|
+
async request(method, path, options = {}) {
|
|
432
|
+
const headers = {
|
|
433
|
+
Accept: "application/json",
|
|
434
|
+
...this.#options.headers
|
|
435
|
+
};
|
|
436
|
+
const language = acceptLanguage(this.#options.locale);
|
|
437
|
+
if (language) {
|
|
438
|
+
headers["Accept-Language"] = language;
|
|
439
|
+
}
|
|
440
|
+
let body;
|
|
441
|
+
if (options.json !== void 0) {
|
|
442
|
+
headers["Content-Type"] = "application/json";
|
|
443
|
+
body = JSON.stringify(options.json);
|
|
444
|
+
} else if (options.form) {
|
|
445
|
+
body = options.form;
|
|
446
|
+
}
|
|
447
|
+
const credentials = options.anonymous ? null : options.credentials ?? this.#options.credentials();
|
|
448
|
+
if (credentials) {
|
|
449
|
+
headers["User-Api-Key"] = credentials.key;
|
|
450
|
+
headers["User-Api-Client-Id"] = credentials.clientId;
|
|
451
|
+
}
|
|
452
|
+
const url = this.url(path, options.query);
|
|
453
|
+
const lifetime = options.detached ? void 0 : this.#options.lifetime;
|
|
454
|
+
if (lifetime?.aborted) {
|
|
455
|
+
throw new DOMException("The comments client was disposed", "AbortError");
|
|
456
|
+
}
|
|
457
|
+
const signal = combineSignals(options.signal, lifetime);
|
|
458
|
+
this.#options.log(method, url);
|
|
459
|
+
let response;
|
|
460
|
+
try {
|
|
461
|
+
response = await this.#fetch(url, {
|
|
462
|
+
method,
|
|
463
|
+
headers,
|
|
464
|
+
body,
|
|
465
|
+
credentials: "omit",
|
|
466
|
+
mode: "cors",
|
|
467
|
+
signal
|
|
468
|
+
});
|
|
469
|
+
} catch (error2) {
|
|
470
|
+
if (signal?.aborted || isAbortError(error2)) {
|
|
471
|
+
throw error2;
|
|
472
|
+
}
|
|
473
|
+
throw unreachableError(this.forum.base, pageOrigin(), error2);
|
|
474
|
+
}
|
|
475
|
+
if (response.status === 204) {
|
|
476
|
+
return void 0;
|
|
477
|
+
}
|
|
478
|
+
const text = await response.text();
|
|
479
|
+
const data = parseJson(text);
|
|
480
|
+
if (response.ok) {
|
|
481
|
+
if (data === void 0) {
|
|
482
|
+
throw new CommentsError("unknown", "The forum returned a non-JSON response", {
|
|
483
|
+
status: response.status
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return data;
|
|
487
|
+
}
|
|
488
|
+
const errorBody = isObject(data) ? data : null;
|
|
489
|
+
const error = errorFromResponse(response.status, errorBody, response.headers);
|
|
490
|
+
if (response.status === 401 && error.serverType === "invalid_key" && credentials && errorBody?.degraded !== true) {
|
|
491
|
+
this.#options.onInvalidKey(credentials.key);
|
|
492
|
+
}
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
function acceptLanguage(locale) {
|
|
497
|
+
const value = locale?.replace(/_/g, "-");
|
|
498
|
+
return value && /^[0-9A-Za-z *,\-.;=]+$/.test(value) ? value : void 0;
|
|
499
|
+
}
|
|
500
|
+
function combineSignals(a, b) {
|
|
501
|
+
if (!a || !b) {
|
|
502
|
+
return a ?? b;
|
|
503
|
+
}
|
|
504
|
+
if (typeof AbortSignal.any === "function") {
|
|
505
|
+
return AbortSignal.any([a, b]);
|
|
506
|
+
}
|
|
507
|
+
const controller = new AbortController();
|
|
508
|
+
const abort = () => controller.abort();
|
|
509
|
+
if (a.aborted || b.aborted) {
|
|
510
|
+
abort();
|
|
511
|
+
}
|
|
512
|
+
a.addEventListener("abort", abort, { once: true });
|
|
513
|
+
b.addEventListener("abort", abort, { once: true });
|
|
514
|
+
return controller.signal;
|
|
515
|
+
}
|
|
516
|
+
function parseJson(text) {
|
|
517
|
+
if (!text) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
return JSON.parse(text);
|
|
522
|
+
} catch {
|
|
523
|
+
return void 0;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function isObject(value) {
|
|
527
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/core/pkce.ts
|
|
531
|
+
function base64url(bytes) {
|
|
532
|
+
let binary = "";
|
|
533
|
+
for (const byte of bytes) {
|
|
534
|
+
binary += String.fromCharCode(byte);
|
|
535
|
+
}
|
|
536
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
537
|
+
}
|
|
538
|
+
function randomToken(bytes) {
|
|
539
|
+
const buffer = new Uint8Array(bytes);
|
|
540
|
+
globalThis.crypto.getRandomValues(buffer);
|
|
541
|
+
return base64url(buffer);
|
|
542
|
+
}
|
|
543
|
+
async function codeChallenge(verifier) {
|
|
544
|
+
const subtle = globalThis.crypto?.subtle;
|
|
545
|
+
if (!subtle) {
|
|
546
|
+
throw new Error("Signing in requires a secure context (https or localhost).");
|
|
547
|
+
}
|
|
548
|
+
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
549
|
+
return base64url(new Uint8Array(digest));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// src/core/auth.ts
|
|
553
|
+
var PENDING_TTL_MS = 15 * 6e4;
|
|
554
|
+
var POPUP_NAME = "gc-auth";
|
|
555
|
+
var POPUP_WIDTH = 520;
|
|
556
|
+
var POPUP_HEIGHT = 680;
|
|
557
|
+
var CLOSED_CHECK_MS = 500;
|
|
558
|
+
var CLOSED_GRACE_MS = 1e3;
|
|
559
|
+
var AuthController = class {
|
|
560
|
+
clientId;
|
|
561
|
+
#options;
|
|
562
|
+
#keys;
|
|
563
|
+
#stored;
|
|
564
|
+
#state;
|
|
565
|
+
#emitter = new Emitter();
|
|
566
|
+
#attempt = null;
|
|
567
|
+
#redirect = null;
|
|
568
|
+
#stopSync = null;
|
|
569
|
+
/** Bumped on sign-out and dispose; an exchange started earlier must not sign anyone in. */
|
|
570
|
+
#generation = 0;
|
|
571
|
+
constructor(options) {
|
|
572
|
+
this.#options = options;
|
|
573
|
+
const prefix = `${STORAGE_PREFIX}:${options.forum.base}`;
|
|
574
|
+
this.#keys = {
|
|
575
|
+
auth: `${prefix}:auth`,
|
|
576
|
+
clientId: `${prefix}:client_id`,
|
|
577
|
+
pending: `${prefix}:pending`
|
|
578
|
+
};
|
|
579
|
+
this.clientId = this.#resolveClientId(options.clientId);
|
|
580
|
+
this.#stored = this.#readStored();
|
|
581
|
+
this.#state = Object.freeze({
|
|
582
|
+
status: this.#stored ? "signed-in" : "anonymous",
|
|
583
|
+
user: this.#stored?.user ?? null,
|
|
584
|
+
degraded: false,
|
|
585
|
+
error: null
|
|
586
|
+
});
|
|
587
|
+
const win = getWindow();
|
|
588
|
+
if (options.syncAcrossTabs && win) {
|
|
589
|
+
win.addEventListener("storage", this.#onStorage);
|
|
590
|
+
this.#stopSync = () => win.removeEventListener("storage", this.#onStorage);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
get status() {
|
|
594
|
+
return this.#state.status;
|
|
595
|
+
}
|
|
596
|
+
get user() {
|
|
597
|
+
return this.#state.user;
|
|
598
|
+
}
|
|
599
|
+
get providers() {
|
|
600
|
+
return this.#options.providers();
|
|
601
|
+
}
|
|
602
|
+
state() {
|
|
603
|
+
return this.#state;
|
|
604
|
+
}
|
|
605
|
+
onChange(listener) {
|
|
606
|
+
return this.#emitter.on(listener);
|
|
607
|
+
}
|
|
608
|
+
/** Headers for the current key; the client id is the one that minted it. */
|
|
609
|
+
credentials() {
|
|
610
|
+
return this.#stored ? { key: this.#stored.key, clientId: this.#stored.client_id } : null;
|
|
611
|
+
}
|
|
612
|
+
signIn(options = {}) {
|
|
613
|
+
if (this.#attempt) {
|
|
614
|
+
const popup2 = this.#attempt.popup;
|
|
615
|
+
if (popup2 && !popup2.closed) {
|
|
616
|
+
popup2.focus();
|
|
617
|
+
return this.#attempt.promise;
|
|
618
|
+
}
|
|
619
|
+
this.#attempt.cancel(new CommentsError("cancelled", "Sign-in was restarted"));
|
|
620
|
+
}
|
|
621
|
+
const win = getWindow();
|
|
622
|
+
if (!win) {
|
|
623
|
+
return Promise.reject(new CommentsError("unknown", "Signing in needs a browser window"));
|
|
624
|
+
}
|
|
625
|
+
const previous = this.#readPending();
|
|
626
|
+
const nonce = previous?.nonce ?? randomToken(16);
|
|
627
|
+
const verifier = previous?.verifier ?? randomToken(32);
|
|
628
|
+
const opened = (options.mode ?? "popup") === "popup" ? openPopup(win) : null;
|
|
629
|
+
const popup = opened && !opened.closed ? opened : null;
|
|
630
|
+
let popupNavigated = false;
|
|
631
|
+
let cancelledWith = null;
|
|
632
|
+
let rejectCancelled = () => {
|
|
633
|
+
};
|
|
634
|
+
const cancelled = new Promise((_, reject) => {
|
|
635
|
+
rejectCancelled = reject;
|
|
636
|
+
});
|
|
637
|
+
cancelled.catch(() => {
|
|
638
|
+
});
|
|
639
|
+
let stopPopup = () => {
|
|
640
|
+
};
|
|
641
|
+
const writePending = (mode) => this.#options.pendingStorage && writeJson(this.#options.pendingStorage, this.#keys.pending, {
|
|
642
|
+
nonce,
|
|
643
|
+
verifier,
|
|
644
|
+
mode,
|
|
645
|
+
created_at: previous?.created_at ?? Date.now()
|
|
646
|
+
});
|
|
647
|
+
const run = async () => {
|
|
648
|
+
const challenge = await codeChallenge(verifier);
|
|
649
|
+
if (cancelledWith) {
|
|
650
|
+
throw cancelledWith;
|
|
651
|
+
}
|
|
652
|
+
const url = this.#startUrl(nonce, challenge, options.provider);
|
|
653
|
+
if (popup) {
|
|
654
|
+
if (popup.closed) {
|
|
655
|
+
throw new CommentsError("cancelled", "The sign-in window was closed");
|
|
656
|
+
}
|
|
657
|
+
writePending("popup");
|
|
658
|
+
popupNavigated = navigatePopup(popup, url);
|
|
659
|
+
}
|
|
660
|
+
if (!popupNavigated) {
|
|
661
|
+
if (!this.#options.pendingStorage) {
|
|
662
|
+
throw new CommentsError(
|
|
663
|
+
"storage_unavailable",
|
|
664
|
+
"Signing in needs a popup or browser storage, and this page has neither. Allow popups for this site, or enable site data (localStorage/sessionStorage)."
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
writePending("redirect");
|
|
668
|
+
this.#options.log("popup unavailable, redirecting");
|
|
669
|
+
win.addEventListener("pageshow", this.#onPageShow);
|
|
670
|
+
(this.#options.navigate ?? ((target) => win.location.assign(target)))(url);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
const outcome = await this.#waitForPopup(win, popup, nonce, (stop) => {
|
|
674
|
+
stopPopup = stop;
|
|
675
|
+
});
|
|
676
|
+
if (outcome.kind === "user") {
|
|
677
|
+
return outcome.user;
|
|
678
|
+
}
|
|
679
|
+
return this.#exchange(outcome.code, verifier, nonce, () => cancelledWith);
|
|
680
|
+
};
|
|
681
|
+
this.#set({ status: "signing-in", error: null });
|
|
682
|
+
const attempt = {
|
|
683
|
+
popup,
|
|
684
|
+
cancel: (error) => {
|
|
685
|
+
cancelledWith = error;
|
|
686
|
+
stopPopup();
|
|
687
|
+
rejectCancelled(error);
|
|
688
|
+
},
|
|
689
|
+
promise: Promise.race([run(), cancelled]).then(
|
|
690
|
+
(user) => {
|
|
691
|
+
if (this.#attempt === attempt) {
|
|
692
|
+
this.#attempt = null;
|
|
693
|
+
}
|
|
694
|
+
return user;
|
|
695
|
+
},
|
|
696
|
+
(error) => {
|
|
697
|
+
const failure = toCommentsError(error);
|
|
698
|
+
if (popup && !popupNavigated && !popup.closed) {
|
|
699
|
+
popup.close();
|
|
700
|
+
}
|
|
701
|
+
if (this.#attempt !== attempt) {
|
|
702
|
+
throw failure;
|
|
703
|
+
}
|
|
704
|
+
this.#attempt = null;
|
|
705
|
+
if (failure.type !== "cancelled") {
|
|
706
|
+
this.#removePending();
|
|
707
|
+
}
|
|
708
|
+
this.#set({ status: this.#stored ? "signed-in" : "anonymous", error: failure });
|
|
709
|
+
throw failure;
|
|
710
|
+
}
|
|
711
|
+
)
|
|
712
|
+
};
|
|
713
|
+
this.#attempt = attempt;
|
|
714
|
+
return attempt.promise;
|
|
715
|
+
}
|
|
716
|
+
cancelSignIn() {
|
|
717
|
+
this.#attempt?.cancel(new CommentsError("cancelled", "Sign-in was cancelled"));
|
|
718
|
+
}
|
|
719
|
+
async signOut() {
|
|
720
|
+
this.#generation++;
|
|
721
|
+
this.cancelSignIn();
|
|
722
|
+
this.#removePending();
|
|
723
|
+
if (this.#stored) {
|
|
724
|
+
try {
|
|
725
|
+
await this.#options.http.request("POST", "/auth/revoke");
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.#options.log("revoke failed", error);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
this.#clearStored();
|
|
731
|
+
this.#set({ status: "anonymous", user: null, degraded: false, error: null });
|
|
732
|
+
}
|
|
733
|
+
handleRedirect() {
|
|
734
|
+
if (!this.#redirect) {
|
|
735
|
+
this.#redirect = this.#consumeRedirect();
|
|
736
|
+
this.#redirect.catch(() => {
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
return this.#redirect;
|
|
740
|
+
}
|
|
741
|
+
/** Called by the HTTP layer on 401 `invalid_key` for the key it sent. */
|
|
742
|
+
invalidate(sentKey) {
|
|
743
|
+
if (!this.#stored || this.#stored.key !== sentKey) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const latest = this.#readStored();
|
|
747
|
+
if (latest && latest.key !== sentKey) {
|
|
748
|
+
this.#stored = latest;
|
|
749
|
+
this.#set({ status: "signed-in", user: latest.user, error: null });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
this.#clearStored();
|
|
753
|
+
this.#set({
|
|
754
|
+
status: this.#attempt ? "signing-in" : "anonymous",
|
|
755
|
+
user: null,
|
|
756
|
+
degraded: false,
|
|
757
|
+
error: new CommentsError("invalid_key", "Your session has expired. Please sign in again.", {
|
|
758
|
+
status: 401
|
|
759
|
+
})
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/** Folds `/comments/config` into the auth state. */
|
|
763
|
+
applyConfig(config) {
|
|
764
|
+
if (this.#stored && config.current_user) {
|
|
765
|
+
this.#stored = { ...this.#stored, user: config.current_user };
|
|
766
|
+
writeJson(this.#options.storage, this.#keys.auth, this.#stored);
|
|
767
|
+
}
|
|
768
|
+
this.#set({ degraded: config.degraded === true, user: this.#stored?.user ?? null });
|
|
769
|
+
}
|
|
770
|
+
dispose() {
|
|
771
|
+
this.#generation++;
|
|
772
|
+
this.cancelSignIn();
|
|
773
|
+
getWindow()?.removeEventListener("pageshow", this.#onPageShow);
|
|
774
|
+
this.#stopSync?.();
|
|
775
|
+
this.#stopSync = null;
|
|
776
|
+
this.#emitter.clear();
|
|
777
|
+
}
|
|
778
|
+
async #consumeRedirect() {
|
|
779
|
+
const win = getWindow();
|
|
780
|
+
const hash = win?.location.hash ?? "";
|
|
781
|
+
if (!win || !hash.includes(`${REDIRECT_CODE_PARAM}=`)) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const params = new URLSearchParams(hash.slice(1));
|
|
785
|
+
const code = params.get(REDIRECT_CODE_PARAM);
|
|
786
|
+
const nonce = params.get(REDIRECT_NONCE_PARAM);
|
|
787
|
+
const pending = this.#readPending();
|
|
788
|
+
if (!code || !nonce || !pending || pending.nonce !== nonce) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
win.history.replaceState(win.history.state, "", win.location.pathname + win.location.search);
|
|
792
|
+
this.#set({ status: "signing-in", error: null });
|
|
793
|
+
try {
|
|
794
|
+
const user = await this.#exchange(code, pending.verifier, nonce);
|
|
795
|
+
if (pending.mode === "popup") {
|
|
796
|
+
win.close();
|
|
797
|
+
}
|
|
798
|
+
return user;
|
|
799
|
+
} catch (error) {
|
|
800
|
+
const failure = toCommentsError(error);
|
|
801
|
+
if (failure.type !== "cancelled") {
|
|
802
|
+
this.#removePending();
|
|
803
|
+
}
|
|
804
|
+
this.#set({ status: this.#stored ? "signed-in" : "anonymous", error: failure });
|
|
805
|
+
throw failure;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async #exchange(code, verifier, nonce, cancelled = () => null) {
|
|
809
|
+
const generation = this.#generation;
|
|
810
|
+
const tracked = this.#readPending()?.nonce === nonce;
|
|
811
|
+
const response = await this.#options.http.request("POST", "/auth/exchange", {
|
|
812
|
+
json: { code, client_id: this.clientId, code_verifier: verifier },
|
|
813
|
+
anonymous: true,
|
|
814
|
+
detached: true
|
|
815
|
+
});
|
|
816
|
+
const cancellation = cancelled() ?? (generation !== this.#generation ? new CommentsError("cancelled", "Signed out while signing in") : null) ?? // Sign-out in another tab removes the shared pending entry.
|
|
817
|
+
(tracked && this.#readPending()?.nonce !== nonce ? new CommentsError("cancelled", "The sign-in was abandoned") : null);
|
|
818
|
+
if (cancellation) {
|
|
819
|
+
this.#options.http.request("POST", "/auth/revoke", {
|
|
820
|
+
credentials: { key: response.key, clientId: response.client_id || this.clientId },
|
|
821
|
+
detached: true
|
|
822
|
+
}).catch(() => {
|
|
823
|
+
});
|
|
824
|
+
throw cancellation;
|
|
825
|
+
}
|
|
826
|
+
const stored = {
|
|
827
|
+
key: response.key,
|
|
828
|
+
client_id: response.client_id || this.clientId,
|
|
829
|
+
expires_at: response.expires_at ?? null,
|
|
830
|
+
user: response.user
|
|
831
|
+
};
|
|
832
|
+
this.#stored = stored;
|
|
833
|
+
writeJson(this.#options.storage, this.#keys.auth, stored);
|
|
834
|
+
this.#removePending();
|
|
835
|
+
this.#set({ status: "signed-in", user: stored.user, degraded: false, error: null });
|
|
836
|
+
return stored.user;
|
|
837
|
+
}
|
|
838
|
+
#waitForPopup(win, popup, nonce, registerStop) {
|
|
839
|
+
return new Promise((resolve, reject) => {
|
|
840
|
+
let closedSince = null;
|
|
841
|
+
const onMessage = (event) => {
|
|
842
|
+
if (event.origin !== this.#options.forum.origin || event.source !== popup) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const data = event.data;
|
|
846
|
+
if (!data || typeof data !== "object" || data.type !== AUTH_MESSAGE_TYPE || data.nonce !== nonce || typeof data.code !== "string") {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
stop();
|
|
850
|
+
resolve({ kind: "code", code: data.code });
|
|
851
|
+
};
|
|
852
|
+
const onStorage = (event) => {
|
|
853
|
+
if (event.key !== this.#keys.auth || !event.newValue) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const stored = this.#readStored();
|
|
857
|
+
if (stored) {
|
|
858
|
+
stop();
|
|
859
|
+
resolve({ kind: "user", user: stored.user });
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
const checkClosed = setInterval(() => {
|
|
863
|
+
if (!popup.closed || !getDocument()?.hasFocus()) {
|
|
864
|
+
closedSince = null;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
closedSince ??= Date.now();
|
|
868
|
+
if (Date.now() - closedSince >= CLOSED_GRACE_MS) {
|
|
869
|
+
stop();
|
|
870
|
+
reject(new CommentsError("cancelled", "The sign-in window was closed"));
|
|
871
|
+
}
|
|
872
|
+
}, CLOSED_CHECK_MS);
|
|
873
|
+
const timeout = setTimeout(() => {
|
|
874
|
+
stop();
|
|
875
|
+
reject(new CommentsError("cancelled", "Sign-in timed out"));
|
|
876
|
+
}, PENDING_TTL_MS);
|
|
877
|
+
const stop = () => {
|
|
878
|
+
win.removeEventListener("message", onMessage);
|
|
879
|
+
win.removeEventListener("storage", onStorage);
|
|
880
|
+
clearInterval(checkClosed);
|
|
881
|
+
clearTimeout(timeout);
|
|
882
|
+
};
|
|
883
|
+
win.addEventListener("message", onMessage);
|
|
884
|
+
if (this.#options.syncAcrossTabs) {
|
|
885
|
+
win.addEventListener("storage", onStorage);
|
|
886
|
+
}
|
|
887
|
+
registerStop(stop);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
#startUrl(nonce, challenge, provider) {
|
|
891
|
+
const query = buildQuery({
|
|
892
|
+
origin: getWindow()?.location.origin,
|
|
893
|
+
client_id: this.clientId,
|
|
894
|
+
nonce,
|
|
895
|
+
code_challenge: challenge,
|
|
896
|
+
return_to: currentUrlWithoutHash(),
|
|
897
|
+
provider
|
|
898
|
+
});
|
|
899
|
+
return `${this.#options.forum.base}/comments/auth/start?${query}`;
|
|
900
|
+
}
|
|
901
|
+
#onPageShow = (event) => {
|
|
902
|
+
if (!event.persisted) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
getWindow()?.removeEventListener("pageshow", this.#onPageShow);
|
|
906
|
+
if (!this.#attempt && this.#state.status === "signing-in") {
|
|
907
|
+
this.#set({ status: this.#stored ? "signed-in" : "anonymous" });
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
#removePending() {
|
|
911
|
+
if (this.#options.pendingStorage) {
|
|
912
|
+
removeItem(this.#options.pendingStorage, this.#keys.pending);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
#readPending() {
|
|
916
|
+
const storage = this.#options.pendingStorage;
|
|
917
|
+
const pending = storage ? readJson(storage, this.#keys.pending) : null;
|
|
918
|
+
if (!pending || typeof pending.nonce !== "string" || typeof pending.verifier !== "string" || typeof pending.created_at !== "number" || Date.now() - pending.created_at > PENDING_TTL_MS) {
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
return pending;
|
|
922
|
+
}
|
|
923
|
+
#onStorage = (event) => {
|
|
924
|
+
if (event.key !== null && event.key !== this.#keys.auth) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const stored = this.#readStored();
|
|
928
|
+
if (stored?.key === this.#stored?.key) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
this.#stored = stored;
|
|
932
|
+
this.#set({
|
|
933
|
+
status: stored ? "signed-in" : this.#attempt ? "signing-in" : "anonymous",
|
|
934
|
+
user: stored?.user ?? null,
|
|
935
|
+
degraded: false,
|
|
936
|
+
error: null
|
|
937
|
+
});
|
|
938
|
+
};
|
|
939
|
+
#resolveClientId(explicit) {
|
|
940
|
+
if (explicit !== void 0) {
|
|
941
|
+
if (!CLIENT_ID_FORMAT.test(explicit)) {
|
|
942
|
+
throw new TypeError(`${PACKAGE_NAME}: "clientId" must match ${CLIENT_ID_FORMAT}`);
|
|
943
|
+
}
|
|
944
|
+
return explicit;
|
|
945
|
+
}
|
|
946
|
+
const { storage } = this.#options;
|
|
947
|
+
let stored = null;
|
|
948
|
+
try {
|
|
949
|
+
stored = storage.getItem(this.#keys.clientId);
|
|
950
|
+
} catch {
|
|
951
|
+
stored = null;
|
|
952
|
+
}
|
|
953
|
+
if (stored && CLIENT_ID_FORMAT.test(stored)) {
|
|
954
|
+
return stored;
|
|
955
|
+
}
|
|
956
|
+
const generated = randomToken(24);
|
|
957
|
+
try {
|
|
958
|
+
storage.setItem(this.#keys.clientId, generated);
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
return generated;
|
|
962
|
+
}
|
|
963
|
+
#readStored() {
|
|
964
|
+
const stored = readJson(this.#options.storage, this.#keys.auth);
|
|
965
|
+
if (!stored || typeof stored.key !== "string" || typeof stored.client_id !== "string" || !stored.user || typeof stored.user !== "object") {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
if (stored.expires_at && Date.parse(stored.expires_at) <= Date.now()) {
|
|
969
|
+
removeItem(this.#options.storage, this.#keys.auth);
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
return stored;
|
|
973
|
+
}
|
|
974
|
+
#clearStored() {
|
|
975
|
+
this.#stored = null;
|
|
976
|
+
removeItem(this.#options.storage, this.#keys.auth);
|
|
977
|
+
}
|
|
978
|
+
#set(patch) {
|
|
979
|
+
const next = { ...this.#state, ...patch };
|
|
980
|
+
if (next.status === this.#state.status && next.degraded === this.#state.degraded && next.error === this.#state.error && sameUser(next.user, this.#state.user)) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
this.#state = Object.freeze(next);
|
|
984
|
+
this.#emitter.emit(this.#state);
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
function openPopup(win) {
|
|
988
|
+
const left = Math.round(win.screenX + Math.max(0, (win.outerWidth - POPUP_WIDTH) / 2));
|
|
989
|
+
const top = Math.round(win.screenY + Math.max(0, (win.outerHeight - POPUP_HEIGHT) / 2));
|
|
990
|
+
try {
|
|
991
|
+
return win.open(
|
|
992
|
+
"",
|
|
993
|
+
POPUP_NAME,
|
|
994
|
+
`popup=yes,width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top}`
|
|
995
|
+
);
|
|
996
|
+
} catch {
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function navigatePopup(popup, url) {
|
|
1001
|
+
try {
|
|
1002
|
+
popup.location.replace(url);
|
|
1003
|
+
return true;
|
|
1004
|
+
} catch {
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function sameUser(a, b) {
|
|
1009
|
+
if (a === b) {
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
if (!a || !b) {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1016
|
+
}
|
|
1017
|
+
function toCommentsError(error) {
|
|
1018
|
+
if (isCommentsError(error)) {
|
|
1019
|
+
return error;
|
|
1020
|
+
}
|
|
1021
|
+
return new CommentsError("unknown", error instanceof Error ? error.message : String(error), {
|
|
1022
|
+
cause: error
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/core/log.ts
|
|
1027
|
+
function createLogger(debug) {
|
|
1028
|
+
if (!debug) {
|
|
1029
|
+
return () => {
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
return (...args) => console.debug(`[${PACKAGE_NAME}]`, ...args);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/core/realtime.ts
|
|
1036
|
+
var STATUS_CHANNEL = "/__status";
|
|
1037
|
+
var CHUNK_SEPARATOR = "\r\n|\r\n";
|
|
1038
|
+
var ESCAPED_SEPARATOR = /\r\n\|\|\r\n/g;
|
|
1039
|
+
var QUICK_RESPONSE_MS = 1e3;
|
|
1040
|
+
var SHORT_POLL_INTERVAL_MS = 15e3;
|
|
1041
|
+
var MIN_BACKOFF_MS = 5e3;
|
|
1042
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
1043
|
+
function retryDelay(failures) {
|
|
1044
|
+
const ceiling = Math.min(MIN_BACKOFF_MS * 2 ** (failures - 1), MAX_BACKOFF_MS);
|
|
1045
|
+
return ceiling / 2 + Math.random() * (ceiling / 2);
|
|
1046
|
+
}
|
|
1047
|
+
var MessageBusPoller = class {
|
|
1048
|
+
clientId = randomToken(16);
|
|
1049
|
+
#options;
|
|
1050
|
+
#channels = /* @__PURE__ */ new Map();
|
|
1051
|
+
#statusEmitter = new Emitter();
|
|
1052
|
+
#status = "idle";
|
|
1053
|
+
#controller = null;
|
|
1054
|
+
#wake = null;
|
|
1055
|
+
#running = false;
|
|
1056
|
+
#stopped = false;
|
|
1057
|
+
#failures = 0;
|
|
1058
|
+
#seq = 0;
|
|
1059
|
+
constructor(options) {
|
|
1060
|
+
this.#options = options;
|
|
1061
|
+
}
|
|
1062
|
+
get status() {
|
|
1063
|
+
return this.#status;
|
|
1064
|
+
}
|
|
1065
|
+
get url() {
|
|
1066
|
+
return `${this.#options.base}/message-bus/${this.clientId}/poll`;
|
|
1067
|
+
}
|
|
1068
|
+
subscribe(channel, lastId, listener) {
|
|
1069
|
+
let subscribers = this.#channels.get(channel);
|
|
1070
|
+
const before = subscribers ? cursorOf(subscribers) : void 0;
|
|
1071
|
+
if (!subscribers) {
|
|
1072
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
1073
|
+
this.#channels.set(channel, subscribers);
|
|
1074
|
+
}
|
|
1075
|
+
subscribers.set(listener, lastId);
|
|
1076
|
+
if (before === void 0 || lastId < before) {
|
|
1077
|
+
this.#restart();
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
channel,
|
|
1081
|
+
advanceTo: (id) => {
|
|
1082
|
+
const current = this.#channels.get(channel);
|
|
1083
|
+
const cursor = current?.get(listener);
|
|
1084
|
+
if (current && cursor !== void 0 && id > cursor) {
|
|
1085
|
+
current.set(listener, id);
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
unsubscribe: () => {
|
|
1089
|
+
const current = this.#channels.get(channel);
|
|
1090
|
+
if (!current?.delete(listener)) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (current.size === 0) {
|
|
1094
|
+
this.#channels.delete(channel);
|
|
1095
|
+
this.#restart();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
onStatus(listener) {
|
|
1101
|
+
return this.#statusEmitter.on(listener);
|
|
1102
|
+
}
|
|
1103
|
+
/** The position the poll asks a channel from (the oldest subscriber cursor). */
|
|
1104
|
+
cursor(channel) {
|
|
1105
|
+
const subscribers = this.#channels.get(channel);
|
|
1106
|
+
return subscribers ? cursorOf(subscribers) : void 0;
|
|
1107
|
+
}
|
|
1108
|
+
stop() {
|
|
1109
|
+
this.#stopped = true;
|
|
1110
|
+
this.#channels.clear();
|
|
1111
|
+
this.#controller?.abort();
|
|
1112
|
+
this.#wake?.();
|
|
1113
|
+
this.#setStatus("idle");
|
|
1114
|
+
}
|
|
1115
|
+
#restart() {
|
|
1116
|
+
if (this.#stopped) {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
this.#controller?.abort();
|
|
1120
|
+
this.#wake?.();
|
|
1121
|
+
if (this.#channels.size === 0) {
|
|
1122
|
+
this.#setStatus("idle");
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (!this.#running) {
|
|
1126
|
+
void this.#loop();
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
async #loop() {
|
|
1130
|
+
this.#running = true;
|
|
1131
|
+
try {
|
|
1132
|
+
while (!this.#stopped && this.#channels.size > 0) {
|
|
1133
|
+
if (this.#status === "idle") {
|
|
1134
|
+
this.#setStatus("connecting");
|
|
1135
|
+
}
|
|
1136
|
+
const controller = new AbortController();
|
|
1137
|
+
this.#controller = controller;
|
|
1138
|
+
const startedAt = Date.now();
|
|
1139
|
+
let received = 0;
|
|
1140
|
+
try {
|
|
1141
|
+
const response = await this.#options.fetch(this.url, {
|
|
1142
|
+
method: "POST",
|
|
1143
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1144
|
+
body: this.#body(),
|
|
1145
|
+
credentials: "omit",
|
|
1146
|
+
mode: "cors",
|
|
1147
|
+
signal: controller.signal
|
|
1148
|
+
});
|
|
1149
|
+
if (!response.ok) {
|
|
1150
|
+
throw new Error(`MessageBus poll failed with status ${response.status}`);
|
|
1151
|
+
}
|
|
1152
|
+
await readMessageBusResponse(response, (messages) => {
|
|
1153
|
+
received += messages.length;
|
|
1154
|
+
this.#deliver(messages);
|
|
1155
|
+
});
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
if (controller.signal.aborted) {
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
this.#failures++;
|
|
1161
|
+
this.#options.log("message-bus poll failed", error);
|
|
1162
|
+
if (this.#failures >= (this.#options.failureThreshold ?? 2)) {
|
|
1163
|
+
this.#setStatus("failed");
|
|
1164
|
+
}
|
|
1165
|
+
await this.#sleep(retryDelay(this.#failures));
|
|
1166
|
+
continue;
|
|
1167
|
+
} finally {
|
|
1168
|
+
if (this.#controller === controller) {
|
|
1169
|
+
this.#controller = null;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
this.#failures = 0;
|
|
1173
|
+
this.#setStatus("connected");
|
|
1174
|
+
if (received === 0 && Date.now() - startedAt < QUICK_RESPONSE_MS) {
|
|
1175
|
+
await this.#sleep(SHORT_POLL_INTERVAL_MS);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
} finally {
|
|
1179
|
+
this.#running = false;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
#body() {
|
|
1183
|
+
const parts = [];
|
|
1184
|
+
for (const [channel, subscribers] of this.#channels) {
|
|
1185
|
+
parts.push(`${encodeURIComponent(channel).replace(/%2F/gi, "/")}=${cursorOf(subscribers)}`);
|
|
1186
|
+
}
|
|
1187
|
+
parts.push(`__seq=${++this.#seq}`);
|
|
1188
|
+
return parts.join("&");
|
|
1189
|
+
}
|
|
1190
|
+
#deliver(messages) {
|
|
1191
|
+
for (const raw of messages) {
|
|
1192
|
+
if (!raw || typeof raw !== "object") {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
const message = raw;
|
|
1196
|
+
if (message.channel === STATUS_CHANNEL) {
|
|
1197
|
+
const positions = message.data ?? {};
|
|
1198
|
+
for (const [channel, id] of Object.entries(positions)) {
|
|
1199
|
+
const subscribers2 = this.#channels.get(channel);
|
|
1200
|
+
if (subscribers2 && typeof id === "number") {
|
|
1201
|
+
this.#applyStatus(subscribers2, id);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const subscribers = this.#channels.get(message.channel);
|
|
1207
|
+
if (!subscribers || typeof message.message_id !== "number") {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
for (const [listener, cursor] of [...subscribers]) {
|
|
1211
|
+
if (cursor >= 0 && message.message_id <= cursor) {
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
subscribers.set(listener, message.message_id);
|
|
1215
|
+
notify(listener, { kind: "message", data: message.data, message });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
#applyStatus(subscribers, id) {
|
|
1220
|
+
for (const [listener, cursor] of [...subscribers]) {
|
|
1221
|
+
subscribers.set(listener, id);
|
|
1222
|
+
if (cursor === -1 || id === cursor) {
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
if (id > cursor) {
|
|
1226
|
+
notify(listener, { kind: "bump", lastId: id });
|
|
1227
|
+
} else if (cursor >= 0) {
|
|
1228
|
+
notify(listener, { kind: "gap", lastId: id });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
#sleep(ms) {
|
|
1233
|
+
return new Promise((resolve) => {
|
|
1234
|
+
const done = () => {
|
|
1235
|
+
clearTimeout(timer);
|
|
1236
|
+
if (this.#wake === done) {
|
|
1237
|
+
this.#wake = null;
|
|
1238
|
+
}
|
|
1239
|
+
resolve();
|
|
1240
|
+
};
|
|
1241
|
+
const timer = setTimeout(done, ms);
|
|
1242
|
+
this.#wake = done;
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
#setStatus(status) {
|
|
1246
|
+
if (status !== this.#status) {
|
|
1247
|
+
this.#status = status;
|
|
1248
|
+
this.#statusEmitter.emit(status);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
function cursorOf(subscribers) {
|
|
1253
|
+
return Math.min(...subscribers.values());
|
|
1254
|
+
}
|
|
1255
|
+
function notify(listener, event) {
|
|
1256
|
+
try {
|
|
1257
|
+
listener(event);
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
console.error(error);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async function readMessageBusResponse(response, onBatch) {
|
|
1263
|
+
let buffer = "";
|
|
1264
|
+
const emit = (chunk) => {
|
|
1265
|
+
const text = chunk.replace(ESCAPED_SEPARATOR, CHUNK_SEPARATOR).trim();
|
|
1266
|
+
if (!text) {
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const parsed = JSON.parse(text);
|
|
1270
|
+
if (Array.isArray(parsed)) {
|
|
1271
|
+
onBatch(parsed);
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
const drain = (final) => {
|
|
1275
|
+
let index;
|
|
1276
|
+
while ((index = buffer.indexOf(CHUNK_SEPARATOR)) !== -1) {
|
|
1277
|
+
const chunk = buffer.slice(0, index);
|
|
1278
|
+
buffer = buffer.slice(index + CHUNK_SEPARATOR.length);
|
|
1279
|
+
emit(chunk);
|
|
1280
|
+
}
|
|
1281
|
+
if (final) {
|
|
1282
|
+
emit(buffer);
|
|
1283
|
+
buffer = "";
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
const reader = response.body?.getReader();
|
|
1287
|
+
if (!reader) {
|
|
1288
|
+
buffer = await response.text();
|
|
1289
|
+
drain(true);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const decoder = new TextDecoder();
|
|
1293
|
+
for (; ; ) {
|
|
1294
|
+
const { done, value } = await reader.read();
|
|
1295
|
+
if (done) {
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1299
|
+
drain(false);
|
|
1300
|
+
}
|
|
1301
|
+
buffer += decoder.decode();
|
|
1302
|
+
drain(true);
|
|
1303
|
+
}
|
|
1304
|
+
var IntervalRefresher = class {
|
|
1305
|
+
#task;
|
|
1306
|
+
#baseMs;
|
|
1307
|
+
#maxMs;
|
|
1308
|
+
#intervalMs;
|
|
1309
|
+
#timer;
|
|
1310
|
+
#inFlight = null;
|
|
1311
|
+
#active = false;
|
|
1312
|
+
#lastRunAt = 0;
|
|
1313
|
+
#stopListening = null;
|
|
1314
|
+
constructor(task, options = {}) {
|
|
1315
|
+
this.#task = task;
|
|
1316
|
+
this.#baseMs = options.baseMs ?? 3e4;
|
|
1317
|
+
this.#maxMs = options.maxMs ?? 12e4;
|
|
1318
|
+
this.#intervalMs = this.#baseMs;
|
|
1319
|
+
}
|
|
1320
|
+
get active() {
|
|
1321
|
+
return this.#active;
|
|
1322
|
+
}
|
|
1323
|
+
get intervalMs() {
|
|
1324
|
+
return this.#intervalMs;
|
|
1325
|
+
}
|
|
1326
|
+
start() {
|
|
1327
|
+
if (this.#active) {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
this.#active = true;
|
|
1331
|
+
this.#intervalMs = this.#baseMs;
|
|
1332
|
+
this.#lastRunAt = Date.now();
|
|
1333
|
+
this.#stopListening = onVisibilityChange(this.#onVisibilityChange);
|
|
1334
|
+
this.#schedule();
|
|
1335
|
+
}
|
|
1336
|
+
stop() {
|
|
1337
|
+
this.#active = false;
|
|
1338
|
+
clearTimeout(this.#timer);
|
|
1339
|
+
this.#stopListening?.();
|
|
1340
|
+
this.#stopListening = null;
|
|
1341
|
+
}
|
|
1342
|
+
runNow() {
|
|
1343
|
+
if (!this.#inFlight) {
|
|
1344
|
+
this.#inFlight = this.#run().finally(() => {
|
|
1345
|
+
this.#inFlight = null;
|
|
1346
|
+
this.#schedule();
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
return this.#inFlight;
|
|
1350
|
+
}
|
|
1351
|
+
async #run() {
|
|
1352
|
+
clearTimeout(this.#timer);
|
|
1353
|
+
let changed = false;
|
|
1354
|
+
try {
|
|
1355
|
+
changed = await this.#task();
|
|
1356
|
+
} catch {
|
|
1357
|
+
changed = false;
|
|
1358
|
+
}
|
|
1359
|
+
this.#lastRunAt = Date.now();
|
|
1360
|
+
this.#intervalMs = changed ? this.#baseMs : Math.min(this.#intervalMs * 2, this.#maxMs);
|
|
1361
|
+
}
|
|
1362
|
+
#schedule() {
|
|
1363
|
+
clearTimeout(this.#timer);
|
|
1364
|
+
if (!this.#active || this.#inFlight || isDocumentHidden()) {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
this.#timer = setTimeout(() => void this.runNow(), this.#intervalMs);
|
|
1368
|
+
}
|
|
1369
|
+
#onVisibilityChange = () => {
|
|
1370
|
+
if (!this.#active) {
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (isDocumentHidden()) {
|
|
1374
|
+
clearTimeout(this.#timer);
|
|
1375
|
+
} else if (Date.now() - this.#lastRunAt >= this.#intervalMs) {
|
|
1376
|
+
void this.runNow();
|
|
1377
|
+
} else {
|
|
1378
|
+
this.#schedule();
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
// src/core/tree.ts
|
|
1384
|
+
function findComment(roots, id) {
|
|
1385
|
+
return find(roots, (c) => c.id === id, 0, []);
|
|
1386
|
+
}
|
|
1387
|
+
function findByNumber(roots, number) {
|
|
1388
|
+
return find(roots, (c) => c.number === number, 0, []);
|
|
1389
|
+
}
|
|
1390
|
+
function find(nodes, match, depth, path) {
|
|
1391
|
+
for (const node of nodes) {
|
|
1392
|
+
const nodePath = [...path, node.id];
|
|
1393
|
+
if (match(node)) {
|
|
1394
|
+
return { node, depth, path: nodePath };
|
|
1395
|
+
}
|
|
1396
|
+
if (node.children.length > 0) {
|
|
1397
|
+
const found = find(node.children, match, depth + 1, nodePath);
|
|
1398
|
+
if (found) {
|
|
1399
|
+
return found;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|
|
1405
|
+
function updateComment(roots, id, update) {
|
|
1406
|
+
return mapNode(roots, id, update) ?? roots;
|
|
1407
|
+
}
|
|
1408
|
+
function mapNode(nodes, id, update) {
|
|
1409
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1410
|
+
const node = nodes[i];
|
|
1411
|
+
if (node.id === id) {
|
|
1412
|
+
const next = update(node);
|
|
1413
|
+
if (next === node) {
|
|
1414
|
+
return null;
|
|
1415
|
+
}
|
|
1416
|
+
const copy = nodes.slice();
|
|
1417
|
+
copy[i] = next;
|
|
1418
|
+
return copy;
|
|
1419
|
+
}
|
|
1420
|
+
if (node.children.length > 0) {
|
|
1421
|
+
const children = mapNode(node.children, id, update);
|
|
1422
|
+
if (children) {
|
|
1423
|
+
const copy = nodes.slice();
|
|
1424
|
+
copy[i] = { ...node, children };
|
|
1425
|
+
return copy;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
function mergeComment(local, incoming) {
|
|
1432
|
+
const children = mergeChildren(local.children, incoming.children);
|
|
1433
|
+
const stale = Date.parse(incoming.updated_at) < Date.parse(local.updated_at);
|
|
1434
|
+
if (stale || sameFields(local, incoming)) {
|
|
1435
|
+
return children === local.children ? local : { ...local, children };
|
|
1436
|
+
}
|
|
1437
|
+
return { ...incoming, children };
|
|
1438
|
+
}
|
|
1439
|
+
function mergeChildren(local, incoming) {
|
|
1440
|
+
if (incoming.length === 0) {
|
|
1441
|
+
return local;
|
|
1442
|
+
}
|
|
1443
|
+
const localById = new Map(local.map((c) => [c.id, c]));
|
|
1444
|
+
const merged = incoming.map((c) => {
|
|
1445
|
+
const existing = localById.get(c.id);
|
|
1446
|
+
return existing ? mergeComment(existing, c) : c;
|
|
1447
|
+
});
|
|
1448
|
+
const seen = new Set(incoming.map((c) => c.id));
|
|
1449
|
+
for (const c of local) {
|
|
1450
|
+
if (!seen.has(c.id)) {
|
|
1451
|
+
merged.push(c);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const unchanged = merged.length === local.length && merged.every((c, i) => c === local[i]);
|
|
1455
|
+
return unchanged ? local : merged;
|
|
1456
|
+
}
|
|
1457
|
+
function sameFields(a, b) {
|
|
1458
|
+
for (const key of Object.keys(b)) {
|
|
1459
|
+
if (key === "children") {
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
const left = a[key];
|
|
1463
|
+
const right = b[key];
|
|
1464
|
+
if (left === right) {
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (typeof left !== "object" || typeof right !== "object" || JSON.stringify(left) !== JSON.stringify(right)) {
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
function patchComment(roots, incoming) {
|
|
1474
|
+
return updateComment(roots, incoming.id, (local) => mergeComment(local, incoming));
|
|
1475
|
+
}
|
|
1476
|
+
function tombstone(comment) {
|
|
1477
|
+
if (comment.deleted && comment.html === "" && comment.author === null) {
|
|
1478
|
+
return comment;
|
|
1479
|
+
}
|
|
1480
|
+
return {
|
|
1481
|
+
...comment,
|
|
1482
|
+
deleted: true,
|
|
1483
|
+
html: "",
|
|
1484
|
+
author: null,
|
|
1485
|
+
likes: { ...comment.likes, can_like: false },
|
|
1486
|
+
can: { ...comment.can, edit: false, delete: false, flag: false }
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
function tombstoneComment(roots, id) {
|
|
1490
|
+
return updateComment(roots, id, tombstone);
|
|
1491
|
+
}
|
|
1492
|
+
function insertRoot(roots, comment, position) {
|
|
1493
|
+
if (findComment(roots, comment.id)) {
|
|
1494
|
+
return patchComment(roots, comment);
|
|
1495
|
+
}
|
|
1496
|
+
if (position === "end") {
|
|
1497
|
+
return [...roots, comment];
|
|
1498
|
+
}
|
|
1499
|
+
let index = 0;
|
|
1500
|
+
while (index < roots.length && roots[index].pinned) {
|
|
1501
|
+
index++;
|
|
1502
|
+
}
|
|
1503
|
+
return [...roots.slice(0, index), comment, ...roots.slice(index)];
|
|
1504
|
+
}
|
|
1505
|
+
function insertReply(roots, parentId, comment, position, { bumpCounts }) {
|
|
1506
|
+
if (findComment(roots, comment.id)) {
|
|
1507
|
+
return patchComment(roots, comment);
|
|
1508
|
+
}
|
|
1509
|
+
const location = findComment(roots, parentId);
|
|
1510
|
+
if (!location) {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
const ancestors = new Set(location.path);
|
|
1514
|
+
return mapPath(roots, ancestors, (node) => {
|
|
1515
|
+
const isParent = node.id === parentId;
|
|
1516
|
+
const children = isParent ? position === "start" ? [comment, ...node.children] : [...node.children, comment] : node.children;
|
|
1517
|
+
if (!bumpCounts) {
|
|
1518
|
+
return { ...node, children };
|
|
1519
|
+
}
|
|
1520
|
+
return {
|
|
1521
|
+
...node,
|
|
1522
|
+
children,
|
|
1523
|
+
direct_reply_count: isParent ? node.direct_reply_count + 1 : node.direct_reply_count,
|
|
1524
|
+
total_descendant_count: node.total_descendant_count + 1
|
|
1525
|
+
};
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
function mapPath(nodes, ids, update) {
|
|
1529
|
+
return nodes.map((node) => {
|
|
1530
|
+
if (!ids.has(node.id)) {
|
|
1531
|
+
return node;
|
|
1532
|
+
}
|
|
1533
|
+
const children = node.children.some((c) => ids.has(c.id)) ? mapPath(node.children, ids, update) : node.children;
|
|
1534
|
+
return update(children === node.children ? node : { ...node, children });
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
function mergeSiblings(local, incoming, tree) {
|
|
1538
|
+
const byId = new Map(incoming.map((c) => [c.id, c]));
|
|
1539
|
+
let changed = false;
|
|
1540
|
+
const merged = local.map((c) => {
|
|
1541
|
+
const next = byId.get(c.id);
|
|
1542
|
+
if (!next) {
|
|
1543
|
+
return c;
|
|
1544
|
+
}
|
|
1545
|
+
const result = mergeComment(c, next);
|
|
1546
|
+
changed ||= result !== c;
|
|
1547
|
+
return result;
|
|
1548
|
+
});
|
|
1549
|
+
const localIds = new Set(local.map((c) => c.id));
|
|
1550
|
+
const fresh = incoming.filter((c) => !localIds.has(c.id) && !findComment(tree, c.id));
|
|
1551
|
+
if (!changed && fresh.length === 0) {
|
|
1552
|
+
return local;
|
|
1553
|
+
}
|
|
1554
|
+
return [...merged, ...fresh];
|
|
1555
|
+
}
|
|
1556
|
+
function collectIds(roots) {
|
|
1557
|
+
const ids = [];
|
|
1558
|
+
const walk = (nodes) => {
|
|
1559
|
+
for (const node of nodes) {
|
|
1560
|
+
if (!node.deleted) {
|
|
1561
|
+
ids.push(node.id);
|
|
1562
|
+
}
|
|
1563
|
+
walk(node.children);
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
walk(roots);
|
|
1567
|
+
return ids;
|
|
1568
|
+
}
|
|
1569
|
+
function maxNumber(roots) {
|
|
1570
|
+
let max = 0;
|
|
1571
|
+
for (const node of roots) {
|
|
1572
|
+
max = Math.max(max, node.number, maxNumber(node.children));
|
|
1573
|
+
}
|
|
1574
|
+
return max;
|
|
1575
|
+
}
|
|
1576
|
+
function applyPins(roots, pinnedIds) {
|
|
1577
|
+
const pinned = new Set(pinnedIds);
|
|
1578
|
+
const flagged = roots.map(
|
|
1579
|
+
(c) => c.pinned === pinned.has(c.id) ? c : { ...c, pinned: pinned.has(c.id) }
|
|
1580
|
+
);
|
|
1581
|
+
const order = new Map(pinnedIds.map((id, index) => [id, index]));
|
|
1582
|
+
const featured = flagged.filter((c) => c.pinned).sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
|
1583
|
+
return [...featured, ...flagged.filter((c) => !c.pinned)];
|
|
1584
|
+
}
|
|
1585
|
+
function rootPosition(sort) {
|
|
1586
|
+
return sort === "oldest" ? "end" : "start";
|
|
1587
|
+
}
|
|
1588
|
+
function replyPosition(sort) {
|
|
1589
|
+
return sort === "newest" ? "start" : "end";
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// src/core/thread.ts
|
|
1593
|
+
var MAX_REFETCH_RETRIES = 4;
|
|
1594
|
+
var MAX_RESOLVE_RETRIES = 5;
|
|
1595
|
+
var RECONCILE_BATCHES_PER_RUN = 4;
|
|
1596
|
+
var MAX_GAP_PAGES = 5;
|
|
1597
|
+
var CommentThread = class {
|
|
1598
|
+
composer = createComposerTimer();
|
|
1599
|
+
#deps;
|
|
1600
|
+
#state;
|
|
1601
|
+
#events = new Emitter();
|
|
1602
|
+
/** Live-arrived roots, oldest first, not yet shown. */
|
|
1603
|
+
#incoming = [];
|
|
1604
|
+
#incomingIdentity = 0;
|
|
1605
|
+
/** Bumped by every reload; page and branch responses from an older one are dropped. */
|
|
1606
|
+
#generation = 0;
|
|
1607
|
+
#loadController = null;
|
|
1608
|
+
#journal = [];
|
|
1609
|
+
/** Local event counter (see `Ticket`). */
|
|
1610
|
+
#seq = 0;
|
|
1611
|
+
/** Event seq at which each comment was seen deleted; older responses cannot revive it. */
|
|
1612
|
+
#deletedAt = /* @__PURE__ */ new Map();
|
|
1613
|
+
#likes = /* @__PURE__ */ new Map();
|
|
1614
|
+
#realtimeKey = null;
|
|
1615
|
+
#subscription = null;
|
|
1616
|
+
#stopStatus = null;
|
|
1617
|
+
#refresher;
|
|
1618
|
+
/** Highest top-level post number known to exist; `null` until known exactly. */
|
|
1619
|
+
#baseline = null;
|
|
1620
|
+
#reconcileOffset = 0;
|
|
1621
|
+
#refetch = /* @__PURE__ */ new Map();
|
|
1622
|
+
#refetchScheduled = false;
|
|
1623
|
+
#refetchChain = Promise.resolve();
|
|
1624
|
+
#refetchAttempts = 0;
|
|
1625
|
+
#refetchTimer;
|
|
1626
|
+
#resolving = null;
|
|
1627
|
+
#resolveAttempts = 0;
|
|
1628
|
+
#resolveTimer;
|
|
1629
|
+
#disposed = false;
|
|
1630
|
+
constructor(thread, deps, sort) {
|
|
1631
|
+
this.#deps = deps;
|
|
1632
|
+
this.#state = Object.freeze({
|
|
1633
|
+
thread,
|
|
1634
|
+
virtual: isVirtualThread(thread),
|
|
1635
|
+
comments: [],
|
|
1636
|
+
sort: sort ?? thread.sort,
|
|
1637
|
+
status: "idle",
|
|
1638
|
+
error: null,
|
|
1639
|
+
page: 0,
|
|
1640
|
+
hasMore: false,
|
|
1641
|
+
loadingMore: false,
|
|
1642
|
+
pendingNew: 0,
|
|
1643
|
+
branches: {},
|
|
1644
|
+
awaitingApproval: [],
|
|
1645
|
+
connection: "off"
|
|
1646
|
+
});
|
|
1647
|
+
this.#refresher = new IntervalRefresher(() => this.#refreshOnce());
|
|
1648
|
+
}
|
|
1649
|
+
get id() {
|
|
1650
|
+
return this.#state.thread.id;
|
|
1651
|
+
}
|
|
1652
|
+
snapshot() {
|
|
1653
|
+
return this.#state;
|
|
1654
|
+
}
|
|
1655
|
+
subscribe(listener) {
|
|
1656
|
+
return this.#events.on(listener);
|
|
1657
|
+
}
|
|
1658
|
+
async load() {
|
|
1659
|
+
if (this.#disposed) {
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
const thread = this.#state.thread;
|
|
1663
|
+
if (isVirtualThread(thread)) {
|
|
1664
|
+
if (this.#state.status === "ready") {
|
|
1665
|
+
await this.#reresolve();
|
|
1666
|
+
if (!this.#state.virtual) {
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
this.#set({ status: "ready", comments: [], hasMore: false, page: 0 });
|
|
1671
|
+
this.#ensureRealtime();
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
const generation = ++this.#generation;
|
|
1675
|
+
this.#loadController?.abort();
|
|
1676
|
+
const controller = new AbortController();
|
|
1677
|
+
this.#loadController = controller;
|
|
1678
|
+
this.#journal = [];
|
|
1679
|
+
const ticket = this.#ticket();
|
|
1680
|
+
const sort = this.#state.sort;
|
|
1681
|
+
this.#set({ status: "loading", error: null });
|
|
1682
|
+
try {
|
|
1683
|
+
let baseline = null;
|
|
1684
|
+
if (thread.realtime === "poll" && sort !== "newest") {
|
|
1685
|
+
baseline = await this.#readBaseline(thread.id, controller.signal);
|
|
1686
|
+
}
|
|
1687
|
+
const response = await this.#deps.http.request("GET", threadPath(thread.id), {
|
|
1688
|
+
query: { sort, page: 0 },
|
|
1689
|
+
signal: controller.signal
|
|
1690
|
+
});
|
|
1691
|
+
if (generation !== this.#generation || !this.#current(ticket)) {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const comments = response.comments.map((c) => this.#sanitize(c, ticket));
|
|
1695
|
+
const effectiveSort = response.sort ?? sort;
|
|
1696
|
+
const exact = effectiveSort === "newest" || !response.has_more;
|
|
1697
|
+
this.#baseline = baseline !== null ? Math.max(baseline, maxNumber(comments)) : exact ? maxNumber(comments) : null;
|
|
1698
|
+
this.#incoming = this.#incomingIdentity === ticket.identity ? this.#incoming.filter((c) => !findComment(comments, c.id)) : [];
|
|
1699
|
+
this.#set({
|
|
1700
|
+
thread: response.thread,
|
|
1701
|
+
comments,
|
|
1702
|
+
sort: effectiveSort,
|
|
1703
|
+
page: response.page ?? 0,
|
|
1704
|
+
hasMore: response.has_more,
|
|
1705
|
+
loadingMore: false,
|
|
1706
|
+
branches: {},
|
|
1707
|
+
pendingNew: this.#incoming.length,
|
|
1708
|
+
status: "ready",
|
|
1709
|
+
error: null
|
|
1710
|
+
});
|
|
1711
|
+
this.#subscription?.advanceTo(response.thread.message_bus_last_id);
|
|
1712
|
+
this.#loadController = null;
|
|
1713
|
+
this.#replayJournal();
|
|
1714
|
+
this.#ensureRealtime();
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
if (generation !== this.#generation || isAbortError(error)) {
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
this.#journal = [];
|
|
1720
|
+
const failure = toCommentsError2(error);
|
|
1721
|
+
this.#set({ status: "error", error: failure });
|
|
1722
|
+
throw failure;
|
|
1723
|
+
} finally {
|
|
1724
|
+
if (this.#loadController === controller) {
|
|
1725
|
+
this.#loadController = null;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
async loadMore() {
|
|
1730
|
+
const state = this.#state;
|
|
1731
|
+
if (state.virtual || !state.hasMore || state.loadingMore || state.status !== "ready") {
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const generation = this.#generation;
|
|
1735
|
+
const ticket = this.#ticket();
|
|
1736
|
+
this.#set({ loadingMore: true });
|
|
1737
|
+
try {
|
|
1738
|
+
const response = await this.#deps.http.request(
|
|
1739
|
+
"GET",
|
|
1740
|
+
threadPath(state.thread.id),
|
|
1741
|
+
{ query: { sort: state.sort, page: state.page + 1 } }
|
|
1742
|
+
);
|
|
1743
|
+
if (generation !== this.#generation || !this.#current(ticket)) {
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const page = response.comments.map((c) => this.#sanitize(c, ticket));
|
|
1747
|
+
let comments = this.#state.comments;
|
|
1748
|
+
const fresh = [];
|
|
1749
|
+
for (const comment of page) {
|
|
1750
|
+
if (findComment(comments, comment.id)) {
|
|
1751
|
+
comments = patchComment(comments, comment);
|
|
1752
|
+
} else {
|
|
1753
|
+
fresh.push(comment);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
const ids = new Set(page.map((c) => c.id));
|
|
1757
|
+
this.#incoming = this.#incoming.filter((c) => !ids.has(c.id));
|
|
1758
|
+
this.#set({
|
|
1759
|
+
comments: fresh.length > 0 ? [...comments, ...fresh] : comments,
|
|
1760
|
+
page: response.page ?? state.page + 1,
|
|
1761
|
+
hasMore: response.has_more,
|
|
1762
|
+
loadingMore: false,
|
|
1763
|
+
pendingNew: this.#incoming.length
|
|
1764
|
+
});
|
|
1765
|
+
} catch (error) {
|
|
1766
|
+
if (generation === this.#generation) {
|
|
1767
|
+
this.#set({ loadingMore: false });
|
|
1768
|
+
}
|
|
1769
|
+
throw toCommentsError2(error);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
async loadReplies(commentId) {
|
|
1773
|
+
const location = findComment(this.#state.comments, commentId);
|
|
1774
|
+
if (!location) {
|
|
1775
|
+
throw notInThread(commentId);
|
|
1776
|
+
}
|
|
1777
|
+
const branch = this.#state.branches[commentId];
|
|
1778
|
+
if (branch?.loading) {
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const page = (branch?.page ?? -1) + 1;
|
|
1782
|
+
const generation = this.#generation;
|
|
1783
|
+
const ticket = this.#ticket();
|
|
1784
|
+
this.#setBranch(commentId, {
|
|
1785
|
+
page: branch?.page ?? -1,
|
|
1786
|
+
loading: true,
|
|
1787
|
+
hasMore: branch?.hasMore ?? true
|
|
1788
|
+
});
|
|
1789
|
+
try {
|
|
1790
|
+
const response = await this.#deps.http.request(
|
|
1791
|
+
"GET",
|
|
1792
|
+
`${threadPath(this.id)}/children/${location.node.number}`,
|
|
1793
|
+
{ query: { sort: this.#state.sort, page, depth: location.depth + 1 } }
|
|
1794
|
+
);
|
|
1795
|
+
if (generation !== this.#generation || !this.#current(ticket)) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const children = response.comments.map((c) => this.#sanitize(c, ticket));
|
|
1799
|
+
const tree = this.#state.comments;
|
|
1800
|
+
let comments = updateComment(tree, commentId, (node) => {
|
|
1801
|
+
const merged = mergeSiblings(node.children, children, tree);
|
|
1802
|
+
return merged === node.children ? node : { ...node, children: merged };
|
|
1803
|
+
});
|
|
1804
|
+
for (const child of children) {
|
|
1805
|
+
const elsewhere = findComment(comments, child.id);
|
|
1806
|
+
if (elsewhere && elsewhere.path.at(-2) !== commentId) {
|
|
1807
|
+
comments = patchComment(comments, child);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
this.#set({ comments });
|
|
1811
|
+
this.#setBranch(commentId, {
|
|
1812
|
+
page: response.page ?? page,
|
|
1813
|
+
loading: false,
|
|
1814
|
+
hasMore: response.has_more
|
|
1815
|
+
});
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
if (generation === this.#generation) {
|
|
1818
|
+
this.#setBranch(commentId, {
|
|
1819
|
+
page: branch?.page ?? -1,
|
|
1820
|
+
loading: false,
|
|
1821
|
+
hasMore: branch?.hasMore ?? true
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
throw toCommentsError2(error);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
async setSort(sort) {
|
|
1828
|
+
if (sort === this.#state.sort && this.#state.status === "ready") {
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
this.#set({ sort });
|
|
1832
|
+
await this.load();
|
|
1833
|
+
}
|
|
1834
|
+
async post(options) {
|
|
1835
|
+
const timer = options.composer ?? this.composer;
|
|
1836
|
+
const measured = timer.measure();
|
|
1837
|
+
const thread = this.#state.thread;
|
|
1838
|
+
const json = {
|
|
1839
|
+
raw: options.body,
|
|
1840
|
+
reply_to_id: options.replyTo,
|
|
1841
|
+
typing_duration_msecs: options.typingDurationMsecs ?? measured.typingDurationMsecs,
|
|
1842
|
+
composer_open_duration_msecs: options.composerOpenDurationMsecs ?? measured.composerOpenDurationMsecs
|
|
1843
|
+
};
|
|
1844
|
+
if (isVirtualThread(thread)) {
|
|
1845
|
+
const { url, title, identifier } = this.#deps.page;
|
|
1846
|
+
json.page = { url, title, identifier };
|
|
1847
|
+
}
|
|
1848
|
+
const ticket = this.#ticket();
|
|
1849
|
+
const response = await this.#deps.http.request(
|
|
1850
|
+
"POST",
|
|
1851
|
+
`${threadPath(thread.id)}/comments`,
|
|
1852
|
+
{ json }
|
|
1853
|
+
);
|
|
1854
|
+
timer.stop();
|
|
1855
|
+
const current = this.#current(ticket);
|
|
1856
|
+
if ("pending" in response) {
|
|
1857
|
+
if (current) {
|
|
1858
|
+
this.#set({ awaitingApproval: [...this.#state.awaitingApproval, response.pending] });
|
|
1859
|
+
this.#events.emit({ type: "comment.pending", pending: response.pending });
|
|
1860
|
+
}
|
|
1861
|
+
return { type: "pending", pending: response.pending };
|
|
1862
|
+
}
|
|
1863
|
+
const { comment } = response;
|
|
1864
|
+
if (!current) {
|
|
1865
|
+
return { type: "created", comment };
|
|
1866
|
+
}
|
|
1867
|
+
const wasVirtual = this.#state.virtual;
|
|
1868
|
+
if (wasVirtual) {
|
|
1869
|
+
this.#adopt(response.thread);
|
|
1870
|
+
} else {
|
|
1871
|
+
this.#mergeThreadMeta(response.thread);
|
|
1872
|
+
}
|
|
1873
|
+
this.#insertOwn(comment, options.replyTo, ticket);
|
|
1874
|
+
this.#events.emit({ type: "comment.posted", comment });
|
|
1875
|
+
if (wasVirtual && response.thread.count > 1) {
|
|
1876
|
+
void this.load().catch(() => {
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
return { type: "created", comment };
|
|
1880
|
+
}
|
|
1881
|
+
async edit(commentId, body) {
|
|
1882
|
+
const ticket = this.#ticket();
|
|
1883
|
+
const response = await this.#deps.http.request(
|
|
1884
|
+
"PATCH",
|
|
1885
|
+
this.#commentPath(commentId),
|
|
1886
|
+
{ json: { raw: body } }
|
|
1887
|
+
);
|
|
1888
|
+
if (this.#current(ticket)) {
|
|
1889
|
+
this.#applyComments([response.comment], ticket, () => false);
|
|
1890
|
+
this.#events.emit({ type: "comment.edited", comment: response.comment });
|
|
1891
|
+
}
|
|
1892
|
+
return response.comment;
|
|
1893
|
+
}
|
|
1894
|
+
async remove(commentId) {
|
|
1895
|
+
const ticket = this.#ticket();
|
|
1896
|
+
await this.#deps.http.request("DELETE", this.#commentPath(commentId));
|
|
1897
|
+
if (!this.#current(ticket)) {
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
this.#markDeleted(commentId);
|
|
1901
|
+
const after = this.#ticket();
|
|
1902
|
+
try {
|
|
1903
|
+
const current = (await this.#fetchByIds([commentId])).comments[0];
|
|
1904
|
+
if (current) {
|
|
1905
|
+
this.#applyComments([current], after, () => false);
|
|
1906
|
+
}
|
|
1907
|
+
} catch {
|
|
1908
|
+
}
|
|
1909
|
+
this.#events.emit({ type: "comment.removed", id: commentId });
|
|
1910
|
+
}
|
|
1911
|
+
like(commentId) {
|
|
1912
|
+
return this.#toggleLike(commentId, true);
|
|
1913
|
+
}
|
|
1914
|
+
unlike(commentId) {
|
|
1915
|
+
return this.#toggleLike(commentId, false);
|
|
1916
|
+
}
|
|
1917
|
+
async flag(commentId, options) {
|
|
1918
|
+
await this.#deps.http.request("POST", `${this.#commentPath(commentId)}/flag`, {
|
|
1919
|
+
json: { flag_type: options.type, message: options.message }
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
async pin(commentId) {
|
|
1923
|
+
await this.#setPin(commentId, "PUT");
|
|
1924
|
+
}
|
|
1925
|
+
async unpin(commentId) {
|
|
1926
|
+
await this.#setPin(commentId, "DELETE");
|
|
1927
|
+
}
|
|
1928
|
+
async raw(commentId) {
|
|
1929
|
+
const ticket = this.#ticket();
|
|
1930
|
+
const response = await this.#deps.http.request(
|
|
1931
|
+
"GET",
|
|
1932
|
+
`${this.#commentPath(commentId)}/raw`
|
|
1933
|
+
);
|
|
1934
|
+
this.#assertCurrent(ticket);
|
|
1935
|
+
return response.raw;
|
|
1936
|
+
}
|
|
1937
|
+
showPending() {
|
|
1938
|
+
if (this.#incoming.length === 0) {
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
let comments = this.#state.comments;
|
|
1942
|
+
const position = rootPosition(this.#state.sort);
|
|
1943
|
+
for (const comment of this.#incoming) {
|
|
1944
|
+
comments = insertRoot(comments, comment, position);
|
|
1945
|
+
}
|
|
1946
|
+
this.#incoming = [];
|
|
1947
|
+
this.#set({ comments, pendingNew: 0 });
|
|
1948
|
+
}
|
|
1949
|
+
async context(commentId) {
|
|
1950
|
+
const ticket = this.#ticket();
|
|
1951
|
+
let number = findComment(this.#state.comments, commentId)?.node.number;
|
|
1952
|
+
if (number === void 0) {
|
|
1953
|
+
number = (await this.#fetchByIds([commentId])).comments[0]?.number;
|
|
1954
|
+
this.#assertCurrent(ticket);
|
|
1955
|
+
}
|
|
1956
|
+
if (number === void 0) {
|
|
1957
|
+
throw notInThread(commentId);
|
|
1958
|
+
}
|
|
1959
|
+
const response = await this.#deps.http.request(
|
|
1960
|
+
"GET",
|
|
1961
|
+
`${threadPath(this.id)}/context/${number}`,
|
|
1962
|
+
{ query: { sort: this.#state.sort } }
|
|
1963
|
+
);
|
|
1964
|
+
this.#assertCurrent(ticket);
|
|
1965
|
+
const ancestors = response.ancestors.map((c) => this.#sanitize(c, ticket));
|
|
1966
|
+
const target = this.#sanitize(response.comment, ticket);
|
|
1967
|
+
const chain = [...ancestors, target];
|
|
1968
|
+
let comments = this.#state.comments;
|
|
1969
|
+
const [top, ...rest] = chain;
|
|
1970
|
+
if (top && !findComment(comments, top.id)) {
|
|
1971
|
+
comments = insertRoot(comments, top, "start");
|
|
1972
|
+
}
|
|
1973
|
+
let parentId = top?.id;
|
|
1974
|
+
for (const node of rest) {
|
|
1975
|
+
if (parentId !== void 0 && !findComment(comments, node.id)) {
|
|
1976
|
+
comments = insertReply(comments, parentId, node, "start", { bumpCounts: false }) ?? comments;
|
|
1977
|
+
}
|
|
1978
|
+
parentId = node.id;
|
|
1979
|
+
}
|
|
1980
|
+
this.#incoming = this.#incoming.filter((c) => !chain.some((n) => n.id === c.id));
|
|
1981
|
+
this.#set({ comments, pendingNew: this.#incoming.length });
|
|
1982
|
+
return {
|
|
1983
|
+
ancestors,
|
|
1984
|
+
ancestorsTruncated: response.ancestors_truncated,
|
|
1985
|
+
comment: target
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
dispose() {
|
|
1989
|
+
if (this.#disposed) {
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
this.#disposed = true;
|
|
1993
|
+
this.#generation++;
|
|
1994
|
+
this.#loadController?.abort();
|
|
1995
|
+
clearTimeout(this.#refetchTimer);
|
|
1996
|
+
clearTimeout(this.#resolveTimer);
|
|
1997
|
+
this.#stopRealtime();
|
|
1998
|
+
this.#events.clear();
|
|
1999
|
+
this.#deps.onDispose();
|
|
2000
|
+
}
|
|
2001
|
+
// Realtime
|
|
2002
|
+
#realtimeKeyFor(thread) {
|
|
2003
|
+
return isVirtualThread(thread) ? `pending:${thread.pending_channel}` : `${thread.realtime}:${thread.topic_id}`;
|
|
2004
|
+
}
|
|
2005
|
+
/** (Re)connects whenever the thread, its channel or its realtime mode changes. */
|
|
2006
|
+
#ensureRealtime() {
|
|
2007
|
+
const thread = this.#state.thread;
|
|
2008
|
+
const key = this.#realtimeKeyFor(thread);
|
|
2009
|
+
if (this.#disposed || this.#realtimeKey === key) {
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
this.#stopRealtime();
|
|
2013
|
+
this.#realtimeKey = key;
|
|
2014
|
+
const { realtime } = this.#deps;
|
|
2015
|
+
if (isVirtualThread(thread)) {
|
|
2016
|
+
this.#subscription = realtime.subscribe(thread.pending_channel, -2, () => {
|
|
2017
|
+
void this.#reresolve();
|
|
2018
|
+
});
|
|
2019
|
+
} else if (thread.realtime === "live") {
|
|
2020
|
+
this.#subscription = realtime.subscribe(
|
|
2021
|
+
`/topic/${thread.topic_id}`,
|
|
2022
|
+
thread.message_bus_last_id,
|
|
2023
|
+
(event) => this.#onTopicEvent(event)
|
|
2024
|
+
);
|
|
2025
|
+
} else {
|
|
2026
|
+
this.#startFallback(false);
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
this.#stopStatus = realtime.onStatus((status) => this.#onTransportStatus(status));
|
|
2030
|
+
this.#onTransportStatus(realtime.status);
|
|
2031
|
+
}
|
|
2032
|
+
/** Interval refreshes; `immediate` also runs one now (catching up after a failure). */
|
|
2033
|
+
#startFallback(immediate) {
|
|
2034
|
+
this.#set({ connection: "interval" });
|
|
2035
|
+
if (this.#refresher.active) {
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
this.#refresher.start();
|
|
2039
|
+
if (immediate) {
|
|
2040
|
+
void this.#refresher.runNow();
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
#stopRealtime() {
|
|
2044
|
+
this.#subscription?.unsubscribe();
|
|
2045
|
+
this.#subscription = null;
|
|
2046
|
+
this.#stopStatus?.();
|
|
2047
|
+
this.#stopStatus = null;
|
|
2048
|
+
this.#refresher.stop();
|
|
2049
|
+
this.#realtimeKey = null;
|
|
2050
|
+
this.#set({ connection: "off" });
|
|
2051
|
+
}
|
|
2052
|
+
#onTransportStatus(status) {
|
|
2053
|
+
if (status === "failed") {
|
|
2054
|
+
this.#startFallback(true);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
const recovering = this.#refresher.active;
|
|
2058
|
+
this.#refresher.stop();
|
|
2059
|
+
this.#set({ connection: "live" });
|
|
2060
|
+
if (recovering && status === "connected") {
|
|
2061
|
+
void this.#refresher.runNow();
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
#onTopicEvent(event) {
|
|
2065
|
+
if (event.kind === "gap") {
|
|
2066
|
+
void this.#refresher.runNow();
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
if (event.kind !== "message") {
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
const message = event.data;
|
|
2073
|
+
if (!message || typeof message !== "object") {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
const id = typeof message.id === "number" ? message.id : null;
|
|
2077
|
+
switch (message.type) {
|
|
2078
|
+
case "created":
|
|
2079
|
+
if (id !== null) {
|
|
2080
|
+
this.#queueRefetch(id, true);
|
|
2081
|
+
}
|
|
2082
|
+
break;
|
|
2083
|
+
case "recovered":
|
|
2084
|
+
if (id !== null) {
|
|
2085
|
+
this.#deletedAt.delete(id);
|
|
2086
|
+
this.#event();
|
|
2087
|
+
this.#queueRefetch(id, true);
|
|
2088
|
+
}
|
|
2089
|
+
break;
|
|
2090
|
+
case "revised":
|
|
2091
|
+
case "rebaked":
|
|
2092
|
+
case "liked":
|
|
2093
|
+
case "unliked":
|
|
2094
|
+
if (id !== null && this.#knows(id)) {
|
|
2095
|
+
this.#queueRefetch(id, false);
|
|
2096
|
+
}
|
|
2097
|
+
break;
|
|
2098
|
+
case "deleted":
|
|
2099
|
+
case "destroyed":
|
|
2100
|
+
if (id !== null) {
|
|
2101
|
+
this.#markDeleted(id);
|
|
2102
|
+
}
|
|
2103
|
+
break;
|
|
2104
|
+
case "stats":
|
|
2105
|
+
if (typeof message.posts_count === "number" && !isVirtualThread(this.#state.thread)) {
|
|
2106
|
+
const count = Math.max(message.posts_count - 1, 0);
|
|
2107
|
+
if (count !== this.#state.thread.count) {
|
|
2108
|
+
this.#set({ thread: { ...this.#state.thread, count } });
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
break;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
#knows(id) {
|
|
2115
|
+
return findComment(this.#state.comments, id) !== null || findComment(this.#incoming, id) !== null;
|
|
2116
|
+
}
|
|
2117
|
+
#queueRefetch(id, allowInsert) {
|
|
2118
|
+
this.#refetch.set(id, (this.#refetch.get(id) ?? false) || allowInsert);
|
|
2119
|
+
if (this.#refetchTimer === void 0) {
|
|
2120
|
+
this.#scheduleRefetch();
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
#scheduleRefetch() {
|
|
2124
|
+
if (this.#refetchScheduled) {
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
this.#refetchScheduled = true;
|
|
2128
|
+
queueMicrotask(() => {
|
|
2129
|
+
this.#refetchChain = this.#refetchChain.then(() => this.#flushRefetch());
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
async #flushRefetch() {
|
|
2133
|
+
const pending = new Map(this.#refetch);
|
|
2134
|
+
this.#refetch.clear();
|
|
2135
|
+
this.#refetchScheduled = false;
|
|
2136
|
+
if (this.#disposed || isVirtualThread(this.#state.thread) || pending.size === 0) {
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const ticket = this.#ticket();
|
|
2140
|
+
const ids = [...pending.keys()];
|
|
2141
|
+
for (let i = 0; i < ids.length; i += REFETCH_BATCH_SIZE) {
|
|
2142
|
+
try {
|
|
2143
|
+
const response = await this.#fetchByIds(ids.slice(i, i + REFETCH_BATCH_SIZE));
|
|
2144
|
+
if (!this.#current(ticket)) {
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
this.#refetchAttempts = 0;
|
|
2148
|
+
this.#mergeThreadMeta(response.thread);
|
|
2149
|
+
this.#applyComments(response.comments, ticket, (c) => pending.get(c.id) === true);
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
if (this.#disposed || isAbortError(error)) {
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
this.#deps.log("refetch failed", error);
|
|
2155
|
+
for (const id of ids.slice(i)) {
|
|
2156
|
+
this.#refetch.set(id, (this.#refetch.get(id) ?? false) || pending.get(id) === true);
|
|
2157
|
+
}
|
|
2158
|
+
this.#retryRefetch(error);
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/** Retries failed hydration with backoff (honouring rate limits), then reconciles. */
|
|
2164
|
+
#retryRefetch(error) {
|
|
2165
|
+
this.#refetchAttempts++;
|
|
2166
|
+
if (this.#refetchAttempts > MAX_REFETCH_RETRIES) {
|
|
2167
|
+
this.#refetchAttempts = 0;
|
|
2168
|
+
this.#refetch.clear();
|
|
2169
|
+
void this.#refresher.runNow();
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
const waitSeconds = isCommentsError(error) ? error.waitSeconds : void 0;
|
|
2173
|
+
const delay = waitSeconds !== void 0 ? waitSeconds * 1e3 : Math.min(1e3 * 2 ** this.#refetchAttempts, 3e4);
|
|
2174
|
+
clearTimeout(this.#refetchTimer);
|
|
2175
|
+
this.#refetchTimer = setTimeout(() => {
|
|
2176
|
+
this.#refetchTimer = void 0;
|
|
2177
|
+
this.#scheduleRefetch();
|
|
2178
|
+
}, delay);
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* One fallback refresh; resolves `true` when anything changed. Discovers new
|
|
2182
|
+
* top-level comments by post number (whatever the display sort), then
|
|
2183
|
+
* re-reads the loaded ones so edits, likes and deletions show up.
|
|
2184
|
+
*/
|
|
2185
|
+
async #refreshOnce() {
|
|
2186
|
+
const thread = this.#state.thread;
|
|
2187
|
+
if (isVirtualThread(thread)) {
|
|
2188
|
+
await this.#reresolve();
|
|
2189
|
+
return !this.#state.virtual;
|
|
2190
|
+
}
|
|
2191
|
+
const ticket = this.#ticket();
|
|
2192
|
+
const generation = this.#generation;
|
|
2193
|
+
const stale = () => generation !== this.#generation || !this.#current(ticket);
|
|
2194
|
+
const countBefore = thread.count;
|
|
2195
|
+
let changed = false;
|
|
2196
|
+
if (this.#baseline === null) {
|
|
2197
|
+
const baseline = await this.#readBaseline(thread.id);
|
|
2198
|
+
if (stale()) {
|
|
2199
|
+
return false;
|
|
2200
|
+
}
|
|
2201
|
+
this.#baseline = Math.max(baseline, maxNumber(this.#state.comments), maxNumber(this.#incoming));
|
|
2202
|
+
changed = true;
|
|
2203
|
+
} else {
|
|
2204
|
+
const baseline = this.#baseline;
|
|
2205
|
+
let highest = baseline;
|
|
2206
|
+
const found = [];
|
|
2207
|
+
for (let page = 0; page < MAX_GAP_PAGES; page++) {
|
|
2208
|
+
const response = await this.#deps.http.request("GET", threadPath(thread.id), {
|
|
2209
|
+
query: { sort: "newest", page }
|
|
2210
|
+
});
|
|
2211
|
+
if (stale()) {
|
|
2212
|
+
return false;
|
|
2213
|
+
}
|
|
2214
|
+
this.#mergeThreadMeta(response.thread);
|
|
2215
|
+
found.push(...response.comments);
|
|
2216
|
+
highest = Math.max(highest, maxNumber(response.comments));
|
|
2217
|
+
if (!response.has_more || response.comments.some((c) => c.number <= baseline)) {
|
|
2218
|
+
break;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
this.#baseline = highest;
|
|
2222
|
+
changed = this.#applyComments(found, ticket, (c) => c.number > baseline);
|
|
2223
|
+
}
|
|
2224
|
+
changed = await this.#reconcileLoaded(ticket, stale) || changed;
|
|
2225
|
+
return changed || this.#state.thread.count !== countBefore;
|
|
2226
|
+
}
|
|
2227
|
+
/** Re-reads a rotating window of loaded comments; missing ones are gone for this reader. */
|
|
2228
|
+
async #reconcileLoaded(ticket, stale) {
|
|
2229
|
+
const ids = collectIds(this.#state.comments);
|
|
2230
|
+
if (ids.length === 0) {
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
2233
|
+
const start = this.#reconcileOffset % ids.length;
|
|
2234
|
+
const window2 = [...ids.slice(start), ...ids.slice(0, start)].slice(
|
|
2235
|
+
0,
|
|
2236
|
+
REFETCH_BATCH_SIZE * RECONCILE_BATCHES_PER_RUN
|
|
2237
|
+
);
|
|
2238
|
+
this.#reconcileOffset = start + window2.length;
|
|
2239
|
+
let changed = false;
|
|
2240
|
+
for (let i = 0; i < window2.length; i += REFETCH_BATCH_SIZE) {
|
|
2241
|
+
const batch = window2.slice(i, i + REFETCH_BATCH_SIZE);
|
|
2242
|
+
const response = await this.#fetchByIds(batch);
|
|
2243
|
+
if (stale()) {
|
|
2244
|
+
return changed;
|
|
2245
|
+
}
|
|
2246
|
+
this.#mergeThreadMeta(response.thread);
|
|
2247
|
+
changed = this.#applyComments(response.comments, ticket, () => false) || changed;
|
|
2248
|
+
const returned = new Set(response.comments.map((c) => c.id));
|
|
2249
|
+
for (const id of batch) {
|
|
2250
|
+
if (!returned.has(id) && findComment(this.#state.comments, id)?.node.deleted === false) {
|
|
2251
|
+
this.#markDeleted(id);
|
|
2252
|
+
changed = true;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return changed;
|
|
2257
|
+
}
|
|
2258
|
+
async #readBaseline(threadId, signal) {
|
|
2259
|
+
const response = await this.#deps.http.request("GET", threadPath(threadId), {
|
|
2260
|
+
query: { sort: "newest", page: 0 },
|
|
2261
|
+
signal
|
|
2262
|
+
});
|
|
2263
|
+
return maxNumber(response.comments);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Re-resolves a virtual thread (did its topic appear? did permissions
|
|
2267
|
+
* change?). Failures surface on `snapshot().error` and retry with backoff,
|
|
2268
|
+
* then on the slow interval until the topic exists or the handle is disposed.
|
|
2269
|
+
*/
|
|
2270
|
+
#reresolve() {
|
|
2271
|
+
this.#resolving ??= (async () => {
|
|
2272
|
+
const ticket = this.#ticket();
|
|
2273
|
+
try {
|
|
2274
|
+
const thread = await this.#deps.resolve();
|
|
2275
|
+
if (this.#disposed || !this.#state.virtual) {
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (!this.#current(ticket)) {
|
|
2279
|
+
queueMicrotask(() => void this.#reresolve());
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
this.#resolveAttempts = 0;
|
|
2283
|
+
clearTimeout(this.#resolveTimer);
|
|
2284
|
+
if (isVirtualThread(thread)) {
|
|
2285
|
+
this.#set({ thread: sameJson(thread, this.#state.thread) ? this.#state.thread : thread, error: null });
|
|
2286
|
+
this.#ensureRealtime();
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
this.#adopt(thread);
|
|
2290
|
+
await this.load();
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
if (this.#disposed || isAbortError(error)) {
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
this.#deps.log("re-resolving the thread failed", error);
|
|
2296
|
+
this.#set({ error: toCommentsError2(error) });
|
|
2297
|
+
this.#scheduleResolveRetry();
|
|
2298
|
+
} finally {
|
|
2299
|
+
this.#resolving = null;
|
|
2300
|
+
}
|
|
2301
|
+
})();
|
|
2302
|
+
return this.#resolving;
|
|
2303
|
+
}
|
|
2304
|
+
#scheduleResolveRetry() {
|
|
2305
|
+
this.#resolveAttempts++;
|
|
2306
|
+
clearTimeout(this.#resolveTimer);
|
|
2307
|
+
if (this.#resolveAttempts > MAX_RESOLVE_RETRIES) {
|
|
2308
|
+
this.#resolveAttempts = 0;
|
|
2309
|
+
this.#startFallback(false);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
this.#resolveTimer = setTimeout(
|
|
2313
|
+
() => void this.#reresolve(),
|
|
2314
|
+
Math.min(2e3 * 2 ** (this.#resolveAttempts - 1), 3e4)
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
/** Switches a virtual thread to its newly created topic. */
|
|
2318
|
+
#adopt(thread) {
|
|
2319
|
+
clearTimeout(this.#resolveTimer);
|
|
2320
|
+
this.#resolveAttempts = 0;
|
|
2321
|
+
this.#set({ thread, virtual: false, status: "ready", error: null });
|
|
2322
|
+
this.#ensureRealtime();
|
|
2323
|
+
this.#events.emit({ type: "thread.created", thread });
|
|
2324
|
+
}
|
|
2325
|
+
// Applying server data
|
|
2326
|
+
#ticket() {
|
|
2327
|
+
return { seq: this.#seq, identity: this.#deps.identity() };
|
|
2328
|
+
}
|
|
2329
|
+
#event() {
|
|
2330
|
+
return ++this.#seq;
|
|
2331
|
+
}
|
|
2332
|
+
/** The response belongs to this handle's current reader. */
|
|
2333
|
+
#current(ticket) {
|
|
2334
|
+
return !this.#disposed && ticket.identity === this.#deps.identity();
|
|
2335
|
+
}
|
|
2336
|
+
#assertCurrent(ticket) {
|
|
2337
|
+
if (!this.#current(ticket)) {
|
|
2338
|
+
throw new CommentsError("cancelled", "The reader changed while this request was running");
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* Adjusts a server comment (recursively) for what happened locally after the
|
|
2343
|
+
* request started: deletions win, and a like in flight keeps showing the
|
|
2344
|
+
* reader's latest intent.
|
|
2345
|
+
*/
|
|
2346
|
+
#sanitize(comment, ticket) {
|
|
2347
|
+
let node = comment;
|
|
2348
|
+
const deletedAt = this.#deletedAt.get(node.id);
|
|
2349
|
+
if (deletedAt !== void 0 && deletedAt > ticket.seq && !node.deleted) {
|
|
2350
|
+
node = tombstone(node);
|
|
2351
|
+
}
|
|
2352
|
+
if (this.#likes.has(node.id)) {
|
|
2353
|
+
const shown = findComment(this.#state.comments, node.id)?.node.likes;
|
|
2354
|
+
if (shown && !sameJson(shown, node.likes)) {
|
|
2355
|
+
node = { ...node, likes: shown };
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (node.children.length > 0) {
|
|
2359
|
+
const children = node.children.map((child) => this.#sanitize(child, ticket));
|
|
2360
|
+
if (children.some((child, index) => child !== node.children[index])) {
|
|
2361
|
+
node = { ...node, children };
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return node;
|
|
2365
|
+
}
|
|
2366
|
+
/** Folds server comments in: patch known ones, place insertable new ones. */
|
|
2367
|
+
#applyComments(comments, ticket, insertable) {
|
|
2368
|
+
if (!this.#current(ticket) || comments.length === 0) {
|
|
2369
|
+
return false;
|
|
2370
|
+
}
|
|
2371
|
+
const sanitized = comments.map((c) => this.#sanitize(c, ticket));
|
|
2372
|
+
this.#record({
|
|
2373
|
+
kind: "comments",
|
|
2374
|
+
comments: sanitized,
|
|
2375
|
+
insertable: new Set(sanitized.filter(insertable).map((c) => c.id)),
|
|
2376
|
+
ticket
|
|
2377
|
+
});
|
|
2378
|
+
let tree = this.#state.comments;
|
|
2379
|
+
let incoming = this.#incoming;
|
|
2380
|
+
const received = [];
|
|
2381
|
+
const me = this.#deps.currentUserId();
|
|
2382
|
+
const sort = this.#state.sort;
|
|
2383
|
+
for (const comment of sanitized) {
|
|
2384
|
+
if (findComment(tree, comment.id)) {
|
|
2385
|
+
tree = patchComment(tree, comment);
|
|
2386
|
+
continue;
|
|
2387
|
+
}
|
|
2388
|
+
if (findComment(incoming, comment.id)) {
|
|
2389
|
+
incoming = patchComment(incoming, comment);
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
if (!insertable(comment) || this.#deletedAt.has(comment.id)) {
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const parentNumber = comment.reply_to?.number;
|
|
2396
|
+
if (parentNumber === void 0) {
|
|
2397
|
+
if (me !== null && comment.author?.id === me) {
|
|
2398
|
+
tree = insertRoot(tree, comment, rootPosition(sort));
|
|
2399
|
+
received.push({ comment, buffered: false });
|
|
2400
|
+
} else {
|
|
2401
|
+
incoming = [...incoming, comment];
|
|
2402
|
+
received.push({ comment, buffered: true });
|
|
2403
|
+
}
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
const position = replyPosition(sort);
|
|
2407
|
+
const parentInTree = findByNumber(tree, parentNumber);
|
|
2408
|
+
if (parentInTree) {
|
|
2409
|
+
tree = insertReply(tree, parentInTree.node.id, comment, position, { bumpCounts: true }) ?? tree;
|
|
2410
|
+
received.push({ comment, buffered: false });
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
const parentInIncoming = findByNumber(incoming, parentNumber);
|
|
2414
|
+
if (parentInIncoming) {
|
|
2415
|
+
incoming = insertReply(incoming, parentInIncoming.node.id, comment, position, {
|
|
2416
|
+
bumpCounts: true
|
|
2417
|
+
}) ?? incoming;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
const changed = tree !== this.#state.comments || incoming !== this.#incoming;
|
|
2421
|
+
if (incoming !== this.#incoming) {
|
|
2422
|
+
this.#incomingIdentity = ticket.identity;
|
|
2423
|
+
}
|
|
2424
|
+
this.#incoming = incoming;
|
|
2425
|
+
this.#set({ comments: tree, pendingNew: incoming.length });
|
|
2426
|
+
for (const entry of received) {
|
|
2427
|
+
this.#events.emit({ type: "comment.received", ...entry });
|
|
2428
|
+
}
|
|
2429
|
+
return changed;
|
|
2430
|
+
}
|
|
2431
|
+
/** Records a deletion (barrier for older responses) and tombstones it everywhere. */
|
|
2432
|
+
#markDeleted(id) {
|
|
2433
|
+
this.#deletedAt.set(id, this.#event());
|
|
2434
|
+
this.#record({ kind: "tombstone", id });
|
|
2435
|
+
this.#applyTombstone(id);
|
|
2436
|
+
}
|
|
2437
|
+
#applyTombstone(id) {
|
|
2438
|
+
this.#incoming = tombstoneComment(
|
|
2439
|
+
this.#incoming.filter((c) => c.id !== id),
|
|
2440
|
+
id
|
|
2441
|
+
);
|
|
2442
|
+
this.#set({
|
|
2443
|
+
comments: tombstoneComment(this.#state.comments, id),
|
|
2444
|
+
pendingNew: this.#incoming.length
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
#record(entry) {
|
|
2448
|
+
if (this.#loadController) {
|
|
2449
|
+
this.#journal.push(entry);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
/** Re-applies facts that the just-loaded snapshot may predate. */
|
|
2453
|
+
#replayJournal() {
|
|
2454
|
+
const journal = this.#journal;
|
|
2455
|
+
this.#journal = [];
|
|
2456
|
+
for (const entry of journal) {
|
|
2457
|
+
if (entry.kind === "tombstone") {
|
|
2458
|
+
this.#applyTombstone(entry.id);
|
|
2459
|
+
} else if (entry.kind === "pins") {
|
|
2460
|
+
if (entry.identity === this.#deps.identity()) {
|
|
2461
|
+
this.#applyPins(entry.ids);
|
|
2462
|
+
}
|
|
2463
|
+
} else {
|
|
2464
|
+
this.#applyComments(entry.comments, entry.ticket, (c) => entry.insertable.has(c.id));
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
// Helpers
|
|
2469
|
+
#insertOwn(comment, replyTo, ticket) {
|
|
2470
|
+
this.#record({ kind: "comments", comments: [comment], insertable: /* @__PURE__ */ new Set([comment.id]), ticket });
|
|
2471
|
+
const sort = this.#state.sort;
|
|
2472
|
+
let comments = this.#state.comments;
|
|
2473
|
+
if (replyTo !== void 0) {
|
|
2474
|
+
comments = insertReply(comments, replyTo, comment, replyPosition(sort), { bumpCounts: true }) ?? comments;
|
|
2475
|
+
} else {
|
|
2476
|
+
comments = insertRoot(comments, comment, rootPosition(sort));
|
|
2477
|
+
}
|
|
2478
|
+
this.#incoming = this.#incoming.filter((c) => c.id !== comment.id);
|
|
2479
|
+
this.#set({ comments, pendingNew: this.#incoming.length });
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* Likes are optimistic. Requests for one comment are sent in order and only
|
|
2483
|
+
* the latest intent's outcome (or rollback) touches what is shown.
|
|
2484
|
+
*/
|
|
2485
|
+
async #toggleLike(commentId, liked) {
|
|
2486
|
+
const node = findComment(this.#state.comments, commentId)?.node;
|
|
2487
|
+
if (!node) {
|
|
2488
|
+
throw notInThread(commentId);
|
|
2489
|
+
}
|
|
2490
|
+
const ticket = this.#ticket();
|
|
2491
|
+
const seq = this.#event();
|
|
2492
|
+
const intent = this.#likes.get(commentId) ?? {
|
|
2493
|
+
seq,
|
|
2494
|
+
confirmed: node.likes,
|
|
2495
|
+
chain: Promise.resolve()
|
|
2496
|
+
};
|
|
2497
|
+
intent.seq = seq;
|
|
2498
|
+
this.#likes.set(commentId, intent);
|
|
2499
|
+
if (node.likes.liked !== liked) {
|
|
2500
|
+
this.#set({
|
|
2501
|
+
comments: updateComment(this.#state.comments, commentId, (c) => ({
|
|
2502
|
+
...c,
|
|
2503
|
+
likes: { ...c.likes, liked, count: Math.max(0, c.likes.count + (liked ? 1 : -1)) }
|
|
2504
|
+
}))
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
const request = intent.chain.catch(() => {
|
|
2508
|
+
}).then(
|
|
2509
|
+
() => this.#deps.http.request(
|
|
2510
|
+
liked ? "PUT" : "DELETE",
|
|
2511
|
+
`${this.#commentPath(commentId)}/like`
|
|
2512
|
+
)
|
|
2513
|
+
);
|
|
2514
|
+
intent.chain = request;
|
|
2515
|
+
try {
|
|
2516
|
+
const response = await request;
|
|
2517
|
+
if (!this.#current(ticket)) {
|
|
2518
|
+
return response.comment;
|
|
2519
|
+
}
|
|
2520
|
+
intent.confirmed = response.comment.likes;
|
|
2521
|
+
if (intent.seq === seq) {
|
|
2522
|
+
this.#likes.delete(commentId);
|
|
2523
|
+
}
|
|
2524
|
+
this.#applyComments([response.comment], ticket, () => false);
|
|
2525
|
+
return response.comment;
|
|
2526
|
+
} catch (error) {
|
|
2527
|
+
if (this.#current(ticket) && intent.seq === seq) {
|
|
2528
|
+
this.#likes.delete(commentId);
|
|
2529
|
+
this.#set({
|
|
2530
|
+
comments: updateComment(
|
|
2531
|
+
this.#state.comments,
|
|
2532
|
+
commentId,
|
|
2533
|
+
(c) => sameJson(c.likes, intent.confirmed) ? c : { ...c, likes: intent.confirmed }
|
|
2534
|
+
)
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
throw toCommentsError2(error);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
async #setPin(commentId, method) {
|
|
2541
|
+
const ticket = this.#ticket();
|
|
2542
|
+
const response = await this.#deps.http.request(
|
|
2543
|
+
method,
|
|
2544
|
+
`${this.#commentPath(commentId)}/pin`
|
|
2545
|
+
);
|
|
2546
|
+
if (this.#current(ticket)) {
|
|
2547
|
+
this.#record({ kind: "pins", ids: response.pinned_ids, identity: ticket.identity });
|
|
2548
|
+
this.#applyPins(response.pinned_ids);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
#applyPins(pinnedIds) {
|
|
2552
|
+
const thread = this.#state.thread;
|
|
2553
|
+
if (isVirtualThread(thread)) {
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
this.#set({
|
|
2557
|
+
thread: sameJson(thread.pinned_ids, pinnedIds) ? thread : { ...thread, pinned_ids: pinnedIds },
|
|
2558
|
+
comments: applyPins(this.#state.comments, pinnedIds)
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
#mergeThreadMeta(thread) {
|
|
2562
|
+
const current = this.#state.thread;
|
|
2563
|
+
if (isVirtualThread(current) || current.id !== thread.id) {
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
const next = { ...thread, sort: current.sort, message_bus_last_id: current.message_bus_last_id };
|
|
2567
|
+
if (!sameJson(next, current)) {
|
|
2568
|
+
this.#set({ thread: next });
|
|
2569
|
+
if (this.#realtimeKey !== null && next.realtime !== current.realtime) {
|
|
2570
|
+
this.#ensureRealtime();
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
#fetchByIds(ids) {
|
|
2575
|
+
return this.#deps.http.request("GET", threadPath(this.id), {
|
|
2576
|
+
query: { ids, sort: this.#state.sort }
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
#commentPath(commentId) {
|
|
2580
|
+
return `${threadPath(this.id)}/comments/${commentId}`;
|
|
2581
|
+
}
|
|
2582
|
+
#setBranch(commentId, branch) {
|
|
2583
|
+
this.#set({ branches: { ...this.#state.branches, [commentId]: branch } });
|
|
2584
|
+
}
|
|
2585
|
+
#set(patch) {
|
|
2586
|
+
let changed = false;
|
|
2587
|
+
for (const key of Object.keys(patch)) {
|
|
2588
|
+
if (patch[key] !== this.#state[key]) {
|
|
2589
|
+
changed = true;
|
|
2590
|
+
break;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
if (!changed || this.#disposed) {
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
this.#state = Object.freeze({ ...this.#state, ...patch });
|
|
2597
|
+
this.#events.emit({ type: "change", snapshot: this.#state });
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
function sameJson(a, b) {
|
|
2601
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
2602
|
+
}
|
|
2603
|
+
function notInThread(commentId) {
|
|
2604
|
+
return new CommentsError("not_found", `Comment ${commentId} is not loaded in this thread`, {
|
|
2605
|
+
status: 404
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
function toCommentsError2(error) {
|
|
2609
|
+
if (isCommentsError(error)) {
|
|
2610
|
+
return error;
|
|
2611
|
+
}
|
|
2612
|
+
return new CommentsError("unknown", error instanceof Error ? error.message : String(error), {
|
|
2613
|
+
cause: error
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// src/core/client.ts
|
|
2618
|
+
var DEGRADED_RECHECK_MS = 6e4;
|
|
2619
|
+
var MAX_DEGRADED_RECHECK_MS = 10 * 6e4;
|
|
2620
|
+
var MAX_DEGRADED_CHECKS = 6;
|
|
2621
|
+
function createClient(options) {
|
|
2622
|
+
return createClientWithInternals(options, {});
|
|
2623
|
+
}
|
|
2624
|
+
function createClientWithInternals(options, internals) {
|
|
2625
|
+
const forum = normalizeForum(options.forum);
|
|
2626
|
+
const log = createLogger(options.debug);
|
|
2627
|
+
const { storage, syncAcrossTabs } = options.storage ? { storage: options.storage, syncAcrossTabs: isLocalStorage(options.storage) } : (() => {
|
|
2628
|
+
const resolved = browserStorage();
|
|
2629
|
+
return { storage: resolved.storage, syncAcrossTabs: resolved.isLocalStorage };
|
|
2630
|
+
})();
|
|
2631
|
+
let latestConfig = null;
|
|
2632
|
+
let configPromise = null;
|
|
2633
|
+
const threads = /* @__PURE__ */ new Set();
|
|
2634
|
+
const lifetime = new AbortController();
|
|
2635
|
+
const disposedError = () => new CommentsError("cancelled", "The comments client was disposed");
|
|
2636
|
+
let auth;
|
|
2637
|
+
const http = new Http({
|
|
2638
|
+
forum,
|
|
2639
|
+
fetch: options.fetch,
|
|
2640
|
+
headers: options.headers,
|
|
2641
|
+
locale: options.locale,
|
|
2642
|
+
credentials: () => auth.credentials(),
|
|
2643
|
+
onInvalidKey: (key) => auth.invalidate(key),
|
|
2644
|
+
log,
|
|
2645
|
+
lifetime: lifetime.signal
|
|
2646
|
+
});
|
|
2647
|
+
auth = new AuthController({
|
|
2648
|
+
http,
|
|
2649
|
+
forum,
|
|
2650
|
+
storage,
|
|
2651
|
+
pendingStorage: signInStorage(storage),
|
|
2652
|
+
syncAcrossTabs,
|
|
2653
|
+
clientId: options.clientId,
|
|
2654
|
+
log,
|
|
2655
|
+
providers: () => latestConfig?.auth_providers ?? [],
|
|
2656
|
+
navigate: internals.navigate
|
|
2657
|
+
});
|
|
2658
|
+
const realtime = internals.realtime ?? new MessageBusPoller({ base: forum.base, fetch: http.fetch, log });
|
|
2659
|
+
const batcher = new CountsBatcher(http);
|
|
2660
|
+
let configGeneration = 0;
|
|
2661
|
+
let degradedRecheck;
|
|
2662
|
+
let degradedChecks = 0;
|
|
2663
|
+
function config() {
|
|
2664
|
+
if (lifetime.signal.aborted) {
|
|
2665
|
+
return Promise.reject(disposedError());
|
|
2666
|
+
}
|
|
2667
|
+
if (!configPromise) {
|
|
2668
|
+
const generation = configGeneration;
|
|
2669
|
+
configPromise = http.request("GET", "/config").then(
|
|
2670
|
+
(loaded) => {
|
|
2671
|
+
if (lifetime.signal.aborted) {
|
|
2672
|
+
throw disposedError();
|
|
2673
|
+
}
|
|
2674
|
+
if (generation !== configGeneration) {
|
|
2675
|
+
return config();
|
|
2676
|
+
}
|
|
2677
|
+
latestConfig = loaded;
|
|
2678
|
+
auth.applyConfig(loaded);
|
|
2679
|
+
clearTimeout(degradedRecheck);
|
|
2680
|
+
if (loaded.degraded) {
|
|
2681
|
+
scheduleDegradedRecheck();
|
|
2682
|
+
} else {
|
|
2683
|
+
degradedChecks = 0;
|
|
2684
|
+
}
|
|
2685
|
+
return loaded;
|
|
2686
|
+
},
|
|
2687
|
+
(error) => {
|
|
2688
|
+
if (generation === configGeneration) {
|
|
2689
|
+
configPromise = null;
|
|
2690
|
+
}
|
|
2691
|
+
throw error;
|
|
2692
|
+
}
|
|
2693
|
+
);
|
|
2694
|
+
}
|
|
2695
|
+
return configPromise;
|
|
2696
|
+
}
|
|
2697
|
+
function scheduleDegradedRecheck() {
|
|
2698
|
+
if (lifetime.signal.aborted || degradedChecks >= MAX_DEGRADED_CHECKS) {
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
const delay = Math.min(DEGRADED_RECHECK_MS * 2 ** degradedChecks, MAX_DEGRADED_RECHECK_MS);
|
|
2702
|
+
degradedChecks++;
|
|
2703
|
+
clearTimeout(degradedRecheck);
|
|
2704
|
+
degradedRecheck = setTimeout(() => {
|
|
2705
|
+
configPromise = null;
|
|
2706
|
+
config().catch(() => scheduleDegradedRecheck());
|
|
2707
|
+
}, delay);
|
|
2708
|
+
}
|
|
2709
|
+
let lastUserId = auth.user?.id ?? null;
|
|
2710
|
+
let identity = 0;
|
|
2711
|
+
const stopAuthListener = auth.onChange((state) => {
|
|
2712
|
+
if (state.status === "signing-in") {
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const userId = state.status === "signed-in" ? state.user?.id ?? null : null;
|
|
2716
|
+
if (userId === lastUserId) {
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
lastUserId = userId;
|
|
2720
|
+
identity++;
|
|
2721
|
+
const hadConfig = configPromise !== null;
|
|
2722
|
+
configGeneration++;
|
|
2723
|
+
configPromise = null;
|
|
2724
|
+
if (hadConfig) {
|
|
2725
|
+
void config().catch(() => {
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
for (const thread of threads) {
|
|
2729
|
+
void thread.load().catch(() => {
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
async function open(openOptions = {}) {
|
|
2734
|
+
if (lifetime.signal.aborted) {
|
|
2735
|
+
throw disposedError();
|
|
2736
|
+
}
|
|
2737
|
+
const url = openOptions.url ?? defaultPageUrl();
|
|
2738
|
+
if (!url) {
|
|
2739
|
+
throw new CommentsError("invalid_url", "No page URL: pass `url` outside the browser");
|
|
2740
|
+
}
|
|
2741
|
+
const page = {
|
|
2742
|
+
url,
|
|
2743
|
+
title: openOptions.title ?? defaultPageTitle(),
|
|
2744
|
+
identifier: openOptions.identifier
|
|
2745
|
+
};
|
|
2746
|
+
const resolve = async () => (await http.request("POST", "/threads", { json: page })).thread;
|
|
2747
|
+
const thread = await resolve();
|
|
2748
|
+
if (lifetime.signal.aborted) {
|
|
2749
|
+
throw disposedError();
|
|
2750
|
+
}
|
|
2751
|
+
const handle = new CommentThread(
|
|
2752
|
+
thread,
|
|
2753
|
+
{
|
|
2754
|
+
http,
|
|
2755
|
+
realtime,
|
|
2756
|
+
currentUserId: () => auth.user?.id ?? null,
|
|
2757
|
+
identity: () => identity,
|
|
2758
|
+
page,
|
|
2759
|
+
resolve,
|
|
2760
|
+
log,
|
|
2761
|
+
onDispose: () => threads.delete(handle)
|
|
2762
|
+
},
|
|
2763
|
+
openOptions.sort
|
|
2764
|
+
);
|
|
2765
|
+
threads.add(handle);
|
|
2766
|
+
await handle.load().catch(() => {
|
|
2767
|
+
});
|
|
2768
|
+
return handle;
|
|
2769
|
+
}
|
|
2770
|
+
async function upload(threadId, file, uploadOptions = {}) {
|
|
2771
|
+
const form = new FormData();
|
|
2772
|
+
const name = uploadOptions.filename ?? file.name ?? "upload";
|
|
2773
|
+
form.append("file", file, name);
|
|
2774
|
+
if (uploadOptions.pasted) {
|
|
2775
|
+
form.append("pasted", "true");
|
|
2776
|
+
}
|
|
2777
|
+
return http.request("POST", `${threadPath(threadId)}/attachments`, { form });
|
|
2778
|
+
}
|
|
2779
|
+
const client = {
|
|
2780
|
+
forum: forum.base,
|
|
2781
|
+
forumOrigin: forum.origin,
|
|
2782
|
+
locale: options.locale,
|
|
2783
|
+
auth,
|
|
2784
|
+
threads: { open },
|
|
2785
|
+
attachments: { upload },
|
|
2786
|
+
config,
|
|
2787
|
+
counts: (refs) => batcher.request(refs),
|
|
2788
|
+
count: async (ref) => (await batcher.request([ref]))[encodeRef(ref)] ?? 0,
|
|
2789
|
+
preview: async (body) => (await http.request("POST", "/preview", { json: { raw: body } })).html,
|
|
2790
|
+
mentions: async (query, threadId) => (await http.request("GET", "/mentions", {
|
|
2791
|
+
query: { q: query, thread: threadId }
|
|
2792
|
+
})).users,
|
|
2793
|
+
dispose() {
|
|
2794
|
+
if (lifetime.signal.aborted) {
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
lifetime.abort();
|
|
2798
|
+
for (const thread of [...threads]) {
|
|
2799
|
+
thread.dispose();
|
|
2800
|
+
}
|
|
2801
|
+
realtime.stop();
|
|
2802
|
+
clearTimeout(degradedRecheck);
|
|
2803
|
+
stopAuthListener();
|
|
2804
|
+
auth.dispose();
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
if (getWindow()) {
|
|
2808
|
+
void auth.handleRedirect();
|
|
2809
|
+
}
|
|
2810
|
+
return client;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// src/core/registry.ts
|
|
2814
|
+
function createClientRegistry(create = createClient) {
|
|
2815
|
+
const clients = /* @__PURE__ */ new Map();
|
|
2816
|
+
return {
|
|
2817
|
+
acquire(options) {
|
|
2818
|
+
const key = normalizeForum(options.forum).base;
|
|
2819
|
+
let client = clients.get(key);
|
|
2820
|
+
if (!client) {
|
|
2821
|
+
client = create(options);
|
|
2822
|
+
clients.set(key, client);
|
|
2823
|
+
}
|
|
2824
|
+
return client;
|
|
2825
|
+
},
|
|
2826
|
+
get(forum) {
|
|
2827
|
+
return clients.get(normalizeForum(forum).base);
|
|
2828
|
+
},
|
|
2829
|
+
clear(forum) {
|
|
2830
|
+
const keys = forum === void 0 ? [...clients.keys()] : [normalizeForum(forum).base];
|
|
2831
|
+
for (const key of keys) {
|
|
2832
|
+
clients.get(key)?.dispose();
|
|
2833
|
+
clients.delete(key);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
var clientRegistry = createClientRegistry();
|
|
2839
|
+
|
|
2840
|
+
// src/messages.ts
|
|
2841
|
+
var en = {
|
|
2842
|
+
signIn: "Sign in",
|
|
2843
|
+
signInWith: "Continue with {provider}",
|
|
2844
|
+
signOut: "Sign out",
|
|
2845
|
+
signingIn: "Finish signing in in the window that opened.",
|
|
2846
|
+
signInCancel: "Cancel",
|
|
2847
|
+
sessionExpired: "Your session expired. Sign in again to comment.",
|
|
2848
|
+
joinConversation: "Join the conversation",
|
|
2849
|
+
joinConversationBody: "Sign in with your {forum} account to comment.",
|
|
2850
|
+
composerPlaceholder: "Add a comment\u2026",
|
|
2851
|
+
post: "Post",
|
|
2852
|
+
posting: "Posting\u2026",
|
|
2853
|
+
reply: "Reply",
|
|
2854
|
+
replyingTo: "Replying to @{username}",
|
|
2855
|
+
cancel: "Cancel",
|
|
2856
|
+
save: "Save",
|
|
2857
|
+
edit: "Edit",
|
|
2858
|
+
edited: "Edited",
|
|
2859
|
+
delete: "Delete",
|
|
2860
|
+
deleteConfirm: "Delete this comment?",
|
|
2861
|
+
deleted: "This comment was deleted.",
|
|
2862
|
+
hidden: "This comment was hidden after reports from the community.",
|
|
2863
|
+
like: "Like",
|
|
2864
|
+
unlike: "Remove like",
|
|
2865
|
+
likeCount: { zero: "No likes", one: "{count} like", other: "{count} likes" },
|
|
2866
|
+
report: "Report",
|
|
2867
|
+
reportTitle: "Why are you reporting this comment?",
|
|
2868
|
+
"report.off_topic": "Off-topic",
|
|
2869
|
+
"report.inappropriate": "Inappropriate",
|
|
2870
|
+
"report.spam": "Spam",
|
|
2871
|
+
"report.illegal": "Illegal",
|
|
2872
|
+
"report.notify_moderators": "Something else",
|
|
2873
|
+
reportMessagePlaceholder: "Tell the moderators more (optional)",
|
|
2874
|
+
reportSent: "Thanks, the moderators will take a look.",
|
|
2875
|
+
pin: "Feature",
|
|
2876
|
+
unpin: "Unfeature",
|
|
2877
|
+
pinned: "Featured",
|
|
2878
|
+
pending: "Your comment was submitted and is awaiting approval.",
|
|
2879
|
+
loadMore: "Load more comments",
|
|
2880
|
+
loadReplies: { one: "View {count} reply", other: "View {count} replies" },
|
|
2881
|
+
continueThread: "Continue this thread",
|
|
2882
|
+
newComments: { one: "{count} new comment", other: "{count} new comments" },
|
|
2883
|
+
commentCount: { zero: "No comments yet", one: "{count} comment", other: "{count} comments" },
|
|
2884
|
+
sortBy: "Sort by",
|
|
2885
|
+
"sort.best": "Best",
|
|
2886
|
+
"sort.hot": "Hot",
|
|
2887
|
+
"sort.newest": "Newest",
|
|
2888
|
+
"sort.oldest": "Oldest",
|
|
2889
|
+
closed: "Comments are closed.",
|
|
2890
|
+
readOnly: "Comments are read-only right now.",
|
|
2891
|
+
guidelines: "Community guidelines",
|
|
2892
|
+
preview: "Preview",
|
|
2893
|
+
write: "Write",
|
|
2894
|
+
unavailable: "Comments are unavailable right now.",
|
|
2895
|
+
retry: "Try again",
|
|
2896
|
+
rateLimited: {
|
|
2897
|
+
one: "You're doing that too often. Try again in {count} second.",
|
|
2898
|
+
other: "You're doing that too often. Try again in {count} seconds."
|
|
2899
|
+
},
|
|
2900
|
+
genericError: "Something went wrong. Please try again.",
|
|
2901
|
+
copyLink: "Copy link",
|
|
2902
|
+
linkCopied: "Link copied",
|
|
2903
|
+
viewOnForum: "View on {forum}",
|
|
2904
|
+
staff: "Staff",
|
|
2905
|
+
upload: "Attach image",
|
|
2906
|
+
uploading: "Uploading\u2026",
|
|
2907
|
+
comments: "Comments",
|
|
2908
|
+
signedInAs: "Signed in as {username}",
|
|
2909
|
+
loadMoreReplies: { one: "View {count} more reply", other: "View {count} more replies" },
|
|
2910
|
+
backToAll: "Back to all comments",
|
|
2911
|
+
ancestorsTruncated: "Some earlier replies are not shown.",
|
|
2912
|
+
moreActions: "More actions",
|
|
2913
|
+
commentBy: "Comment by {name}",
|
|
2914
|
+
deletedComment: "Deleted comment",
|
|
2915
|
+
repliesTo: "Replies to {name}",
|
|
2916
|
+
replyPlaceholder: "Write a reply\u2026",
|
|
2917
|
+
editing: "Editing your comment",
|
|
2918
|
+
tooShort: "Comments need at least {min} characters.",
|
|
2919
|
+
tooLong: "Comments can have at most {max} characters.",
|
|
2920
|
+
formatting: "Formatting",
|
|
2921
|
+
bold: "Bold",
|
|
2922
|
+
italic: "Italic",
|
|
2923
|
+
insertLink: "Insert link",
|
|
2924
|
+
code: "Code",
|
|
2925
|
+
quote: "Quote",
|
|
2926
|
+
mentionSuggestions: "Mention suggestions",
|
|
2927
|
+
previewEmpty: "Nothing to preview yet.",
|
|
2928
|
+
loading: "Loading\u2026",
|
|
2929
|
+
cannotComment: "You can't comment on this page.",
|
|
2930
|
+
reportMessageRequired: "Tell the moderators what's wrong",
|
|
2931
|
+
close: "Close"
|
|
2932
|
+
};
|
|
2933
|
+
var ptBR = {
|
|
2934
|
+
signIn: "Entrar",
|
|
2935
|
+
signInWith: "Continuar com {provider}",
|
|
2936
|
+
signOut: "Sair",
|
|
2937
|
+
signingIn: "Conclua o login na janela que foi aberta.",
|
|
2938
|
+
signInCancel: "Cancelar",
|
|
2939
|
+
sessionExpired: "Sua sess\xE3o expirou. Entre novamente para comentar.",
|
|
2940
|
+
joinConversation: "Participe da conversa",
|
|
2941
|
+
joinConversationBody: "Entre com sua conta de {forum} para comentar.",
|
|
2942
|
+
composerPlaceholder: "Escreva um coment\xE1rio\u2026",
|
|
2943
|
+
post: "Publicar",
|
|
2944
|
+
posting: "Publicando\u2026",
|
|
2945
|
+
reply: "Responder",
|
|
2946
|
+
replyingTo: "Respondendo a @{username}",
|
|
2947
|
+
cancel: "Cancelar",
|
|
2948
|
+
save: "Salvar",
|
|
2949
|
+
edit: "Editar",
|
|
2950
|
+
edited: "Editado",
|
|
2951
|
+
delete: "Excluir",
|
|
2952
|
+
deleteConfirm: "Excluir este coment\xE1rio?",
|
|
2953
|
+
deleted: "Este coment\xE1rio foi exclu\xEDdo.",
|
|
2954
|
+
hidden: "Este coment\xE1rio foi ocultado ap\xF3s den\xFAncias da comunidade.",
|
|
2955
|
+
like: "Curtir",
|
|
2956
|
+
unlike: "Descurtir",
|
|
2957
|
+
likeCount: { zero: "Nenhuma curtida", one: "{count} curtida", other: "{count} curtidas" },
|
|
2958
|
+
report: "Denunciar",
|
|
2959
|
+
reportTitle: "Por que voc\xEA est\xE1 denunciando este coment\xE1rio?",
|
|
2960
|
+
"report.off_topic": "Fora do assunto",
|
|
2961
|
+
"report.inappropriate": "Inapropriado",
|
|
2962
|
+
"report.spam": "Spam",
|
|
2963
|
+
"report.illegal": "Ilegal",
|
|
2964
|
+
"report.notify_moderators": "Outro motivo",
|
|
2965
|
+
reportMessagePlaceholder: "Conte mais aos moderadores (opcional)",
|
|
2966
|
+
reportSent: "Obrigado, os moderadores v\xE3o analisar.",
|
|
2967
|
+
pin: "Destacar",
|
|
2968
|
+
unpin: "Remover destaque",
|
|
2969
|
+
pinned: "Em destaque",
|
|
2970
|
+
pending: "Seu coment\xE1rio foi enviado e aguarda aprova\xE7\xE3o.",
|
|
2971
|
+
loadMore: "Carregar mais coment\xE1rios",
|
|
2972
|
+
loadReplies: { one: "Ver {count} resposta", other: "Ver {count} respostas" },
|
|
2973
|
+
continueThread: "Continuar esta conversa",
|
|
2974
|
+
newComments: { one: "{count} novo coment\xE1rio", other: "{count} novos coment\xE1rios" },
|
|
2975
|
+
commentCount: {
|
|
2976
|
+
zero: "Nenhum coment\xE1rio ainda",
|
|
2977
|
+
one: "{count} coment\xE1rio",
|
|
2978
|
+
other: "{count} coment\xE1rios"
|
|
2979
|
+
},
|
|
2980
|
+
sortBy: "Ordenar por",
|
|
2981
|
+
"sort.best": "Melhores",
|
|
2982
|
+
"sort.hot": "Em alta",
|
|
2983
|
+
"sort.newest": "Mais recentes",
|
|
2984
|
+
"sort.oldest": "Mais antigos",
|
|
2985
|
+
closed: "Os coment\xE1rios est\xE3o encerrados.",
|
|
2986
|
+
readOnly: "Os coment\xE1rios est\xE3o somente leitura no momento.",
|
|
2987
|
+
guidelines: "Diretrizes da comunidade",
|
|
2988
|
+
preview: "Pr\xE9-visualizar",
|
|
2989
|
+
write: "Escrever",
|
|
2990
|
+
unavailable: "Os coment\xE1rios est\xE3o indispon\xEDveis no momento.",
|
|
2991
|
+
retry: "Tentar novamente",
|
|
2992
|
+
rateLimited: {
|
|
2993
|
+
one: "Voc\xEA est\xE1 fazendo isso com muita frequ\xEAncia. Tente novamente em {count} segundo.",
|
|
2994
|
+
other: "Voc\xEA est\xE1 fazendo isso com muita frequ\xEAncia. Tente novamente em {count} segundos."
|
|
2995
|
+
},
|
|
2996
|
+
genericError: "Algo deu errado. Tente novamente.",
|
|
2997
|
+
copyLink: "Copiar link",
|
|
2998
|
+
linkCopied: "Link copiado",
|
|
2999
|
+
viewOnForum: "Ver em {forum}",
|
|
3000
|
+
staff: "Equipe",
|
|
3001
|
+
upload: "Anexar imagem",
|
|
3002
|
+
uploading: "Enviando\u2026",
|
|
3003
|
+
comments: "Coment\xE1rios",
|
|
3004
|
+
signedInAs: "Conectado como {username}",
|
|
3005
|
+
loadMoreReplies: { one: "Ver mais {count} resposta", other: "Ver mais {count} respostas" },
|
|
3006
|
+
backToAll: "Voltar para todos os coment\xE1rios",
|
|
3007
|
+
ancestorsTruncated: "Algumas respostas anteriores n\xE3o s\xE3o exibidas.",
|
|
3008
|
+
moreActions: "Mais a\xE7\xF5es",
|
|
3009
|
+
commentBy: "Coment\xE1rio de {name}",
|
|
3010
|
+
deletedComment: "Coment\xE1rio exclu\xEDdo",
|
|
3011
|
+
repliesTo: "Respostas a {name}",
|
|
3012
|
+
replyPlaceholder: "Escreva uma resposta\u2026",
|
|
3013
|
+
editing: "Editando seu coment\xE1rio",
|
|
3014
|
+
tooShort: "Coment\xE1rios precisam ter pelo menos {min} caracteres.",
|
|
3015
|
+
tooLong: "Coment\xE1rios podem ter no m\xE1ximo {max} caracteres.",
|
|
3016
|
+
formatting: "Formata\xE7\xE3o",
|
|
3017
|
+
bold: "Negrito",
|
|
3018
|
+
italic: "It\xE1lico",
|
|
3019
|
+
insertLink: "Inserir link",
|
|
3020
|
+
code: "C\xF3digo",
|
|
3021
|
+
quote: "Cita\xE7\xE3o",
|
|
3022
|
+
mentionSuggestions: "Sugest\xF5es de men\xE7\xE3o",
|
|
3023
|
+
previewEmpty: "Nada para pr\xE9-visualizar ainda.",
|
|
3024
|
+
loading: "Carregando\u2026",
|
|
3025
|
+
cannotComment: "Voc\xEA n\xE3o pode comentar nesta p\xE1gina.",
|
|
3026
|
+
reportMessageRequired: "Conte aos moderadores o que h\xE1 de errado",
|
|
3027
|
+
close: "Fechar"
|
|
3028
|
+
};
|
|
3029
|
+
var locales = { en, "pt-BR": ptBR };
|
|
3030
|
+
function resolveLocale(locale) {
|
|
3031
|
+
return locale?.toLowerCase().startsWith("pt") ? "pt-BR" : "en";
|
|
3032
|
+
}
|
|
3033
|
+
function createTranslator(locale, overrides = {}) {
|
|
3034
|
+
const resolved = resolveLocale(locale);
|
|
3035
|
+
const table = { ...locales[resolved], ...overrides };
|
|
3036
|
+
const rules = new Intl.PluralRules(resolved);
|
|
3037
|
+
return (key, vars) => {
|
|
3038
|
+
const value = table[key];
|
|
3039
|
+
let template;
|
|
3040
|
+
if (typeof value === "string") {
|
|
3041
|
+
template = value;
|
|
3042
|
+
} else {
|
|
3043
|
+
const count = Number(vars?.count ?? 0);
|
|
3044
|
+
const category = rules.select(count);
|
|
3045
|
+
template = (count === 0 ? value.zero : void 0) ?? value[category] ?? value.other;
|
|
3046
|
+
}
|
|
3047
|
+
return template.replace(
|
|
3048
|
+
/\{(\w+)\}/g,
|
|
3049
|
+
(match, name) => vars && name in vars ? String(vars[name]) : match
|
|
3050
|
+
);
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
export { CommentsError, PACKAGE_NAME, STORAGE_PREFIX, clientRegistry, createClient, createClientRegistry, createComposerTimer, createTranslator, currentUrlWithoutHash, defaultPageUrl, en, encodeRef, findComment, isCommentsError, isVirtualThread, locales, memoryStorage, normalizeForum, ptBR, resolveLocale };
|
|
3055
|
+
//# sourceMappingURL=chunk-6E3M3PQN.js.map
|
|
3056
|
+
//# sourceMappingURL=chunk-6E3M3PQN.js.map
|