@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.
- package/README.md +21 -19
- package/contracts/render-policy.jcs +1 -1
- package/package.json +4 -3
- package/src/core/index.js +74 -9
- package/src/core/internal/appearance/base-layer.js +84 -3
- package/src/core/internal/appearance/styling-contract.js +2 -2
- package/src/core/internal/components.js +8 -16
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +5 -3
- package/src/core/internal/icons.js +2 -0
- package/src/core/internal/interactions.js +349 -0
- package/src/core/internal/messages.js +6 -0
- package/src/core/internal/page-kinds.js +25 -2
- package/src/core/internal/render-policy-content.js +40 -5
- package/src/core/internal/robots.js +5 -13
- package/src/modules/interactions/browser/gala-interactions.js +1891 -0
- package/types/index.d.ts +32 -2
|
@@ -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
|
+
})();
|