@schukai/monster 4.97.0 → 4.98.1

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,1034 @@
1
+ /**
2
+ * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
3
+ * Node module: @schukai/monster
4
+ *
5
+ * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
6
+ * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
7
+ *
8
+ * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
9
+ * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
10
+ * For more information about purchasing a commercial license, please contact Volker Schukai.
11
+ *
12
+ * SPDX-License-Identifier: AGPL-3.0
13
+ */
14
+
15
+ import { instanceSymbol } from "../../constants.mjs";
16
+ import {
17
+ assembleMethodSymbol,
18
+ CustomElement,
19
+ registerCustomElement,
20
+ } from "../../dom/customelement.mjs";
21
+ import { ThreadStyleSheet } from "./stylesheet/thread.mjs";
22
+ import { Entry } from "./thread/entry.mjs";
23
+ import { validateInstance, validateString } from "../../types/validate.mjs";
24
+ import "./state.mjs";
25
+ import { isArray } from "../../types/is.mjs";
26
+ import { fireCustomEvent } from "../../dom/events.mjs";
27
+ import { ProxyObserver } from "../../types/proxyobserver.mjs";
28
+ import { Updater } from "../../dom/updater.mjs";
29
+ import { Pathfinder } from "../../data/pathfinder.mjs";
30
+ import { getLocaleOfDocument } from "../../dom/locale.mjs";
31
+
32
+ export { Thread };
33
+
34
+ /**
35
+ * @private
36
+ * @type {symbol}
37
+ */
38
+ const collapsedStateSymbol = Symbol("collapsedState");
39
+ const entriesSymbol = Symbol("entries");
40
+ const entryMapSymbol = Symbol("entryMap");
41
+ const entryObserverMapSymbol = Symbol("entryObserverMap");
42
+ const entryUpdaterMapSymbol = Symbol("entryUpdaterMap");
43
+ const entryElementMapSymbol = Symbol("entryElementMap");
44
+ const entryTemplateSymbol = Symbol("entryTemplate");
45
+ const entriesListSymbol = Symbol("entriesList");
46
+ const emptyStateSymbol = Symbol("emptyStateElement");
47
+ const idCounterSymbol = Symbol("idCounter");
48
+ const timeAgoIntervalSymbol = Symbol("timeAgoInterval");
49
+
50
+ /**
51
+ * A discussion thread with hierarchical entries.
52
+ *
53
+ * @fragments /fragments/components/state/thread
54
+ *
55
+ * @example /examples/components/state/thread-simple Thread
56
+ *
57
+ * @issue https://localhost.alvine.dev:8444/development/issues/open/374.html
58
+ *
59
+ * @since 3.77.0
60
+ * @copyright Volker Schukai
61
+ * @summary The thread control visualizes nested discussion entries.
62
+ **/
63
+ class Thread extends CustomElement {
64
+ /**
65
+ * @return {void}
66
+ */
67
+ [assembleMethodSymbol]() {
68
+ super[assembleMethodSymbol]();
69
+
70
+ initControlReferences.call(this);
71
+ this[entriesSymbol] = [];
72
+ this[collapsedStateSymbol] = new Map();
73
+ this[entryMapSymbol] = new Map();
74
+ this[entryObserverMapSymbol] = new Map();
75
+ this[entryUpdaterMapSymbol] = new Map();
76
+ this[entryElementMapSymbol] = new Map();
77
+ this[idCounterSymbol] = 0;
78
+ initTimeAgoTicker.call(this);
79
+ initEventHandler.call(this);
80
+ }
81
+
82
+ /**
83
+ * This method is called by the `instanceof` operator.
84
+ * @return {symbol}
85
+ */
86
+ static get [instanceSymbol]() {
87
+ return Symbol.for("@schukai/monster/components/state/thread@@instance");
88
+ }
89
+
90
+ /**
91
+ * To set the options via the HTML tag, the attribute `data-monster-options` must be used.
92
+ * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
93
+ *
94
+ * The individual configuration values can be found in the table.
95
+ *
96
+ * @property {Object} templates Template definitions
97
+ * @property {string} templates.main Main template
98
+ * @property {Object} labels Labels
99
+ * @property {string} labels.nothingToReport Label for empty state
100
+ * @property {number} updateFrequency Update frequency in milliseconds for the timestamp
101
+ */
102
+ get defaults() {
103
+ return Object.assign({}, super.defaults, {
104
+ templates: {
105
+ main: getTemplate(),
106
+ },
107
+
108
+ labels: {
109
+ nothingToReport: "There is nothing to report yet.",
110
+ },
111
+
112
+ features: {
113
+ timeAgoMaxHours: 12,
114
+ },
115
+
116
+ updateFrequency: 10000,
117
+
118
+ entries: [],
119
+
120
+ length: 0,
121
+
122
+ timestamp: 0,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * @param {string} path
128
+ * @param {*} defaultValue
129
+ * @return {*}
130
+ */
131
+ getOption(path, defaultValue = undefined) {
132
+ if (path === "entries" || path?.startsWith("entries.")) {
133
+ try {
134
+ return new Pathfinder({
135
+ entries: this[entriesSymbol],
136
+ }).getVia(path);
137
+ } catch (e) {
138
+ return defaultValue;
139
+ }
140
+ }
141
+
142
+ return super.getOption(path, defaultValue);
143
+ }
144
+
145
+ /**
146
+ * @param {string} path
147
+ * @param {*} value
148
+ * @return {Thread}
149
+ */
150
+ setOption(path, value) {
151
+ if (path === "entries") {
152
+ const prepared = prepareEntries(value);
153
+ this[entriesSymbol] = prepared;
154
+ this[idCounterSymbol] = 0;
155
+ this[collapsedStateSymbol] = new Map();
156
+ renderEntries.call(this, prepared);
157
+ super.setOption("length", countEntries(prepared));
158
+ return this;
159
+ }
160
+
161
+ super.setOption(path, value);
162
+ return this;
163
+ }
164
+
165
+ /**
166
+ * @param {object|string} options
167
+ * @return {Thread}
168
+ */
169
+ setOptions(options) {
170
+ if (options && typeof options === "object" && options.entries) {
171
+ const { entries, ...rest } = options;
172
+ if (Object.keys(rest).length > 0) {
173
+ super.setOptions(rest);
174
+ }
175
+ this.setOption("entries", entries);
176
+ return this;
177
+ }
178
+
179
+ super.setOptions(options);
180
+ return this;
181
+ }
182
+
183
+ /**
184
+ * Clear the thread.
185
+ *
186
+ * @return {Thread}
187
+ */
188
+ clear() {
189
+ this.setOption("entries", []);
190
+ this.setOption("length", 0);
191
+ return this;
192
+ }
193
+
194
+ /**
195
+ * Add an entry to the thread.
196
+ *
197
+ * @param {Entry|Object} entry
198
+ * @param {string|null} parentId
199
+ * @return {Thread}
200
+ */
201
+ addEntry(entry, parentId = null) {
202
+ entry = normalizeEntry(entry);
203
+
204
+ let entries = this.getOption("entries");
205
+ if (!isArray(entries)) {
206
+ entries = [];
207
+ this[entriesSymbol] = entries;
208
+ }
209
+
210
+ if (parentId) {
211
+ const parent =
212
+ this[entryMapSymbol]?.get(parentId) || findEntryById(entries, parentId);
213
+ if (!parent) {
214
+ throw new Error(`parent entry not found: ${parentId}`);
215
+ }
216
+ applyEntryDefaults(entry, { isTopLevel: false });
217
+
218
+ if (parent.collapsed === true) {
219
+ parent.hiddenChildren = isArray(parent.hiddenChildren)
220
+ ? parent.hiddenChildren
221
+ : [];
222
+ parent.hiddenChildren.push(entry);
223
+ } else {
224
+ parent.children = isArray(parent.children) ? parent.children : [];
225
+ parent.children.push(entry);
226
+
227
+ const parentElement = this[entryElementMapSymbol]?.get(parentId);
228
+ const childrenList = parentElement?.querySelector(
229
+ "[data-monster-role=children]",
230
+ );
231
+ if (childrenList) {
232
+ renderEntry(this, entry, childrenList);
233
+ }
234
+ }
235
+
236
+ parent.replyCount = countEntries([
237
+ ...parent.children,
238
+ ...parent.hiddenChildren,
239
+ ]);
240
+ syncEntryField(this, parentId, "replyCount", parent.replyCount);
241
+ } else {
242
+ entries.push(entry);
243
+ applyEntryDefaults(entry, {
244
+ isTopLevel: true,
245
+ newestEntry: null,
246
+ });
247
+
248
+ if (this[entriesListSymbol]) {
249
+ renderEntry(this, entry, this[entriesListSymbol]);
250
+ }
251
+ if (this[emptyStateSymbol]) {
252
+ this[emptyStateSymbol].style.display = "none";
253
+ }
254
+ }
255
+
256
+ indexEntries(this, [entry]);
257
+ super.setOption("length", countEntries(entries));
258
+
259
+ return this;
260
+ }
261
+
262
+ /**
263
+ * Add a message entry to the thread.
264
+ *
265
+ * @param {string} message
266
+ * @param {Date} date
267
+ * @param {string|null} parentId
268
+ * @return {Thread}
269
+ * @throws {TypeError} message is not a string
270
+ */
271
+ addMessage(message, date, parentId = null) {
272
+ if (!date) {
273
+ date = new Date();
274
+ }
275
+
276
+ validateString(message);
277
+
278
+ this.addEntry(
279
+ new Entry({
280
+ message: message,
281
+ date: date,
282
+ }),
283
+ parentId,
284
+ );
285
+
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ *
291
+ * @return {string}
292
+ */
293
+ static getTag() {
294
+ return "monster-thread";
295
+ }
296
+
297
+ /**
298
+ * @return {CSSStyleSheet[]}
299
+ */
300
+ static getCSSStyleSheet() {
301
+ return [ThreadStyleSheet];
302
+ }
303
+
304
+ /**
305
+ * Toggle the collapsed state of an entry.
306
+ *
307
+ * @param {string} entryId
308
+ * @return {Thread}
309
+ */
310
+ toggleEntry(entryId) {
311
+ const current = this[collapsedStateSymbol]?.get(entryId) === true;
312
+ const next = !current;
313
+ this[collapsedStateSymbol]?.set(entryId, next);
314
+ setEntryCollapsed(this, entryId, next);
315
+ setCollapsedInDom(this, entryId, next);
316
+ return this;
317
+ }
318
+
319
+ /**
320
+ * Get collapsed state for all entries.
321
+ *
322
+ * @return {Object<string, boolean>}
323
+ */
324
+ getCollapsedState() {
325
+ const state = {};
326
+ if (!(this[collapsedStateSymbol] instanceof Map)) {
327
+ return state;
328
+ }
329
+ for (const [key, value] of this[collapsedStateSymbol].entries()) {
330
+ state[key] = value;
331
+ }
332
+ return state;
333
+ }
334
+
335
+ /**
336
+ * Set collapsed state for entries by id.
337
+ *
338
+ * @param {Object<string, boolean>} stateMap
339
+ * @return {Thread}
340
+ */
341
+ setCollapsedState(stateMap) {
342
+ if (!stateMap || typeof stateMap !== "object") {
343
+ return this;
344
+ }
345
+
346
+ if (!(this[collapsedStateSymbol] instanceof Map)) {
347
+ this[collapsedStateSymbol] = new Map();
348
+ }
349
+
350
+ for (const [entryId, value] of Object.entries(stateMap)) {
351
+ const collapsed = Boolean(value);
352
+ this[collapsedStateSymbol].set(entryId, collapsed);
353
+ setEntryCollapsed(this, entryId, collapsed);
354
+ setCollapsedInDom(this, entryId, collapsed);
355
+ }
356
+ return this;
357
+ }
358
+
359
+ /**
360
+ * Get ids of entries that are currently open.
361
+ *
362
+ * @return {string[]}
363
+ */
364
+ getOpenEntries() {
365
+ return collectCollapsedIds(this[collapsedStateSymbol], false);
366
+ }
367
+
368
+ /**
369
+ * Get ids of entries that are currently collapsed.
370
+ *
371
+ * @return {string[]}
372
+ */
373
+ getClosedEntries() {
374
+ return collectCollapsedIds(this[collapsedStateSymbol], true);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * @private
380
+ */
381
+ function initEventHandler() {
382
+ const root = this.shadowRoot || this;
383
+ root.addEventListener("click", (event) => {
384
+ const button = event.target.closest("[data-action=toggle]");
385
+ if (!button) {
386
+ return;
387
+ }
388
+
389
+ const entryId = button.getAttribute("data-entry-id");
390
+ if (!entryId) {
391
+ return;
392
+ }
393
+
394
+ this.toggleEntry(entryId);
395
+ const entry = this[entryMapSymbol]?.get(entryId) || null;
396
+ const collapsed = this[collapsedStateSymbol]?.get(entryId) === true;
397
+ fireCustomEvent(
398
+ this,
399
+ collapsed ? "monster-thread-collapse" : "monster-thread-expand",
400
+ {
401
+ entryId,
402
+ entry,
403
+ },
404
+ );
405
+ });
406
+
407
+ root.addEventListener("click", (event) => {
408
+ const button = event.target.closest("button[data-action]");
409
+ if (!button) {
410
+ return;
411
+ }
412
+
413
+ const action = button.getAttribute("data-action");
414
+ if (!action || action === "toggle") {
415
+ return;
416
+ }
417
+
418
+ const entryId = button.getAttribute("data-entry-id");
419
+ const entry = this[entryMapSymbol]?.get(entryId) || null;
420
+ fireCustomEvent(this, "monster-thread-action", {
421
+ action,
422
+ entryId,
423
+ entry,
424
+ });
425
+ });
426
+ }
427
+
428
+ /**
429
+ * @private
430
+ * @param {Entry|Object} entry
431
+ * @return {Entry}
432
+ */
433
+ function normalizeEntry(entry) {
434
+ if (entry instanceof Entry) {
435
+ return entry;
436
+ }
437
+
438
+ if (entry && typeof entry === "object") {
439
+ return new Entry(entry);
440
+ }
441
+
442
+ validateInstance(entry, Entry);
443
+ return entry;
444
+ }
445
+
446
+ /**
447
+ * @private
448
+ * @param {Entry[]|*} entries
449
+ * @return {Entry[]}
450
+ */
451
+ function prepareEntries(entries) {
452
+ const list = isArray(entries) ? entries.map(normalizeEntry) : [];
453
+ const newestEntry = findNewestEntry(list);
454
+
455
+ for (const entry of list) {
456
+ applyEntryDefaults(entry, {
457
+ isTopLevel: true,
458
+ newestEntry,
459
+ });
460
+ }
461
+
462
+ return list;
463
+ }
464
+
465
+ /**
466
+ * @private
467
+ * @param {Entry} entry
468
+ * @param {{isTopLevel:boolean, newestEntry?:Entry}} context
469
+ * @return {void}
470
+ */
471
+ function applyEntryDefaults(entry, { isTopLevel, newestEntry } = {}) {
472
+ entry.children = isArray(entry.children)
473
+ ? entry.children.map(normalizeEntry)
474
+ : [];
475
+ entry.hiddenChildren = isArray(entry.hiddenChildren)
476
+ ? entry.hiddenChildren.map(normalizeEntry)
477
+ : [];
478
+
479
+ if (
480
+ (entry.collapsed === false ||
481
+ entry.collapsed === null ||
482
+ entry.collapsed === undefined) &&
483
+ entry.children.length === 0 &&
484
+ entry.hiddenChildren.length > 0
485
+ ) {
486
+ entry.children = entry.hiddenChildren;
487
+ entry.hiddenChildren = [];
488
+ }
489
+
490
+ for (let index = 0; index < entry.children.length; index += 1) {
491
+ const child = entry.children[index];
492
+ applyEntryDefaults(child, {
493
+ isTopLevel: false,
494
+ });
495
+ }
496
+
497
+ entry.replyCount = countEntries([...entry.children, ...entry.hiddenChildren]);
498
+
499
+ if (entry.collapsed === null || entry.collapsed === undefined) {
500
+ if (isTopLevel) {
501
+ entry.collapsed = newestEntry ? entry !== newestEntry : false;
502
+ } else {
503
+ entry.collapsed = false;
504
+ }
505
+ }
506
+
507
+ if (entry.collapsed === true && entry.children.length > 0) {
508
+ entry.hiddenChildren = entry.children;
509
+ entry.children = [];
510
+ }
511
+ }
512
+
513
+ /**
514
+ * @private
515
+ * @param {Entry[]} entries
516
+ * @param {string} id
517
+ * @return {Entry|null}
518
+ */
519
+ function findEntryById(entries, id) {
520
+ for (const entry of entries) {
521
+ if (entry?.id === id) {
522
+ return entry;
523
+ }
524
+
525
+ const children = isArray(entry?.children) ? entry.children : [];
526
+ const match = findEntryById(children, id);
527
+ if (match) {
528
+ return match;
529
+ }
530
+
531
+ const hidden = isArray(entry?.hiddenChildren) ? entry.hiddenChildren : [];
532
+ const hiddenMatch = findEntryById(hidden, id);
533
+ if (hiddenMatch) {
534
+ return hiddenMatch;
535
+ }
536
+ }
537
+
538
+ return null;
539
+ }
540
+
541
+ /**
542
+ * @private
543
+ * @param {Entry[]} entries
544
+ * @return {number}
545
+ */
546
+ function countEntries(entries) {
547
+ let count = 0;
548
+ for (const entry of entries) {
549
+ count += 1;
550
+ if (isArray(entry?.children) && entry.children.length > 0) {
551
+ count += countEntries(entry.children);
552
+ }
553
+ if (isArray(entry?.hiddenChildren) && entry.hiddenChildren.length > 0) {
554
+ count += countEntries(entry.hiddenChildren);
555
+ }
556
+ }
557
+ return count;
558
+ }
559
+
560
+ /**
561
+ * @private
562
+ * @param {Entry[]} entries
563
+ * @return {Entry|null}
564
+ */
565
+ function findNewestEntry(entries) {
566
+ if (!isArray(entries) || entries.length === 0) {
567
+ return null;
568
+ }
569
+
570
+ let newest = entries[entries.length - 1];
571
+ let newestTime = getEntryTimestamp(newest);
572
+
573
+ for (const entry of entries) {
574
+ const time = getEntryTimestamp(entry);
575
+ if (time >= newestTime) {
576
+ newest = entry;
577
+ newestTime = time;
578
+ }
579
+ }
580
+
581
+ return newest;
582
+ }
583
+
584
+ /**
585
+ * @private
586
+ * @param {Entry} entry
587
+ * @return {number}
588
+ */
589
+ function getEntryTimestamp(entry) {
590
+ if (!entry?.date) {
591
+ return Number.NEGATIVE_INFINITY;
592
+ }
593
+
594
+ const time = new Date(entry.date).getTime();
595
+ return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
596
+ }
597
+
598
+ /**
599
+ * @private
600
+ * @param {Thread} thread
601
+ * @param {string} entryId
602
+ * @param {boolean} collapsed
603
+ * @return {void}
604
+ */
605
+ function setCollapsedInDom(thread, entryId, collapsed) {
606
+ const root = thread.shadowRoot || thread;
607
+ const item = thread[entryElementMapSymbol]?.get(entryId);
608
+ if (!item) {
609
+ return;
610
+ }
611
+
612
+ item.setAttribute("data-collapsed", collapsed ? "true" : "false");
613
+ const children = item.querySelector("[data-monster-role=children]");
614
+ if (children) {
615
+ children.setAttribute("data-collapsed", collapsed ? "true" : "false");
616
+ }
617
+ }
618
+
619
+ /**
620
+ * @private
621
+ * @param {Thread} thread
622
+ * @param {string} entryId
623
+ * @param {boolean} collapsed
624
+ * @return {void}
625
+ */
626
+ function setEntryCollapsed(thread, entryId, collapsed) {
627
+ const entry = thread[entryMapSymbol]?.get(entryId);
628
+ if (!entry) {
629
+ return;
630
+ }
631
+
632
+ entry.collapsed = collapsed;
633
+ syncEntryField(thread, entryId, "collapsed", collapsed);
634
+
635
+ if (collapsed) {
636
+ if (entry.children.length > 0) {
637
+ entry.hiddenChildren = entry.children;
638
+ entry.children = [];
639
+ }
640
+ } else if (entry.hiddenChildren.length > 0) {
641
+ entry.children = entry.hiddenChildren;
642
+ entry.hiddenChildren = [];
643
+ }
644
+
645
+ const childrenContainer = thread[entryElementMapSymbol]
646
+ ?.get(entryId)
647
+ ?.querySelector("[data-monster-role=children]");
648
+ if (!childrenContainer) {
649
+ return;
650
+ }
651
+
652
+ if (collapsed) {
653
+ clearContainer(childrenContainer);
654
+ removeEntrySubtree(thread, entry.hiddenChildren);
655
+ childrenContainer.style.display = "none";
656
+ return;
657
+ }
658
+
659
+ for (const child of entry.children) {
660
+ renderEntry(thread, child, childrenContainer);
661
+ }
662
+ childrenContainer.style.display = "";
663
+ }
664
+
665
+ /**
666
+ * @private
667
+ * @param {Map<string, boolean>} stateMap
668
+ * @param {boolean} collapsed
669
+ * @return {string[]}
670
+ */
671
+ function collectCollapsedIds(stateMap, collapsed) {
672
+ if (!(stateMap instanceof Map)) {
673
+ return [];
674
+ }
675
+
676
+ const list = [];
677
+ for (const [key, value] of stateMap.entries()) {
678
+ if (Boolean(value) === collapsed) {
679
+ list.push(key);
680
+ }
681
+ }
682
+ return list;
683
+ }
684
+
685
+ /**
686
+ * @private
687
+ * @return {void}
688
+ */
689
+ function initControlReferences() {
690
+ if (!this.shadowRoot) {
691
+ throw new Error("no shadow-root is defined");
692
+ }
693
+
694
+ this[entriesListSymbol] = this.shadowRoot.querySelector(
695
+ "[data-monster-role=entries-list]",
696
+ );
697
+ this[emptyStateSymbol] = this.shadowRoot.querySelector(
698
+ "[data-monster-role=empty-state]",
699
+ );
700
+ this[entryTemplateSymbol] = this.shadowRoot.querySelector("template#entry");
701
+ }
702
+
703
+ /**
704
+ * @private
705
+ * @return {void}
706
+ */
707
+ function initTimeAgoTicker() {
708
+ if (this[timeAgoIntervalSymbol]) {
709
+ return;
710
+ }
711
+
712
+ const refresh = () => {
713
+ updateTimeAgo(this);
714
+ };
715
+
716
+ refresh();
717
+ this[timeAgoIntervalSymbol] = setInterval(
718
+ refresh,
719
+ this.getOption("updateFrequency"),
720
+ );
721
+ }
722
+
723
+ /**
724
+ * @private
725
+ * @param {Entry[]} entries
726
+ * @return {void}
727
+ */
728
+ function renderEntries(entries) {
729
+ if (!this[entriesListSymbol]) {
730
+ return;
731
+ }
732
+
733
+ clearContainer(this[entriesListSymbol]);
734
+ this[entryMapSymbol] = new Map();
735
+ this[entryObserverMapSymbol] = new Map();
736
+ this[entryUpdaterMapSymbol] = new Map();
737
+ this[entryElementMapSymbol] = new Map();
738
+
739
+ indexEntries(this, entries);
740
+
741
+ const fragment = document.createDocumentFragment();
742
+ for (const entry of entries) {
743
+ renderEntry(this, entry, fragment);
744
+ }
745
+ this[entriesListSymbol].appendChild(fragment);
746
+ updateTimeAgo(this);
747
+
748
+ if (this[emptyStateSymbol]) {
749
+ this[emptyStateSymbol].style.display =
750
+ entries.length > 0 ? "none" : "block";
751
+ }
752
+ }
753
+
754
+ /**
755
+ * @private
756
+ * @param {Thread} thread
757
+ * @param {Entry} entry
758
+ * @param {HTMLElement} parentList
759
+ * @return {void}
760
+ */
761
+ function renderEntry(thread, entry, parentList) {
762
+ if (!entry.id) {
763
+ thread[idCounterSymbol] += 1;
764
+ entry.id = `entry-${thread[idCounterSymbol]}`;
765
+ }
766
+
767
+ const template = thread[entryTemplateSymbol];
768
+ if (!template) {
769
+ return;
770
+ }
771
+
772
+ const fragment = template.content.cloneNode(true);
773
+ const item = fragment.querySelector("[data-monster-role=entry]");
774
+ if (!item) {
775
+ return;
776
+ }
777
+
778
+ item.setAttribute("data-entry-id", entry.id);
779
+ item.setAttribute("data-collapsed", entry.collapsed ? "true" : "false");
780
+
781
+ parentList.appendChild(item);
782
+
783
+ const observer = new ProxyObserver({ entry });
784
+ const updater = new Updater(item, observer);
785
+ updater.run().catch(() => {});
786
+
787
+ thread[entryObserverMapSymbol].set(entry.id, observer);
788
+ thread[entryUpdaterMapSymbol].set(entry.id, updater);
789
+ thread[entryElementMapSymbol].set(entry.id, item);
790
+
791
+ const childrenContainer = item.querySelector("[data-monster-role=children]");
792
+ if (!childrenContainer) {
793
+ return;
794
+ }
795
+
796
+ const timeAgo = item.querySelector("[data-monster-role=time-ago]");
797
+ if (timeAgo) {
798
+ timeAgo.dataset.entryId = entry.id;
799
+ }
800
+
801
+ const toggleButton = item.querySelector("[data-action=toggle]");
802
+ if (toggleButton) {
803
+ toggleButton.setAttribute("data-entry-id", entry.id);
804
+ }
805
+
806
+ const children = entry.collapsed ? [] : entry.children;
807
+ if (children.length === 0 && entry.hiddenChildren.length === 0) {
808
+ childrenContainer.style.display = "none";
809
+ } else {
810
+ childrenContainer.style.display = "";
811
+ }
812
+ for (const child of children) {
813
+ renderEntry(thread, child, childrenContainer);
814
+ }
815
+ }
816
+
817
+ /**
818
+ * @private
819
+ * @param {Thread} thread
820
+ * @return {void}
821
+ */
822
+ function updateTimeAgo(thread) {
823
+ const locale = getLocaleOfDocument().toString();
824
+ const maxHours = Number(thread.getOption("features.timeAgoMaxHours", 12));
825
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "always" });
826
+ for (const [entryId, element] of thread[entryElementMapSymbol].entries()) {
827
+ const entry = thread[entryMapSymbol]?.get(entryId);
828
+ if (!entry?.date) {
829
+ continue;
830
+ }
831
+ const timeElement = element.querySelector("[data-monster-role=time-ago]");
832
+ if (!timeElement) {
833
+ continue;
834
+ }
835
+ try {
836
+ timeElement.textContent = formatRelativeTime(
837
+ new Date(entry.date),
838
+ locale,
839
+ maxHours,
840
+ rtf,
841
+ );
842
+ } catch (e) {}
843
+ }
844
+ }
845
+
846
+ /**
847
+ * @private
848
+ * @param {Date} date
849
+ * @param {string} locale
850
+ * @param {number} maxHours
851
+ * @param {Intl.RelativeTimeFormat} rtf
852
+ * @return {string}
853
+ */
854
+ function formatRelativeTime(date, locale, maxHours, rtf) {
855
+ let diffSeconds = Math.floor((Date.now() - date.getTime()) / 1000);
856
+ if (!Number.isFinite(diffSeconds) || diffSeconds < 0) {
857
+ diffSeconds = 0;
858
+ }
859
+
860
+ if (diffSeconds < 5) {
861
+ return "just now";
862
+ }
863
+
864
+ if (diffSeconds < 60) {
865
+ return rtf.format(-diffSeconds, "second");
866
+ }
867
+
868
+ const diffMinutes = Math.floor(diffSeconds / 60);
869
+ if (diffMinutes < 60) {
870
+ return rtf.format(-diffMinutes, "minute");
871
+ }
872
+
873
+ const diffHours = Math.floor(diffMinutes / 60);
874
+ if (diffHours < maxHours) {
875
+ return rtf.format(-diffHours, "hour");
876
+ }
877
+
878
+ return date.toLocaleDateString(locale, {
879
+ year: "numeric",
880
+ month: "2-digit",
881
+ day: "2-digit",
882
+ });
883
+ }
884
+
885
+ /**
886
+ * @private
887
+ * @param {Thread} thread
888
+ * @param {Entry[]} entries
889
+ * @return {void}
890
+ */
891
+ function indexEntries(thread, entries) {
892
+ for (const entry of entries) {
893
+ if (!entry.id) {
894
+ thread[idCounterSymbol] += 1;
895
+ entry.id = `entry-${thread[idCounterSymbol]}`;
896
+ }
897
+
898
+ thread[entryMapSymbol].set(entry.id, entry);
899
+ thread[collapsedStateSymbol].set(entry.id, Boolean(entry.collapsed));
900
+
901
+ const children = [
902
+ ...(isArray(entry.children) ? entry.children : []),
903
+ ...(isArray(entry.hiddenChildren) ? entry.hiddenChildren : []),
904
+ ];
905
+
906
+ if (children.length > 0) {
907
+ indexEntries(thread, children);
908
+ }
909
+ }
910
+ }
911
+
912
+ /**
913
+ * @private
914
+ * @param {Thread} thread
915
+ * @param {string} entryId
916
+ * @param {string} key
917
+ * @param {*} value
918
+ * @return {void}
919
+ */
920
+ function syncEntryField(thread, entryId, key, value) {
921
+ const observer = thread[entryObserverMapSymbol]?.get(entryId);
922
+ if (!observer) {
923
+ return;
924
+ }
925
+
926
+ const subject = observer.getSubject();
927
+ if (!subject?.entry) {
928
+ return;
929
+ }
930
+
931
+ subject.entry[key] = value;
932
+ }
933
+
934
+ /**
935
+ * @private
936
+ * @param {Thread} thread
937
+ * @param {Entry[]} entries
938
+ * @return {void}
939
+ */
940
+ function removeEntrySubtree(thread, entries) {
941
+ for (const entry of entries) {
942
+ if (entry?.id) {
943
+ thread[entryObserverMapSymbol]?.delete(entry.id);
944
+ thread[entryUpdaterMapSymbol]?.delete(entry.id);
945
+ thread[entryElementMapSymbol]?.delete(entry.id);
946
+ }
947
+ if (isArray(entry?.children) && entry.children.length > 0) {
948
+ removeEntrySubtree(thread, entry.children);
949
+ }
950
+ if (isArray(entry?.hiddenChildren) && entry.hiddenChildren.length > 0) {
951
+ removeEntrySubtree(thread, entry.hiddenChildren);
952
+ }
953
+ }
954
+ }
955
+
956
+ /**
957
+ * @private
958
+ * @param {HTMLElement} container
959
+ * @return {void}
960
+ */
961
+ function clearContainer(container) {
962
+ while (container.firstChild) {
963
+ container.removeChild(container.firstChild);
964
+ }
965
+ }
966
+
967
+ /**
968
+ * @private
969
+ * @return {string}
970
+ */
971
+ function getTemplate() {
972
+ // language=HTML
973
+ return `
974
+ <template id="entry">
975
+ <li data-monster-role="entry">
976
+ <div data-monster-role="entry-card">
977
+ <div data-monster-role="meta">
978
+ <span data-monster-replace="path:entry.user"
979
+ data-monster-attributes="class path:entry.user | ?:user:hidden"></span>
980
+ <span data-monster-replace="path:entry.title"
981
+ data-monster-attributes="class path:entry.title | ?:title:hidden"></span>
982
+ <span data-monster-role="time-ago"
983
+ data-monster-replace="path:entry.date | time-ago"
984
+ data-monster-attributes="title path:entry.date | datetime"></span>
985
+ </div>
986
+ <div data-monster-role="message"
987
+ data-monster-replace="path:entry.message"
988
+ data-monster-attributes="class path:entry.message | ?:message:hidden"></div>
989
+ <div data-monster-role="thread-controls">
990
+ <button type="button"
991
+ class="monster-button-outline-secondary"
992
+ data-action="toggle"
993
+ data-monster-attributes="data-entry-id path:entry.id, data-reply-count path:entry.replyCount">
994
+ Replies
995
+ <span data-monster-role="badge"
996
+ data-monster-replace="path:entry.replyCount"
997
+ data-monster-attributes="data-reply-count path:entry.replyCount"></span>
998
+ </button>
999
+ <div data-monster-role="actions"
1000
+ data-monster-replace="path:entry.actions"
1001
+ data-monster-attributes="class path:entry.actions | ?:actions:hidden"></div>
1002
+ </div>
1003
+ </div>
1004
+ <ul data-monster-role="children"></ul>
1005
+ </li>
1006
+ </template>
1007
+
1008
+ <div part="control" data-monster-role="control">
1009
+ <div data-monster-role="empty-state">
1010
+ <monster-state>
1011
+ <div part="visual">
1012
+ <svg width="4rem" height="4rem" viewBox="0 -12 512.00032 512"
1013
+ xmlns="http://www.w3.org/2000/svg">
1014
+ <path d="m455.074219 172.613281 53.996093-53.996093c2.226563-2.222657 3.273438-5.367188 2.828126-8.480469-.441407-3.113281-2.328126-5.839844-5.085938-7.355469l-64.914062-35.644531c-4.839844-2.65625-10.917969-.886719-13.578126 3.953125-2.65625 4.84375-.890624 10.921875 3.953126 13.578125l53.234374 29.230469-46.339843 46.335937-166.667969-91.519531 46.335938-46.335938 46.839843 25.722656c4.839844 2.65625 10.921875.890626 13.578125-3.953124 2.660156-4.839844.890625-10.921876-3.953125-13.578126l-53.417969-29.335937c-3.898437-2.140625-8.742187-1.449219-11.882812 1.695313l-54 54-54-54c-3.144531-3.144532-7.988281-3.832032-11.882812-1.695313l-184.929688 101.546875c-2.757812 1.515625-4.644531 4.238281-5.085938 7.355469-.445312 3.113281.601563 6.257812 2.828126 8.480469l53.996093 53.996093-53.996093 53.992188c-2.226563 2.226562-3.273438 5.367187-2.828126 8.484375.441407 3.113281 2.328126 5.839844 5.085938 7.351562l55.882812 30.6875v102.570313c0 3.652343 1.988282 7.011719 5.1875 8.769531l184.929688 101.542969c1.5.824219 3.15625 1.234375 4.8125 1.234375s3.3125-.410156 4.8125-1.234375l184.929688-101.542969c3.199218-1.757812 5.1875-5.117188 5.1875-8.769531v-102.570313l55.882812-30.683594c2.757812-1.515624 4.644531-4.242187 5.085938-7.355468.445312-3.113282-.601563-6.257813-2.828126-8.480469zm-199.074219 90.132813-164.152344-90.136719 164.152344-90.140625 164.152344 90.140625zm-62.832031-240.367188 46.332031 46.335938-166.667969 91.519531-46.335937-46.335937zm-120.328125 162.609375 166.667968 91.519531-46.339843 46.339844-166.671875-91.519531zm358.089844 184.796875-164.929688 90.5625v-102.222656c0-5.523438-4.476562-10-10-10s-10 4.476562-10 10v102.222656l-164.929688-90.5625v-85.671875l109.046876 59.878907c1.511718.828124 3.167968 1.234374 4.808593 1.234374 2.589844 0 5.152344-1.007812 7.074219-2.929687l54-54 54 54c1.921875 1.925781 4.484375 2.929687 7.074219 2.929687 1.640625 0 3.296875-.40625 4.808593-1.234374l109.046876-59.878907zm-112.09375-46.9375-46.339844-46.34375 166.667968-91.515625 46.34375 46.335938zm0 0"/>
1015
+ <path d="m404.800781 68.175781c2.628907 0 5.199219-1.070312 7.070313-2.933593 1.859375-1.859376 2.929687-4.4375 2.929687-7.066407 0-2.632812-1.070312-5.210937-2.929687-7.070312-1.859375-1.863281-4.441406-2.929688-7.070313-2.929688-2.640625 0-5.210937 1.066407-7.070312 2.929688-1.871094 1.859375-2.929688 4.4375-2.929688 7.070312 0 2.628907 1.058594 5.207031 2.929688 7.066407 1.859375 1.863281 4.441406 2.933593 7.070312 2.933593zm0 0"/>
1016
+ <path d="m256 314.925781c-2.628906 0-5.210938 1.066407-7.070312 2.929688-1.859376 1.867187-2.929688 4.4375-2.929688 7.070312 0 2.636719 1.070312 5.207031 2.929688 7.078125 1.859374 1.859375 4.441406 2.921875 7.070312 2.921875s5.210938-1.0625 7.070312-2.921875c1.859376-1.871094 2.929688-4.441406 2.929688-7.078125 0-2.632812-1.070312-5.203125-2.929688-7.070312-1.859374-1.863281-4.441406-2.929688-7.070312-2.929688zm0 0"/>
1017
+ </svg>
1018
+ </div>
1019
+ <div part="content" data-monster-replace="path:labels.nothingToReport">
1020
+ There is nothing to report yet.
1021
+ </div>
1022
+ </monster-state>
1023
+ </div>
1024
+ <div data-monster-role="entries">
1025
+ <ul data-monster-role="entries-list"></ul>
1026
+ </div>
1027
+ <div part="editor" data-monster-role="editor">
1028
+ <slot name="editor"></slot>
1029
+ </div>
1030
+ </div>
1031
+ `;
1032
+ }
1033
+
1034
+ registerCustomElement(Thread);