@rathnasgala2/template 3.0.1 → 3.2.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.
@@ -0,0 +1,2176 @@
1
+ /**
2
+ * The reader-interactions browser script, emitted by the renderer as
3
+ * `assets/gala-interactions-v1.js` on article routes of publications whose
4
+ * repository enables the interactions module. A same-origin external file
5
+ * (CSP `script-src 'self'`), loaded with `defer`. Plain ES2020, no
6
+ * dependencies, no build step, one IIFE.
7
+ *
8
+ * It enhances the server-rendered `[data-gala-interactions]` section (reaction
9
+ * buttons, comment region, status live region) and nothing else; the article
10
+ * is fully readable with it absent or failing. Configuration comes only from
11
+ * that element's `data-*` attributes.
12
+ *
13
+ * Reader sign-in is a top-level redirect to the App plus OAuth 2.0
14
+ * authorization code with PKCE (RFC 7636):
15
+ *
16
+ * 1. `state` and `codeVerifier` are random, the challenge is the SHA-256 of
17
+ * the verifier, and `{state, codeVerifier, intent, createdAt}` is kept in
18
+ * `sessionStorage["gala.reader.pending.<publicationId>"]`.
19
+ * 2. The page navigates (top level) to `<appOrigin>/reader/connect?...`.
20
+ * 3. On return the URL fragment carries `gala-reader-code` and
21
+ * `gala-reader-state`. The fragment is replaced with `#comments` at once,
22
+ * the state is compared, the code is exchanged for a publication-scoped
23
+ * bearer token, which is kept in
24
+ * `localStorage["gala.reader.session.<publicationId>"]`, and the pending
25
+ * intent is replayed (a reaction is applied; a comment draft is restored
26
+ * into the composer and never auto-posted).
27
+ *
28
+ * Every request is `mode: 'cors'`, `credentials: 'omit'`, bounded by a 15 s
29
+ * `AbortController` timeout, and carries the bearer token explicitly. Comment
30
+ * POSTs carry an `Idempotency-Key` that is reused while the same draft is
31
+ * retried.
32
+ *
33
+ * Appeals: a signed-in reader's thread read also carries that reader's own
34
+ * removed or held comments, each with an `own` object (`state` REMOVED or HELD,
35
+ * the `appeal` so far or null, and `canAppeal`) and no body. Such a comment is
36
+ * shown as "Removed by the site's moderators" (held: "Hidden while the
37
+ * moderators review it"), headed by a one-line context, "Your comment from
38
+ * <relative date>" (from `createdAt`), that tells the reader which comment it
39
+ * is; when `canAppeal` is true it offers an Appeal button that opens a note
40
+ * field (up to 1,000 characters) and posts `{note}` to
41
+ * `/v2/public/comments/{commentId}/appeals` with the bearer token. The
42
+ * appeal's state (pending, restored, kept removed) is written under the
43
+ * comment, and only there: sending an appeal updates the comment's state and
44
+ * redraws it, it never adds a sentence of its own, so "Appeal sent. ..." shows
45
+ * once, from the state, whether the reader has just sent it or the thread was
46
+ * read again. There is one appeal per removal; the moderators decide, never the
47
+ * script.
48
+ *
49
+ * Not used, by design: iframes, popups (`window.open`), `postMessage`,
50
+ * FedCM, cookies, third-party scripts or requests.
51
+ *
52
+ * Security: every piece of text from the API reaches the DOM through
53
+ * `textContent`, `createTextNode` or `setAttribute`; API data is never parsed
54
+ * as HTML and no `innerHTML` is used at all. URLs in comments are linked by
55
+ * splitting text nodes, only for http(s), with
56
+ * `rel="nofollow ugc noopener noreferrer"`.
57
+ *
58
+ * Storage: every `localStorage` / `sessionStorage` access is wrapped; with
59
+ * storage unavailable reading still works and sign-in explains why it
60
+ * cannot proceed.
61
+ *
62
+ * Navigation seam: sign-in dispatches a cancelable `gala:navigate` event on
63
+ * the section (detail `{url}`) before assigning `location`; preventing it
64
+ * keeps the page in place (used by the tests, which cannot navigate a DOM
65
+ * emulation).
66
+ */
67
+
68
+ (function () {
69
+ 'use strict';
70
+
71
+ const root = /** @type {HTMLElement} */ (
72
+ document.querySelector('[data-gala-interactions]')
73
+ );
74
+ if (!root) return;
75
+
76
+ // ---------------------------------------------------------------- constants
77
+ const REQUEST_TIMEOUT_MS = 15000;
78
+ const COMMENT_LIMIT = 10000;
79
+ const COUNTER_THRESHOLD = 500;
80
+ const NOTE_LIMIT = 1000;
81
+ const PENDING_MAX_AGE_MS = 15 * 60 * 1000;
82
+ const RELATIVE_DAYS_LIMIT = 7;
83
+ const LINK_REL = 'nofollow ugc noopener noreferrer';
84
+ const URL_PATTERN = /https?:\/\/[^\s<>"']+/g;
85
+ const TRAILING_PUNCTUATION = /[.,;:!?'")\]}]+$/;
86
+
87
+ const MESSAGES = {
88
+ signInAgain: 'Sign in again to continue.',
89
+ tooFast: "You're posting too quickly. Try again in a minute.",
90
+ commentsClosed: 'Comments are closed.',
91
+ empty: 'Write something first.',
92
+ tooLong: "That's longer than 10,000 characters.",
93
+ tooManyLinks: 'Too many links in one comment.',
94
+ blocked:
95
+ "That comment contains something we can't post (for example a password or key).",
96
+ network: "Couldn't reach Galascribe. Your draft is kept.",
97
+ unavailable: 'Responses are unavailable right now.',
98
+ storageBlocked:
99
+ 'Your browser blocked storage this site needs to sign you in.',
100
+ signInUnsupported: "This browser can't sign you in on this page.",
101
+ signInFailed: "Sign-in didn't complete. Try again.",
102
+ signInInvalid: "That sign-in link wasn't valid. Try again.",
103
+ generic: 'Something went wrong. Try again.',
104
+ repliesOff: 'Replies are turned off.',
105
+ tooDeep: "Replies can't go any deeper.",
106
+ parentGone: 'The comment you replied to is no longer available.',
107
+ duplicate: 'You already posted that.',
108
+ restricted: "Your account can't comment here right now.",
109
+ editClosed: 'The edit window has closed.',
110
+ notAuthor: 'Only the author can change this comment.',
111
+ gone: 'That comment is no longer available.',
112
+ posted: 'Comment posted.',
113
+ updated: 'Comment updated.',
114
+ deleted: 'Comment deleted.',
115
+ reported: 'Thanks. Your report was received.',
116
+ signedOut: 'Signed out.',
117
+ signInLabel: 'Sign in with GitHub to join the conversation',
118
+ signInShort: 'Sign in with GitHub',
119
+ post: 'Post',
120
+ save: 'Save',
121
+ cancel: 'Cancel',
122
+ reply: 'Reply',
123
+ edit: 'Edit',
124
+ remove: 'Delete',
125
+ report: 'Report',
126
+ confirmDelete: 'Delete this comment?',
127
+ composerLabel: 'Write a comment',
128
+ replyLabel: 'Write a reply',
129
+ editLabel: 'Edit your comment',
130
+ signedInAs: 'Signed in as ',
131
+ signOut: 'Sign out',
132
+ showMoreReplies: 'Show more replies',
133
+ deletedTombstone: 'This comment was deleted.',
134
+ removedTombstone: 'This comment was removed.',
135
+ ownFrom: 'Your comment from ',
136
+ removedOwn: "Removed by the site's moderators",
137
+ heldOwn: 'Hidden while the moderators review it',
138
+ appeal: 'Appeal',
139
+ appealAria: 'Appeal this decision',
140
+ appealLabel:
141
+ 'Tell the moderators why this should be restored (up to 1,000 characters)',
142
+ appealSend: 'Send',
143
+ appealSent: 'Appeal sent. The moderators will look again.',
144
+ appealRestored: 'Restored after review',
145
+ appealDenied: 'Kept removed after review',
146
+ appealEmpty: 'Write why you are appealing first.',
147
+ appealTooLong: "That's longer than 1,000 characters.",
148
+ appealExists: 'You already appealed this decision.',
149
+ appealNotAllowed: "This comment can't be appealed.",
150
+ appealTooMany: "You've sent several appeals today. Try again tomorrow.",
151
+ reportTitle: 'Report this comment',
152
+ reportReason: 'Reason',
153
+ reportNote: 'Details (optional)',
154
+ reportSubmit: 'Send report',
155
+ justNow: 'just now',
156
+ edited: ' (edited)',
157
+ };
158
+
159
+ const REPORT_REASONS = [
160
+ ['SPAM', 'Spam'],
161
+ ['HARASSMENT', 'Harassment'],
162
+ ['HATE', 'Hate speech'],
163
+ ['SEXUAL_CONTENT', 'Sexual content'],
164
+ ['VIOLENCE', 'Violence'],
165
+ ['PRIVACY', 'Private information'],
166
+ ['SELF_HARM', 'Self-harm'],
167
+ ['OTHER', 'Something else'],
168
+ ];
169
+
170
+ // -------------------------------------------------------------- configuration
171
+ const data = root.dataset;
172
+ const config = {
173
+ apiOrigin: String(data.apiOrigin || '').replace(/\/+$/, ''),
174
+ appOrigin: String(data.appOrigin || '').replace(/\/+$/, ''),
175
+ publicationId: data.publicationId || '',
176
+ contentId: data.contentId || '',
177
+ canonicalUrl: data.canonicalUrl || '',
178
+ reactionsEnabled: data.reactionsEnabled === 'true',
179
+ commentsEnabled: data.commentsEnabled === 'true',
180
+ countReactions: data.countReactions !== 'false',
181
+ countComments: data.countComments !== 'false',
182
+ allowReplies: data.allowReplies === 'true',
183
+ maxDepth: clampDepth(Number.parseInt(data.maxDepth || '3', 10)),
184
+ };
185
+ if (!config.apiOrigin || !config.publicationId || !config.contentId) return;
186
+
187
+ const PENDING_KEY = 'gala.reader.pending.' + config.publicationId;
188
+ const SESSION_KEY = 'gala.reader.session.' + config.publicationId;
189
+ const PUBLICATION_PATH =
190
+ '/v2/public/publications/' + encodeURIComponent(config.publicationId);
191
+ const CONTENT_PATH =
192
+ PUBLICATION_PATH + '/contents/' + encodeURIComponent(config.contentId);
193
+
194
+ // ------------------------------------------------------------------ elements
195
+ const statusEl = /** @type {HTMLElement | null} */ (
196
+ root.querySelector('[data-gala-status]')
197
+ );
198
+ const reactionsEl = /** @type {HTMLElement | null} */ (
199
+ root.querySelector('[data-gala-reactions]')
200
+ );
201
+ const commentsEl = /** @type {HTMLElement | null} */ (
202
+ root.querySelector('[data-gala-comments]')
203
+ );
204
+ const noticeEl = /** @type {HTMLElement | null} */ (
205
+ root.querySelector('[data-gala-static-notice]')
206
+ );
207
+ const slotEl = /** @type {HTMLElement | null} */ (
208
+ root.querySelector('[data-gala-composer-slot]')
209
+ );
210
+ const listEl = /** @type {HTMLElement | null} */ (
211
+ root.querySelector('[data-gala-comment-list]')
212
+ );
213
+ const moreEl = /** @type {HTMLButtonElement | null} */ (
214
+ root.querySelector('[data-gala-more]')
215
+ );
216
+ const countEl = /** @type {HTMLElement | null} */ (
217
+ root.querySelector('[data-gala-comment-count]')
218
+ );
219
+ const titleEl = /** @type {HTMLElement | null} */ (
220
+ root.querySelector('.g-comments__title')
221
+ );
222
+
223
+ // --------------------------------------------------------------------- state
224
+
225
+ /**
226
+ * @typedef {'localStorage' | 'sessionStorage'} StorageKind
227
+ * @typedef {{displayName: string, initials: string}} ReaderView
228
+ * @typedef {{accessToken: string, expiresAt: string, reader: ReaderView}} Session
229
+ * @typedef {{lowerBound: number, display: string, label: string}} PublicCount
230
+ * @typedef {{isAuthor: boolean, canEdit: boolean, canDelete: boolean, canReply: boolean}} CommentViewer
231
+ * @typedef {{state: 'PENDING' | 'RESTORED' | 'DENIED', decidedAt?: string}} CommentAppeal
232
+ * @typedef {{state: 'REMOVED' | 'HELD', appeal: CommentAppeal | null, canAppeal: boolean}} CommentOwn
233
+ * @typedef {{id: string, parentId: string | null, depth: number, state: 'VISIBLE' | 'REMOVED' | 'DELETED' | 'HELD', author: ReaderView | null, body: string | null, createdAt: string, editedAt: string | null, replies: CommentView[], moreReplies: boolean, viewer: CommentViewer, own?: CommentOwn}} CommentView
234
+ * @typedef {{items: CommentView[], nextCursor: string | null, count: PublicCount | null}} CommentPage
235
+ * @typedef {{key: string, label: string, visual: {kind: string, token: string}, count: PublicCount | null, viewerActive: boolean}} ReactionItem
236
+ * @typedef {{enabled: boolean, items: ReactionItem[]}} ReactionsBlock
237
+ * @typedef {{reactions: ReactionsBlock, comments: {enabled: boolean, open: boolean, allowReplies: boolean, maxDepth: number}, viewer: ReaderView | null, asOf: string}} InteractionsView
238
+ * @typedef {{kind: 'reaction', key: string} | {kind: 'comment', parentId: string | null, body: string} | {kind: 'signin'}} Intent
239
+ * @typedef {{auth?: boolean, body?: unknown, idempotencyKey?: string}} ApiOptions
240
+ * @typedef {HTMLLIElement & {_comment: CommentView}} CommentItem
241
+ * @typedef {{mode: string, parentId: string | null, commentId: string | null, draft: {body: string, parentId: string | null, key: string} | null, busy: boolean, update: () => void, element: HTMLElement, input: HTMLTextAreaElement, close: () => void}} Composer
242
+ * @typedef {{mode: 'new' | 'reply' | 'edit', parentId?: string | null, commentId?: string | null, body?: string, onDone?: (result: any) => void, onCancel?: () => void}} ComposerOptions
243
+ */
244
+ /** @type {{accessToken: string, expiresAt: string, reader: {displayName: string, initials: string}} | null} */
245
+ let session = /** @type {Session | null} */ (null);
246
+ let memorySession = /** @type {Session | null} */ (null);
247
+ const live = {
248
+ allowReplies: config.allowReplies,
249
+ maxDepth: config.maxDepth,
250
+ commentsOpen: config.commentsEnabled,
251
+ nextCursor: /** @type {string | null} */ (null),
252
+ loadedIds: new Set(),
253
+ };
254
+ const composers = new Set();
255
+ let rootComposer = /** @type {Composer | null} */ (null);
256
+ let inline = /** @type {{composer: Composer, item: CommentItem} | null} */ (
257
+ null
258
+ );
259
+ let loadToken = 0;
260
+
261
+ // ------------------------------------------------------------------- helpers
262
+ /**
263
+ * @param {number} value
264
+ * @returns {number}
265
+ */
266
+ function clampDepth(value) {
267
+ if (!Number.isFinite(value)) return 3;
268
+ return Math.min(4, Math.max(1, value));
269
+ }
270
+
271
+ /**
272
+ * @template {keyof HTMLElementTagNameMap} K
273
+ * @param {K} tag
274
+ * @param {string} [className]
275
+ * @param {string} [text]
276
+ * @returns {HTMLElementTagNameMap[K]}
277
+ */
278
+ function make(tag, className, text) {
279
+ const node = document.createElement(tag);
280
+ if (className) node.className = className;
281
+ if (text !== undefined) node.textContent = text;
282
+ return node;
283
+ }
284
+
285
+ /**
286
+ * @param {string} className
287
+ * @param {string} text
288
+ * @param {string} [label]
289
+ * @returns {HTMLButtonElement}
290
+ */
291
+ function button(className, text, label) {
292
+ const node = make('button', className, text);
293
+ node.type = 'button';
294
+ if (label) node.setAttribute('aria-label', label);
295
+ return node;
296
+ }
297
+
298
+ let idCounter = 0;
299
+ /**
300
+ * @param {string} prefix
301
+ * @returns {string}
302
+ */
303
+ function uniqueId(prefix) {
304
+ idCounter += 1;
305
+ return 'g-' + prefix + '-' + idCounter;
306
+ }
307
+
308
+ /**
309
+ * @param {string} message
310
+ */
311
+ function announce(message) {
312
+ if (!statusEl) return;
313
+ // Clear first so repeating the same text is announced again.
314
+ statusEl.textContent = '';
315
+ setTimeout(() => {
316
+ statusEl.textContent = message;
317
+ }, 30);
318
+ }
319
+
320
+ /**
321
+ * @param {string} message
322
+ */
323
+ function announceNow(message) {
324
+ if (statusEl) statusEl.textContent = message;
325
+ }
326
+
327
+ /**
328
+ * @param {string} text
329
+ * @returns {number}
330
+ */
331
+ function codePoints(text) {
332
+ let n = 0;
333
+ for (const ch of text) n += ch ? 1 : 0;
334
+ return n;
335
+ }
336
+
337
+ /**
338
+ * @param {Uint8Array} bytes
339
+ * @returns {string}
340
+ */
341
+ function base64url(bytes) {
342
+ let binary = '';
343
+ for (let i = 0; i < bytes.length; i += 1) {
344
+ binary += String.fromCharCode(bytes[i]);
345
+ }
346
+ return btoa(binary)
347
+ .replace(/\+/g, '-')
348
+ .replace(/\//g, '_')
349
+ .replace(/=+$/, '');
350
+ }
351
+
352
+ /**
353
+ * @param {number} byteLength
354
+ * @returns {string}
355
+ */
356
+ function randomToken(byteLength) {
357
+ const bytes = new Uint8Array(byteLength);
358
+ crypto.getRandomValues(bytes);
359
+ return base64url(bytes);
360
+ }
361
+
362
+ function reducedMotion() {
363
+ try {
364
+ return !!(
365
+ window.matchMedia &&
366
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
367
+ );
368
+ } catch {
369
+ return false;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * @param {HTMLElement|null|undefined} node
375
+ */
376
+ function focusNode(node) {
377
+ if (!node) return;
378
+ try {
379
+ node.focus({ preventScroll: reducedMotion() });
380
+ } catch {
381
+ node.focus();
382
+ }
383
+ if (node.scrollIntoView) {
384
+ try {
385
+ node.scrollIntoView({
386
+ block: 'nearest',
387
+ behavior: reducedMotion() ? 'auto' : 'smooth',
388
+ });
389
+ } catch {
390
+ // Scrolling is a nicety only.
391
+ }
392
+ }
393
+ }
394
+
395
+ // ------------------------------------------------------------------- storage
396
+ /**
397
+ * @param {StorageKind} kind
398
+ * @returns {Storage}
399
+ */
400
+ function store(kind) {
401
+ return kind === 'localStorage'
402
+ ? window.localStorage
403
+ : window.sessionStorage;
404
+ }
405
+ /**
406
+ * @param {StorageKind} kind
407
+ * @param {string} key
408
+ * @returns {string|null}
409
+ */
410
+ function storageGet(kind, key) {
411
+ try {
412
+ return store(kind).getItem(key);
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+ /**
418
+ * @param {StorageKind} kind
419
+ * @param {string} key
420
+ * @param {string} value
421
+ * @returns {boolean}
422
+ */
423
+ function storageSet(kind, key, value) {
424
+ try {
425
+ store(kind).setItem(key, value);
426
+ return true;
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+ /**
432
+ * @param {StorageKind} kind
433
+ * @param {string} key
434
+ */
435
+ function storageRemove(kind, key) {
436
+ try {
437
+ store(kind).removeItem(key);
438
+ } catch {
439
+ // Nothing to remove if storage is unavailable.
440
+ }
441
+ }
442
+ /**
443
+ * @param {StorageKind} kind
444
+ * @returns {boolean}
445
+ */
446
+ function storageWorks(kind) {
447
+ const probe = 'gala.reader.probe';
448
+ if (!storageSet(kind, probe, '1')) return false;
449
+ storageRemove(kind, probe);
450
+ return true;
451
+ }
452
+
453
+ /**
454
+ * @param {StorageKind} kind
455
+ * @param {string} key
456
+ * @returns {Record<string, any>|null}
457
+ */
458
+ function readJson(kind, key) {
459
+ const raw = storageGet(kind, key);
460
+ if (!raw) return null;
461
+ try {
462
+ const value = JSON.parse(raw);
463
+ return value && typeof value === 'object' ? value : null;
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * @returns {Session | null}
471
+ */
472
+ function loadSession() {
473
+ const stored = readJson('localStorage', SESSION_KEY) || memorySession;
474
+ if (
475
+ !stored ||
476
+ typeof stored.accessToken !== 'string' ||
477
+ !stored.accessToken ||
478
+ !stored.reader ||
479
+ typeof stored.reader.displayName !== 'string'
480
+ ) {
481
+ if (stored) storageRemove('localStorage', SESSION_KEY);
482
+ return null;
483
+ }
484
+ const expires = Date.parse(stored.expiresAt);
485
+ if (!Number.isFinite(expires) || expires <= Date.now()) {
486
+ storageRemove('localStorage', SESSION_KEY);
487
+ memorySession = null;
488
+ return null;
489
+ }
490
+ return /** @type {Session} */ (stored);
491
+ }
492
+
493
+ /**
494
+ * @param {Session} value
495
+ */
496
+ function saveSession(value) {
497
+ memorySession = value;
498
+ storageSet('localStorage', SESSION_KEY, JSON.stringify(value));
499
+ }
500
+
501
+ function sessionName() {
502
+ return session ? session.reader.displayName : '';
503
+ }
504
+
505
+ function clearSession() {
506
+ session = null;
507
+ memorySession = null;
508
+ storageRemove('localStorage', SESSION_KEY);
509
+ }
510
+
511
+ // ------------------------------------------------------------------- network
512
+ class ApiError extends Error {
513
+ /**
514
+ * @param {string} kind
515
+ * @param {number} status
516
+ * @param {string} code
517
+ * @param {string} reason
518
+ */
519
+ constructor(kind, status, code, reason) {
520
+ super(kind);
521
+ this.kind = kind;
522
+ this.status = status;
523
+ this.code = code;
524
+ this.reason = reason;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * @param {string} method
530
+ * @param {string} path
531
+ * @param {ApiOptions} [options]
532
+ * @returns {Promise<any>}
533
+ */
534
+ async function api(method, path, options) {
535
+ const opts = options || {};
536
+ const controller = new AbortController();
537
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
538
+ /** @type {Record<string, string>} */
539
+ const headers = { Accept: 'application/json' };
540
+ if (opts.auth !== false && session) {
541
+ headers.Authorization = 'Bearer ' + session.accessToken;
542
+ }
543
+ if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
544
+ if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
545
+ let response;
546
+ try {
547
+ response = await fetch(config.apiOrigin + path, {
548
+ method,
549
+ headers,
550
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
551
+ mode: 'cors',
552
+ credentials: 'omit',
553
+ signal: controller.signal,
554
+ });
555
+ } catch {
556
+ throw new ApiError('network', 0, '', '');
557
+ } finally {
558
+ clearTimeout(timer);
559
+ }
560
+ let payload = null;
561
+ if (response.status !== 204) {
562
+ try {
563
+ payload = await response.json();
564
+ } catch {
565
+ payload = null;
566
+ }
567
+ }
568
+ if (response.ok) return payload;
569
+ const code =
570
+ payload && typeof payload.code === 'string' ? payload.code : '';
571
+ throw new ApiError('problem', response.status, code, reasonOf(payload));
572
+ }
573
+
574
+ /**
575
+ * @param {any} payload
576
+ * @returns {string}
577
+ */
578
+ function reasonOf(payload) {
579
+ if (!payload || typeof payload !== 'object') return '';
580
+ if (typeof payload.reason === 'string') return payload.reason;
581
+ const known = ['EMPTY', 'TOO_LONG', 'TOO_MANY_LINKS', 'BLOCKED_PATTERN'];
582
+ if (Array.isArray(payload.errors)) {
583
+ for (const entry of payload.errors) {
584
+ if (entry && known.includes(entry.code)) return entry.code;
585
+ if (entry && known.includes(entry.reason)) return entry.reason;
586
+ }
587
+ }
588
+ return '';
589
+ }
590
+
591
+ // Turn a failed call into the reader-facing sentence. A 401 (or a token for
592
+ // another publication) also drops the stored session.
593
+ /**
594
+ * @param {unknown} error
595
+ * @returns {string}
596
+ */
597
+ function failureMessage(error) {
598
+ if (!(error instanceof ApiError)) return MESSAGES.generic;
599
+ if (error.kind === 'network') return MESSAGES.network;
600
+ if (error.status === 401 || error.code === 'READER_TOKEN_SCOPE') {
601
+ handleSignedOutByServer();
602
+ return MESSAGES.signInAgain;
603
+ }
604
+ if (error.status === 429 || error.code === 'RATE_LIMITED') {
605
+ return MESSAGES.tooFast;
606
+ }
607
+ switch (error.code) {
608
+ case 'COMMENTS_CLOSED':
609
+ return MESSAGES.commentsClosed;
610
+ case 'COMMENT_INVALID':
611
+ if (error.reason === 'EMPTY') return MESSAGES.empty;
612
+ if (error.reason === 'TOO_LONG') return MESSAGES.tooLong;
613
+ if (error.reason === 'TOO_MANY_LINKS') return MESSAGES.tooManyLinks;
614
+ if (error.reason === 'BLOCKED_PATTERN') return MESSAGES.blocked;
615
+ return MESSAGES.generic;
616
+ case 'REPLIES_NOT_ALLOWED':
617
+ return MESSAGES.repliesOff;
618
+ case 'REPLY_TOO_DEEP':
619
+ return MESSAGES.tooDeep;
620
+ case 'PARENT_NOT_FOUND':
621
+ return MESSAGES.parentGone;
622
+ case 'DUPLICATE_COMMENT':
623
+ return MESSAGES.duplicate;
624
+ case 'READER_RESTRICTED':
625
+ return MESSAGES.restricted;
626
+ case 'EDIT_WINDOW_CLOSED':
627
+ return MESSAGES.editClosed;
628
+ case 'NOT_COMMENT_AUTHOR':
629
+ return MESSAGES.notAuthor;
630
+ case 'COMMENT_NOT_FOUND':
631
+ return MESSAGES.gone;
632
+ default:
633
+ return MESSAGES.generic;
634
+ }
635
+ }
636
+
637
+ function handleSignedOutByServer() {
638
+ if (!session && !memorySession) return;
639
+ clearSession();
640
+ refreshComposers();
641
+ }
642
+
643
+ // ----------------------------------------------------------- sign-in (PKCE)
644
+ /**
645
+ * @param {Intent} intent
646
+ * @returns {Promise<void>}
647
+ */
648
+ async function startSignIn(intent) {
649
+ if (!storageWorks('sessionStorage') || !storageWorks('localStorage')) {
650
+ announceNow(MESSAGES.storageBlocked);
651
+ showNotice(MESSAGES.storageBlocked);
652
+ return;
653
+ }
654
+ if (!crypto || !crypto.subtle || !crypto.getRandomValues) {
655
+ announceNow(MESSAGES.signInUnsupported);
656
+ return;
657
+ }
658
+ let challenge;
659
+ const state = randomToken(32);
660
+ const verifier = randomToken(64);
661
+ try {
662
+ const digest = await crypto.subtle.digest(
663
+ 'SHA-256',
664
+ new TextEncoder().encode(verifier),
665
+ );
666
+ challenge = base64url(new Uint8Array(digest));
667
+ } catch {
668
+ announceNow(MESSAGES.signInUnsupported);
669
+ return;
670
+ }
671
+ const stored = storageSet(
672
+ 'sessionStorage',
673
+ PENDING_KEY,
674
+ JSON.stringify({
675
+ state,
676
+ codeVerifier: verifier,
677
+ intent,
678
+ createdAt: Date.now(),
679
+ }),
680
+ );
681
+ if (!stored) {
682
+ announceNow(MESSAGES.storageBlocked);
683
+ showNotice(MESSAGES.storageBlocked);
684
+ return;
685
+ }
686
+ const target =
687
+ config.appOrigin +
688
+ '/reader/connect?publication=' +
689
+ encodeURIComponent(config.publicationId) +
690
+ '&return=' +
691
+ encodeURIComponent(config.canonicalUrl + '#comments') +
692
+ '&state=' +
693
+ encodeURIComponent(state) +
694
+ '&challenge=' +
695
+ encodeURIComponent(challenge);
696
+ navigate(target);
697
+ }
698
+
699
+ /**
700
+ * @param {string} url
701
+ */
702
+ function navigate(url) {
703
+ const event = new CustomEvent('gala:navigate', {
704
+ detail: { url },
705
+ cancelable: true,
706
+ bubbles: true,
707
+ });
708
+ if (root.dispatchEvent(event)) window.location.assign(url);
709
+ }
710
+
711
+ // Handle a return from the App: read the fragment, scrub it from the URL
712
+ // immediately, verify state, exchange the code. Resolves to the intent to
713
+ // replay (or null).
714
+ async function completeSignInFromFragment() {
715
+ const hash = window.location.hash || '';
716
+ if (hash.indexOf('gala-reader-') === -1) return null;
717
+ const params = new URLSearchParams(hash.replace(/^#/, ''));
718
+ const code = params.get('gala-reader-code');
719
+ const returnedState = params.get('gala-reader-state');
720
+ try {
721
+ window.history.replaceState(
722
+ null,
723
+ '',
724
+ window.location.pathname + window.location.search + '#comments',
725
+ );
726
+ } catch {
727
+ // The fragment stays visible; the single-use code is still protected.
728
+ }
729
+ const pending = readJson('sessionStorage', PENDING_KEY);
730
+ storageRemove('sessionStorage', PENDING_KEY);
731
+ if (
732
+ !pending ||
733
+ !code ||
734
+ !returnedState ||
735
+ pending.state !== returnedState ||
736
+ typeof pending.codeVerifier !== 'string' ||
737
+ !(Date.now() - Number(pending.createdAt) < PENDING_MAX_AGE_MS)
738
+ ) {
739
+ announceNow(MESSAGES.signInInvalid);
740
+ return null;
741
+ }
742
+ try {
743
+ const result = await api('POST', '/v2/reader/tokens', {
744
+ auth: false,
745
+ body: {
746
+ code,
747
+ codeVerifier: pending.codeVerifier,
748
+ publicationId: config.publicationId,
749
+ },
750
+ });
751
+ if (!result || typeof result.accessToken !== 'string' || !result.reader) {
752
+ throw new ApiError('problem', 200, '', '');
753
+ }
754
+ session = {
755
+ accessToken: result.accessToken,
756
+ expiresAt: result.expiresAt,
757
+ reader: result.reader,
758
+ };
759
+ saveSession(session);
760
+ } catch (error) {
761
+ announceNow(
762
+ error instanceof ApiError && error.kind === 'network'
763
+ ? MESSAGES.network
764
+ : MESSAGES.signInFailed,
765
+ );
766
+ return null;
767
+ }
768
+ return pending.intent && typeof pending.intent === 'object'
769
+ ? pending.intent
770
+ : { kind: 'signin' };
771
+ }
772
+
773
+ async function signOut() {
774
+ if (session) {
775
+ try {
776
+ await api('DELETE', '/v2/reader/tokens/current');
777
+ } catch {
778
+ // Whether or not the server heard us, this browser forgets the token.
779
+ }
780
+ }
781
+ clearSession();
782
+ refreshComposers();
783
+ announce(MESSAGES.signedOut);
784
+ loadAll();
785
+ }
786
+
787
+ // ------------------------------------------------------------------- notices
788
+ let noticeNode = noticeEl;
789
+
790
+ /**
791
+ * @param {string} text
792
+ */
793
+ function showNotice(text) {
794
+ const node = noticeNode || make('p', 'g-interactions__notice');
795
+ if (!noticeNode) {
796
+ noticeNode = node;
797
+ root.appendChild(node);
798
+ }
799
+ node.textContent = text;
800
+ node.hidden = false;
801
+ }
802
+
803
+ function hideNotice() {
804
+ if (noticeNode) noticeNode.hidden = true;
805
+ }
806
+
807
+ // ----------------------------------------------------------------- reactions
808
+ const reactionBusy = new Set();
809
+
810
+ function reactionButtons() {
811
+ return reactionsEl
812
+ ? Array.from(
813
+ /** @type {NodeListOf<HTMLButtonElement>} */ (
814
+ reactionsEl.querySelectorAll('[data-reaction-key]')
815
+ ),
816
+ )
817
+ : [];
818
+ }
819
+
820
+ /**
821
+ * @param {HTMLElement} node
822
+ * @param {boolean} active
823
+ */
824
+ function setPressed(node, active) {
825
+ node.setAttribute('aria-pressed', active ? 'true' : 'false');
826
+ node.classList.toggle('g-reaction--active', !!active);
827
+ }
828
+
829
+ /**
830
+ * @param {HTMLElement} node
831
+ * @param {PublicCount|null|undefined} count
832
+ * @param {string} noun
833
+ */
834
+ function setCount(node, count, noun) {
835
+ const target = node.querySelector('[data-gala-count]');
836
+ if (!target) return;
837
+ if (count && config.countReactions && typeof count.display === 'string') {
838
+ target.textContent = count.display;
839
+ const label = node.querySelector('.g-reaction__label');
840
+ const base = label ? label.textContent : '';
841
+ node.setAttribute(
842
+ 'aria-label',
843
+ (base ? base + ': ' : '') + (count.label || count.display + ' ' + noun),
844
+ );
845
+ } else {
846
+ target.textContent = '';
847
+ node.removeAttribute('aria-label');
848
+ }
849
+ }
850
+
851
+ /**
852
+ * @param {ReactionsBlock|null|undefined} block
853
+ */
854
+ function applyReactions(block) {
855
+ if (!reactionsEl) return;
856
+ if (!block || block.enabled === false) {
857
+ reactionsEl.hidden = true;
858
+ return;
859
+ }
860
+ const items = new Map();
861
+ for (const item of block.items || []) items.set(item.key, item);
862
+ for (const node of reactionButtons()) {
863
+ const key = node.getAttribute('data-reaction-key');
864
+ const item = items.get(key);
865
+ if (!item) {
866
+ node.hidden = true;
867
+ continue;
868
+ }
869
+ node.hidden = false;
870
+ node.disabled = false;
871
+ setPressed(node, !!item.viewerActive);
872
+ setCount(node, item.count, 'reactions');
873
+ }
874
+ }
875
+
876
+ /**
877
+ * @param {HTMLElement} node
878
+ * @param {boolean} wantActive
879
+ * @returns {Promise<void>}
880
+ */
881
+ async function setReaction(node, wantActive) {
882
+ const key = node.getAttribute('data-reaction-key');
883
+ if (!key || reactionBusy.has(key)) return;
884
+ reactionBusy.add(key);
885
+ const wasActive = node.getAttribute('aria-pressed') === 'true';
886
+ const wasCount = node.querySelector('[data-gala-count]');
887
+ const previousText = wasCount ? wasCount.textContent : '';
888
+ const previousLabel = node.getAttribute('aria-label');
889
+ setPressed(node, wantActive);
890
+ const path = CONTENT_PATH + '/reactions/' + encodeURIComponent(key);
891
+ try {
892
+ const result = await api(wantActive ? 'PUT' : 'DELETE', path);
893
+ setPressed(
894
+ node,
895
+ result && typeof result.active === 'boolean'
896
+ ? result.active
897
+ : wantActive,
898
+ );
899
+ if (result) setCount(node, result.count, 'reactions');
900
+ } catch (error) {
901
+ setPressed(node, wasActive);
902
+ if (wasCount) wasCount.textContent = previousText;
903
+ if (previousLabel === null) node.removeAttribute('aria-label');
904
+ else node.setAttribute('aria-label', previousLabel);
905
+ announce(failureMessage(error));
906
+ } finally {
907
+ reactionBusy.delete(key);
908
+ }
909
+ }
910
+
911
+ /**
912
+ * @param {Event} event
913
+ */
914
+ function onReactionClick(event) {
915
+ const node = /** @type {HTMLElement} */ (event.currentTarget);
916
+ if (!session) {
917
+ startSignIn({
918
+ kind: 'reaction',
919
+ key: node.getAttribute('data-reaction-key') || '',
920
+ });
921
+ return;
922
+ }
923
+ setReaction(node, node.getAttribute('aria-pressed') !== 'true');
924
+ }
925
+
926
+ // ------------------------------------------------------------------- linkify
927
+ /**
928
+ * @param {HTMLElement} parent
929
+ * @param {string} text
930
+ */
931
+ function appendLinkified(parent, text) {
932
+ let last = 0;
933
+ URL_PATTERN.lastIndex = 0;
934
+ let match = URL_PATTERN.exec(text);
935
+ while (match) {
936
+ let candidate = match[0];
937
+ const trimmed = candidate.replace(TRAILING_PUNCTUATION, '');
938
+ candidate = trimmed || candidate;
939
+ let href = '';
940
+ try {
941
+ const parsed = new URL(candidate);
942
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
943
+ href = parsed.href;
944
+ }
945
+ } catch {
946
+ href = '';
947
+ }
948
+ if (href) {
949
+ if (match.index > last) {
950
+ parent.appendChild(
951
+ document.createTextNode(text.slice(last, match.index)),
952
+ );
953
+ }
954
+ const anchor = make('a', '', candidate);
955
+ anchor.setAttribute('href', href);
956
+ anchor.setAttribute('rel', LINK_REL);
957
+ parent.appendChild(anchor);
958
+ last = match.index + candidate.length;
959
+ }
960
+ URL_PATTERN.lastIndex = match.index + Math.max(candidate.length, 1);
961
+ match = URL_PATTERN.exec(text);
962
+ }
963
+ if (last < text.length) {
964
+ parent.appendChild(document.createTextNode(text.slice(last)));
965
+ }
966
+ }
967
+
968
+ /**
969
+ * @param {string} text
970
+ * @returns {HTMLElement}
971
+ */
972
+ function renderBody(text) {
973
+ const body = make('div', 'g-comment__body');
974
+ const lines = String(text).split('\n');
975
+ lines.forEach((line, index) => {
976
+ if (index > 0) body.appendChild(document.createElement('br'));
977
+ appendLinkified(body, line);
978
+ });
979
+ return body;
980
+ }
981
+
982
+ // ---------------------------------------------------------------------- time
983
+ function languageTag() {
984
+ const lang = document.documentElement.getAttribute('lang');
985
+ return lang || undefined;
986
+ }
987
+
988
+ /**
989
+ * @param {string} iso
990
+ * @param {boolean} edited
991
+ * @returns {string}
992
+ */
993
+ function timeLabel(iso, edited) {
994
+ const when = Date.parse(iso);
995
+ let label = iso;
996
+ if (Number.isFinite(when)) {
997
+ const seconds = Math.round((when - Date.now()) / 1000);
998
+ const elapsed = Math.abs(seconds);
999
+ try {
1000
+ if (elapsed < 45) {
1001
+ label = MESSAGES.justNow;
1002
+ } else if (elapsed >= RELATIVE_DAYS_LIMIT * 86400) {
1003
+ label = new Intl.DateTimeFormat(languageTag(), {
1004
+ dateStyle: 'medium',
1005
+ }).format(when);
1006
+ } else {
1007
+ const formatter = new Intl.RelativeTimeFormat(languageTag(), {
1008
+ numeric: 'auto',
1009
+ style: 'short',
1010
+ });
1011
+ if (elapsed < 3600) {
1012
+ label = formatter.format(Math.round(seconds / 60), 'minute');
1013
+ } else if (elapsed < 86400) {
1014
+ label = formatter.format(Math.round(seconds / 3600), 'hour');
1015
+ } else {
1016
+ label = formatter.format(Math.round(seconds / 86400), 'day');
1017
+ }
1018
+ }
1019
+ } catch {
1020
+ label = new Date(when).toISOString().slice(0, 10);
1021
+ }
1022
+ }
1023
+ return edited ? label + MESSAGES.edited : label;
1024
+ }
1025
+
1026
+ /**
1027
+ * @param {string} iso
1028
+ * @returns {string}
1029
+ */
1030
+ function fullDate(iso) {
1031
+ const when = Date.parse(iso);
1032
+ if (!Number.isFinite(when)) return '';
1033
+ try {
1034
+ return new Intl.DateTimeFormat(languageTag(), {
1035
+ dateStyle: 'long',
1036
+ timeStyle: 'short',
1037
+ }).format(when);
1038
+ } catch {
1039
+ return new Date(when).toISOString();
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * A comment's `<time>` element: the given text, the machine-readable date
1045
+ * and the full date as its tooltip.
1046
+ *
1047
+ * @param {string} iso
1048
+ * @param {string} text
1049
+ * @returns {HTMLElement}
1050
+ */
1051
+ function timeElement(iso, text) {
1052
+ const time = make('time', 'g-comment__time', text);
1053
+ time.setAttribute('datetime', iso);
1054
+ const title = fullDate(iso);
1055
+ if (title) time.setAttribute('title', title);
1056
+ return time;
1057
+ }
1058
+
1059
+ // ------------------------------------------------------------------ comments
1060
+ /**
1061
+ * @param {PublicCount|null|undefined} count
1062
+ */
1063
+ function setCommentCount(count) {
1064
+ if (!countEl) return;
1065
+ if (count && config.countComments && typeof count.display === 'string') {
1066
+ countEl.textContent = count.display;
1067
+ countEl.setAttribute('role', 'img');
1068
+ countEl.setAttribute('aria-label', count.label || count.display);
1069
+ } else {
1070
+ countEl.textContent = '';
1071
+ countEl.removeAttribute('role');
1072
+ countEl.removeAttribute('aria-label');
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * The state of the signed-in reader's own removed or held comment, which
1078
+ * arrives with an `own` object and no body.
1079
+ *
1080
+ * @param {CommentView} comment
1081
+ * @returns {CommentOwn | null}
1082
+ */
1083
+ function ownView(comment) {
1084
+ const own = comment.own;
1085
+ return own &&
1086
+ typeof own === 'object' &&
1087
+ (own.state === 'REMOVED' || own.state === 'HELD')
1088
+ ? own
1089
+ : null;
1090
+ }
1091
+
1092
+ /**
1093
+ * The one-line context of the reader's own removed or held comment, which
1094
+ * arrives with no body: "Your comment from <relative date>". Null when the
1095
+ * comment has no usable `createdAt`.
1096
+ *
1097
+ * @param {CommentView} comment
1098
+ * @returns {HTMLElement | null}
1099
+ */
1100
+ function ownContext(comment) {
1101
+ if (!Number.isFinite(Date.parse(comment.createdAt))) return null;
1102
+ return timeElement(
1103
+ comment.createdAt,
1104
+ MESSAGES.ownFrom + timeLabel(comment.createdAt, false),
1105
+ );
1106
+ }
1107
+
1108
+ /**
1109
+ * The line saying where the reader's appeal stands, if there is one.
1110
+ *
1111
+ * @param {CommentView} comment
1112
+ * @returns {HTMLElement | null}
1113
+ */
1114
+ function appealResult(comment) {
1115
+ const appeal = comment.own && comment.own.appeal;
1116
+ const text = !appeal
1117
+ ? ''
1118
+ : appeal.state === 'PENDING'
1119
+ ? MESSAGES.appealSent
1120
+ : appeal.state === 'RESTORED'
1121
+ ? MESSAGES.appealRestored
1122
+ : appeal.state === 'DENIED'
1123
+ ? MESSAGES.appealDenied
1124
+ : '';
1125
+ return text ? make('p', 'g-interactions__notice', text) : null;
1126
+ }
1127
+
1128
+ /**
1129
+ * @param {CommentView} comment
1130
+ * @returns {boolean}
1131
+ */
1132
+ function canReplyTo(comment) {
1133
+ if (!live.commentsOpen || !live.allowReplies) return false;
1134
+ if (comment.state !== 'VISIBLE' || ownView(comment)) return false;
1135
+ if (comment.depth >= live.maxDepth - 1) return false;
1136
+ if (session && comment.viewer && comment.viewer.canReply === false) {
1137
+ return false;
1138
+ }
1139
+ return true;
1140
+ }
1141
+
1142
+ /**
1143
+ * @param {CommentView} comment
1144
+ * @returns {CommentItem}
1145
+ */
1146
+ function renderComment(comment) {
1147
+ const item = /** @type {any} */ (make('li', 'g-comment'));
1148
+ item.id = 'g-comment-' + comment.id;
1149
+ item.setAttribute('tabindex', '-1');
1150
+ item.setAttribute('data-comment-id', comment.id);
1151
+ item.setAttribute('data-depth', String(comment.depth));
1152
+ item._comment = comment;
1153
+ live.loadedIds.add(comment.id);
1154
+ fillComment(item, comment);
1155
+ return item;
1156
+ }
1157
+
1158
+ /**
1159
+ * @param {CommentItem} item
1160
+ * @param {CommentView} comment
1161
+ */
1162
+ function fillComment(item, comment) {
1163
+ const existingReplies = item.querySelector(':scope > .g-comment__replies');
1164
+ while (item.firstChild) item.removeChild(item.firstChild);
1165
+ item._comment = comment;
1166
+ const own = ownView(comment);
1167
+ item.classList.toggle(
1168
+ 'g-comment--tombstone',
1169
+ comment.state !== 'VISIBLE' || !!own,
1170
+ );
1171
+ item.classList.toggle(
1172
+ 'g-comment--mine',
1173
+ !!own || !!(comment.viewer && comment.viewer.isAuthor),
1174
+ );
1175
+
1176
+ const head = make('div', 'g-comment__head');
1177
+ if (own) {
1178
+ // No body comes back for these, so say which comment it is by its date.
1179
+ const context = ownContext(comment);
1180
+ if (context) item.appendChild(context);
1181
+ item.appendChild(
1182
+ make(
1183
+ 'p',
1184
+ 'g-comment__body',
1185
+ own.state === 'HELD' ? MESSAGES.heldOwn : MESSAGES.removedOwn,
1186
+ ),
1187
+ );
1188
+ const result = appealResult(comment);
1189
+ if (result) item.appendChild(result);
1190
+ if (own.canAppeal === true) item.appendChild(renderAppealAction(item));
1191
+ } else if (comment.state === 'VISIBLE' && comment.author) {
1192
+ const avatar = make('span', 'g-comment__avatar', comment.author.initials);
1193
+ avatar.setAttribute('aria-hidden', 'true');
1194
+ head.appendChild(avatar);
1195
+ head.appendChild(
1196
+ make('span', 'g-comment__author', comment.author.displayName),
1197
+ );
1198
+ head.appendChild(
1199
+ timeElement(
1200
+ comment.createdAt,
1201
+ timeLabel(comment.createdAt, !!comment.editedAt),
1202
+ ),
1203
+ );
1204
+ item.appendChild(head);
1205
+ item.appendChild(renderBody(comment.body || ''));
1206
+ // A comment restored on appeal says so.
1207
+ const result = appealResult(comment);
1208
+ if (result) item.appendChild(result);
1209
+ item.appendChild(renderActions(item, comment));
1210
+ } else {
1211
+ item.appendChild(
1212
+ make(
1213
+ 'p',
1214
+ 'g-comment__body',
1215
+ comment.state === 'REMOVED'
1216
+ ? MESSAGES.removedTombstone
1217
+ : MESSAGES.deletedTombstone,
1218
+ ),
1219
+ );
1220
+ }
1221
+ if (existingReplies) {
1222
+ item.appendChild(existingReplies);
1223
+ } else if (comment.replies && comment.replies.length) {
1224
+ item.appendChild(renderReplies(comment));
1225
+ }
1226
+ if (comment.moreReplies) addMoreReplies(item, comment);
1227
+ }
1228
+
1229
+ /**
1230
+ * @param {CommentView} comment
1231
+ * @returns {HTMLElement}
1232
+ */
1233
+ function renderReplies(comment) {
1234
+ const list = make('ol', 'g-comment__replies');
1235
+ for (const reply of comment.replies || []) {
1236
+ list.appendChild(renderComment(reply));
1237
+ }
1238
+ return list;
1239
+ }
1240
+
1241
+ /**
1242
+ * @param {HTMLElement} item
1243
+ * @returns {HTMLElement}
1244
+ */
1245
+ function ensureReplies(item) {
1246
+ let list = /** @type {HTMLElement | null} */ (
1247
+ item.querySelector(':scope > .g-comment__replies')
1248
+ );
1249
+ if (!list) {
1250
+ list = make('ol', 'g-comment__replies');
1251
+ const more = item.querySelector(':scope > .g-comment__more-replies');
1252
+ if (more) item.insertBefore(list, more);
1253
+ else item.appendChild(list);
1254
+ }
1255
+ return list;
1256
+ }
1257
+
1258
+ /**
1259
+ * @param {CommentItem} item
1260
+ * @param {CommentView} comment
1261
+ */
1262
+ function addMoreReplies(item, comment) {
1263
+ const more = button(
1264
+ 'g-comment__action g-comment__more-replies',
1265
+ MESSAGES.showMoreReplies,
1266
+ );
1267
+ let cursor = /** @type {string | null} */ (null);
1268
+ let first = true;
1269
+ more.addEventListener('click', async () => {
1270
+ more.disabled = true;
1271
+ try {
1272
+ const query = new URLSearchParams({ parentId: comment.id });
1273
+ if (cursor) query.set('cursor', cursor);
1274
+ const page = await api(
1275
+ 'GET',
1276
+ CONTENT_PATH + '/comments?' + query.toString(),
1277
+ );
1278
+ const list = ensureReplies(item);
1279
+ if (first) {
1280
+ while (list.firstChild) list.removeChild(list.firstChild);
1281
+ first = false;
1282
+ }
1283
+ for (const reply of page.items || [])
1284
+ list.appendChild(renderComment(reply));
1285
+ cursor = page.nextCursor || null;
1286
+ if (!cursor) more.remove();
1287
+ else more.disabled = false;
1288
+ } catch (error) {
1289
+ more.disabled = false;
1290
+ announce(failureMessage(error));
1291
+ }
1292
+ });
1293
+ item.appendChild(more);
1294
+ }
1295
+
1296
+ /**
1297
+ * @param {CommentItem} item
1298
+ * @param {CommentView} comment
1299
+ * @returns {HTMLElement}
1300
+ */
1301
+ function renderActions(item, comment) {
1302
+ const actions = make('div', 'g-comment__actions');
1303
+ const name = comment.author ? comment.author.displayName : '';
1304
+ if (canReplyTo(comment)) {
1305
+ const reply = button(
1306
+ 'g-comment__action',
1307
+ MESSAGES.reply,
1308
+ MESSAGES.reply + ' to ' + name,
1309
+ );
1310
+ reply.addEventListener('click', () =>
1311
+ openInlineComposer(item, 'reply', reply),
1312
+ );
1313
+ actions.appendChild(reply);
1314
+ }
1315
+ if (comment.viewer && comment.viewer.canEdit) {
1316
+ const edit = button(
1317
+ 'g-comment__action',
1318
+ MESSAGES.edit,
1319
+ MESSAGES.edit + ' your comment',
1320
+ );
1321
+ edit.addEventListener('click', () =>
1322
+ openInlineComposer(item, 'edit', edit),
1323
+ );
1324
+ actions.appendChild(edit);
1325
+ }
1326
+ if (comment.viewer && comment.viewer.canDelete) {
1327
+ const remove = button(
1328
+ 'g-comment__action',
1329
+ MESSAGES.remove,
1330
+ MESSAGES.remove + ' your comment',
1331
+ );
1332
+ remove.addEventListener('click', () =>
1333
+ confirmDelete(item, remove, actions),
1334
+ );
1335
+ actions.appendChild(remove);
1336
+ }
1337
+ if (!(comment.viewer && comment.viewer.isAuthor)) {
1338
+ const report = button(
1339
+ 'g-comment__action',
1340
+ MESSAGES.report,
1341
+ MESSAGES.report + ' comment by ' + name,
1342
+ );
1343
+ report.addEventListener('click', () => openReport(item, report));
1344
+ actions.appendChild(report);
1345
+ }
1346
+ return actions;
1347
+ }
1348
+
1349
+ /**
1350
+ * @param {CommentItem} item
1351
+ * @param {HTMLElement} trigger
1352
+ * @param {HTMLElement} actions
1353
+ */
1354
+ function confirmDelete(item, trigger, actions) {
1355
+ trigger.hidden = true;
1356
+ const box = make('span', 'g-comment__confirm');
1357
+ box.setAttribute('role', 'group');
1358
+ box.setAttribute('aria-label', MESSAGES.confirmDelete);
1359
+ box.appendChild(make('span', '', MESSAGES.confirmDelete + ' '));
1360
+ const yes = button('g-comment__action', MESSAGES.remove);
1361
+ const no = button('g-comment__action', MESSAGES.cancel);
1362
+ box.appendChild(yes);
1363
+ box.appendChild(no);
1364
+ actions.appendChild(box);
1365
+ no.focus();
1366
+ const close = () => {
1367
+ box.remove();
1368
+ trigger.hidden = false;
1369
+ focusNode(trigger);
1370
+ };
1371
+ no.addEventListener('click', close);
1372
+ box.addEventListener('keydown', (event) => {
1373
+ if (event.key === 'Escape') {
1374
+ event.stopPropagation();
1375
+ close();
1376
+ }
1377
+ });
1378
+ yes.addEventListener('click', async () => {
1379
+ yes.disabled = true;
1380
+ no.disabled = true;
1381
+ try {
1382
+ await api(
1383
+ 'DELETE',
1384
+ '/v2/public/comments/' + encodeURIComponent(item._comment.id),
1385
+ );
1386
+ applyDeleted(item);
1387
+ announce(MESSAGES.deleted);
1388
+ } catch (error) {
1389
+ yes.disabled = false;
1390
+ no.disabled = false;
1391
+ announce(failureMessage(error));
1392
+ }
1393
+ });
1394
+ }
1395
+
1396
+ /**
1397
+ * @param {CommentItem} item
1398
+ */
1399
+ function applyDeleted(item) {
1400
+ const replies = item.querySelector(':scope > .g-comment__replies');
1401
+ const hasReplies = !!(replies && replies.children.length);
1402
+ const parentList = /** @type {HTMLElement | null} */ (item.parentNode);
1403
+ if (hasReplies) {
1404
+ const next = Object.assign({}, item._comment, {
1405
+ state: 'DELETED',
1406
+ author: null,
1407
+ body: null,
1408
+ viewer: {
1409
+ isAuthor: false,
1410
+ canEdit: false,
1411
+ canDelete: false,
1412
+ canReply: false,
1413
+ },
1414
+ });
1415
+ fillComment(item, next);
1416
+ focusNode(item);
1417
+ return;
1418
+ }
1419
+ item.remove();
1420
+ live.loadedIds.delete(item._comment.id);
1421
+ if (
1422
+ parentList &&
1423
+ parentList.classList.contains('g-comment__replies') &&
1424
+ !parentList.children.length
1425
+ ) {
1426
+ parentList.remove();
1427
+ }
1428
+ focusNode(titleEl || commentsEl);
1429
+ }
1430
+
1431
+ // ------------------------------------------------------------------ composer
1432
+ /**
1433
+ * @param {Composer} composer
1434
+ * @param {string} body
1435
+ * @returns {string}
1436
+ */
1437
+ function currentDraftKey(composer, body) {
1438
+ if (
1439
+ !composer.draft ||
1440
+ composer.draft.body !== body ||
1441
+ composer.draft.parentId !== composer.parentId
1442
+ ) {
1443
+ composer.draft = {
1444
+ body,
1445
+ parentId: composer.parentId,
1446
+ key: crypto.randomUUID(),
1447
+ };
1448
+ }
1449
+ return composer.draft.key;
1450
+ }
1451
+
1452
+ /**
1453
+ * @param {ComposerOptions} options
1454
+ * @returns {Composer}
1455
+ */
1456
+ function createComposer(options) {
1457
+ const composer = /** @type {Composer} */ ({
1458
+ mode: options.mode,
1459
+ parentId: options.parentId || null,
1460
+ commentId: options.commentId || null,
1461
+ draft: null,
1462
+ busy: false,
1463
+ });
1464
+ const wrapper = make('div', 'g-composer');
1465
+ const inputId = uniqueId('composer');
1466
+ const label = make(
1467
+ 'label',
1468
+ '',
1469
+ options.mode === 'reply'
1470
+ ? MESSAGES.replyLabel
1471
+ : options.mode === 'edit'
1472
+ ? MESSAGES.editLabel
1473
+ : MESSAGES.composerLabel,
1474
+ );
1475
+ label.setAttribute('for', inputId);
1476
+ const input = make('textarea', 'g-composer__input');
1477
+ input.id = inputId;
1478
+ input.rows = options.mode === 'new' ? 4 : 3;
1479
+ input.value = options.body || '';
1480
+ const counter = make('p', 'g-composer__counter');
1481
+ counter.id = uniqueId('counter');
1482
+ counter.hidden = true;
1483
+ input.setAttribute('aria-describedby', counter.id);
1484
+ const error = make('p', 'g-composer__error');
1485
+ error.setAttribute('role', 'alert');
1486
+ error.hidden = true;
1487
+ const actions = make('div', 'g-composer__actions');
1488
+ const submit = button(
1489
+ 'g-composer__submit',
1490
+ options.mode === 'edit' ? MESSAGES.save : MESSAGES.post,
1491
+ );
1492
+ const signin = button(
1493
+ 'g-composer__signin',
1494
+ options.mode === 'new' ? MESSAGES.signInLabel : MESSAGES.signInShort,
1495
+ );
1496
+ const cancel = button('g-composer__cancel', MESSAGES.cancel);
1497
+ cancel.hidden = options.mode === 'new';
1498
+ actions.appendChild(submit);
1499
+ actions.appendChild(signin);
1500
+ actions.appendChild(cancel);
1501
+ wrapper.appendChild(label);
1502
+ wrapper.appendChild(input);
1503
+ wrapper.appendChild(counter);
1504
+ wrapper.appendChild(error);
1505
+ wrapper.appendChild(actions);
1506
+
1507
+ let identity = null;
1508
+ if (options.mode === 'new') {
1509
+ identity = make('p', 'g-composer__identity');
1510
+ wrapper.appendChild(identity);
1511
+ }
1512
+
1513
+ /**
1514
+ * @param {string} text
1515
+ */
1516
+ function showError(text) {
1517
+ error.textContent = text;
1518
+ error.hidden = !text;
1519
+ if (text) announceNow(text);
1520
+ }
1521
+
1522
+ function updateCounter() {
1523
+ const used = codePoints(input.value);
1524
+ const remaining = COMMENT_LIMIT - used;
1525
+ if (remaining < COUNTER_THRESHOLD) {
1526
+ counter.hidden = false;
1527
+ counter.textContent =
1528
+ remaining >= 0
1529
+ ? remaining + ' characters left'
1530
+ : 'Over the limit by ' + -remaining + ' characters';
1531
+ } else {
1532
+ counter.hidden = true;
1533
+ counter.textContent = '';
1534
+ }
1535
+ }
1536
+
1537
+ composer.update = () => {
1538
+ const signedIn = !!session;
1539
+ const hasDraft = input.value !== '';
1540
+ const showInput = signedIn || hasDraft || options.mode === 'edit';
1541
+ label.hidden = !showInput;
1542
+ input.hidden = !showInput;
1543
+ submit.hidden = !signedIn;
1544
+ signin.hidden = signedIn;
1545
+ if (identity) {
1546
+ while (identity.firstChild) identity.removeChild(identity.firstChild);
1547
+ identity.hidden = !signedIn;
1548
+ if (signedIn) {
1549
+ identity.appendChild(
1550
+ document.createTextNode(
1551
+ MESSAGES.signedInAs + sessionName() + ' · ',
1552
+ ),
1553
+ );
1554
+ const out = button('g-interactions__signout', MESSAGES.signOut);
1555
+ out.addEventListener('click', signOut);
1556
+ identity.appendChild(out);
1557
+ }
1558
+ }
1559
+ updateCounter();
1560
+ };
1561
+
1562
+ composer.element = wrapper;
1563
+ composer.input = input;
1564
+ composer.close = () => {
1565
+ composers.delete(composer);
1566
+ wrapper.remove();
1567
+ };
1568
+
1569
+ input.addEventListener('input', () => {
1570
+ updateCounter();
1571
+ showError('');
1572
+ });
1573
+ input.addEventListener('keydown', (event) => {
1574
+ if (event.key === 'Escape' && options.mode !== 'new') {
1575
+ event.preventDefault();
1576
+ cancel.click();
1577
+ }
1578
+ });
1579
+ cancel.addEventListener('click', () => {
1580
+ composer.close();
1581
+ if (options.onCancel) options.onCancel();
1582
+ });
1583
+ signin.addEventListener('click', () => {
1584
+ startSignIn({
1585
+ kind: 'comment',
1586
+ parentId: composer.parentId,
1587
+ body: input.value,
1588
+ });
1589
+ });
1590
+ submit.addEventListener('click', async () => {
1591
+ if (composer.busy) return;
1592
+ const body = input.value.trim();
1593
+ if (!body) {
1594
+ showError(MESSAGES.empty);
1595
+ return;
1596
+ }
1597
+ if (codePoints(body) > COMMENT_LIMIT) {
1598
+ showError(MESSAGES.tooLong);
1599
+ return;
1600
+ }
1601
+ composer.busy = true;
1602
+ submit.disabled = true;
1603
+ submit.setAttribute('aria-busy', 'true');
1604
+ showError('');
1605
+ try {
1606
+ let result;
1607
+ if (composer.mode === 'edit') {
1608
+ result = await api(
1609
+ 'POST',
1610
+ '/v2/public/comments/' +
1611
+ encodeURIComponent(String(composer.commentId)) +
1612
+ '/revisions',
1613
+ { body: { body } },
1614
+ );
1615
+ } else {
1616
+ result = await api('POST', CONTENT_PATH + '/comments', {
1617
+ body: { body, parentId: composer.parentId },
1618
+ idempotencyKey: currentDraftKey(composer, body),
1619
+ });
1620
+ }
1621
+ composer.draft = null;
1622
+ input.value = '';
1623
+ updateCounter();
1624
+ if (options.onDone) options.onDone(result);
1625
+ } catch (failure) {
1626
+ showError(failureMessage(failure));
1627
+ } finally {
1628
+ composer.busy = false;
1629
+ submit.disabled = false;
1630
+ submit.removeAttribute('aria-busy');
1631
+ }
1632
+ });
1633
+
1634
+ composers.add(composer);
1635
+ composer.update();
1636
+ return composer;
1637
+ }
1638
+
1639
+ function refreshComposers() {
1640
+ for (const composer of composers) composer.update();
1641
+ }
1642
+
1643
+ /**
1644
+ * @param {string} body
1645
+ */
1646
+ function mountRootComposer(body) {
1647
+ if (!slotEl) return;
1648
+ if (rootComposer) rootComposer.close();
1649
+ while (slotEl.firstChild) slotEl.removeChild(slotEl.firstChild);
1650
+ if (!live.commentsOpen) {
1651
+ slotEl.appendChild(
1652
+ make('p', 'g-interactions__notice', MESSAGES.commentsClosed),
1653
+ );
1654
+ rootComposer = null;
1655
+ return;
1656
+ }
1657
+ rootComposer = createComposer({
1658
+ mode: 'new',
1659
+ body,
1660
+ onDone: (comment) => {
1661
+ insertNewComment(comment, null);
1662
+ },
1663
+ });
1664
+ slotEl.appendChild(rootComposer.element);
1665
+ }
1666
+
1667
+ /**
1668
+ * @param {CommentView} comment
1669
+ * @param {HTMLElement|null} parentItem
1670
+ */
1671
+ function insertNewComment(comment, parentItem) {
1672
+ const item = renderComment(comment);
1673
+ if (parentItem) {
1674
+ ensureReplies(parentItem).appendChild(item);
1675
+ } else if (listEl) {
1676
+ listEl.insertBefore(item, listEl.firstChild);
1677
+ }
1678
+ announce(MESSAGES.posted);
1679
+ focusNode(item);
1680
+ }
1681
+
1682
+ function closeInline() {
1683
+ if (inline) {
1684
+ inline.composer.close();
1685
+ inline = null;
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * @param {CommentItem} item
1691
+ * @param {'reply'|'edit'} mode
1692
+ * @param {HTMLElement|null} trigger
1693
+ * @param {string} [presetBody]
1694
+ */
1695
+ function openInlineComposer(item, mode, trigger, presetBody) {
1696
+ closeInline();
1697
+ const comment = item._comment;
1698
+ const done = (/** @type {any} */ result) => {
1699
+ /** @type {{composer: Composer}} */ (inline).composer.close();
1700
+ inline = null;
1701
+ if (mode === 'edit') {
1702
+ fillComment(item, result);
1703
+ announce(MESSAGES.updated);
1704
+ focusNode(item);
1705
+ } else {
1706
+ insertNewComment(result, item);
1707
+ }
1708
+ };
1709
+ const composer = createComposer({
1710
+ mode,
1711
+ parentId: mode === 'reply' ? comment.id : null,
1712
+ commentId: comment.id,
1713
+ body: mode === 'edit' ? comment.body || '' : presetBody || '',
1714
+ onDone: done,
1715
+ onCancel: () => {
1716
+ inline = null;
1717
+ if (trigger && trigger.isConnected) focusNode(trigger);
1718
+ else focusNode(item);
1719
+ },
1720
+ });
1721
+ inline = { composer, item };
1722
+ const replies = item.querySelector(':scope > .g-comment__replies');
1723
+ if (replies) item.insertBefore(composer.element, replies);
1724
+ else item.appendChild(composer.element);
1725
+ focusNode(composer.input);
1726
+ }
1727
+
1728
+ // -------------------------------------------------------------------- report
1729
+ /**
1730
+ * @param {CommentItem} item
1731
+ * @param {HTMLElement} trigger
1732
+ */
1733
+ function openReport(item, trigger) {
1734
+ if (!session) {
1735
+ startSignIn({ kind: 'signin' });
1736
+ return;
1737
+ }
1738
+ const dialog = make('dialog', 'g-report');
1739
+ const titleId = uniqueId('report-title');
1740
+ dialog.setAttribute('aria-labelledby', titleId);
1741
+ const heading = make('h3', '', MESSAGES.reportTitle);
1742
+ heading.id = titleId;
1743
+ const reasonId = uniqueId('report-reason');
1744
+ const reasonLabel = make('label', '', MESSAGES.reportReason);
1745
+ reasonLabel.setAttribute('for', reasonId);
1746
+ const select = make('select');
1747
+ select.id = reasonId;
1748
+ for (const pair of REPORT_REASONS) {
1749
+ const option = make('option', '', pair[1]);
1750
+ option.value = pair[0];
1751
+ select.appendChild(option);
1752
+ }
1753
+ const noteId = uniqueId('report-note');
1754
+ const noteLabel = make('label', '', MESSAGES.reportNote);
1755
+ noteLabel.setAttribute('for', noteId);
1756
+ const note = make('textarea');
1757
+ note.id = noteId;
1758
+ note.rows = 3;
1759
+ note.setAttribute('maxlength', String(NOTE_LIMIT));
1760
+ const error = make('p', 'g-composer__error');
1761
+ error.setAttribute('role', 'alert');
1762
+ error.hidden = true;
1763
+ const actions = make('div', 'g-composer__actions');
1764
+ const send = button('g-composer__submit', MESSAGES.reportSubmit);
1765
+ const cancel = button('g-composer__cancel', MESSAGES.cancel);
1766
+ actions.appendChild(send);
1767
+ actions.appendChild(cancel);
1768
+ for (const node of [
1769
+ heading,
1770
+ reasonLabel,
1771
+ select,
1772
+ noteLabel,
1773
+ note,
1774
+ error,
1775
+ actions,
1776
+ ]) {
1777
+ dialog.appendChild(node);
1778
+ }
1779
+
1780
+ let closed = false;
1781
+ const finish = () => {
1782
+ if (closed) return;
1783
+ closed = true;
1784
+ if (dialog.open && typeof dialog.close === 'function') {
1785
+ try {
1786
+ dialog.close();
1787
+ } catch {
1788
+ dialog.removeAttribute('open');
1789
+ }
1790
+ } else {
1791
+ dialog.removeAttribute('open');
1792
+ }
1793
+ dialog.remove();
1794
+ focusNode(trigger.isConnected ? trigger : item);
1795
+ };
1796
+ dialog.addEventListener('cancel', (event) => {
1797
+ event.preventDefault();
1798
+ finish();
1799
+ });
1800
+ dialog.addEventListener('keydown', (event) => {
1801
+ if (event.key === 'Escape') {
1802
+ event.preventDefault();
1803
+ finish();
1804
+ }
1805
+ });
1806
+ cancel.addEventListener('click', finish);
1807
+ send.addEventListener('click', async () => {
1808
+ send.disabled = true;
1809
+ error.hidden = true;
1810
+ /** @type {{reasonCode: string, note?: string}} */
1811
+ const payload = { reasonCode: select.value };
1812
+ const text = note.value.trim();
1813
+ if (text) payload.note = text.slice(0, NOTE_LIMIT);
1814
+ try {
1815
+ await api(
1816
+ 'POST',
1817
+ '/v2/public/comments/' +
1818
+ encodeURIComponent(item._comment.id) +
1819
+ '/reports',
1820
+ { body: payload },
1821
+ );
1822
+ finish();
1823
+ announce(MESSAGES.reported);
1824
+ } catch (failure) {
1825
+ send.disabled = false;
1826
+ error.textContent = failureMessage(failure);
1827
+ error.hidden = false;
1828
+ }
1829
+ });
1830
+
1831
+ root.appendChild(dialog);
1832
+ if (typeof dialog.showModal === 'function') {
1833
+ try {
1834
+ dialog.showModal();
1835
+ } catch {
1836
+ dialog.setAttribute('open', '');
1837
+ }
1838
+ } else {
1839
+ dialog.setAttribute('open', '');
1840
+ }
1841
+ focusNode(select);
1842
+ }
1843
+
1844
+ // -------------------------------------------------------------------- appeal
1845
+ /**
1846
+ * @param {CommentItem} item
1847
+ * @returns {HTMLElement}
1848
+ */
1849
+ function renderAppealAction(item) {
1850
+ const actions = make('div', 'g-comment__actions');
1851
+ const appeal = button(
1852
+ 'g-comment__action',
1853
+ MESSAGES.appeal,
1854
+ MESSAGES.appealAria,
1855
+ );
1856
+ appeal.addEventListener('click', () => openAppeal(item, appeal));
1857
+ actions.appendChild(appeal);
1858
+ return actions;
1859
+ }
1860
+
1861
+ /**
1862
+ * The sentence for a failed appeal; the problems an appeal adds are named
1863
+ * here, everything else is worded as for any other request.
1864
+ *
1865
+ * @param {unknown} error
1866
+ * @returns {string}
1867
+ */
1868
+ function appealFailureMessage(error) {
1869
+ if (error instanceof ApiError && error.kind === 'problem') {
1870
+ if (error.code === 'APPEAL_EXISTS') return MESSAGES.appealExists;
1871
+ if (error.code === 'APPEAL_NOT_ALLOWED') return MESSAGES.appealNotAllowed;
1872
+ if (error.status === 429 || error.code === 'RATE_LIMITED') {
1873
+ return MESSAGES.appealTooMany;
1874
+ }
1875
+ }
1876
+ return failureMessage(error);
1877
+ }
1878
+
1879
+ /**
1880
+ * Show the new state of an appeal under its comment.
1881
+ *
1882
+ * @param {CommentItem} item
1883
+ * @param {CommentAppeal | null} appeal
1884
+ */
1885
+ function applyAppeal(item, appeal) {
1886
+ const comment = item._comment;
1887
+ fillComment(
1888
+ item,
1889
+ Object.assign({}, comment, {
1890
+ own: Object.assign({}, comment.own, { appeal, canAppeal: false }),
1891
+ }),
1892
+ );
1893
+ focusNode(item);
1894
+ }
1895
+
1896
+ /**
1897
+ * @param {CommentItem} item
1898
+ * @param {HTMLElement} trigger
1899
+ */
1900
+ function openAppeal(item, trigger) {
1901
+ if (!session) {
1902
+ startSignIn({ kind: 'signin' });
1903
+ return;
1904
+ }
1905
+ const wrapper = make('div', 'g-composer');
1906
+ const inputId = uniqueId('appeal');
1907
+ const label = make('label', '', MESSAGES.appealLabel);
1908
+ label.setAttribute('for', inputId);
1909
+ const input = make('textarea', 'g-composer__input');
1910
+ input.id = inputId;
1911
+ input.rows = 3;
1912
+ input.setAttribute('maxlength', String(NOTE_LIMIT));
1913
+ const error = make('p', 'g-composer__error');
1914
+ error.setAttribute('role', 'alert');
1915
+ error.hidden = true;
1916
+ const actions = make('div', 'g-composer__actions');
1917
+ const send = button('g-composer__submit', MESSAGES.appealSend);
1918
+ const cancel = button('g-composer__cancel', MESSAGES.cancel);
1919
+ actions.appendChild(send);
1920
+ actions.appendChild(cancel);
1921
+ for (const node of [label, input, error, actions]) {
1922
+ wrapper.appendChild(node);
1923
+ }
1924
+
1925
+ /**
1926
+ * @param {string} text
1927
+ */
1928
+ const showError = (text) => {
1929
+ error.textContent = text;
1930
+ error.hidden = !text;
1931
+ if (text) announceNow(text);
1932
+ };
1933
+ const close = () => {
1934
+ wrapper.remove();
1935
+ trigger.hidden = false;
1936
+ };
1937
+ cancel.addEventListener('click', () => {
1938
+ close();
1939
+ focusNode(trigger);
1940
+ });
1941
+ input.addEventListener('input', () => showError(''));
1942
+ input.addEventListener('keydown', (event) => {
1943
+ if (event.key === 'Escape') {
1944
+ event.preventDefault();
1945
+ cancel.click();
1946
+ }
1947
+ });
1948
+ send.addEventListener('click', async () => {
1949
+ if (!session) {
1950
+ startSignIn({ kind: 'signin' });
1951
+ return;
1952
+ }
1953
+ const note = input.value.trim();
1954
+ if (!note) {
1955
+ showError(MESSAGES.appealEmpty);
1956
+ return;
1957
+ }
1958
+ if (codePoints(note) > NOTE_LIMIT) {
1959
+ showError(MESSAGES.appealTooLong);
1960
+ return;
1961
+ }
1962
+ send.disabled = true;
1963
+ send.setAttribute('aria-busy', 'true');
1964
+ showError('');
1965
+ try {
1966
+ await api(
1967
+ 'POST',
1968
+ '/v2/public/comments/' +
1969
+ encodeURIComponent(item._comment.id) +
1970
+ '/appeals',
1971
+ { body: { note } },
1972
+ );
1973
+ // "Appeal sent" is drawn by the redraw alone, under the comment where
1974
+ // focus lands; the status line does not repeat it.
1975
+ close();
1976
+ applyAppeal(item, { state: 'PENDING' });
1977
+ } catch (failure) {
1978
+ const message = appealFailureMessage(failure);
1979
+ if (
1980
+ failure instanceof ApiError &&
1981
+ (failure.code === 'APPEAL_EXISTS' ||
1982
+ failure.code === 'APPEAL_NOT_ALLOWED')
1983
+ ) {
1984
+ // The server's answer is final for this removal: stop offering it.
1985
+ close();
1986
+ applyAppeal(
1987
+ item,
1988
+ failure.code === 'APPEAL_EXISTS'
1989
+ ? item._comment.own?.appeal || { state: 'PENDING' }
1990
+ : item._comment.own?.appeal || null,
1991
+ );
1992
+ announce(message);
1993
+ } else {
1994
+ send.disabled = false;
1995
+ showError(message);
1996
+ }
1997
+ } finally {
1998
+ send.removeAttribute('aria-busy');
1999
+ }
2000
+ });
2001
+
2002
+ trigger.hidden = true;
2003
+ const replies = item.querySelector(':scope > .g-comment__replies');
2004
+ if (replies) item.insertBefore(wrapper, replies);
2005
+ else item.appendChild(wrapper);
2006
+ focusNode(input);
2007
+ }
2008
+
2009
+ // ------------------------------------------------------------------- loading
2010
+ /**
2011
+ * @param {CommentPage} page
2012
+ * @param {boolean} replace
2013
+ */
2014
+ function renderPage(page, replace) {
2015
+ if (!listEl) return;
2016
+ if (replace) {
2017
+ while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
2018
+ live.loadedIds.clear();
2019
+ }
2020
+ for (const comment of page.items || []) {
2021
+ if (live.loadedIds.has(comment.id)) continue;
2022
+ listEl.appendChild(renderComment(comment));
2023
+ }
2024
+ live.nextCursor = page.nextCursor || null;
2025
+ if (moreEl) moreEl.hidden = !live.nextCursor;
2026
+ }
2027
+
2028
+ async function loadAll() {
2029
+ const token = (loadToken += 1);
2030
+ let failures = 0;
2031
+ const tasks = [];
2032
+ tasks.push(
2033
+ api('GET', CONTENT_PATH + '/interactions').then(
2034
+ (view) => {
2035
+ if (token !== loadToken) return;
2036
+ if (view.viewer && session) {
2037
+ session.reader = view.viewer;
2038
+ saveSession(session);
2039
+ }
2040
+ if (view.comments) {
2041
+ live.commentsOpen =
2042
+ view.comments.enabled !== false && view.comments.open !== false;
2043
+ if (typeof view.comments.allowReplies === 'boolean') {
2044
+ live.allowReplies = view.comments.allowReplies;
2045
+ }
2046
+ if (Number.isFinite(view.comments.maxDepth)) {
2047
+ live.maxDepth = clampDepth(view.comments.maxDepth);
2048
+ }
2049
+ if (view.comments.enabled === false && commentsEl)
2050
+ commentsEl.hidden = true;
2051
+ }
2052
+ applyReactions(view.reactions);
2053
+ refreshComposers();
2054
+ if (rootComposer && !live.commentsOpen) mountRootComposer('');
2055
+ },
2056
+ (error) => {
2057
+ failures += 1;
2058
+ if (error instanceof ApiError && error.status === 401)
2059
+ failureMessage(error);
2060
+ },
2061
+ ),
2062
+ );
2063
+ if (config.commentsEnabled && listEl) {
2064
+ tasks.push(
2065
+ api('GET', CONTENT_PATH + '/comments').then(
2066
+ (page) => {
2067
+ if (token !== loadToken) return;
2068
+ renderPage(page, true);
2069
+ setCommentCount(page.count);
2070
+ },
2071
+ (error) => {
2072
+ failures += 1;
2073
+ if (error instanceof ApiError && error.status === 401)
2074
+ failureMessage(error);
2075
+ },
2076
+ ),
2077
+ );
2078
+ }
2079
+ await Promise.all(tasks);
2080
+ if (token !== loadToken) return;
2081
+ if (failures > 0) {
2082
+ showNotice(MESSAGES.unavailable);
2083
+ announceNow(MESSAGES.unavailable);
2084
+ } else {
2085
+ hideNotice();
2086
+ }
2087
+ }
2088
+
2089
+ async function loadMore() {
2090
+ if (!live.nextCursor || !moreEl) return;
2091
+ moreEl.disabled = true;
2092
+ try {
2093
+ const query = new URLSearchParams({ cursor: live.nextCursor });
2094
+ const page = await api(
2095
+ 'GET',
2096
+ CONTENT_PATH + '/comments?' + query.toString(),
2097
+ );
2098
+ renderPage(page, false);
2099
+ } catch (error) {
2100
+ announce(failureMessage(error));
2101
+ } finally {
2102
+ moreEl.disabled = false;
2103
+ }
2104
+ }
2105
+
2106
+ // -------------------------------------------------------------------- replay
2107
+ /**
2108
+ * @param {Intent|null} intent
2109
+ * @returns {Promise<void>}
2110
+ */
2111
+ async function replay(intent) {
2112
+ if (!intent) return;
2113
+ if (intent.kind === 'reaction' && typeof intent.key === 'string') {
2114
+ const node = reactionButtons().find(
2115
+ (candidate) =>
2116
+ candidate.getAttribute('data-reaction-key') === intent.key,
2117
+ );
2118
+ if (
2119
+ node &&
2120
+ !node.hidden &&
2121
+ node.getAttribute('aria-pressed') !== 'true'
2122
+ ) {
2123
+ await setReaction(node, true);
2124
+ }
2125
+ if (node) focusNode(node);
2126
+ } else if (intent.kind === 'comment') {
2127
+ const body = typeof intent.body === 'string' ? intent.body : '';
2128
+ /** @type {any} */
2129
+ const target = intent.parentId
2130
+ ? listEl &&
2131
+ listEl.querySelector(
2132
+ '[data-comment-id="' + cssEscape(intent.parentId) + '"]',
2133
+ )
2134
+ : null;
2135
+ if (target && target._comment && canReplyTo(target._comment)) {
2136
+ openInlineComposer(target, 'reply', null, body);
2137
+ } else if (rootComposer) {
2138
+ rootComposer.input.value = body;
2139
+ rootComposer.update();
2140
+ focusNode(rootComposer.input);
2141
+ }
2142
+ } else if (session) {
2143
+ announce(MESSAGES.signedInAs + sessionName() + '.');
2144
+ }
2145
+ }
2146
+
2147
+ /**
2148
+ * @param {string} value
2149
+ * @returns {string}
2150
+ */
2151
+ function cssEscape(value) {
2152
+ return String(value).replace(/[^A-Za-z0-9_-]/g, '');
2153
+ }
2154
+
2155
+ // ---------------------------------------------------------------------- init
2156
+ async function init() {
2157
+ if (noticeNode) noticeNode.hidden = true;
2158
+ for (const node of reactionButtons()) {
2159
+ node.addEventListener('click', onReactionClick);
2160
+ }
2161
+ if (moreEl) moreEl.addEventListener('click', loadMore);
2162
+
2163
+ session = loadSession();
2164
+ if (config.commentsEnabled) mountRootComposer('');
2165
+
2166
+ const intent = await completeSignInFromFragment();
2167
+ if (!session) session = loadSession();
2168
+ refreshComposers();
2169
+ await loadAll();
2170
+ if (intent) await replay(intent);
2171
+ }
2172
+
2173
+ init().catch(() => {
2174
+ showNotice(MESSAGES.unavailable);
2175
+ });
2176
+ })();