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