UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,035 lines (913 loc) 28.4 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { ThreadStyleSheet } from "./stylesheet/thread.mjs"; import { Entry } from "./thread/entry.mjs"; import "./thread/message.mjs"; import { validateInstance, validateString } from "../../types/validate.mjs"; import "./state.mjs"; import { isArray } from "../../types/is.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { ProxyObserver } from "../../types/proxyobserver.mjs"; import { Updater } from "../../dom/updater.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; export { Thread }; /** * @private * @type {symbol} */ const collapsedStateSymbol = Symbol("collapsedState"); const entriesSymbol = Symbol("entries"); const entryMapSymbol = Symbol("entryMap"); const entryObserverMapSymbol = Symbol("entryObserverMap"); const entryUpdaterMapSymbol = Symbol("entryUpdaterMap"); const entryElementMapSymbol = Symbol("entryElementMap"); const entryTemplateSymbol = Symbol("entryTemplate"); const entriesListSymbol = Symbol("entriesList"); const emptyStateSymbol = Symbol("emptyStateElement"); const idCounterSymbol = Symbol("idCounter"); const timeAgoIntervalSymbol = Symbol("timeAgoInterval"); /** * A discussion thread with hierarchical entries. * * @fragments /fragments/components/state/thread * * @example /examples/components/state/thread-simple Thread * * @issue https://localhost.alvine.dev:8444/development/issues/open/374.html * * @since 3.77.0 * @copyright Volker Schukai * @summary The thread control visualizes nested discussion entries. **/ class Thread extends CustomElement { /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); this[entriesSymbol] = []; this[collapsedStateSymbol] = new Map(); this[entryMapSymbol] = new Map(); this[entryObserverMapSymbol] = new Map(); this[entryUpdaterMapSymbol] = new Map(); this[entryElementMapSymbol] = new Map(); this[idCounterSymbol] = 0; initTimeAgoTicker.call(this); initEventHandler.call(this); } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/state/thread@@instance"); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} labels Labels * @property {string} labels.nothingToReport Label for empty state * @property {number} updateFrequency Update frequency in milliseconds for the timestamp */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: { nothingToReport: "There is nothing to report yet.", }, features: { timeAgoMaxHours: 12, }, updateFrequency: 10000, entries: [], length: 0, timestamp: 0, }); } /** * @param {string} path * @param {*} defaultValue * @return {*} */ getOption(path, defaultValue = undefined) { if (path === "entries" || path?.startsWith("entries.")) { try { return new Pathfinder({ entries: this[entriesSymbol], }).getVia(path); } catch (e) { return defaultValue; } } return super.getOption(path, defaultValue); } /** * @param {string} path * @param {*} value * @return {Thread} */ setOption(path, value) { if (path === "entries") { const prepared = prepareEntries(value); this[entriesSymbol] = prepared; this[idCounterSymbol] = 0; this[collapsedStateSymbol] = new Map(); renderEntries.call(this, prepared); super.setOption("length", countEntries(prepared)); return this; } super.setOption(path, value); return this; } /** * @param {object|string} options * @return {Thread} */ setOptions(options) { if (options && typeof options === "object" && options.entries) { const { entries, ...rest } = options; if (Object.keys(rest).length > 0) { super.setOptions(rest); } this.setOption("entries", entries); return this; } super.setOptions(options); return this; } /** * Clear the thread. * * @return {Thread} */ clear() { this.setOption("entries", []); this.setOption("length", 0); return this; } /** * Add an entry to the thread. * * @param {Entry|Object} entry * @param {string|null} parentId * @return {Thread} */ addEntry(entry, parentId = null) { entry = normalizeEntry(entry); let entries = this.getOption("entries"); if (!isArray(entries)) { entries = []; this[entriesSymbol] = entries; } if (parentId) { const parent = this[entryMapSymbol]?.get(parentId) || findEntryById(entries, parentId); if (!parent) { throw new Error(`parent entry not found: ${parentId}`); } applyEntryDefaults(entry, { isTopLevel: false }); if (parent.collapsed === true) { parent.hiddenChildren = isArray(parent.hiddenChildren) ? parent.hiddenChildren : []; parent.hiddenChildren.push(entry); } else { parent.children = isArray(parent.children) ? parent.children : []; parent.children.push(entry); const parentElement = this[entryElementMapSymbol]?.get(parentId); const childrenList = parentElement?.querySelector( "[data-monster-role=children]", ); if (childrenList) { renderEntry(this, entry, childrenList); } } parent.replyCount = countEntries([ ...parent.children, ...parent.hiddenChildren, ]); syncEntryField(this, parentId, "replyCount", parent.replyCount); } else { entries.push(entry); applyEntryDefaults(entry, { isTopLevel: true, newestEntry: null, }); if (this[entriesListSymbol]) { renderEntry(this, entry, this[entriesListSymbol]); } if (this[emptyStateSymbol]) { this[emptyStateSymbol].style.display = "none"; } } indexEntries(this, [entry]); super.setOption("length", countEntries(entries)); return this; } /** * Add a message entry to the thread. * * @param {string} message * @param {Date} date * @param {string|null} parentId * @return {Thread} * @throws {TypeError} message is not a string */ addMessage(message, date, parentId = null) { if (!date) { date = new Date(); } validateString(message); this.addEntry( new Entry({ message: message, date: date, }), parentId, ); return this; } /** * * @return {string} */ static getTag() { return "monster-thread"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [ThreadStyleSheet]; } /** * Toggle the collapsed state of an entry. * * @param {string} entryId * @return {Thread} */ toggleEntry(entryId) { const current = this[collapsedStateSymbol]?.get(entryId) === true; const next = !current; this[collapsedStateSymbol]?.set(entryId, next); setEntryCollapsed(this, entryId, next); setCollapsedInDom(this, entryId, next); return this; } /** * Get collapsed state for all entries. * * @return {Object<string, boolean>} */ getCollapsedState() { const state = {}; if (!(this[collapsedStateSymbol] instanceof Map)) { return state; } for (const [key, value] of this[collapsedStateSymbol].entries()) { state[key] = value; } return state; } /** * Set collapsed state for entries by id. * * @param {Object<string, boolean>} stateMap * @return {Thread} */ setCollapsedState(stateMap) { if (!stateMap || typeof stateMap !== "object") { return this; } if (!(this[collapsedStateSymbol] instanceof Map)) { this[collapsedStateSymbol] = new Map(); } for (const [entryId, value] of Object.entries(stateMap)) { const collapsed = Boolean(value); this[collapsedStateSymbol].set(entryId, collapsed); setEntryCollapsed(this, entryId, collapsed); setCollapsedInDom(this, entryId, collapsed); } return this; } /** * Get ids of entries that are currently open. * * @return {string[]} */ getOpenEntries() { return collectCollapsedIds(this[collapsedStateSymbol], false); } /** * Get ids of entries that are currently collapsed. * * @return {string[]} */ getClosedEntries() { return collectCollapsedIds(this[collapsedStateSymbol], true); } } /** * @private */ function initEventHandler() { const root = this.shadowRoot || this; root.addEventListener("click", (event) => { const button = event.target.closest("[data-action=toggle]"); if (!button) { return; } const entryId = button.getAttribute("data-entry-id"); if (!entryId) { return; } this.toggleEntry(entryId); const entry = this[entryMapSymbol]?.get(entryId) || null; const collapsed = this[collapsedStateSymbol]?.get(entryId) === true; fireCustomEvent( this, collapsed ? "monster-thread-collapse" : "monster-thread-expand", { entryId, entry, }, ); }); root.addEventListener("click", (event) => { const button = event.target.closest("button[data-action]"); if (!button) { return; } const action = button.getAttribute("data-action"); if (!action || action === "toggle") { return; } const entryId = button.getAttribute("data-entry-id"); const entry = this[entryMapSymbol]?.get(entryId) || null; fireCustomEvent(this, "monster-thread-action", { action, entryId, entry, }); }); } /** * @private * @param {Entry|Object} entry * @return {Entry} */ function normalizeEntry(entry) { if (entry instanceof Entry) { return entry; } if (entry && typeof entry === "object") { return new Entry(entry); } validateInstance(entry, Entry); return entry; } /** * @private * @param {Entry[]|*} entries * @return {Entry[]} */ function prepareEntries(entries) { const list = isArray(entries) ? entries.map(normalizeEntry) : []; const newestEntry = findNewestEntry(list); for (const entry of list) { applyEntryDefaults(entry, { isTopLevel: true, newestEntry, }); } return list; } /** * @private * @param {Entry} entry * @param {{isTopLevel:boolean, newestEntry?:Entry}} context * @return {void} */ function applyEntryDefaults(entry, { isTopLevel, newestEntry } = {}) { entry.children = isArray(entry.children) ? entry.children.map(normalizeEntry) : []; entry.hiddenChildren = isArray(entry.hiddenChildren) ? entry.hiddenChildren.map(normalizeEntry) : []; if ( (entry.collapsed === false || entry.collapsed === null || entry.collapsed === undefined) && entry.children.length === 0 && entry.hiddenChildren.length > 0 ) { entry.children = entry.hiddenChildren; entry.hiddenChildren = []; } for (let index = 0; index < entry.children.length; index += 1) { const child = entry.children[index]; applyEntryDefaults(child, { isTopLevel: false, }); } entry.replyCount = countEntries([...entry.children, ...entry.hiddenChildren]); if (entry.collapsed === null || entry.collapsed === undefined) { if (isTopLevel) { entry.collapsed = newestEntry ? entry !== newestEntry : false; } else { entry.collapsed = false; } } if (entry.collapsed === true && entry.children.length > 0) { entry.hiddenChildren = entry.children; entry.children = []; } } /** * @private * @param {Entry[]} entries * @param {string} id * @return {Entry|null} */ function findEntryById(entries, id) { for (const entry of entries) { if (entry?.id === id) { return entry; } const children = isArray(entry?.children) ? entry.children : []; const match = findEntryById(children, id); if (match) { return match; } const hidden = isArray(entry?.hiddenChildren) ? entry.hiddenChildren : []; const hiddenMatch = findEntryById(hidden, id); if (hiddenMatch) { return hiddenMatch; } } return null; } /** * @private * @param {Entry[]} entries * @return {number} */ function countEntries(entries) { let count = 0; for (const entry of entries) { count += 1; if (isArray(entry?.children) && entry.children.length > 0) { count += countEntries(entry.children); } if (isArray(entry?.hiddenChildren) && entry.hiddenChildren.length > 0) { count += countEntries(entry.hiddenChildren); } } return count; } /** * @private * @param {Entry[]} entries * @return {Entry|null} */ function findNewestEntry(entries) { if (!isArray(entries) || entries.length === 0) { return null; } let newest = entries[entries.length - 1]; let newestTime = getEntryTimestamp(newest); for (const entry of entries) { const time = getEntryTimestamp(entry); if (time >= newestTime) { newest = entry; newestTime = time; } } return newest; } /** * @private * @param {Entry} entry * @return {number} */ function getEntryTimestamp(entry) { if (!entry?.date) { return Number.NEGATIVE_INFINITY; } const time = new Date(entry.date).getTime(); return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time; } /** * @private * @param {Thread} thread * @param {string} entryId * @param {boolean} collapsed * @return {void} */ function setCollapsedInDom(thread, entryId, collapsed) { const root = thread.shadowRoot || thread; const item = thread[entryElementMapSymbol]?.get(entryId); if (!item) { return; } item.setAttribute("data-collapsed", collapsed ? "true" : "false"); const children = item.querySelector("[data-monster-role=children]"); if (children) { children.setAttribute("data-collapsed", collapsed ? "true" : "false"); } } /** * @private * @param {Thread} thread * @param {string} entryId * @param {boolean} collapsed * @return {void} */ function setEntryCollapsed(thread, entryId, collapsed) { const entry = thread[entryMapSymbol]?.get(entryId); if (!entry) { return; } entry.collapsed = collapsed; syncEntryField(thread, entryId, "collapsed", collapsed); if (collapsed) { if (entry.children.length > 0) { entry.hiddenChildren = entry.children; entry.children = []; } } else if (entry.hiddenChildren.length > 0) { entry.children = entry.hiddenChildren; entry.hiddenChildren = []; } const childrenContainer = thread[entryElementMapSymbol] ?.get(entryId) ?.querySelector("[data-monster-role=children]"); if (!childrenContainer) { return; } if (collapsed) { clearContainer(childrenContainer); removeEntrySubtree(thread, entry.hiddenChildren); childrenContainer.style.display = "none"; return; } for (const child of entry.children) { renderEntry(thread, child, childrenContainer); } childrenContainer.style.display = ""; } /** * @private * @param {Map<string, boolean>} stateMap * @param {boolean} collapsed * @return {string[]} */ function collectCollapsedIds(stateMap, collapsed) { if (!(stateMap instanceof Map)) { return []; } const list = []; for (const [key, value] of stateMap.entries()) { if (Boolean(value) === collapsed) { list.push(key); } } return list; } /** * @private * @return {void} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[entriesListSymbol] = this.shadowRoot.querySelector( "[data-monster-role=entries-list]", ); this[emptyStateSymbol] = this.shadowRoot.querySelector( "[data-monster-role=empty-state]", ); this[entryTemplateSymbol] = this.shadowRoot.querySelector("template#entry"); } /** * @private * @return {void} */ function initTimeAgoTicker() { if (this[timeAgoIntervalSymbol]) { return; } const refresh = () => { updateTimeAgo(this); }; refresh(); this[timeAgoIntervalSymbol] = setInterval( refresh, this.getOption("updateFrequency"), ); } /** * @private * @param {Entry[]} entries * @return {void} */ function renderEntries(entries) { if (!this[entriesListSymbol]) { return; } clearContainer(this[entriesListSymbol]); this[entryMapSymbol] = new Map(); this[entryObserverMapSymbol] = new Map(); this[entryUpdaterMapSymbol] = new Map(); this[entryElementMapSymbol] = new Map(); indexEntries(this, entries); const fragment = document.createDocumentFragment(); for (const entry of entries) { renderEntry(this, entry, fragment); } this[entriesListSymbol].appendChild(fragment); updateTimeAgo(this); if (this[emptyStateSymbol]) { this[emptyStateSymbol].style.display = entries.length > 0 ? "none" : "block"; } } /** * @private * @param {Thread} thread * @param {Entry} entry * @param {HTMLElement} parentList * @return {void} */ function renderEntry(thread, entry, parentList) { if (!entry.id) { thread[idCounterSymbol] += 1; entry.id = `entry-${thread[idCounterSymbol]}`; } const template = thread[entryTemplateSymbol]; if (!template) { return; } const fragment = template.content.cloneNode(true); const item = fragment.querySelector("[data-monster-role=entry]"); if (!item) { return; } item.setAttribute("data-entry-id", entry.id); item.setAttribute("data-collapsed", entry.collapsed ? "true" : "false"); parentList.appendChild(item); const observer = new ProxyObserver({ entry }); const updater = new Updater(item, observer); updater.run().catch(() => {}); thread[entryObserverMapSymbol].set(entry.id, observer); thread[entryUpdaterMapSymbol].set(entry.id, updater); thread[entryElementMapSymbol].set(entry.id, item); const childrenContainer = item.querySelector("[data-monster-role=children]"); if (!childrenContainer) { return; } const timeAgo = item.querySelector("[data-monster-role=time-ago]"); if (timeAgo) { timeAgo.dataset.entryId = entry.id; } const toggleButton = item.querySelector("[data-action=toggle]"); if (toggleButton) { toggleButton.setAttribute("data-entry-id", entry.id); } const children = entry.collapsed ? [] : entry.children; if (children.length === 0 && entry.hiddenChildren.length === 0) { childrenContainer.style.display = "none"; } else { childrenContainer.style.display = ""; } for (const child of children) { renderEntry(thread, child, childrenContainer); } } /** * @private * @param {Thread} thread * @return {void} */ function updateTimeAgo(thread) { const locale = getLocaleOfDocument().toString(); const maxHours = Number(thread.getOption("features.timeAgoMaxHours", 12)); const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "always" }); for (const [entryId, element] of thread[entryElementMapSymbol].entries()) { const entry = thread[entryMapSymbol]?.get(entryId); if (!entry?.date) { continue; } const timeElement = element.querySelector("[data-monster-role=time-ago]"); if (!timeElement) { continue; } try { timeElement.textContent = formatRelativeTime( new Date(entry.date), locale, maxHours, rtf, ); } catch (e) {} } } /** * @private * @param {Date} date * @param {string} locale * @param {number} maxHours * @param {Intl.RelativeTimeFormat} rtf * @return {string} */ function formatRelativeTime(date, locale, maxHours, rtf) { let diffSeconds = Math.floor((Date.now() - date.getTime()) / 1000); if (!Number.isFinite(diffSeconds) || diffSeconds < 0) { diffSeconds = 0; } if (diffSeconds < 5) { return "just now"; } if (diffSeconds < 60) { return rtf.format(-diffSeconds, "second"); } const diffMinutes = Math.floor(diffSeconds / 60); if (diffMinutes < 60) { return rtf.format(-diffMinutes, "minute"); } const diffHours = Math.floor(diffMinutes / 60); if (diffHours < maxHours) { return rtf.format(-diffHours, "hour"); } return date.toLocaleDateString(locale, { year: "numeric", month: "2-digit", day: "2-digit", }); } /** * @private * @param {Thread} thread * @param {Entry[]} entries * @return {void} */ function indexEntries(thread, entries) { for (const entry of entries) { if (!entry.id) { thread[idCounterSymbol] += 1; entry.id = `entry-${thread[idCounterSymbol]}`; } thread[entryMapSymbol].set(entry.id, entry); thread[collapsedStateSymbol].set(entry.id, Boolean(entry.collapsed)); const children = [ ...(isArray(entry.children) ? entry.children : []), ...(isArray(entry.hiddenChildren) ? entry.hiddenChildren : []), ]; if (children.length > 0) { indexEntries(thread, children); } } } /** * @private * @param {Thread} thread * @param {string} entryId * @param {string} key * @param {*} value * @return {void} */ function syncEntryField(thread, entryId, key, value) { const observer = thread[entryObserverMapSymbol]?.get(entryId); if (!observer) { return; } const subject = observer.getSubject(); if (!subject?.entry) { return; } subject.entry[key] = value; } /** * @private * @param {Thread} thread * @param {Entry[]} entries * @return {void} */ function removeEntrySubtree(thread, entries) { for (const entry of entries) { if (entry?.id) { thread[entryObserverMapSymbol]?.delete(entry.id); thread[entryUpdaterMapSymbol]?.delete(entry.id); thread[entryElementMapSymbol]?.delete(entry.id); } if (isArray(entry?.children) && entry.children.length > 0) { removeEntrySubtree(thread, entry.children); } if (isArray(entry?.hiddenChildren) && entry.hiddenChildren.length > 0) { removeEntrySubtree(thread, entry.hiddenChildren); } } } /** * @private * @param {HTMLElement} container * @return {void} */ function clearContainer(container) { while (container.firstChild) { container.removeChild(container.firstChild); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="entry"> <li data-monster-role="entry"> <div data-monster-role="entry-card"> <div data-monster-role="meta"> <span data-monster-replace="path:entry.user" data-monster-attributes="class path:entry.user | ?:user:hidden"></span> <span data-monster-replace="path:entry.title" data-monster-attributes="class path:entry.title | ?:title:hidden"></span> <span data-monster-role="time-ago" data-monster-replace="path:entry.date | time-ago" data-monster-attributes="title path:entry.date | datetime"></span> </div> <monster-thread-message data-monster-role="message" data-monster-attributes="data-monster-option-content path:entry.message | default: , class path:entry.message | ?:message:hidden"></monster-thread-message> <div data-monster-role="thread-controls"> <button type="button" class="monster-button-outline-secondary" data-action="toggle" data-monster-attributes="data-entry-id path:entry.id, data-reply-count path:entry.replyCount"> Replies <span data-monster-role="badge" data-monster-replace="path:entry.replyCount" data-monster-attributes="data-reply-count path:entry.replyCount"></span> </button> <div data-monster-role="actions" data-monster-replace="path:entry.actions" data-monster-attributes="class path:entry.actions | ?:actions:hidden"></div> </div> </div> <ul data-monster-role="children"></ul> </li> </template> <div part="control" data-monster-role="control"> <div data-monster-role="empty-state"> <monster-state> <div part="visual"> <svg width="4rem" height="4rem" viewBox="0 -12 512.00032 512" xmlns="http://www.w3.org/2000/svg"> <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"/> <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"/> <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"/> </svg> </div> <div part="content" data-monster-replace="path:labels.nothingToReport"> There is nothing to report yet. </div> </monster-state> </div> <div data-monster-role="entries"> <ul data-monster-role="entries-list"></ul> </div> <div part="editor" data-monster-role="editor"> <slot name="editor"></slot> </div> </div> `; } registerCustomElement(Thread);