diff --git a/.scheduler-memory/latest/daily/memories.md b/.scheduler-memory/latest/daily/memories.md index a1f4ea8b..dc393739 100644 --- a/.scheduler-memory/latest/daily/memories.md +++ b/.scheduler-memory/latest/daily/memories.md @@ -1,3 +1,9 @@ -### 1. [2026-02-25] (scheduler, daily, retrieve) +### 1. [2026-02-26] (scheduler, daily, retrieve) + +Memory retrieval seed for daily :: scheduler memory retrieval + +--- + +### 2. [2026-02-25] (scheduler, daily, retrieve) Memory retrieval seed for daily :: scheduler memory retrieval \ No newline at end of file diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json index 338191d2..2f075bb9 100644 --- a/.scheduler-memory/memory-store.json +++ b/.scheduler-memory/memory-store.json @@ -17,7 +17,32 @@ "importance": 0.4, "embedding_id": null, "created_at": 1772043447415, - "last_seen": 1772043447434, + "last_seen": 1772132732113, + "source": "scheduler-retrieve", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "34dd591f-15cc-4914-8c70-ec7510b29abb", + { + "schema_version": 1, + "id": "34dd591f-15cc-4914-8c70-ec7510b29abb", + "agent_id": "scheduler-memory-daily", + "session_id": "session-mm3u2nd3", + "type": "batch_window", + "content": "Memory retrieval seed for daily :: scheduler memory retrieval", + "summary": "Memory retrieval seed for daily :: scheduler memory retrieval", + "tags": [ + "scheduler", + "daily", + "retrieve" + ], + "importance": 0.4, + "embedding_id": null, + "created_at": 1772132732103, + "last_seen": 1772132732113, "source": "scheduler-retrieve", "ttl_days": null, "merged_into": null, diff --git a/.scheduler-memory/session-mm3u2nd3/retrieve.json b/.scheduler-memory/session-mm3u2nd3/retrieve.json new file mode 100644 index 00000000..0f7567e8 --- /dev/null +++ b/.scheduler-memory/session-mm3u2nd3/retrieve.json @@ -0,0 +1,18 @@ +{ + "cadence": "daily", + "operation": "retrieve", + "runId": "session-mm3u2nd3", + "servicePath": "src/services/memory/index.js#getRelevantMemories", + "inputs": { + "agentId": "scheduler-memory-daily", + "promptPath": "", + "promptIntent": "scheduler memory retrieval", + "query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval", + "events": 1 + }, + "outputs": { + "ingestedCount": 1, + "retrievedCount": 2 + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mm3u2nd3/retrieve.ok b/.scheduler-memory/session-mm3u2nd3/retrieve.ok new file mode 100644 index 00000000..84b17199 --- /dev/null +++ b/.scheduler-memory/session-mm3u2nd3/retrieve.ok @@ -0,0 +1 @@ +MEMORY_RETRIEVED diff --git a/.scheduler-memory/session-mm3u4jnd/store.json b/.scheduler-memory/session-mm3u4jnd/store.json new file mode 100644 index 00000000..c0b468b7 --- /dev/null +++ b/.scheduler-memory/session-mm3u4jnd/store.json @@ -0,0 +1,18 @@ +{ + "cadence": "daily", + "operation": "store", + "runId": "session-mm3u4jnd", + "servicePath": "src/services/memory/index.js#ingestEvents", + "inputs": { + "agentId": "scheduler-memory-daily", + "promptPath": "", + "promptIntent": "scheduler memory store", + "events": 0, + "sourceFile": "none" + }, + "outputs": { + "storedCount": 0, + "summaries": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mm3u4jnd/store.ok b/.scheduler-memory/session-mm3u4jnd/store.ok new file mode 100644 index 00000000..1e40bb67 --- /dev/null +++ b/.scheduler-memory/session-mm3u4jnd/store.ok @@ -0,0 +1 @@ +MEMORY_STORED diff --git a/.scheduler-memory/session-mm3v12n7/retrieve.json b/.scheduler-memory/session-mm3v12n7/retrieve.json new file mode 100644 index 00000000..6c85587c --- /dev/null +++ b/.scheduler-memory/session-mm3v12n7/retrieve.json @@ -0,0 +1,18 @@ +{ + "cadence": "daily", + "operation": "retrieve", + "runId": "session-mm3v12n7", + "servicePath": "src/services/memory/index.js#getRelevantMemories", + "inputs": { + "agentId": "scheduler-memory-daily", + "promptPath": "", + "promptIntent": "scheduler memory retrieval", + "query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval", + "events": 1 + }, + "outputs": { + "ingestedCount": 1, + "retrievedCount": 2 + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mm3v12n7/retrieve.ok b/.scheduler-memory/session-mm3v12n7/retrieve.ok new file mode 100644 index 00000000..84b17199 --- /dev/null +++ b/.scheduler-memory/session-mm3v12n7/retrieve.ok @@ -0,0 +1 @@ +MEMORY_RETRIEVED diff --git a/.scheduler-memory/session-mm3v4xpe/store.json b/.scheduler-memory/session-mm3v4xpe/store.json new file mode 100644 index 00000000..5f214051 --- /dev/null +++ b/.scheduler-memory/session-mm3v4xpe/store.json @@ -0,0 +1,18 @@ +{ + "cadence": "daily", + "operation": "store", + "runId": "session-mm3v4xpe", + "servicePath": "src/services/memory/index.js#ingestEvents", + "inputs": { + "agentId": "scheduler-memory-daily", + "promptPath": "", + "promptIntent": "scheduler memory store", + "events": 0, + "sourceFile": "none" + }, + "outputs": { + "storedCount": 0, + "summaries": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mm3v4xpe/store.ok b/.scheduler-memory/session-mm3v4xpe/store.ok new file mode 100644 index 00000000..1e40bb67 --- /dev/null +++ b/.scheduler-memory/session-mm3v4xpe/store.ok @@ -0,0 +1 @@ +MEMORY_STORED diff --git a/embed.html b/embed.html index 3f20931d..eefea746 100644 --- a/embed.html +++ b/embed.html @@ -96,6 +96,7 @@ id="embedWatchButton" href="#" target="_blank" + rel="noopener noreferrer" class="flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/20 backdrop-blur-md" > Watch on diff --git a/js/app.js b/js/app.js index ac71b4b6..c4a54347 100644 --- a/js/app.js +++ b/js/app.js @@ -2,6 +2,7 @@ import { nostrClient } from "./nostrClientFacade.js"; import { recordVideoView } from "./nostrViewEventsFacade.js"; +import { getDTagValueFromTags } from "./nostr/nip71.js"; import { torrentClient } from "./webtorrent.js"; import { emit } from "./embedDiagnostics.js"; import { @@ -1370,18 +1371,7 @@ class Application { return this.similarContentController.extractDTagValue(tags); } // Fallback if controller not initialized (though it should be) - if (!Array.isArray(tags)) { - return ""; - } - for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - if (tag[0] === "d" && typeof tag[1] === "string") { - return tag[1]; - } - } - return ""; + return getDTagValueFromTags(tags); } deriveVideoPointerInfo(video) { diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index 3001a3bb..d6300a37 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -467,6 +467,56 @@ async function decryptGiftWrap(event, decryptors, actorPubkey) { }); } +function raceOrdered(promises) { + if (!promises || promises.length === 0) { + return Promise.reject(new AggregateError([], "No promises to race")); + } + + return new Promise((resolve, reject) => { + let pendingCount = promises.length; + const errors = new Array(promises.length); + const results = new Array(promises.length); + const states = new Array(promises.length).fill("pending"); + let scanIndex = 0; + + promises.forEach((p, i) => { + Promise.resolve(p).then( + (val) => { + states[i] = "fulfilled"; + results[i] = val; + + if (i === scanIndex) { + resolve(val); + } + }, + (err) => { + states[i] = "rejected"; + errors[i] = err; + pendingCount--; + + if (i === scanIndex) { + while (scanIndex < promises.length) { + if (states[scanIndex] === "fulfilled") { + resolve(results[scanIndex]); + return; + } + if (states[scanIndex] === "rejected") { + scanIndex++; + } else { + return; + } + } + } + + if (pendingCount === 0 && scanIndex >= promises.length) { + reject(new AggregateError(errors, "All promises rejected")); + } + }, + ); + }); + }); +} + async function decryptLegacyDm(event, decryptors, actorPubkey) { const ciphertext = typeof event?.content === "string" ? event.content : ""; const senderPubkey = normalizeHex(event?.pubkey); @@ -564,7 +614,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { // Race all decryption attempts to return the first successful result // This significantly reduces latency compared to sequential attempts try { - return await Promise.any(attempts); + return await raceOrdered(attempts); } catch (aggregateError) { const aggregatedErrors = aggregateError.errors || []; errors.push(...aggregatedErrors); diff --git a/js/nostr/videoEventBuffer.js b/js/nostr/videoEventBuffer.js index df6c87a5..0af08a73 100644 --- a/js/nostr/videoEventBuffer.js +++ b/js/nostr/videoEventBuffer.js @@ -130,6 +130,7 @@ export class VideoEventBuffer { } } } + updatedVideos.push(video); continue; } diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index f707bc45..a174aa28 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -1234,17 +1234,15 @@ class WatchHistoryManager { const months = Object.keys(records).sort(); - const results = await pMap( - months, - async (month) => { + const results = await Promise.all( + months.map(async (month) => { const items = records[month]; // Ideally we check if this month changed before publishing. // For now, we rely on the caller or just publish. // In a real optimized system we'd track dirty flags per month. return this.publishMonthRecord(month, items, options); - }, - { concurrency: 5 }, + }), ); for (const res of results) { diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ea2fb0c7..ff1d9dc5 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1743,9 +1743,59 @@ export class NostrService { limit: this.nostrClient.clampVideoRequestLimit(200), }; const subscription = this.nostrClient.subscribeVideos( - () => { - const updated = this.nostrClient.getActiveVideos(); - applyAndNotify(updated, "subscription"); + (updatedVideos) => { + if (!Array.isArray(updatedVideos) || !updatedVideos.length) { + return; + } + + const activeUpdates = []; + const deletedUpdates = []; + + for (const v of updatedVideos) { + if (v.deleted) { + deletedUpdates.push(v); + } else { + activeUpdates.push(v); + } + } + + if (deletedUpdates.length > 0) { + const map = this.ensureVideosMap(); + for (const v of deletedUpdates) { + if (v.id) { + map.delete(v.id); + } + } + this.markAuthorIndexDirty(); + } + + const filtered = this.filterVideos(activeUpdates, { + blacklistedEventIds, + isAuthorBlocked, + }); + + if (filtered.length > 0) { + this.cacheVideos(filtered); + } + + if (filtered.length > 0 || deletedUpdates.length > 0) { + // Persist the full list (latest N) from the now-updated cache + const map = this.ensureVideosMap(); + const fullList = Array.from(map.values()); + fullList.sort((a, b) => { + const aCreated = Number(a?.created_at) || 0; + const bCreated = Number(b?.created_at) || 0; + return bCreated - aCreated; + }); + + this.emit("videos:updated", { + videos: fullList, + deleted: deletedUpdates, + reason: "subscription", + }); + + persistFilteredVideos(fullList); + } }, subscriptionOptions, ); diff --git a/js/ui/components/DeleteModal.js b/js/ui/components/DeleteModal.js index 80ade25f..7c01ab01 100644 --- a/js/ui/components/DeleteModal.js +++ b/js/ui/components/DeleteModal.js @@ -1,20 +1,6 @@ +import { getDTagValueFromTags } from "../../nostr/index.js"; import { createModalAccessibility } from "./modalAccessibility.js"; -function extractDTagValue(tags) { - if (!Array.isArray(tags)) { - return ""; - } - for (const entry of tags) { - if (!Array.isArray(entry) || entry.length < 2) { - continue; - } - if (entry[0] === "d" && typeof entry[1] === "string") { - return entry[1]; - } - } - return ""; -} - export class DeleteModal { constructor({ removeTrackingScripts, @@ -232,7 +218,7 @@ export class DeleteModal { video && typeof video.videoRootId === "string" && video.videoRootId.trim().length ? video.videoRootId.trim() : ""; - const dTagValue = extractDTagValue(video?.tags); + const dTagValue = getDTagValueFromTags(video?.tags); if (this.subtitle) { const subtitleParts = []; diff --git a/js/ui/components/RevertModal.js b/js/ui/components/RevertModal.js index 3e2d4ab0..ac7c5fdb 100644 --- a/js/ui/components/RevertModal.js +++ b/js/ui/components/RevertModal.js @@ -1,5 +1,7 @@ +import { getDTagValueFromTags } from "../../nostr/nip71.js"; import { Nip71FormManager } from "./nip71FormManager.js"; import { createModalAccessibility } from "./modalAccessibility.js"; +import { getDTagValueFromTags } from "../../nostr/nip71.js"; import { createImetaVariants, createTextTracks, @@ -7,6 +9,12 @@ import { createHashtags, createParticipants, createReferences, + createHeader, + createDescription, + createAudienceFlags, + createEventMetadata, + createNotePointers, + createSection, } from "./revertModalRenderers.js"; // NOTE: Any metadata field added to the Upload or Edit modals must also be @@ -354,7 +362,7 @@ export class RevertModal { if (this.subtitle) { const subtitleParts = []; - const dTagValue = this.extractDTagValue(video.tags); + const dTagValue = getDTagValueFromTags(video.tags); if (dTagValue) { subtitleParts.push(`d=${dTagValue}`); } @@ -604,423 +612,42 @@ export class RevertModal { typeof version.rawMagnet === "string" ? version.rawMagnet.trim() : ""; const displayMagnet = magnet || rawMagnet; const isPrivate = version.isPrivate === true; - const dTagValue = this.extractDTagValue(version.tags); + const dTagValue = getDTagValueFromTags(version.tags); const nip71Metadata = this.buildNip71DisplayMetadata(version); + const dTagValue = getDTagValueFromTags(version.tags); - const fallbackThumbnail = this.fallbackThumbnailSrc; - const thumbnailSrc = thumbnail || fallbackThumbnail; - const thumbnailAlt = thumbnail ? "Revision thumbnail" : "Fallback thumbnail"; - - const urlNode = url ? this.createLinkMarkup(url) : this.createPlaceholder("None"); - - const magnetNode = displayMagnet - ? (() => { - const div = document.createElement("div"); - div.className = "break-all"; - const label = this.truncateMiddle - ? this.truncateMiddle(displayMagnet, 72) - : displayMagnet; - div.textContent = label; - if (isPrivate) { - const span = document.createElement("span"); - span.className = "block mt-1 text-xs text-status-private-on"; - span.textContent = "Magnet stays visible only to you — private notes keep the raw string local."; - div.appendChild(span); - } - return div; - })() - : this.createPlaceholder("None"); - - const chipsDiv = document.createElement("div"); - chipsDiv.className = "flex flex-wrap gap-2"; - - const nsfwFlag = version.isNsfw === true; - const forKidsFlag = version.isForKids === true; - const conflictingAudienceFlags = nsfwFlag && forKidsFlag; - - if (version.deleted) { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "critical"; - s.textContent = "Marked deleted"; - chipsDiv.appendChild(s); - } - if (isPrivate) { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "private"; - s.textContent = "Private"; - chipsDiv.appendChild(s); - } - if (conflictingAudienceFlags) { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "warning"; - s.textContent = "NSFW + For kids conflict"; - chipsDiv.appendChild(s); - } else { - if (nsfwFlag) { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "critical"; - s.textContent = "Marked NSFW"; - chipsDiv.appendChild(s); - } - if (forKidsFlag) { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "success"; - s.textContent = "For kids"; - chipsDiv.appendChild(s); - } - } - if (version.version !== undefined) { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "neutral"; - s.textContent = `Schema v${version.version}`; - chipsDiv.appendChild(s); - } - - const createFlagStatus = (value, {yes, no, unspecified}) => { - if (value === true) { - const s = document.createElement("span"); - s.className = "text-primary"; - s.textContent = yes; - return s; - } - if (value === false) { - const s = document.createElement("span"); - s.className = "text-primary"; - s.textContent = no; - return s; - } - return this.createPlaceholder(unspecified); - }; - - const nsfwStatusNode = createFlagStatus(version.isNsfw, { - yes: "Yes — marked NSFW", - no: "No — not flagged as NSFW", - unspecified: "Not specified", - }); - const kidsStatusNode = createFlagStatus(version.isForKids, { - yes: "Yes — marked for kids", - no: "No — not marked for kids", - unspecified: "Not specified", - }); - - const audienceConflictNotice = conflictingAudienceFlags - ? (() => { - const p = document.createElement("p"); - p.className = "text-xs text-status-warning-on"; - p.textContent = "Conflicting flags detected — this revision is marked both NSFW and for kids. Review before reverting."; - return p; - })() - : null; - - const descriptionNode = description - ? (() => { - const p = document.createElement("p"); - p.className = "whitespace-pre-wrap text-primary"; - p.textContent = description; - return p; - })() - : (() => { - const p = document.createElement("p"); - p.className = "text-subtle"; - p.textContent = "No description provided."; - return p; - })(); - - const rootId = typeof version.videoRootId === "string" ? version.videoRootId : ""; - const rootDisplay = rootId - ? this.truncateMiddle - ? this.truncateMiddle(rootId, 64) - : rootId - : ""; - const eventDisplay = version.id - ? this.truncateMiddle - ? this.truncateMiddle(version.id, 64) - : version.id - : ""; - - const timestampText = [absolute, relative ? `(${relative})` : ""] - .filter(Boolean) - .join(" "); - - const createTimestampNode = (seconds, raw) => { - if (Number.isFinite(seconds)) { - const abs = this.formatAbsoluteTimestamp ? this.formatAbsoluteTimestamp(seconds) : `${seconds}`; - const rel = this.formatTimeAgo ? this.formatTimeAgo(seconds) : ""; - const frag = document.createDocumentFragment(); - if (abs) { - const s = document.createElement("span"); - s.className = "text-primary"; - s.textContent = abs; - frag.appendChild(s); - frag.appendChild(document.createTextNode(" ")); - } - if (rel) { - const s = document.createElement("span"); - s.className = "text-muted"; - s.textContent = `(${rel})`; - frag.appendChild(s); - } - return frag; - } - if (raw) { - const s = document.createElement("span"); - s.className = "text-primary"; - s.textContent = raw; - return s; - } - return this.createPlaceholder(); - }; - - const createDurationNode = (seconds, raw) => { - if (Number.isFinite(seconds)) { - const display = this.formatDurationSeconds(seconds); - const suffix = seconds === 1 ? "second" : "seconds"; - const frag = document.createDocumentFragment(); - const s1 = document.createElement("span"); - s1.className = "text-primary"; - s1.textContent = display; - frag.appendChild(s1); - frag.appendChild(document.createTextNode(" ")); - const s2 = document.createElement("span"); - s2.className = "text-subtle"; - s2.textContent = `(${seconds} ${suffix})`; - frag.appendChild(s2); - return frag; - } - if (raw) { - const s = document.createElement("span"); - s.className = "text-primary"; - s.textContent = raw; - return s; - } - return this.createPlaceholder(); - }; - - const kindValue = nip71Metadata.kind; - const kindNode = (() => { - if (kindValue !== "" && kindValue !== null && kindValue !== undefined) { - const label = typeof kindValue === "number" ? kindValue : `${kindValue}`; - const numeric = Number(label); - let suffix = ""; - if (Number.isFinite(numeric)) { - if (numeric === 22) suffix = "short"; - else if (numeric === 21) suffix = "video"; - } - - const frag = document.createDocumentFragment(); - const code = document.createElement("code"); - code.className = "rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; - code.textContent = `kind ${label}`; - frag.appendChild(code); - - if (suffix) { - frag.appendChild(document.createTextNode(" ")); - const s = document.createElement("span"); - s.className = "text-muted"; - s.textContent = `(${suffix})`; - frag.appendChild(s); - } - return frag; - } - return this.createPlaceholder(); - })(); - - const summaryNode = nip71Metadata.summary - ? (() => { - const p = document.createElement("p"); - p.className = "whitespace-pre-wrap text-primary"; - p.textContent = nip71Metadata.summary; - return p; - })() - : this.createPlaceholder("Not provided"); - - const contentWarningNode = nip71Metadata.contentWarning - ? (() => { - const s = document.createElement("span"); - s.className = "pill"; - s.dataset.size = "compact"; - s.dataset.variant = "warning"; - s.textContent = nip71Metadata.contentWarning; - return s; - })() - : this.createPlaceholder("Not provided"); - - const publishedAtNode = createTimestampNode( - nip71Metadata.publishedAtSeconds, - nip71Metadata.publishedAtRaw - ); - const durationNode = createDurationNode( - nip71Metadata.durationSeconds, - nip71Metadata.durationRaw - ); - const altNode = nip71Metadata.alt - ? (() => { - const p = document.createElement("p"); - p.className = "whitespace-pre-wrap text-primary"; - p.textContent = nip71Metadata.alt; - return p; - })() - : this.createPlaceholder("Not provided"); - - const createSection = (title) => { - const s = document.createElement("section"); - s.className = "space-y-2"; - const h = document.createElement("h4"); - h.className = "text-sm font-semibold text-primary"; - h.textContent = title; - s.appendChild(h); - return s; - }; - - const createDl = () => { - const dl = document.createElement("dl"); - dl.className = "grid gap-3 sm:grid-cols-2 text-xs text-subtle"; - return dl; - }; - - const createDtDd = (label, contentNode, colSpan = 1) => { - const div = document.createElement("div"); - if (colSpan > 1) div.className = "sm:col-span-2"; - - const dt = document.createElement("dt"); - dt.className = "font-semibold text-primary"; - dt.textContent = label; - div.appendChild(dt); - - const dd = document.createElement("dd"); - dd.className = "mt-1"; - dd.appendChild(contentNode); - div.appendChild(dd); - return div; + const utils = { + formatAbsoluteTimestamp: this.formatAbsoluteTimestamp, + formatTimeAgo: this.formatTimeAgo, + truncateMiddle: this.truncateMiddle, + createPlaceholder: this.createPlaceholder.bind(this), + createLinkMarkup: this.createLinkMarkup.bind(this), + createListEmpty: this.createListEmpty.bind(this), + formatDurationSeconds: this.formatDurationSeconds.bind(this), + fallbackThumbnailSrc: this.fallbackThumbnailSrc, }; const wrapper = document.createElement("div"); wrapper.className = "space-y-6"; // Header Area - const headerRow = document.createElement("div"); - headerRow.className = "flex flex-col gap-4 lg:flex-row lg:items-start"; - - const imgDiv = document.createElement("div"); - imgDiv.className = "overflow-hidden rounded-md border border-overlay bg-overlay-strong w-full max-w-sm"; - const img = document.createElement("img"); - img.src = thumbnailSrc; - img.alt = thumbnailAlt; - img.className = "w-full h-auto object-cover"; - img.loading = "lazy"; - imgDiv.appendChild(img); - headerRow.appendChild(imgDiv); - - const headerContent = document.createElement("div"); - headerContent.className = "flex-1 space-y-3"; - - const titleTimeDiv = document.createElement("div"); - titleTimeDiv.className = "space-y-1"; - const titleH3 = document.createElement("h3"); - titleH3.className = "text-lg font-semibold text-primary"; - titleH3.textContent = version.title || "Untitled"; - titleTimeDiv.appendChild(titleH3); - const timeP = document.createElement("p"); - timeP.className = "text-xs text-muted"; - timeP.textContent = timestampText; - titleTimeDiv.appendChild(timeP); - headerContent.appendChild(titleTimeDiv); - - if (chipsDiv.hasChildNodes()) { - headerContent.appendChild(chipsDiv); - } - - const headerDl = createDl(); - headerDl.appendChild(createDtDd("Hosted URL", urlNode)); - headerDl.appendChild(createDtDd("Magnet", magnetNode)); - headerContent.appendChild(headerDl); - - headerRow.appendChild(headerContent); - wrapper.appendChild(headerRow); + wrapper.appendChild(createHeader(version, nip71Metadata, utils)); // Description - const descSection = createSection("Description"); - descSection.appendChild(descriptionNode); - wrapper.appendChild(descSection); + wrapper.appendChild(createDescription( + typeof version.description === "string" ? version.description : "", + utils + )); // Audience Flags - const audSection = createSection("Audience flags"); - const audDl = createDl(); - audDl.appendChild(createDtDd("NSFW flag", nsfwStatusNode)); - audDl.appendChild(createDtDd("For kids flag", kidsStatusNode)); - audSection.appendChild(audDl); - if (audienceConflictNotice) audSection.appendChild(audienceConflictNotice); - wrapper.appendChild(audSection); + wrapper.appendChild(createAudienceFlags(version, utils)); // NIP-71 event metadata - const eventSection = createSection("NIP-71 event metadata"); - const eventDl = createDl(); - eventDl.appendChild(createDtDd("NIP-71 kind", kindNode)); - eventDl.appendChild(createDtDd("Summary", summaryNode, 2)); - eventDl.appendChild(createDtDd("Content warning", contentWarningNode, 2)); - eventDl.appendChild(createDtDd("Published timestamp", publishedAtNode)); - eventDl.appendChild(createDtDd("Duration", durationNode)); - eventDl.appendChild(createDtDd("Alt text", altNode, 2)); - eventSection.appendChild(eventDl); - wrapper.appendChild(eventSection); + wrapper.appendChild(createEventMetadata(nip71Metadata, utils)); // Note pointers - const ptrSection = createSection("Note pointers"); - const ptrDl = createDl(); - ptrDl.appendChild(createDtDd("Mode", document.createTextNode(version.mode || "live"))); - - const dTagNode = dTagValue - ? (() => { - const c = document.createElement("code"); - c.className = "rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; - c.textContent = dTagValue; - return c; - })() - : this.createPlaceholder("Not provided"); - ptrDl.appendChild(createDtDd("d tag", dTagNode)); - - const rootIdNode = rootDisplay - ? (() => { - const c = document.createElement("code"); - c.className = "break-all rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; - c.title = rootId; - c.textContent = rootDisplay; - return c; - })() - : this.createPlaceholder("Not provided"); - ptrDl.appendChild(createDtDd("videoRootId", rootIdNode)); - - const eventIdNode = eventDisplay - ? (() => { - const c = document.createElement("code"); - c.className = "break-all rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; - c.title = version.id || ""; - c.textContent = eventDisplay; - return c; - })() - : (() => { - const s = document.createElement("span"); - s.className = "text-subtle"; - s.textContent = "Unknown"; - return s; - })(); - ptrDl.appendChild(createDtDd("Event ID", eventIdNode)); - - ptrSection.appendChild(ptrDl); - wrapper.appendChild(ptrSection); + wrapper.appendChild(createNotePointers(version, dTagValue, utils)); // NIP-71 media metadata const mediaSection = createSection("NIP-71 media metadata"); @@ -1038,20 +665,12 @@ export class RevertModal { return d; }; - const renderUtils = { - createListEmpty: (t) => this.createListEmpty(t), - createPlaceholder: (t) => this.createPlaceholder(t), - createLinkMarkup: (v, o) => this.createLinkMarkup(v, o), - formatDurationSeconds: (s) => this.formatDurationSeconds(s), - truncateMiddle: this.truncateMiddle, - }; - - mediaDiv.appendChild(createMediaSub("Media variants (imeta)", createImetaVariants(nip71Metadata.imeta, renderUtils))); - mediaDiv.appendChild(createMediaSub("Caption tracks", createTextTracks(nip71Metadata.textTracks, renderUtils))); - mediaDiv.appendChild(createMediaSub("Chapters", createSegments(nip71Metadata.segments, renderUtils))); - mediaDiv.appendChild(createMediaSub("Hashtags", createHashtags(nip71Metadata.hashtags, renderUtils))); - mediaDiv.appendChild(createMediaSub("Participants", createParticipants(nip71Metadata.participants, renderUtils))); - mediaDiv.appendChild(createMediaSub("References", createReferences(nip71Metadata.references, renderUtils))); + mediaDiv.appendChild(createMediaSub("Media variants (imeta)", createImetaVariants(nip71Metadata.imeta, utils))); + mediaDiv.appendChild(createMediaSub("Caption tracks", createTextTracks(nip71Metadata.textTracks, utils))); + mediaDiv.appendChild(createMediaSub("Chapters", createSegments(nip71Metadata.segments, utils))); + mediaDiv.appendChild(createMediaSub("Hashtags", createHashtags(nip71Metadata.hashtags, utils))); + mediaDiv.appendChild(createMediaSub("Participants", createParticipants(nip71Metadata.participants, utils))); + mediaDiv.appendChild(createMediaSub("References", createReferences(nip71Metadata.references, utils))); mediaSection.appendChild(mediaDiv); wrapper.appendChild(mediaSection); @@ -1488,21 +1107,4 @@ export class RevertModal { references, }; } - - - extractDTagValue(tags) { - if (!Array.isArray(tags)) { - return ""; - } - for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - if (tag[0] === "d" && typeof tag[1] === "string") { - return tag[1]; - } - } - return ""; - } } - diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index dfcb9066..dd32fbaa 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -13,11 +13,6 @@ import { SimilarContentController } from "./video-modal/similarContentController import { ModerationController } from "./video-modal/moderationController.js"; import { ZapController } from "./video-modal/zapController.js"; import { LinkPreviewController } from "./video-modal/linkPreviewController.js"; -import { - normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil, - resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil, - registerCommentAvatarFailure as registerCommentAvatarFailureUtil, -} from "./video-modal/utils/commentAvatar.js"; import { renderTagPillStrip, applyTagPreferenceState, @@ -31,9 +26,7 @@ import { derivePointerKeyFromInput, } from "./VideoModalSimilarHelpers.js"; import { - subscribeToVideoViewCount, unsubscribeFromVideoViewCount, - formatViewCount, } from "../../viewCounter.js"; import { getModerationOverrideActionLabels, @@ -41,14 +34,9 @@ import { } from "../moderationUiHelpers.js"; import { buildModerationBadgeText } from "../moderationCopy.js"; import { - formatAbsoluteTimestamp, - formatTimeAgo, formatShortNpub as defaultFormatShortNpub, } from "../../utils/formatters.js"; -import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; import { getBreakpointLg } from "../../designSystem/metrics.js"; -import { HEX64_REGEX } from "../../utils/hex.js"; -import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; const SIMILAR_CONTENT_LIMIT = 10; @@ -228,55 +216,10 @@ export class VideoModal { this.zapController = new ZapController({ modal: this }); this.linkPreviewController = new LinkPreviewController({ modal: this }); - this.commentsRoot = null; - this.commentsContainer = null; - this.commentsCountLabel = null; - this.commentsEmptyState = null; - this.commentsList = null; - this.commentsLoadMoreButton = null; - this.commentsDisabledPlaceholder = null; - this.commentsDisabledPlaceholderDefaultText = ""; - this.commentsComposer = null; - this.commentsInput = null; - this.commentsSubmitButton = null; - this.commentsStatusMessage = null; - this.commentsCharCount = null; - this.commentComposerHint = null; - this.commentComposerDefaultHint = ""; - this.commentComposerDefaultCountText = ""; - this.commentStatusDefaultText = ""; - this.commentComposerMaxLength = 0; - this.commentComposerState = { - disabled: true, - reason: "", - parentCommentId: null, - }; this.shareNostrAuthState = { isLoggedIn: false, hasSigner: false, }; - this.commentThreadContext = { - videoEventId: "", - parentCommentId: null, - }; - this.commentProfiles = new Map(); - this.commentNodes = new Map(); - this.commentChildLists = new Map(); - this.commentNodeElements = new Map(); - this.commentNpubEncodingFailures = new Set(); - this.commentAvatarCache = new Map(); - this.commentAvatarFailures = new Set(); - this.commentRetryButton = null; - this.commentCallbacks = { - teardown: null, - }; - this.boundCommentSubmitHandler = - this.handleCommentComposerSubmit.bind(this); - this.boundCommentInputHandler = - this.handleCommentComposerInput.bind(this); - this.boundCommentLoadMoreHandler = - this.handleCommentLoadMore.bind(this); - this.boundCommentRetryHandler = this.handleCommentRetry.bind(this); this.modalPosterCleanup = null; this.videoEventCleanup = null; @@ -317,7 +260,6 @@ export class VideoModal { this.handleGlobalModerationBlock(event); this.MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif"; - this.DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; this.setMediaLoader(mediaLoader); @@ -795,1993 +737,6 @@ export class VideoModal { } } - attachCommentEventHandlers() { - if (this.commentsComposer && this.boundCommentSubmitHandler) { - this.commentsComposer.addEventListener( - "submit", - this.boundCommentSubmitHandler - ); - } - if (this.commentsInput && this.boundCommentInputHandler) { - this.commentsInput.addEventListener( - "input", - this.boundCommentInputHandler - ); - } - if (this.commentsLoadMoreButton && this.boundCommentLoadMoreHandler) { - this.commentsLoadMoreButton.addEventListener( - "click", - this.boundCommentLoadMoreHandler - ); - } - if (this.commentRetryButton && this.boundCommentRetryHandler) { - this.commentRetryButton.addEventListener( - "click", - this.boundCommentRetryHandler - ); - } - } - - detachCommentEventHandlers() { - if (this.commentsComposer && this.boundCommentSubmitHandler) { - this.commentsComposer.removeEventListener( - "submit", - this.boundCommentSubmitHandler - ); - } - if (this.commentsInput && this.boundCommentInputHandler) { - this.commentsInput.removeEventListener( - "input", - this.boundCommentInputHandler - ); - } - if (this.commentsLoadMoreButton && this.boundCommentLoadMoreHandler) { - this.commentsLoadMoreButton.removeEventListener( - "click", - this.boundCommentLoadMoreHandler - ); - } - if (this.commentRetryButton && this.boundCommentRetryHandler) { - this.commentRetryButton.removeEventListener( - "click", - this.boundCommentRetryHandler - ); - } - } - - handleCommentComposerSubmit(event) { - if (event && typeof event.preventDefault === "function") { - event.preventDefault(); - } - - const triggerElement = - (event && event.submitter) || this.commentsSubmitButton || null; - - if (this.commentComposerState.reason === "login-required") { - this.dispatch("comment:login-required", { triggerElement }); - return; - } - - if (this.commentComposerState.disabled) { - return; - } - - const text = this.getCommentInputValue(); - if (!text) { - return; - } - - this.dispatch("comment:submit", { - text, - parentId: this.commentComposerState.parentCommentId || null, - triggerElement, - }); - } - - handleCommentComposerInput() { - this.updateCommentCharCount(); - this.updateCommentSubmitState(); - } - - handleCommentLoadMore(event) { - if (event && typeof event.preventDefault === "function") { - event.preventDefault(); - } - const triggerElement = - (event && event.currentTarget) || this.commentsLoadMoreButton || null; - this.dispatch("comment:load-more", { - parentId: this.commentThreadContext.parentCommentId || null, - triggerElement, - }); - } - - handleCommentRetry(event) { - if (event && typeof event.preventDefault === "function") { - event.preventDefault(); - } - const triggerElement = - (event && event.currentTarget) || this.commentRetryButton || null; - this.dispatch("comment:retry", { - text: this.getCommentInputValue(), - parentId: this.commentComposerState.parentCommentId || null, - triggerElement, - }); - } - - getCommentInputValue({ trimmed = true } = {}) { - const value = this.commentsInput ? this.commentsInput.value || "" : ""; - return trimmed ? value.trim() : value; - } - - updateCommentSubmitState() { - if (!this.commentsSubmitButton) { - return; - } - const hasText = !!this.getCommentInputValue(); - const shouldDisable = this.commentComposerState.disabled || !hasText; - this.commentsSubmitButton.disabled = shouldDisable; - } - - updateCommentCharCount() { - if (!this.commentsCharCount) { - return; - } - const length = this.commentsInput ? this.commentsInput.value.length : 0; - const safeLength = Number.isFinite(length) ? Math.max(0, length) : 0; - const max = Number.isFinite(this.commentComposerMaxLength) - ? Math.max(0, this.commentComposerMaxLength) - : 0; - const label = max > 0 ? `${safeLength} / ${max}` : `${safeLength}`; - this.commentsCharCount.textContent = label; - } - - setCommentHintText(text) { - if (!this.commentComposerHint) { - return; - } - const message = - typeof text === "string" && text - ? text - : this.commentComposerDefaultHint || ""; - this.commentComposerHint.textContent = message; - } - - removeCommentRetryButton() { - if (!this.commentRetryButton) { - return; - } - this.commentRetryButton.removeEventListener( - "click", - this.boundCommentRetryHandler - ); - if (this.commentRetryButton.parentNode) { - this.commentRetryButton.parentNode.removeChild(this.commentRetryButton); - } - this.commentRetryButton = null; - } - - setCommentStatus(message, { showRetry = false } = {}) { - if (!this.commentsStatusMessage) { - return; - } - - this.removeCommentRetryButton(); - - while (this.commentsStatusMessage.firstChild) { - this.commentsStatusMessage.removeChild( - this.commentsStatusMessage.firstChild - ); - } - - const text = - typeof message === "string" && message - ? message - : this.commentStatusDefaultText || ""; - if (text) { - this.commentsStatusMessage.appendChild( - this.document.createTextNode(text) - ); - } - - if (showRetry) { - const spacer = this.document.createTextNode(" "); - const retryButton = this.document.createElement("button"); - retryButton.type = "button"; - retryButton.classList.add("comment-thread__retry", "focus-ring"); - retryButton.appendChild(this.document.createTextNode("Retry")); - retryButton.addEventListener("click", this.boundCommentRetryHandler); - this.commentsStatusMessage.appendChild(spacer); - this.commentsStatusMessage.appendChild(retryButton); - this.commentRetryButton = retryButton; - } - } - - setCommentsVisibility(isVisible) { - const root = this.commentsContainer || this.commentsRoot; - if (!root) { - return; - } - const shouldShow = Boolean(isVisible); - if (shouldShow) { - root.removeAttribute("hidden"); - root.classList?.remove("hidden"); - } else { - root.setAttribute("hidden", ""); - root.classList?.add("hidden"); - } - } - - showCommentsDisabledMessage(message) { - const placeholder = this.commentsDisabledPlaceholder; - if (placeholder) { - const fallbackText = this.commentsDisabledPlaceholderDefaultText || ""; - const nextText = - typeof message === "string" && message.trim() - ? message.trim() - : fallbackText; - if (nextText) { - placeholder.textContent = nextText; - } else if (fallbackText) { - placeholder.textContent = fallbackText; - } - placeholder.removeAttribute("hidden"); - placeholder.classList?.remove("hidden"); - } - this.setCommentsVisibility(false); - } - - hideCommentsDisabledMessage() { - const placeholder = this.commentsDisabledPlaceholder; - if (placeholder) { - if (this.commentsDisabledPlaceholderDefaultText) { - placeholder.textContent = this.commentsDisabledPlaceholderDefaultText; - } - placeholder.setAttribute("hidden", ""); - placeholder.classList?.add("hidden"); - } - } - - setCommentComposerState({ disabled = false, reason = "" } = {}) { - const normalizedReason = typeof reason === "string" ? reason : ""; - - let shouldDisable = !!disabled; - switch (normalizedReason) { - case "login-required": - case "submitting": - case "disabled": - shouldDisable = true; - break; - case "error": - shouldDisable = false; - break; - default: - break; - } - - this.commentComposerState.disabled = shouldDisable; - this.commentComposerState.reason = normalizedReason; - - if (this.commentsInput) { - this.commentsInput.disabled = shouldDisable; - } - - if (normalizedReason !== "disabled") { - this.hideCommentsDisabledMessage(); - } - - if (this.commentsComposer) { - if (normalizedReason === "disabled") { - this.commentsComposer.setAttribute("hidden", ""); - } else { - this.commentsComposer.removeAttribute("hidden"); - } - } - - if (normalizedReason === "login-required") { - this.setCommentHintText("Log in to add a comment."); - this.setCommentStatus(""); - this.setCommentsVisibility(true); - } else if (normalizedReason === "submitting") { - this.setCommentHintText(this.commentComposerDefaultHint); - this.setCommentStatus("Posting comment…"); - } else if (normalizedReason === "error") { - this.setCommentHintText(this.commentComposerDefaultHint); - this.setCommentStatus("We couldn't post your comment. Try again?", { - showRetry: true, - }); - } else if (normalizedReason === "disabled") { - this.setCommentHintText(this.commentComposerDefaultHint); - this.setCommentStatus(""); - } else { - this.setCommentHintText(this.commentComposerDefaultHint); - this.setCommentStatus(""); - } - - this.updateCommentSubmitState(); - } - - resetCommentComposer() { - if (this.commentsInput) { - this.commentsInput.disabled = false; - this.commentsInput.value = ""; - } - this.hideCommentsDisabledMessage(); - this.commentComposerState = { - disabled: false, - reason: "", - parentCommentId: null, - }; - this.setCommentHintText(this.commentComposerDefaultHint); - this.setCommentStatus(""); - this.updateCommentCharCount(); - this.updateCommentSubmitState(); - } - - setCommentSectionCallbacks({ teardown = null } = {}) { - this.commentCallbacks.teardown = - typeof teardown === "function" ? teardown : null; - } - - invokeCommentTeardown() { - const teardown = this.commentCallbacks.teardown; - if (typeof teardown !== "function") { - return; - } - this.commentCallbacks.teardown = null; - try { - teardown(); - } catch (error) { - this.log("[VideoModal] Failed to teardown comment services", error); - } - } - - renderComments(snapshot) { - if (!this.commentsList || !this.document) { - return; - } - - const commentsMap = this.normalizeCommentMap(snapshot?.commentsById); - const childrenMap = this.normalizeChildrenMap(snapshot?.childrenByParent); - this.commentProfiles = this.normalizeProfileMap(snapshot?.profiles); - - this.commentThreadContext.videoEventId = - typeof snapshot?.videoEventId === "string" - ? snapshot.videoEventId.trim() - : ""; - this.commentThreadContext.parentCommentId = - typeof snapshot?.parentCommentId === "string" && - snapshot.parentCommentId.trim() - ? snapshot.parentCommentId.trim() - : null; - - this.commentNodes.clear(); - this.commentChildLists = new Map(); - this.commentChildLists.set(null, this.commentsList); - - this.commentsList.textContent = ""; - - const fragment = this.document.createDocumentFragment(); - const topLevelIds = this.getChildCommentIds(childrenMap, null); - topLevelIds.forEach((commentId) => { - const node = this.buildCommentTree( - commentId, - commentsMap, - childrenMap, - 0 - ); - if (node) { - fragment.appendChild(node); - } - }); - - this.commentsList.appendChild(fragment); - - this.updateCommentCount(this.commentNodes.size); - this.toggleCommentEmptyState(this.commentNodes.size === 0); - } - - appendComment(event) { - if (!event || typeof event !== "object" || !this.commentsList) { - return; - } - - const commentId = - typeof event.id === "string" && event.id.trim() ? event.id.trim() : ""; - if (!commentId || this.commentNodes.has(commentId)) { - return; - } - - if (event.profile && typeof event.pubkey === "string") { - this.mergeCommentProfile(event.pubkey, event.profile); - } - - const parentId = this.extractParentCommentId(event); - const parentNode = - parentId && this.commentNodes.has(parentId) - ? this.commentNodes.get(parentId) - : null; - const depth = parentNode - ? Math.max(0, Number(parentNode.dataset?.commentDepth) || 0) + 1 - : 0; - - const node = this.createCommentNode(event, { depth }); - if (!node) { - return; - } - - const container = this.getCommentChildrenContainer(parentId); - if (!container) { - this.log("[VideoModal] Missing container for appended comment", parentId); - return; - } - - this.insertCommentNodeSorted(container, node); - this.updateCommentCount(this.commentNodes.size); - this.toggleCommentEmptyState(this.commentNodes.size === 0); - } - - insertCommentNodeSorted(container, node) { - if (!container || !node) { - return; - } - - const timestamp = Number(node.dataset.timestamp || 0); - let inserted = false; - - const children = container.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const childTimestamp = Number(child.dataset.timestamp || 0); - - if (childTimestamp > timestamp) { - container.insertBefore(node, child); - inserted = true; - break; - } - } - - if (!inserted) { - container.appendChild(node); - } - } - - buildCommentTree(commentId, commentsMap, childrenMap, depth = 0) { - const normalizedId = - typeof commentId === "string" && commentId.trim() - ? commentId.trim() - : ""; - if (!normalizedId) { - return null; - } - - const event = commentsMap.get(normalizedId); - if (!event) { - return null; - } - - const node = this.createCommentNode(event, { depth }); - if (!node) { - return null; - } - - const repliesContainer = this.commentChildLists.get(normalizedId); - if (repliesContainer) { - const childIds = this.getChildCommentIds(childrenMap, normalizedId); - if (childIds.length) { - const fragment = this.document.createDocumentFragment(); - childIds.forEach((childId) => { - const childNode = this.buildCommentTree( - childId, - commentsMap, - childrenMap, - depth + 1 - ); - if (childNode) { - fragment.appendChild(childNode); - } - }); - if (fragment.childNodes.length) { - repliesContainer.appendChild(fragment); - } - } - } - - return node; - } - - createCommentNode(event, { depth = 0 } = {}) { - if (!event || typeof event !== "object" || !this.document) { - return null; - } - - const commentId = - typeof event.id === "string" && event.id.trim() ? event.id.trim() : ""; - if (!commentId) { - return null; - } - - if (this.commentNodes.has(commentId)) { - return this.commentNodes.get(commentId); - } - - const listItem = this.document.createElement("li"); - listItem.classList.add("comment-thread__item"); - listItem.dataset.commentId = commentId; - listItem.dataset.commentDepth = String(Math.max(0, depth)); - listItem.dataset.timestamp = String( - Number.isFinite(event.created_at) ? event.created_at : 0 - ); - if (typeof event.pubkey === "string" && event.pubkey.trim()) { - listItem.dataset.commentAuthor = event.pubkey.trim(); - } - - const avatarWrapper = this.document.createElement("div"); - avatarWrapper.classList.add("comment-thread__avatar"); - - const avatarImg = this.document.createElement("img"); - avatarImg.hidden = true; - avatarImg.loading = "lazy"; - avatarImg.decoding = "async"; - avatarWrapper.appendChild(avatarImg); - - const avatarLabel = this.document.createElement("span"); - avatarWrapper.appendChild(avatarLabel); - - const content = this.document.createElement("div"); - content.classList.add("comment-thread__content"); - - const meta = this.document.createElement("div"); - meta.classList.add("comment-thread__meta"); - - const authorEl = this.document.createElement("span"); - authorEl.classList.add("comment-thread__author"); - meta.appendChild(authorEl); - - const timestamp = this.getCommentTimestampLabel(event.created_at); - if (timestamp.text) { - const timeEl = this.document.createElement("time"); - timeEl.classList.add("comment-thread__timestamp"); - if (timestamp.iso) { - timeEl.setAttribute("datetime", timestamp.iso); - } - if (timestamp.title) { - timeEl.title = timestamp.title; - } - timeEl.appendChild(this.document.createTextNode(timestamp.text)); - meta.appendChild(timeEl); - } - - content.appendChild(meta); - - const identityMeta = this.document.createElement("div"); - identityMeta.classList.add( - "flex", - "items-center", - "gap-2", - "text-2xs", - "text-muted-strong" - ); - identityMeta.hidden = true; - const npubLabel = this.document.createElement("span"); - npubLabel.classList.add("truncate"); - identityMeta.appendChild(npubLabel); - content.appendChild(identityMeta); - - const body = this.document.createElement("p"); - body.classList.add("comment-thread__body"); - body.appendChild( - this.document.createTextNode( - typeof event.content === "string" ? event.content : "" - ) - ); - content.appendChild(body); - - const actions = this.document.createElement("div"); - actions.classList.add("comment-thread__actions"); - - const copyButton = this.document.createElement("button"); - copyButton.type = "button"; - copyButton.classList.add("comment-thread__copy-npub"); - copyButton.textContent = "Copy npub"; - copyButton.dataset.originalLabel = "Copy npub"; - copyButton.hidden = true; - this.bindCommentCopyHandler(copyButton); - actions.appendChild(copyButton); - - const muteButton = this.document.createElement("button"); - muteButton.type = "button"; - muteButton.classList.add("comment-thread__mute"); - muteButton.appendChild(this.document.createTextNode("Mute author")); - muteButton.addEventListener("click", (domEvent) => { - const triggerElement = domEvent?.currentTarget || muteButton; - this.dispatch("comment:mute-author", { - commentId, - pubkey: - typeof event.pubkey === "string" && event.pubkey - ? event.pubkey - : "", - triggerElement, - }); - }); - actions.appendChild(muteButton); - - content.appendChild(actions); - - const replies = this.document.createElement("ul"); - replies.classList.add("comment-thread__replies"); - replies.setAttribute("data-comment-replies", ""); - content.appendChild(replies); - - listItem.appendChild(avatarWrapper); - listItem.appendChild(content); - - this.commentNodes.set(commentId, listItem); - this.commentChildLists.set(commentId, replies); - this.commentNodeElements.set(commentId, { - avatarImg, - avatarLabel, - authorEl, - npubContainer: identityMeta, - npubLabel, - copyButton, - }); - - this.updateCommentNodeProfile(commentId, event.pubkey); - - return listItem; - } - - bindCommentCopyHandler(button) { - if (!button) { - return; - } - - button.addEventListener("click", async () => { - const npub = - typeof button.dataset.npub === "string" && button.dataset.npub - ? button.dataset.npub - : ""; - const originalLabel = - typeof button.dataset.originalLabel === "string" && - button.dataset.originalLabel - ? button.dataset.originalLabel - : "Copy npub"; - if (!button.dataset.originalLabel) { - button.dataset.originalLabel = originalLabel; - } - - if (!npub) { - this.showCommentCopyFeedback(button, "Copy failed", "error"); - return; - } - - try { - const clipboard = this.window?.navigator?.clipboard; - if (!clipboard || typeof clipboard.writeText !== "function") { - throw new Error("Clipboard unavailable"); - } - await clipboard.writeText(npub); - this.showCommentCopyFeedback(button, "Copied!", "copied"); - } catch (error) { - this.log("[VideoModal] Failed to copy comment npub", error); - this.showCommentCopyFeedback(button, "Copy failed", "error"); - } - }); - } - - showCommentCopyFeedback(button, message, state = "") { - if (!button) { - return; - } - - const originalLabel = - typeof button.dataset.originalLabel === "string" && - button.dataset.originalLabel - ? button.dataset.originalLabel - : "Copy npub"; - - button.textContent = message; - if (state) { - button.dataset.state = state; - } else if (button.dataset.state) { - delete button.dataset.state; - } - - const timeoutId = Number.parseInt( - button.dataset.feedbackTimeoutId || "", - 10 - ); - if ( - Number.isFinite(timeoutId) && - this.window && - typeof this.window.clearTimeout === "function" - ) { - this.window.clearTimeout(timeoutId); - } - - if (!this.window || typeof this.window.setTimeout !== "function") { - button.textContent = button.dataset.originalLabel || originalLabel; - if (button.dataset.feedbackTimeoutId) { - delete button.dataset.feedbackTimeoutId; - } - return; - } - - const nextId = this.window.setTimeout(() => { - button.textContent = button.dataset.originalLabel || originalLabel; - if (button.dataset.state) { - delete button.dataset.state; - } - if (button.dataset.feedbackTimeoutId) { - delete button.dataset.feedbackTimeoutId; - } - }, UI_FEEDBACK_DELAY_MS); - - button.dataset.feedbackTimeoutId = String(nextId); - } - - updateCommentNodeProfile(commentId, pubkey) { - if (!(this.commentNodeElements instanceof Map)) { - this.commentNodeElements = new Map(); - } - - if (!commentId || !this.commentNodeElements.has(commentId)) { - return; - } - - const node = this.commentNodes.get(commentId); - if (!node) { - return; - } - - const refs = this.commentNodeElements.get(commentId); - const profile = this.getCommentAuthorProfile(pubkey); - - if (refs.authorEl) { - refs.authorEl.textContent = profile.displayName; - } - - if (refs.avatarLabel) { - refs.avatarLabel.textContent = profile.initial; - refs.avatarLabel.hidden = Boolean(profile.avatarUrl); - } - - if (refs.avatarImg) { - if (!refs.avatarImg.dataset.commentAvatarBound) { - const labelRef = refs.avatarLabel; - const imgRef = refs.avatarImg; - refs.avatarImg.addEventListener("error", () => { - const failedSource = - typeof imgRef?.dataset?.commentAvatarSource === "string" - ? imgRef.dataset.commentAvatarSource - : ""; - if (failedSource) { - this.registerCommentAvatarFailure(failedSource); - } - if ( - imgRef && - imgRef.dataset.commentAvatarCurrent !== this.DEFAULT_PROFILE_AVATAR - ) { - imgRef.dataset.commentAvatarCurrent = this.DEFAULT_PROFILE_AVATAR; - imgRef.src = this.DEFAULT_PROFILE_AVATAR; - return; - } - if (imgRef) { - imgRef.hidden = true; - } - if (labelRef) { - labelRef.hidden = false; - } - }); - refs.avatarImg.dataset.commentAvatarBound = "true"; - } - - const nextSource = profile.avatarSource || ""; - refs.avatarImg.dataset.commentAvatarSource = nextSource; - - if (profile.avatarUrl) { - if ( - refs.avatarImg.dataset.commentAvatarCurrent !== profile.avatarUrl - ) { - refs.avatarImg.dataset.commentAvatarCurrent = profile.avatarUrl; - refs.avatarImg.src = profile.avatarUrl; - } - refs.avatarImg.alt = `${profile.displayName}'s avatar`; - refs.avatarImg.hidden = false; - if (refs.avatarLabel) { - refs.avatarLabel.hidden = true; - } - } else { - if ( - refs.avatarImg.dataset.commentAvatarCurrent !== - this.DEFAULT_PROFILE_AVATAR - ) { - refs.avatarImg.dataset.commentAvatarCurrent = - this.DEFAULT_PROFILE_AVATAR; - refs.avatarImg.src = this.DEFAULT_PROFILE_AVATAR; - } - refs.avatarImg.hidden = true; - if (refs.avatarLabel) { - refs.avatarLabel.hidden = false; - } - } - } - - if (refs.npubContainer) { - if (profile.shortNpub) { - refs.npubContainer.hidden = false; - if (refs.npubLabel) { - refs.npubLabel.textContent = profile.shortNpub; - if (profile.npub) { - refs.npubLabel.title = profile.npub; - } else if (refs.npubLabel.title) { - refs.npubLabel.removeAttribute("title"); - } - } - } else { - refs.npubContainer.hidden = true; - if (refs.npubLabel) { - refs.npubLabel.textContent = ""; - if (refs.npubLabel.title) { - refs.npubLabel.removeAttribute("title"); - } - } - } - } - - if (refs.copyButton) { - const originalLabel = - typeof refs.copyButton.dataset.originalLabel === "string" && - refs.copyButton.dataset.originalLabel - ? refs.copyButton.dataset.originalLabel - : "Copy npub"; - - if (profile.npub) { - refs.copyButton.hidden = false; - refs.copyButton.disabled = false; - refs.copyButton.dataset.npub = profile.npub; - refs.copyButton.setAttribute( - "aria-label", - `Copy ${profile.displayName}'s npub` - ); - refs.copyButton.textContent = originalLabel; - if (refs.copyButton.dataset.state) { - delete refs.copyButton.dataset.state; - } - } else { - refs.copyButton.hidden = true; - refs.copyButton.disabled = true; - if (refs.copyButton.dataset.state) { - delete refs.copyButton.dataset.state; - } - if (refs.copyButton.dataset.npub) { - delete refs.copyButton.dataset.npub; - } - refs.copyButton.removeAttribute("aria-label"); - refs.copyButton.textContent = originalLabel; - } - } - } - - updateCommentNodesForAuthor(pubkey) { - if (typeof pubkey !== "string" || !pubkey.trim()) { - return; - } - const normalized = pubkey.trim().toLowerCase(); - this.commentNodes.forEach((node, id) => { - const author = - typeof node?.dataset?.commentAuthor === "string" - ? node.dataset.commentAuthor.trim().toLowerCase() - : ""; - if (author && author === normalized) { - this.updateCommentNodeProfile(id, pubkey); - } - }); - } - - getCommentAuthorName(pubkey) { - return this.getCommentAuthorProfile(pubkey).displayName; - } - - getCommentAvatarInitial(pubkey) { - return this.getCommentAuthorProfile(pubkey).initial; - } - - getCommentAuthorProfile(pubkey) { - const normalized = - typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; - const profileEntry = normalized - ? this.commentProfiles.get(normalized.toLowerCase()) || null - : null; - - const rawPicture = - profileEntry && typeof profileEntry.picture === "string" - ? profileEntry.picture - : ""; - const sanitizedPicture = sanitizeProfileMediaUrl(rawPicture); - const avatar = this.resolveCommentAvatarAsset(normalized, sanitizedPicture); - - let npub = ""; - if (profileEntry && typeof profileEntry.npub === "string") { - const trimmed = profileEntry.npub.trim(); - if (trimmed) { - if (trimmed.startsWith("npub")) { - npub = trimmed; - } else if (HEX64_REGEX.test(trimmed)) { - npub = this.encodeCommentPubkeyToNpub(trimmed); - } - } - } - - if (!npub && profileEntry && typeof profileEntry.pubkey === "string") { - const trimmed = profileEntry.pubkey.trim(); - if (trimmed) { - npub = this.encodeCommentPubkeyToNpub(trimmed); - } - } - - if (!npub && normalized) { - npub = this.encodeCommentPubkeyToNpub(normalized); - } - - let displayName = ""; - if (profileEntry && typeof profileEntry === "object") { - const candidates = [ - profileEntry.display_name, - profileEntry.name, - profileEntry.username, - ]; - for (const candidate of candidates) { - if (typeof candidate !== "string") { - continue; - } - const trimmed = candidate.trim(); - if (trimmed) { - displayName = trimmed; - break; - } - } - } - - if (displayName && HEX64_REGEX.test(displayName)) { - displayName = ""; - } - - if (!displayName && npub) { - displayName = this.helpers.formatShortNpub(npub); - } - - if (!displayName) { - displayName = "Anonymous"; - } - - const shortNpub = npub ? this.helpers.formatShortNpub(npub) : ""; - const initialCandidate = displayName.trim().charAt(0) || ""; - const initial = initialCandidate ? initialCandidate.toUpperCase() : "?"; - - return { - displayName, - avatarUrl: avatar.url, - avatarSource: avatar.source, - npub, - shortNpub, - initial, - }; - } - - resolveCommentAvatarAsset(pubkey, sanitizedPicture) { - return resolveCommentAvatarAssetUtil({ - cache: this.commentAvatarCache, - failures: this.commentAvatarFailures, - defaultAvatar: this.DEFAULT_PROFILE_AVATAR, - pubkey, - sanitizedPicture, - }); - } - - normalizeCommentAvatarKey(value) { - return normalizeCommentAvatarKeyUtil(value); - } - - registerCommentAvatarFailure(sourceUrl) { - registerCommentAvatarFailureUtil({ - cache: this.commentAvatarCache, - failures: this.commentAvatarFailures, - defaultAvatar: this.DEFAULT_PROFILE_AVATAR, - sourceUrl, - }); - } - - encodeCommentPubkeyToNpub(pubkey) { - if (typeof pubkey !== "string") { - return ""; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return ""; - } - - if (trimmed.startsWith("npub1")) { - return trimmed; - } - - if (!HEX64_REGEX.test(trimmed)) { - return ""; - } - - if (this.commentNpubEncodingFailures.has(trimmed)) { - return ""; - } - - try { - const encoded = this.window?.NostrTools?.nip19?.npubEncode?.(trimmed); - if (typeof encoded === "string" && encoded) { - return encoded; - } - } catch (error) { - if (!this.commentNpubEncodingFailures.has(trimmed)) { - this.log("[VideoModal] Failed to encode comment author pubkey", error); - } - } - - this.commentNpubEncodingFailures.add(trimmed); - return ""; - } - - getCommentTimestampLabel(createdAt) { - const numeric = Number(createdAt); - if (!Number.isFinite(numeric) || numeric <= 0) { - return { text: "", iso: "", title: "" }; - } - - const date = new Date(numeric * 1000); - if (Number.isNaN(date.getTime())) { - return { text: "", iso: "", title: "" }; - } - - let text = ""; - try { - text = formatTimeAgo(numeric); - } catch (error) { - this.log("[VideoModal] Failed to format comment timestamp", error); - } - if (!text) { - text = formatAbsoluteTimestamp(numeric); - } - - return { - text, - iso: date.toISOString(), - title: formatAbsoluteTimestamp(numeric), - }; - } - - normalizeCommentMap(candidate) { - if (candidate instanceof Map) { - return new Map(candidate); - } - if (Array.isArray(candidate)) { - const map = new Map(); - candidate.forEach(([key, value]) => { - if (typeof key === "string" && key.trim()) { - map.set(key.trim(), value); - } - }); - return map; - } - if (candidate && typeof candidate === "object") { - const map = new Map(); - Object.entries(candidate).forEach(([key, value]) => { - if (typeof key === "string" && key.trim()) { - map.set(key.trim(), value); - } - }); - return map; - } - return new Map(); - } - - normalizeChildrenMap(candidate) { - const map = new Map(); - if (candidate instanceof Map) { - candidate.forEach((value, key) => { - map.set(this.normalizeParentKey(key), this.normalizeIdList(value)); - }); - return map; - } - if (Array.isArray(candidate)) { - candidate.forEach(([key, value]) => { - map.set(this.normalizeParentKey(key), this.normalizeIdList(value)); - }); - return map; - } - if (candidate && typeof candidate === "object") { - Object.entries(candidate).forEach(([key, value]) => { - map.set(this.normalizeParentKey(key), this.normalizeIdList(value)); - }); - } - return map; - } - - normalizeProfileMap(candidate) { - const map = new Map(); - if (candidate instanceof Map) { - candidate.forEach((value, key) => { - const normalizedKey = - typeof key === "string" && key.trim() ? key.trim().toLowerCase() : ""; - if (normalizedKey) { - map.set(normalizedKey, value); - } - }); - return map; - } - if (Array.isArray(candidate)) { - candidate.forEach(([key, value]) => { - const normalizedKey = - typeof key === "string" && key.trim() ? key.trim().toLowerCase() : ""; - if (normalizedKey) { - map.set(normalizedKey, value); - } - }); - return map; - } - if (candidate && typeof candidate === "object") { - Object.entries(candidate).forEach(([key, value]) => { - const normalizedKey = - typeof key === "string" && key.trim() ? key.trim().toLowerCase() : ""; - if (normalizedKey) { - map.set(normalizedKey, value); - } - }); - } - return map; - } - - normalizeIdList(value) { - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => - typeof entry === "string" && entry.trim() ? entry.trim() : "" - ) - .filter(Boolean); - } - - normalizeParentKey(value) { - if (value === null || value === undefined) { - return null; - } - if (typeof value === "string") { - const trimmed = value.trim(); - if ( - !trimmed || - trimmed === "null" || - trimmed === "undefined" || - trimmed === "__root__" - ) { - return null; - } - return trimmed; - } - return this.normalizeParentKey(String(value)); - } - - getChildCommentIds(childrenMap, parentId) { - const key = this.normalizeParentKey(parentId); - const list = childrenMap.get(key); - return Array.isArray(list) ? [...list] : []; - } - - getCommentChildrenContainer(parentId) { - const key = this.normalizeParentKey(parentId); - if (key === null) { - return this.commentChildLists.get(null) || this.commentsList; - } - if (this.commentChildLists.has(key)) { - return this.commentChildLists.get(key); - } - const parentNode = this.commentNodes.get(key); - if (parentNode) { - const existing = parentNode.querySelector("[data-comment-replies]"); - if (existing) { - this.commentChildLists.set(key, existing); - return existing; - } - } - return this.commentChildLists.get(null) || this.commentsList; - } - - extractParentCommentId(event) { - const tags = Array.isArray(event?.tags) ? event.tags : []; - const values = []; - for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - const [name, value] = tag; - if (name !== "e") { - continue; - } - const normalized = typeof value === "string" ? value.trim() : ""; - if (!normalized || normalized === this.commentThreadContext.videoEventId) { - continue; - } - values.push(normalized); - } - if (!values.length) { - return null; - } - return values[values.length - 1]; - } - - mergeCommentProfile(pubkey, profile) { - if (typeof pubkey !== "string" || !pubkey.trim()) { - return; - } - const normalized = pubkey.trim().toLowerCase(); - this.commentProfiles.set(normalized, profile); - this.updateCommentNodesForAuthor(pubkey); - } - - clearComments() { - if (!(this.commentNodes instanceof Map)) { - this.commentNodes = new Map(); - } else { - this.commentNodes.clear(); - } - if (!(this.commentChildLists instanceof Map)) { - this.commentChildLists = new Map(); - } else { - this.commentChildLists.clear(); - } - if (!(this.commentNodeElements instanceof Map)) { - this.commentNodeElements = new Map(); - } else { - this.commentNodeElements.clear(); - } - if (this.commentNpubEncodingFailures instanceof Set) { - this.commentNpubEncodingFailures.clear(); - } else { - this.commentNpubEncodingFailures = new Set(); - } - if (this.commentsList) { - this.commentsList.textContent = ""; - this.commentChildLists.set(null, this.commentsList); - } - this.updateCommentCount(0); - this.toggleCommentEmptyState(true); - } - - updateCommentCount(count) { - if (!this.commentsCountLabel) { - return; - } - const numeric = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; - const label = numeric === 1 ? "1 comment" : `${numeric} comments`; - this.commentsCountLabel.textContent = label; - } - - toggleCommentEmptyState(isEmpty) { - const shouldShow = Boolean(isEmpty); - if (this.commentsEmptyState) { - if (shouldShow) { - this.commentsEmptyState.removeAttribute("hidden"); - } else { - this.commentsEmptyState.setAttribute("hidden", ""); - } - } - if (this.commentsList) { - if (shouldShow) { - this.commentsList.setAttribute("data-empty", "true"); - } else { - this.commentsList.removeAttribute("data-empty"); - } - } - } - - destroy() { - this.close(); - this.teardownVideoTagsResizeObserver(); - this.commentsController.destroy(); - this.reactionsController.destroy(); - this.similarContentController.destroy(); - this.moderationController.destroy(); - this.zapController.destroy(); - this.linkPreviewController.destroy(); - this.invokeCommentTeardown(); - - if (this.document) { - this.document.removeEventListener( - "video:moderation-override", - this.handleGlobalModerationOverride, - ); - this.document.removeEventListener( - "video:moderation-block", - this.handleGlobalModerationBlock, - ); - this.document.removeEventListener( - "video:moderation-hide", - this.handleGlobalModerationHide, - ); - } - - this.removeModerationPrimaryButton(); - this.removeModerationBlockButton(); - this.moderationOverlay = null; - this.moderationBadge = null; - this.moderationBadgeText = null; - this.moderationActionsContainer = null; - this.moderationPrimaryButton = null; - this.moderationPrimaryMode = ""; - this.moderationBlockButton = null; - this.moderationBadgeId = ""; - this.activeModerationContext = null; - - this.commentProfiles = new Map(); - this.commentNodes = new Map(); - this.commentChildLists = new Map(); - this.commentNodeElements = new Map(); - this.commentComposerState = { - disabled: true, - reason: "", - parentCommentId: null, - }; - this.commentThreadContext = { - videoEventId: "", - parentCommentId: null, - }; - this.commentComposerDefaultHint = ""; - this.commentComposerDefaultCountText = ""; - this.commentStatusDefaultText = ""; - this.commentComposerMaxLength = 0; - - this.commentsRoot = null; - this.commentsContainer = null; - this.commentsCountLabel = null; - this.commentsEmptyState = null; - this.commentsList = null; - this.commentsLoadMoreButton = null; - this.commentsDisabledPlaceholder = null; - this.commentsDisabledPlaceholderDefaultText = ""; - this.commentsComposer = null; - this.commentsInput = null; - this.commentsSubmitButton = null; - this.commentsStatusMessage = null; - this.commentsCharCount = null; - this.commentComposerHint = null; - this.commentRetryButton = null; - } - - bindVideoEvents() { - if ( - !this.modalVideo || - !this.window || - typeof this.window.HTMLVideoElement === "undefined" || - !(this.modalVideo instanceof this.window.HTMLVideoElement) - ) { - return; - } - - const loadedHandler = () => { - this.dispatch("playback:loadeddata", { - video: this.modalVideo, - active: this.activeVideo - }); - }; - const playingHandler = () => { - this.dispatch("playback:playing", { - video: this.modalVideo, - active: this.activeVideo - }); - }; - - this.modalVideo.addEventListener("loadeddata", loadedHandler); - this.modalVideo.addEventListener("playing", playingHandler); - - this.videoEventCleanup = () => { - if (!this.modalVideo) { - return; - } - this.modalVideo.removeEventListener("loadeddata", loadedHandler); - this.modalVideo.removeEventListener("playing", playingHandler); - this.videoEventCleanup = null; - }; - } - - detachVideoEvents() { - if (typeof this.videoEventCleanup === "function") { - this.videoEventCleanup(); - } - this.videoEventCleanup = null; - } - - getAmbientFallbackColor() { - const canvas = this.ambientCanvas; - const doc = - canvas?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - const win = doc?.defaultView || (typeof window !== "undefined" ? window : null); - - if (doc && win && typeof win.getComputedStyle === "function") { - const styles = win.getComputedStyle(doc.documentElement); - const tokens = [ - "--color-overlay-strong", - "--color-overlay", - "--color-surface", - "--color-bg", - ]; - - for (const token of tokens) { - const value = styles.getPropertyValue(token); - if (value && value.trim()) { - return value.trim(); - } - } - } - - return "rgb(0, 0, 0)"; - } - - clearAmbientCanvas() { - const canvas = this.ambientCanvas; - if (!canvas || typeof canvas.getContext !== "function") { - return; - } - const context = canvas.getContext("2d"); - if (!context) { - return; - } - - const width = canvas.width || canvas.clientWidth || 0; - const height = canvas.height || canvas.clientHeight || 0; - if (!width || !height) { - return; - } - - context.fillStyle = this.getAmbientFallbackColor(); - context.fillRect(0, 0, width, height); - } - - teardownAmbientGlow({ clear = true } = {}) { - const detach = this.detachAmbientBackground; - this.detachAmbientBackground = null; - - if (typeof detach === "function") { - try { - detach({ clear }); - return; - } catch (error) { - this.log("[VideoModal] Failed to teardown ambient background", error); - } - } - - if (clear) { - this.clearAmbientCanvas(); - } - } - - attachAmbientGlow() { - if (!this.modalVideo || !this.ambientCanvas) { - this.clearAmbientCanvas(); - return; - } - - try { - this.detachAmbientBackground = attachAmbientBackground( - this.modalVideo, - this.ambientCanvas, - ); - } catch (error) { - this.detachAmbientBackground = null; - this.log("[VideoModal] Failed to attach ambient background", error); - this.clearAmbientCanvas(); - } - } - - bindActionButtons() { - if (this.copyMagnetBtn) { - this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest); - } - if (this.shareBtn && this.shareBtn.dataset.modalMenuHandler !== "true") { - this.shareBtn.dataset.modalMenuHandler = "true"; - this.shareBtn.addEventListener("click", this.handleShareRequest); - } - if (this.embedBtn) { - this.embedBtn.addEventListener("click", this.handleEmbedRequest); - } - - if (this.modalMoreBtn && this.modalMoreBtn.dataset.modalMenuHandler !== "true") { - this.modalMoreBtn.dataset.modalMenuHandler = "true"; - this.modalMoreBtn.addEventListener("click", this.handleModalMoreButtonClick); - } - - if (this.sourceToggleButtons.length > 0) { - this.sourceToggleButtons.forEach((btn) => { - btn.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - const mode = btn.dataset.sourceToggle; - if (mode) { - this.handleSourceToggle(mode); - } - }); - }); - } - - if (this.creatorAvatar) { - this.creatorAvatar.addEventListener( - "click", - this.handleCreatorNavigation - ); - } - if (this.creatorName) { - this.creatorName.addEventListener("click", this.handleCreatorNavigation); - } - } - - - setupModalMorePopover() { - if (!this.modalMoreBtn) { - this.modalMorePopover = null; - this.modalMoreMenuPanel = null; - return; - } - - const documentRef = - this.modalMoreBtn.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - const render = ({ document: doc, close }) => { - const panel = this.buildModalMoreMenuPanel({ document: doc, close }); - this.modalMoreMenuPanel = panel; - return panel; - }; - - const popover = createPopover(this.modalMoreBtn, render, { - document: documentRef, - placement: "bottom-end", - restoreFocusOnClose: true, - }); - - if (!popover) { - this.modalMorePopover = null; - this.modalMoreMenuPanel = null; - return; - } - - const originalOpen = popover.open?.bind(popover); - if (originalOpen) { - popover.open = async (...args) => { - this.refreshModalMoreMenuPanel(); - return originalOpen(...args); - }; - } - - const originalDestroy = popover.destroy?.bind(popover); - if (originalDestroy) { - popover.destroy = (...args) => { - originalDestroy(...args); - if (this.modalMorePopover === popover) { - this.modalMorePopover = null; - this.modalMoreMenuPanel = null; - } - }; - } - - this.modalMoreBtn.dataset.moreMenuToggleBound = "true"; - this.modalMorePopover = popover; - } - - setupModalSharePopover() { - if (!this.shareBtn) { - this.modalSharePopover = null; - return; - } - - const documentRef = - this.shareBtn.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - const render = ({ document: doc, close }) => { - const panel = this.buildModalShareMenuPanel({ document: doc, close }); - return panel; - }; - - const popover = createPopover(this.shareBtn, render, { - document: documentRef, - placement: "bottom-end", - restoreFocusOnClose: true, - }); - - if (!popover) { - this.modalSharePopover = null; - return; - } - - const originalOpen = popover.open?.bind(popover); - if (originalOpen) { - popover.open = async (...args) => { - this.refreshModalShareMenuPanel(); - return originalOpen(...args); - }; - } - - const originalDestroy = popover.destroy?.bind(popover); - if (originalDestroy) { - popover.destroy = (...args) => { - originalDestroy(...args); - if (this.modalSharePopover === popover) { - this.modalSharePopover = null; - } - }; - } - - this.shareBtn.dataset.shareMenuToggleBound = "true"; - this.modalSharePopover = popover; - } - - buildModalShareMenuPanel({ document: doc, close }) { - const isLoggedIn = this.shareNostrAuthState.isLoggedIn; - const hasSigner = this.shareNostrAuthState.hasSigner; - - const hasMagnet = Boolean( - (typeof this.activeVideo?.magnet === "string" && - this.activeVideo.magnet.trim()) || - this.activeVideo?.torrentSupported - ); - - const hasCdn = Boolean( - typeof this.activeVideo?.url === "string" && this.activeVideo.url.trim() - ); - - const panel = createVideoShareMenuPanel({ - document: doc, - video: this.activeVideo, - isLoggedIn, - hasSigner, - hasMagnet, - hasCdn, - context: "modal", - }); - - if (!panel) { - return null; - } - - const buttons = panel.querySelectorAll("button[data-action]"); - buttons.forEach((button) => { - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - const dataset = {}; - Object.entries(button.dataset || {}).forEach(([key, value]) => { - dataset[key] = value; - }); - if (!dataset.context) { - dataset.context = "modal"; - } - - const action = dataset.action || ""; - - // Map menu actions to events - if (action === "share") { - this.dispatch("video:share", { video: this.activeVideo }); - } else if (action === "share-nostr") { - this.dispatch("video:share-nostr", { video: this.activeVideo }); - } else if (action === "copy-magnet") { - this.dispatch("video:copy-magnet", { video: this.activeVideo }); - } else if (action === "copy-cdn") { - this.dispatch("video:copy-cdn", { video: this.activeVideo }); - } - - if (typeof close === "function") { - close({ reason: "action" }); - } - }); - }); - - return panel; - } - - refreshModalShareMenuPanel() { - if (!this.modalSharePopover) { - return; - } - - const existingPanel = this.modalSharePopover.getPanel?.(); - const documentRef = - existingPanel?.ownerDocument || - this.shareBtn?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - if (!documentRef) { - return; - } - - const nextPanel = this.buildModalShareMenuPanel({ - document: documentRef, - close: (options) => this.modalSharePopover?.close(options), - }); - - if (!nextPanel) { - return; - } - - if (existingPanel && existingPanel.parentNode) { - existingPanel.parentNode.replaceChild(nextPanel, existingPanel); - } - } - - setShareNostrAuthState({ isLoggedIn, hasSigner } = {}) { - const nextIsLoggedIn = Boolean(isLoggedIn); - const nextHasSigner = Boolean(hasSigner); - const previous = this.shareNostrAuthState; - - if ( - previous && - previous.isLoggedIn === nextIsLoggedIn && - previous.hasSigner === nextHasSigner - ) { - return; - } - - this.shareNostrAuthState = { - isLoggedIn: nextIsLoggedIn, - hasSigner: nextHasSigner, - }; - - this.refreshModalShareMenuPanel(); - } - - buildModalMoreMenuPanel({ document: doc, close }) { - const panel = createVideoMoreMenuPanel({ - document: doc, - video: this.modalMoreMenuContext.video, - pointerInfo: this.modalMoreMenuContext.pointerInfo, - playbackUrl: this.modalMoreMenuContext.playbackUrl, - playbackMagnet: this.modalMoreMenuContext.playbackMagnet, - canManageBlacklist: this.modalMoreMenuContext.canManageBlacklist, - context: "modal", - }); - - if (!panel) { - return null; - } - - const buttons = panel.querySelectorAll("button[data-action]"); - buttons.forEach((button) => { - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - const dataset = {}; - Object.entries(button.dataset || {}).forEach(([key, value]) => { - dataset[key] = value; - }); - if (!dataset.context) { - dataset.context = "modal"; - } - - const action = dataset.action || ""; - this.dispatch("video:context-action", { - action, - dataset, - }); - - if (typeof close === "function") { - close({ reason: "action" }); - } - }); - }); - - return panel; - } - - refreshModalMoreMenuPanel() { - if (!this.modalMorePopover) { - return; - } - - const existingPanel = - this.modalMorePopover.getPanel?.() || this.modalMoreMenuPanel; - - const documentRef = - existingPanel?.ownerDocument || - this.modalMoreBtn?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - if (!documentRef) { - return; - } - - const nextPanel = this.buildModalMoreMenuPanel({ - document: documentRef, - close: (options) => this.modalMorePopover?.close(options), - }); - - if (!nextPanel) { - return; - } - - const currentPanel = this.modalMorePopover.getPanel?.(); - if (currentPanel && currentPanel.parentNode) { - currentPanel.parentNode.replaceChild(nextPanel, currentPanel); - } else if (existingPanel?.parentNode) { - existingPanel.parentNode.replaceChild(nextPanel, existingPanel); - } - - this.modalMoreMenuPanel = nextPanel; - } - - handleCopyRequest(event) { - event?.preventDefault?.(); - if (this.copyMagnetBtn?.disabled) { - return; - } - this.dispatch("video:copy-magnet", { video: this.activeVideo }); - } - - handleSourceToggle(mode) { - if (!this.activeVideo || (mode !== "url" && mode !== "torrent")) { - return; - } - - this.dispatch("playback:switch-source", { - video: this.activeVideo, - source: mode, - }); - } - - updateSourceAvailability(video) { - const hasUrl = Boolean( - typeof video?.url === "string" && video.url.trim() - ); - const hasMagnet = Boolean( - (typeof video?.magnet === "string" && video.magnet.trim()) || - video?.torrentSupported - ); - const hasPointer = this.hasResolvablePointer(video); - - this.setShareEnabled(hasUrl || hasMagnet); - this.setEmbedEnabled(hasPointer); - - if (!this.sourceToggleContainer) { - return; - } - - if (hasUrl || hasMagnet) { - this.sourceToggleContainer.removeAttribute("hidden"); - this.sourceToggleContainer.classList.remove("hidden"); - } else { - this.sourceToggleContainer.setAttribute("hidden", ""); - this.sourceToggleContainer.classList.add("hidden"); - } - - this.sourceToggleButtons.forEach((btn) => { - const mode = btn.dataset.sourceToggle; - if (mode === "url") { - btn.disabled = !hasUrl; - } else if (mode === "torrent") { - btn.disabled = !hasMagnet; - } - }); - - if (this.activeServersLabel) { - const count = hasUrl ? 1 : 0; - this.activeServersLabel.textContent = - count === 1 ? "1 server" : `${count} servers`; - } - } - - updateSourceToggleState(activeSource) { - if (!this.sourceToggleButtons.length) { - return; - } - - const current = activeSource === "torrent" ? "torrent" : "url"; - this.sourceToggleButtons.forEach((btn) => { - const mode = btn.dataset.sourceToggle; - const isActive = mode === current; - btn.setAttribute("aria-pressed", isActive ? "true" : "false"); - }); - } - - handleShareRequest(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (this.shareBtn?.disabled) { - return; - } - if (this.modalSharePopover?.toggle) { - this.modalSharePopover.toggle(); - return; - } - if (this.modalSharePopover?.isOpen?.()) { - this.modalSharePopover.close?.(); - } else { - this.modalSharePopover?.open?.(); - } - } - - handleEmbedRequest(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (this.embedBtn?.disabled) { - return; - } - this.dispatch("video:embed", { video: this.activeVideo }); - } - - handleCreatorNavigation(event) { - event?.preventDefault?.(); - this.dispatch("creator:navigate", { video: this.activeVideo }); - } - - handleModalMoreButtonClick(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (this.modalMoreBtn?.disabled) { - return; - } - if (this.modalMorePopover?.toggle) { - this.modalMorePopover.toggle(); - return; - } - if (this.modalMorePopover?.isOpen?.()) { - this.modalMorePopover.close?.(); - } else { - this.modalMorePopover?.open?.(); - } - } - - handleReactionClick(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - - const button = event?.currentTarget; - if (!button || typeof button !== "object") { - return; - } - - if (button.disabled || button.getAttribute("aria-disabled") === "true") { - return; - } - - const reactionValue = - typeof button.dataset?.reaction === "string" - ? button.dataset.reaction.trim() - : ""; - const normalized = - reactionValue === "+" - ? "+" - : reactionValue === "-" - ? "-" - : ""; - - if (!normalized) { - return; - } - - const currentReaction = this.reactionState?.userReaction || ""; - if (normalized === currentReaction) { - return; - } - - this.dispatch("video:reaction", { - video: this.activeVideo, - reaction: normalized, - previousReaction: currentReaction, - }); - } - /** * Resets the modal state, populates it with the provided video data, and makes it visible. * Also initializes moderation, comments, and accessibility traps. @@ -2805,7 +760,7 @@ export class VideoModal { // Ensure comments controller is initialized if elements are missing if ( this.commentsController && - (!this.commentsList || !this.commentsList.isConnected) + (!this.commentsController.commentsList || !this.commentsController.commentsList.isConnected) ) { this.commentsController.destroy(); this.commentsController.initialize({ playerModal: this.playerModal }); @@ -2857,9 +812,9 @@ export class VideoModal { if (this.modalMorePopover?.close) { this.modalMorePopover.close({ restoreFocus: false }); } - this.invokeCommentTeardown(); - this.clearComments(); - this.resetCommentComposer(); + this.commentsController.invokeCommentTeardown(); + this.commentsController.clearComments(); + this.commentsController.resetCommentComposer(); this.renderVideoTags([]); this.clearSimilarContent(); this.clearLinkPreviews(); diff --git a/js/ui/components/revertModalRenderers.js b/js/ui/components/revertModalRenderers.js index 1f8bd553..06edca3d 100644 --- a/js/ui/components/revertModalRenderers.js +++ b/js/ui/components/revertModalRenderers.js @@ -437,3 +437,459 @@ export function createReferences(references, utils) { return ul; } + +/* Shared Helpers (internal to this module, or exported if needed elsewhere) */ + +function createDl() { + const dl = document.createElement("dl"); + dl.className = "grid gap-3 sm:grid-cols-2 text-xs text-subtle"; + return dl; +} + +function createDtDd(label, contentNode, colSpan = 1) { + const div = document.createElement("div"); + if (colSpan > 1) div.className = "sm:col-span-2"; + + const dt = document.createElement("dt"); + dt.className = "font-semibold text-primary"; + dt.textContent = label; + div.appendChild(dt); + + const dd = document.createElement("dd"); + dd.className = "mt-1"; + dd.appendChild(contentNode); + div.appendChild(dd); + return div; +} + +export function createSection(title) { + const s = document.createElement("section"); + s.className = "space-y-2"; + const h = document.createElement("h4"); + h.className = "text-sm font-semibold text-primary"; + h.textContent = title; + s.appendChild(h); + return s; +} + +function createTimestampNode(seconds, raw, utils) { + const { formatAbsoluteTimestamp, formatTimeAgo, createPlaceholder } = utils; + if (Number.isFinite(seconds)) { + const abs = formatAbsoluteTimestamp ? formatAbsoluteTimestamp(seconds) : `${seconds}`; + const rel = formatTimeAgo ? formatTimeAgo(seconds) : ""; + const frag = document.createDocumentFragment(); + if (abs) { + const s = document.createElement("span"); + s.className = "text-primary"; + s.textContent = abs; + frag.appendChild(s); + frag.appendChild(document.createTextNode(" ")); + } + if (rel) { + const s = document.createElement("span"); + s.className = "text-muted"; + s.textContent = `(${rel})`; + frag.appendChild(s); + } + return frag; + } + if (raw) { + const s = document.createElement("span"); + s.className = "text-primary"; + s.textContent = raw; + return s; + } + return createPlaceholder(); +} + +function createDurationNode(seconds, raw, utils) { + const { formatDurationSeconds, createPlaceholder } = utils; + if (Number.isFinite(seconds)) { + const display = formatDurationSeconds ? formatDurationSeconds(seconds) : `${seconds}`; + const suffix = seconds === 1 ? "second" : "seconds"; + const frag = document.createDocumentFragment(); + const s1 = document.createElement("span"); + s1.className = "text-primary"; + s1.textContent = display; + frag.appendChild(s1); + frag.appendChild(document.createTextNode(" ")); + const s2 = document.createElement("span"); + s2.className = "text-subtle"; + s2.textContent = `(${seconds} ${suffix})`; + frag.appendChild(s2); + return frag; + } + if (raw) { + const s = document.createElement("span"); + s.className = "text-primary"; + s.textContent = raw; + return s; + } + return createPlaceholder(); +} + +function createFlagStatus(value, { yes, no, unspecified }, utils) { + const { createPlaceholder } = utils; + if (value === true) { + const s = document.createElement("span"); + s.className = "text-primary"; + s.textContent = yes; + return s; + } + if (value === false) { + const s = document.createElement("span"); + s.className = "text-primary"; + s.textContent = no; + return s; + } + return createPlaceholder(unspecified); +} + +/* New Exported Renderers */ + +export function createHeader(version, nip71Metadata, utils) { + const { + formatAbsoluteTimestamp, + formatTimeAgo, + truncateMiddle, + createPlaceholder, + createLinkMarkup, + fallbackThumbnailSrc, + } = utils; + + const wrapper = document.createElement("div"); + wrapper.className = "space-y-6"; + + const createdAt = typeof version.created_at === "number" ? version.created_at : 0; + const absolute = formatAbsoluteTimestamp ? formatAbsoluteTimestamp(createdAt) : ""; + const relative = formatTimeAgo ? formatTimeAgo(createdAt) : ""; + const timestampText = [absolute, relative ? `(${relative})` : ""].filter(Boolean).join(" "); + + const thumbnail = typeof version.thumbnail === "string" ? version.thumbnail.trim() : ""; + const thumbnailSrc = thumbnail || fallbackThumbnailSrc; + const thumbnailAlt = thumbnail ? "Revision thumbnail" : "Fallback thumbnail"; + + const url = typeof version.url === "string" ? version.url.trim() : ""; + const urlNode = url ? createLinkMarkup(url) : createPlaceholder("None"); + + const magnet = typeof version.magnet === "string" ? version.magnet.trim() : ""; + const rawMagnet = typeof version.rawMagnet === "string" ? version.rawMagnet.trim() : ""; + const displayMagnet = magnet || rawMagnet; + const isPrivate = version.isPrivate === true; + + const magnetNode = displayMagnet + ? (() => { + const div = document.createElement("div"); + div.className = "break-all"; + const label = truncateMiddle ? truncateMiddle(displayMagnet, 72) : displayMagnet; + div.textContent = label; + if (isPrivate) { + const span = document.createElement("span"); + span.className = "block mt-1 text-xs text-status-private-on"; + span.textContent = "Magnet stays visible only to you — private notes keep the raw string local."; + div.appendChild(span); + } + return div; + })() + : createPlaceholder("None"); + + // Chips + const chipsDiv = document.createElement("div"); + chipsDiv.className = "flex flex-wrap gap-2"; + + const nsfwFlag = version.isNsfw === true; + const forKidsFlag = version.isForKids === true; + const conflictingAudienceFlags = nsfwFlag && forKidsFlag; + + if (version.deleted) { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "critical"; + s.textContent = "Marked deleted"; + chipsDiv.appendChild(s); + } + if (isPrivate) { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "private"; + s.textContent = "Private"; + chipsDiv.appendChild(s); + } + if (conflictingAudienceFlags) { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "warning"; + s.textContent = "NSFW + For kids conflict"; + chipsDiv.appendChild(s); + } else { + if (nsfwFlag) { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "critical"; + s.textContent = "Marked NSFW"; + chipsDiv.appendChild(s); + } + if (forKidsFlag) { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "success"; + s.textContent = "For kids"; + chipsDiv.appendChild(s); + } + } + if (version.version !== undefined) { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "neutral"; + s.textContent = `Schema v${version.version}`; + chipsDiv.appendChild(s); + } + + // DOM construction + const headerRow = document.createElement("div"); + headerRow.className = "flex flex-col gap-4 lg:flex-row lg:items-start"; + + const imgDiv = document.createElement("div"); + imgDiv.className = "overflow-hidden rounded-md border border-overlay bg-overlay-strong w-full max-w-sm"; + const img = document.createElement("img"); + img.src = thumbnailSrc; + img.alt = thumbnailAlt; + img.className = "w-full h-auto object-cover"; + img.loading = "lazy"; + imgDiv.appendChild(img); + headerRow.appendChild(imgDiv); + + const headerContent = document.createElement("div"); + headerContent.className = "flex-1 space-y-3"; + + const titleTimeDiv = document.createElement("div"); + titleTimeDiv.className = "space-y-1"; + const titleH3 = document.createElement("h3"); + titleH3.className = "text-lg font-semibold text-primary"; + titleH3.textContent = version.title || "Untitled"; + titleTimeDiv.appendChild(titleH3); + const timeP = document.createElement("p"); + timeP.className = "text-xs text-muted"; + timeP.textContent = timestampText; + titleTimeDiv.appendChild(timeP); + headerContent.appendChild(titleTimeDiv); + + if (chipsDiv.hasChildNodes()) { + headerContent.appendChild(chipsDiv); + } + + const headerDl = createDl(); + headerDl.appendChild(createDtDd("Hosted URL", urlNode)); + headerDl.appendChild(createDtDd("Magnet", magnetNode)); + headerContent.appendChild(headerDl); + + headerRow.appendChild(headerContent); + wrapper.appendChild(headerRow); + + return wrapper; +} + +export function createDescription(description, utils) { + const descSection = createSection("Description"); + + const descriptionNode = description + ? (() => { + const p = document.createElement("p"); + p.className = "whitespace-pre-wrap text-primary"; + p.textContent = description; + return p; + })() + : (() => { + const p = document.createElement("p"); + p.className = "text-subtle"; + p.textContent = "No description provided."; + return p; + })(); + + descSection.appendChild(descriptionNode); + return descSection; +} + +export function createAudienceFlags(version, utils) { + const audSection = createSection("Audience flags"); + const audDl = createDl(); + + const nsfwStatusNode = createFlagStatus(version.isNsfw, { + yes: "Yes — marked NSFW", + no: "No — not flagged as NSFW", + unspecified: "Not specified", + }, utils); + + const kidsStatusNode = createFlagStatus(version.isForKids, { + yes: "Yes — marked for kids", + no: "No — not marked for kids", + unspecified: "Not specified", + }, utils); + + audDl.appendChild(createDtDd("NSFW flag", nsfwStatusNode)); + audDl.appendChild(createDtDd("For kids flag", kidsStatusNode)); + audSection.appendChild(audDl); + + if (version.isNsfw === true && version.isForKids === true) { + const p = document.createElement("p"); + p.className = "text-xs text-status-warning-on"; + p.textContent = "Conflicting flags detected — this revision is marked both NSFW and for kids. Review before reverting."; + audSection.appendChild(p); + } + + return audSection; +} + +export function createEventMetadata(nip71Metadata, utils) { + const { createPlaceholder } = utils; + + const eventSection = createSection("NIP-71 event metadata"); + const eventDl = createDl(); + + const kindValue = nip71Metadata.kind; + const kindNode = (() => { + if (kindValue !== "" && kindValue !== null && kindValue !== undefined) { + const label = typeof kindValue === "number" ? kindValue : `${kindValue}`; + const numeric = Number(label); + let suffix = ""; + if (Number.isFinite(numeric)) { + if (numeric === 22) suffix = "short"; + else if (numeric === 21) suffix = "video"; + } + + const frag = document.createDocumentFragment(); + const code = document.createElement("code"); + code.className = "rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; + code.textContent = `kind ${label}`; + frag.appendChild(code); + + if (suffix) { + frag.appendChild(document.createTextNode(" ")); + const s = document.createElement("span"); + s.className = "text-muted"; + s.textContent = `(${suffix})`; + frag.appendChild(s); + } + return frag; + } + return createPlaceholder(); + })(); + eventDl.appendChild(createDtDd("NIP-71 kind", kindNode)); + + const summaryNode = nip71Metadata.summary + ? (() => { + const p = document.createElement("p"); + p.className = "whitespace-pre-wrap text-primary"; + p.textContent = nip71Metadata.summary; + return p; + })() + : createPlaceholder("Not provided"); + eventDl.appendChild(createDtDd("Summary", summaryNode, 2)); + + const contentWarningNode = nip71Metadata.contentWarning + ? (() => { + const s = document.createElement("span"); + s.className = "pill"; + s.dataset.size = "compact"; + s.dataset.variant = "warning"; + s.textContent = nip71Metadata.contentWarning; + return s; + })() + : createPlaceholder("Not provided"); + eventDl.appendChild(createDtDd("Content warning", contentWarningNode, 2)); + + const publishedAtNode = createTimestampNode( + nip71Metadata.publishedAtSeconds, + nip71Metadata.publishedAtRaw, + utils + ); + eventDl.appendChild(createDtDd("Published timestamp", publishedAtNode)); + + const durationNode = createDurationNode( + nip71Metadata.durationSeconds, + nip71Metadata.durationRaw, + utils + ); + eventDl.appendChild(createDtDd("Duration", durationNode)); + + const altNode = nip71Metadata.alt + ? (() => { + const p = document.createElement("p"); + p.className = "whitespace-pre-wrap text-primary"; + p.textContent = nip71Metadata.alt; + return p; + })() + : createPlaceholder("Not provided"); + eventDl.appendChild(createDtDd("Alt text", altNode, 2)); + + eventSection.appendChild(eventDl); + return eventSection; +} + +export function createNotePointers(version, dTagValue, utils) { + const { createPlaceholder, truncateMiddle } = utils; + + const ptrSection = createSection("Note pointers"); + const ptrDl = createDl(); + + ptrDl.appendChild(createDtDd("Mode", document.createTextNode(version.mode || "live"))); + + const dTagNode = dTagValue + ? (() => { + const c = document.createElement("code"); + c.className = "rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; + c.textContent = dTagValue; + return c; + })() + : createPlaceholder("Not provided"); + ptrDl.appendChild(createDtDd("d tag", dTagNode)); + + const rootId = typeof version.videoRootId === "string" ? version.videoRootId : ""; + const rootDisplay = rootId + ? truncateMiddle + ? truncateMiddle(rootId, 64) + : rootId + : ""; + + const rootIdNode = rootDisplay + ? (() => { + const c = document.createElement("code"); + c.className = "break-all rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; + c.title = rootId; + c.textContent = rootDisplay; + return c; + })() + : createPlaceholder("Not provided"); + ptrDl.appendChild(createDtDd("videoRootId", rootIdNode)); + + const eventDisplay = version.id + ? truncateMiddle + ? truncateMiddle(version.id, 64) + : version.id + : ""; + + const eventIdNode = eventDisplay + ? (() => { + const c = document.createElement("code"); + c.className = "break-all rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary"; + c.title = version.id || ""; + c.textContent = eventDisplay; + return c; + })() + : (() => { + const s = document.createElement("span"); + s.className = "text-subtle"; + s.textContent = "Unknown"; + return s; + })(); + ptrDl.appendChild(createDtDd("Event ID", eventIdNode)); + + ptrSection.appendChild(ptrDl); + return ptrSection; +} diff --git a/js/ui/components/video-modal/commentNodeFactory.js b/js/ui/components/video-modal/commentNodeFactory.js new file mode 100644 index 00000000..9eadd4f7 --- /dev/null +++ b/js/ui/components/video-modal/commentNodeFactory.js @@ -0,0 +1,402 @@ +import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js"; +import { formatAbsoluteTimestamp, formatTimeAgo } from "../../../utils/formatters.js"; +import { registerCommentAvatarFailure as registerCommentAvatarFailureUtil } from "./utils/commentAvatar.js"; + +export class CommentNodeFactory { + constructor({ document, window, logger, dispatch, DEFAULT_PROFILE_AVATAR }) { + this.document = document; + this.window = window; + this.logger = logger; + this.dispatch = dispatch; + this.DEFAULT_PROFILE_AVATAR = DEFAULT_PROFILE_AVATAR; + } + + createCommentNode(event, { depth = 0 } = {}) { + if (!event || typeof event !== "object" || !this.document) { + return null; + } + + const commentId = + typeof event.id === "string" && event.id.trim() ? event.id.trim() : ""; + if (!commentId) { + return null; + } + + const listItem = this.document.createElement("li"); + listItem.classList.add("comment-thread__item"); + listItem.dataset.commentId = commentId; + listItem.dataset.commentDepth = String(Math.max(0, depth)); + listItem.dataset.timestamp = String( + Number.isFinite(event.created_at) ? event.created_at : 0 + ); + if (typeof event.pubkey === "string" && event.pubkey.trim()) { + listItem.dataset.commentAuthor = event.pubkey.trim(); + } + + const avatarWrapper = this.document.createElement("div"); + avatarWrapper.classList.add("comment-thread__avatar"); + + const avatarImg = this.document.createElement("img"); + avatarImg.hidden = true; + avatarImg.loading = "lazy"; + avatarImg.decoding = "async"; + avatarWrapper.appendChild(avatarImg); + + const avatarLabel = this.document.createElement("span"); + avatarWrapper.appendChild(avatarLabel); + + const content = this.document.createElement("div"); + content.classList.add("comment-thread__content"); + + const meta = this.document.createElement("div"); + meta.classList.add("comment-thread__meta"); + + const authorEl = this.document.createElement("span"); + authorEl.classList.add("comment-thread__author"); + meta.appendChild(authorEl); + + const timestamp = this.getCommentTimestampLabel(event.created_at); + if (timestamp.text) { + const timeEl = this.document.createElement("time"); + timeEl.classList.add("comment-thread__timestamp"); + if (timestamp.iso) { + timeEl.setAttribute("datetime", timestamp.iso); + } + if (timestamp.title) { + timeEl.title = timestamp.title; + } + timeEl.appendChild(this.document.createTextNode(timestamp.text)); + meta.appendChild(timeEl); + } + + content.appendChild(meta); + + const identityMeta = this.document.createElement("div"); + identityMeta.classList.add( + "flex", + "items-center", + "gap-2", + "text-2xs", + "text-muted-strong" + ); + identityMeta.hidden = true; + const npubLabel = this.document.createElement("span"); + npubLabel.classList.add("truncate"); + identityMeta.appendChild(npubLabel); + content.appendChild(identityMeta); + + const body = this.document.createElement("p"); + body.classList.add("comment-thread__body"); + body.appendChild( + this.document.createTextNode( + typeof event.content === "string" ? event.content : "" + ) + ); + content.appendChild(body); + + const actions = this.document.createElement("div"); + actions.classList.add("comment-thread__actions"); + + const copyButton = this.document.createElement("button"); + copyButton.type = "button"; + copyButton.classList.add("comment-thread__copy-npub"); + copyButton.textContent = "Copy npub"; + copyButton.dataset.originalLabel = "Copy npub"; + copyButton.hidden = true; + this.bindCommentCopyHandler(copyButton); + actions.appendChild(copyButton); + + const muteButton = this.document.createElement("button"); + muteButton.type = "button"; + muteButton.classList.add("comment-thread__mute"); + muteButton.appendChild(this.document.createTextNode("Mute author")); + muteButton.addEventListener("click", (domEvent) => { + const triggerElement = domEvent?.currentTarget || muteButton; + this.dispatch("comment:mute-author", { + commentId, + pubkey: + typeof event.pubkey === "string" && event.pubkey + ? event.pubkey + : "", + triggerElement, + }); + }); + actions.appendChild(muteButton); + + content.appendChild(actions); + + const replies = this.document.createElement("ul"); + replies.classList.add("comment-thread__replies"); + replies.setAttribute("data-comment-replies", ""); + content.appendChild(replies); + + listItem.appendChild(avatarWrapper); + listItem.appendChild(content); + + return { + listItem, + replies, + elements: { + avatarImg, + avatarLabel, + authorEl, + npubContainer: identityMeta, + npubLabel, + copyButton, + } + }; + } + + getCommentTimestampLabel(createdAt) { + const numeric = Number(createdAt); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { text: "", iso: "", title: "" }; + } + + const date = new Date(numeric * 1000); + if (Number.isNaN(date.getTime())) { + return { text: "", iso: "", title: "" }; + } + + let text = ""; + try { + text = formatTimeAgo(numeric); + } catch (error) { + this.logger.log("[VideoModal] Failed to format comment timestamp", error); + } + if (!text) { + text = formatAbsoluteTimestamp(numeric); + } + + return { + text, + iso: date.toISOString(), + title: formatAbsoluteTimestamp(numeric), + }; + } + + bindCommentCopyHandler(button) { + if (!button) { + return; + } + + button.addEventListener("click", async () => { + const npub = + typeof button.dataset.npub === "string" && button.dataset.npub + ? button.dataset.npub + : ""; + const originalLabel = + typeof button.dataset.originalLabel === "string" && + button.dataset.originalLabel + ? button.dataset.originalLabel + : "Copy npub"; + if (!button.dataset.originalLabel) { + button.dataset.originalLabel = originalLabel; + } + + if (!npub) { + this.showCommentCopyFeedback(button, "Copy failed", "error"); + return; + } + + try { + const clipboard = this.window?.navigator?.clipboard; + if (!clipboard || typeof clipboard.writeText !== "function") { + throw new Error("Clipboard unavailable"); + } + await clipboard.writeText(npub); + this.showCommentCopyFeedback(button, "Copied!", "copied"); + } catch (error) { + this.logger.log("[VideoModal] Failed to copy comment npub", error); + this.showCommentCopyFeedback(button, "Copy failed", "error"); + } + }); + } + + showCommentCopyFeedback(button, message, state = "") { + if (!button) { + return; + } + + const originalLabel = + typeof button.dataset.originalLabel === "string" && + button.dataset.originalLabel + ? button.dataset.originalLabel + : "Copy npub"; + + button.textContent = message; + if (state) { + button.dataset.state = state; + } else if (button.dataset.state) { + delete button.dataset.state; + } + + const timeoutId = Number.parseInt( + button.dataset.feedbackTimeoutId || "", + 10 + ); + if ( + Number.isFinite(timeoutId) && + this.window && + typeof this.window.clearTimeout === "function" + ) { + this.window.clearTimeout(timeoutId); + } + + if (!this.window || typeof this.window.setTimeout !== "function") { + button.textContent = button.dataset.originalLabel || originalLabel; + if (button.dataset.feedbackTimeoutId) { + delete button.dataset.feedbackTimeoutId; + } + return; + } + + const nextId = this.window.setTimeout(() => { + button.textContent = button.dataset.originalLabel || originalLabel; + if (button.dataset.state) { + delete button.dataset.state; + } + if (button.dataset.feedbackTimeoutId) { + delete button.dataset.feedbackTimeoutId; + } + }, UI_FEEDBACK_DELAY_MS); + + button.dataset.feedbackTimeoutId = String(nextId); + } + + updateCommentNodeProfile(refs, profile, cache, failures) { + if (!refs) { + return; + } + + if (refs.authorEl) { + refs.authorEl.textContent = profile.displayName; + } + + if (refs.avatarLabel) { + refs.avatarLabel.textContent = profile.initial; + refs.avatarLabel.hidden = Boolean(profile.avatarUrl); + } + + if (refs.avatarImg) { + if (!refs.avatarImg.dataset.commentAvatarBound) { + const labelRef = refs.avatarLabel; + const imgRef = refs.avatarImg; + refs.avatarImg.addEventListener("error", () => { + const failedSource = + typeof imgRef?.dataset?.commentAvatarSource === "string" + ? imgRef.dataset.commentAvatarSource + : ""; + if (failedSource) { + registerCommentAvatarFailureUtil({ + cache, + failures, + defaultAvatar: this.DEFAULT_PROFILE_AVATAR, + sourceUrl: failedSource, + }); + } + if ( + imgRef && + imgRef.dataset.commentAvatarCurrent !== this.DEFAULT_PROFILE_AVATAR + ) { + imgRef.dataset.commentAvatarCurrent = this.DEFAULT_PROFILE_AVATAR; + imgRef.src = this.DEFAULT_PROFILE_AVATAR; + return; + } + if (imgRef) { + imgRef.hidden = true; + } + if (labelRef) { + labelRef.hidden = false; + } + }); + refs.avatarImg.dataset.commentAvatarBound = "true"; + } + + const nextSource = profile.avatarSource || ""; + refs.avatarImg.dataset.commentAvatarSource = nextSource; + + if (profile.avatarUrl) { + if ( + refs.avatarImg.dataset.commentAvatarCurrent !== profile.avatarUrl + ) { + refs.avatarImg.dataset.commentAvatarCurrent = profile.avatarUrl; + refs.avatarImg.src = profile.avatarUrl; + } + refs.avatarImg.alt = `${profile.displayName}'s avatar`; + refs.avatarImg.hidden = false; + if (refs.avatarLabel) { + refs.avatarLabel.hidden = true; + } + } else { + if ( + refs.avatarImg.dataset.commentAvatarCurrent !== + this.DEFAULT_PROFILE_AVATAR + ) { + refs.avatarImg.dataset.commentAvatarCurrent = + this.DEFAULT_PROFILE_AVATAR; + refs.avatarImg.src = this.DEFAULT_PROFILE_AVATAR; + } + refs.avatarImg.hidden = true; + if (refs.avatarLabel) { + refs.avatarLabel.hidden = false; + } + } + } + + if (refs.npubContainer) { + if (profile.shortNpub) { + refs.npubContainer.hidden = false; + if (refs.npubLabel) { + refs.npubLabel.textContent = profile.shortNpub; + if (profile.npub) { + refs.npubLabel.title = profile.npub; + } else if (refs.npubLabel.title) { + refs.npubLabel.removeAttribute("title"); + } + } + } else { + refs.npubContainer.hidden = true; + if (refs.npubLabel) { + refs.npubLabel.textContent = ""; + if (refs.npubLabel.title) { + refs.npubLabel.removeAttribute("title"); + } + } + } + } + + if (refs.copyButton) { + const originalLabel = + typeof refs.copyButton.dataset.originalLabel === "string" && + refs.copyButton.dataset.originalLabel + ? refs.copyButton.dataset.originalLabel + : "Copy npub"; + + if (profile.npub) { + refs.copyButton.hidden = false; + refs.copyButton.disabled = false; + refs.copyButton.dataset.npub = profile.npub; + refs.copyButton.setAttribute( + "aria-label", + `Copy ${profile.displayName}'s npub` + ); + refs.copyButton.textContent = originalLabel; + if (refs.copyButton.dataset.state) { + delete refs.copyButton.dataset.state; + } + } else { + refs.copyButton.hidden = true; + refs.copyButton.disabled = true; + if (refs.copyButton.dataset.state) { + delete refs.copyButton.dataset.state; + } + if (refs.copyButton.dataset.npub) { + delete refs.copyButton.dataset.npub; + } + refs.copyButton.removeAttribute("aria-label"); + refs.copyButton.textContent = originalLabel; + } + } + } +} diff --git a/js/ui/components/video-modal/commentsController.js b/js/ui/components/video-modal/commentsController.js index a89644f0..7d6cd3db 100644 --- a/js/ui/components/video-modal/commentsController.js +++ b/js/ui/components/video-modal/commentsController.js @@ -1,10 +1,67 @@ import logger from "../../../utils/logger.js"; +import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js"; +import { CommentsListRenderer } from "./commentsListRenderer.js"; export class CommentsController { constructor({ modal } = {}) { this.modal = modal; - this.root = null; this.playerModal = null; + this.renderer = new CommentsListRenderer({ controller: this }); + + this.commentsRoot = null; + this.commentsContainer = null; + + // Composer elements + this.commentsComposer = null; + this.commentsInput = null; + this.commentsSubmitButton = null; + this.commentsStatusMessage = null; + this.commentsCharCount = null; + this.commentComposerHint = null; + this.commentRetryButton = null; + this.commentsDisabledPlaceholder = null; + this.commentsDisabledPlaceholderDefaultText = ""; + + // Composer state + this.commentComposerDefaultHint = ""; + this.commentComposerDefaultCountText = ""; + this.commentStatusDefaultText = ""; + this.commentComposerMaxLength = 0; + this.commentComposerState = { + disabled: true, + reason: "", + parentCommentId: null, + }; + + this.commentCallbacks = { + teardown: null, + }; + + this.boundCommentSubmitHandler = + this.handleCommentComposerSubmit.bind(this); + this.boundCommentInputHandler = + this.handleCommentComposerInput.bind(this); + this.boundCommentRetryHandler = this.handleCommentRetry.bind(this); + } + + get document() { + return this.modal?.document || this.playerModal?.ownerDocument || (typeof document !== 'undefined' ? document : null); + } + + get window() { + return this.modal?.window || this.document?.defaultView || (typeof window !== 'undefined' ? window : null); + } + + get logger() { + return this.modal?.logger || logger; + } + + get commentsList() { + return this.renderer?.listRoot; + } + + dispatch(type, detail, options) { + return this.modal?.dispatch(type, detail, options); } initialize({ playerModal } = {}) { @@ -20,15 +77,14 @@ export class CommentsController { this.ensureCommentsDisabledPlaceholder({ root }) || null; - this.root = root; - this.modal.commentsRoot = root; - this.modal.commentsContainer = root; - this.modal.commentsDisabledPlaceholder = commentsDisabledPlaceholder; + this.commentsRoot = root; + this.commentsContainer = root; + this.commentsDisabledPlaceholder = commentsDisabledPlaceholder; if (commentsDisabledPlaceholder) { const disabledText = commentsDisabledPlaceholder.textContent || ""; if (disabledText) { - this.modal.commentsDisabledPlaceholderDefaultText = disabledText; + this.commentsDisabledPlaceholderDefaultText = disabledText; } } @@ -38,52 +94,57 @@ export class CommentsController { ); this.disableComments({ reason: "missing-root", - message: this.modal.commentsDisabledPlaceholderDefaultText || + message: this.commentsDisabledPlaceholderDefaultText || "Comments are unavailable right now.", }); return; } - this.modal.commentsCountLabel = - root?.querySelector("[data-comments-count]") || null; - this.modal.commentsEmptyState = - root?.querySelector("[data-comments-empty]") || null; - this.modal.commentsList = - root?.querySelector("[data-comments-list]") || null; - this.modal.commentsLoadMoreButton = - root?.querySelector("[data-comments-load-more]") || null; - this.modal.commentsComposer = + // Initialize Renderer + const commentsList = root?.querySelector("[data-comments-list]") || null; + const loadMoreButton = root?.querySelector("[data-comments-load-more]") || null; + const countLabel = root?.querySelector("[data-comments-count]") || null; + const emptyState = root?.querySelector("[data-comments-empty]") || null; + + this.renderer.initialize({ + list: commentsList, + loadMoreButton, + countLabel, + emptyState + }); + + this.commentsComposer = root?.querySelector("[data-comments-composer]") || null; - this.modal.commentsInput = - this.modal.commentsComposer?.querySelector("[data-comments-input]") || null; - this.modal.commentsSubmitButton = - this.modal.commentsComposer?.querySelector("[data-comments-submit]") || null; - this.modal.commentsStatusMessage = - this.modal.commentsComposer?.querySelector("[data-comments-status]") || + this.commentsInput = + this.commentsComposer?.querySelector("[data-comments-input]") || null; + this.commentsSubmitButton = + this.commentsComposer?.querySelector("[data-comments-submit]") || null; + this.commentsStatusMessage = + this.commentsComposer?.querySelector("[data-comments-status]") || root?.querySelector("[data-comments-status]") || null; - this.modal.commentsCharCount = - this.modal.commentsComposer?.querySelector("[data-comments-char-count]") || + this.commentsCharCount = + this.commentsComposer?.querySelector("[data-comments-char-count]") || null; - this.modal.commentComposerHint = - this.modal.commentsComposer?.querySelector("#commentComposerHelp") || - this.modal.commentsComposer?.querySelector(".comment-composer__hint") || - this.modal.commentComposerHint || + this.commentComposerHint = + this.commentsComposer?.querySelector("#commentComposerHelp") || + this.commentsComposer?.querySelector(".comment-composer__hint") || + this.commentComposerHint || null; const missingSelectors = [ - { selector: "[data-comments-list]", element: this.modal.commentsList }, + { selector: "[data-comments-list]", element: commentsList }, { selector: "[data-comments-composer]", - element: this.modal.commentsComposer, + element: this.commentsComposer, }, { selector: "[data-comments-input]", - element: this.modal.commentsInput, + element: this.commentsInput, }, { selector: "[data-comments-submit]", - element: this.modal.commentsSubmitButton, + element: this.commentsSubmitButton, }, ] .filter(({ element }) => !element) @@ -95,7 +156,7 @@ export class CommentsController { ); this.disableComments({ reason: "missing-selectors", - message: this.modal.commentsDisabledPlaceholderDefaultText || + message: this.commentsDisabledPlaceholderDefaultText || "Comments are unavailable right now.", }); return; @@ -106,56 +167,384 @@ export class CommentsController { this.playerModal.classList?.remove("comments-disabled"); } - if (this.modal.commentComposerHint) { - const hintText = this.modal.commentComposerHint.textContent || ""; + if (this.commentComposerHint) { + const hintText = this.commentComposerHint.textContent || ""; if (hintText) { - this.modal.commentComposerDefaultHint = hintText; + this.commentComposerDefaultHint = hintText; } } - if (this.modal.commentsCharCount) { - const countText = this.modal.commentsCharCount.textContent || ""; + if (this.commentsCharCount) { + const countText = this.commentsCharCount.textContent || ""; if (countText) { - this.modal.commentComposerDefaultCountText = countText; + this.commentComposerDefaultCountText = countText; } } - if (this.modal.commentsStatusMessage) { - const statusText = this.modal.commentsStatusMessage.textContent || ""; + if (this.commentsStatusMessage) { + const statusText = this.commentsStatusMessage.textContent || ""; if (statusText) { - this.modal.commentStatusDefaultText = statusText; + this.commentStatusDefaultText = statusText; } } - if (this.modal.commentsInput) { - const parsedMax = Number(this.modal.commentsInput.maxLength); + if (this.commentsInput) { + const parsedMax = Number(this.commentsInput.maxLength); if (Number.isFinite(parsedMax) && parsedMax > 0) { - this.modal.commentComposerMaxLength = parsedMax; + this.commentComposerMaxLength = parsedMax; } } - if (this.modal.commentsDisabledPlaceholder) { - this.modal.commentsDisabledPlaceholder.setAttribute("hidden", ""); + if (this.commentsDisabledPlaceholder) { + this.commentsDisabledPlaceholder.setAttribute("hidden", ""); } - if (this.modal.commentsComposer) { - this.modal.commentsComposer.removeAttribute("hidden"); + if (this.commentsComposer) { + this.commentsComposer.removeAttribute("hidden"); } - this.modal.clearComments(); - this.modal.resetCommentComposer(); - this.modal.attachCommentEventHandlers(); + this.clearComments(); + this.resetCommentComposer(); + this.attachCommentEventHandlers(); + } + + attachCommentEventHandlers() { + if (this.commentsComposer && this.boundCommentSubmitHandler) { + this.commentsComposer.addEventListener( + "submit", + this.boundCommentSubmitHandler + ); + } + if (this.commentsInput && this.boundCommentInputHandler) { + this.commentsInput.addEventListener( + "input", + this.boundCommentInputHandler + ); + } + if (this.commentRetryButton && this.boundCommentRetryHandler) { + this.commentRetryButton.addEventListener( + "click", + this.boundCommentRetryHandler + ); + } + } + + detachCommentEventHandlers() { + if (this.commentsComposer && this.boundCommentSubmitHandler) { + this.commentsComposer.removeEventListener( + "submit", + this.boundCommentSubmitHandler + ); + } + if (this.commentsInput && this.boundCommentInputHandler) { + this.commentsInput.removeEventListener( + "input", + this.boundCommentInputHandler + ); + } + if (this.commentRetryButton && this.boundCommentRetryHandler) { + this.commentRetryButton.removeEventListener( + "click", + this.boundCommentRetryHandler + ); + } + } + + handleCommentComposerSubmit(event) { + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + + const triggerElement = + (event && event.submitter) || this.commentsSubmitButton || null; + + if (this.commentComposerState.reason === "login-required") { + this.modal.dispatch("comment:login-required", { triggerElement }); + return; + } + + if (this.commentComposerState.disabled) { + return; + } + + const text = this.getCommentInputValue(); + if (!text) { + return; + } + + this.modal.dispatch("comment:submit", { + text, + parentId: this.commentComposerState.parentCommentId || null, + triggerElement, + }); + } + + handleCommentComposerInput() { + this.updateCommentCharCount(); + this.updateCommentSubmitState(); + } + + handleCommentRetry(event) { + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + const triggerElement = + (event && event.currentTarget) || this.commentRetryButton || null; + this.modal.dispatch("comment:retry", { + text: this.getCommentInputValue(), + parentId: this.commentComposerState.parentCommentId || null, + triggerElement, + }); + } + + getCommentInputValue({ trimmed = true } = {}) { + const value = this.commentsInput ? this.commentsInput.value || "" : ""; + return trimmed ? value.trim() : value; + } + + updateCommentSubmitState() { + if (!this.commentsSubmitButton) { + return; + } + const hasText = !!this.getCommentInputValue(); + const shouldDisable = this.commentComposerState.disabled || !hasText; + this.commentsSubmitButton.disabled = shouldDisable; + } + + updateCommentCharCount() { + if (!this.commentsCharCount) { + return; + } + const length = this.commentsInput ? this.commentsInput.value.length : 0; + const safeLength = Number.isFinite(length) ? Math.max(0, length) : 0; + const max = Number.isFinite(this.commentComposerMaxLength) + ? Math.max(0, this.commentComposerMaxLength) + : 0; + const label = max > 0 ? `${safeLength} / ${max}` : `${safeLength}`; + this.commentsCharCount.textContent = label; + } + + setCommentHintText(text) { + if (!this.commentComposerHint) { + return; + } + const message = + typeof text === "string" && text + ? text + : this.commentComposerDefaultHint || ""; + this.commentComposerHint.textContent = message; + } + + removeCommentRetryButton() { + if (!this.commentRetryButton) { + return; + } + this.commentRetryButton.removeEventListener( + "click", + this.boundCommentRetryHandler + ); + if (this.commentRetryButton.parentNode) { + this.commentRetryButton.parentNode.removeChild(this.commentRetryButton); + } + this.commentRetryButton = null; + } + + setCommentStatus(message, { showRetry = false } = {}) { + if (!this.commentsStatusMessage) { + return; + } + + this.removeCommentRetryButton(); + + while (this.commentsStatusMessage.firstChild) { + this.commentsStatusMessage.removeChild( + this.commentsStatusMessage.firstChild + ); + } + + const text = + typeof message === "string" && message + ? message + : this.commentStatusDefaultText || ""; + if (text) { + this.commentsStatusMessage.appendChild( + this.document.createTextNode(text) + ); + } + + if (showRetry) { + const spacer = this.document.createTextNode(" "); + const retryButton = this.document.createElement("button"); + retryButton.type = "button"; + retryButton.classList.add("comment-thread__retry", "focus-ring"); + retryButton.appendChild(this.document.createTextNode("Retry")); + retryButton.addEventListener("click", this.boundCommentRetryHandler); + this.commentsStatusMessage.appendChild(spacer); + this.commentsStatusMessage.appendChild(retryButton); + this.commentRetryButton = retryButton; + } + } + + setCommentsVisibility(isVisible) { + const root = this.commentsContainer || this.commentsRoot; + if (!root) { + return; + } + const shouldShow = Boolean(isVisible); + if (shouldShow) { + root.removeAttribute("hidden"); + root.classList?.remove("hidden"); + } else { + root.setAttribute("hidden", ""); + root.classList?.add("hidden"); + } + } + + showCommentsDisabledMessage(message) { + const placeholder = this.commentsDisabledPlaceholder; + if (placeholder) { + const fallbackText = this.commentsDisabledPlaceholderDefaultText || ""; + const nextText = + typeof message === "string" && message.trim() + ? message.trim() + : fallbackText; + if (nextText) { + placeholder.textContent = nextText; + } else if (fallbackText) { + placeholder.textContent = fallbackText; + } + placeholder.removeAttribute("hidden"); + placeholder.classList?.remove("hidden"); + } + this.setCommentsVisibility(false); + } + + hideCommentsDisabledMessage() { + const placeholder = this.commentsDisabledPlaceholder; + if (placeholder) { + if (this.commentsDisabledPlaceholderDefaultText) { + placeholder.textContent = this.commentsDisabledPlaceholderDefaultText; + } + placeholder.setAttribute("hidden", ""); + placeholder.classList?.add("hidden"); + } + } + + setCommentComposerState({ disabled = false, reason = "" } = {}) { + const normalizedReason = typeof reason === "string" ? reason : ""; + + let shouldDisable = !!disabled; + switch (normalizedReason) { + case "login-required": + case "submitting": + case "disabled": + shouldDisable = true; + break; + case "error": + shouldDisable = false; + break; + default: + break; + } + + this.commentComposerState.disabled = shouldDisable; + this.commentComposerState.reason = normalizedReason; + + if (this.commentsInput) { + this.commentsInput.disabled = shouldDisable; + } + + if (normalizedReason !== "disabled") { + this.hideCommentsDisabledMessage(); + } + + if (this.commentsComposer) { + if (normalizedReason === "disabled") { + this.commentsComposer.setAttribute("hidden", ""); + } else { + this.commentsComposer.removeAttribute("hidden"); + } + } + + if (normalizedReason === "login-required") { + this.setCommentHintText("Log in to add a comment."); + this.setCommentStatus(""); + this.setCommentsVisibility(true); + } else if (normalizedReason === "submitting") { + this.setCommentHintText(this.commentComposerDefaultHint); + this.setCommentStatus("Posting comment…"); + } else if (normalizedReason === "error") { + this.setCommentHintText(this.commentComposerDefaultHint); + this.setCommentStatus("We couldn't post your comment. Try again?", { + showRetry: true, + }); + } else if (normalizedReason === "disabled") { + this.setCommentHintText(this.commentComposerDefaultHint); + this.setCommentStatus(""); + } else { + this.setCommentHintText(this.commentComposerDefaultHint); + this.setCommentStatus(""); + } + + this.updateCommentSubmitState(); + } + + resetCommentComposer() { + if (this.commentsInput) { + this.commentsInput.disabled = false; + this.commentsInput.value = ""; + } + this.hideCommentsDisabledMessage(); + this.commentComposerState = { + disabled: false, + reason: "", + parentCommentId: null, + }; + this.setCommentHintText(this.commentComposerDefaultHint); + this.setCommentStatus(""); + this.updateCommentCharCount(); + this.updateCommentSubmitState(); + } + + setCommentSectionCallbacks({ teardown = null } = {}) { + this.commentCallbacks.teardown = + typeof teardown === "function" ? teardown : null; + } + + invokeCommentTeardown() { + const teardown = this.commentCallbacks.teardown; + if (typeof teardown !== "function") { + return; + } + this.commentCallbacks.teardown = null; + try { + teardown(); + } catch (error) { + this.logger.log("[VideoModal] Failed to teardown comment services", error); + } + } + + renderComments(snapshot) { + this.renderer.render(snapshot); + } + + appendComment(event) { + this.renderer.append(event); + } + + clearComments() { + this.renderer.clear(); } disableComments({ message = "", reason = "" } = {}) { const placeholder = - this.modal.commentsDisabledPlaceholder || - this.ensureCommentsDisabledPlaceholder({ root: this.root }) || + this.commentsDisabledPlaceholder || + this.ensureCommentsDisabledPlaceholder({ root: this.commentsRoot }) || null; - if (!this.modal.commentsDisabledPlaceholder && placeholder) { - this.modal.commentsDisabledPlaceholder = placeholder; + if (!this.commentsDisabledPlaceholder && placeholder) { + this.commentsDisabledPlaceholder = placeholder; if (placeholder.textContent) { - this.modal.commentsDisabledPlaceholderDefaultText = placeholder.textContent; + this.commentsDisabledPlaceholderDefaultText = placeholder.textContent; } } @@ -164,23 +553,27 @@ export class CommentsController { this.playerModal.classList?.add("comments-disabled"); } - if (this.modal.commentsList) { - this.modal.commentsList.setAttribute("hidden", ""); - this.modal.commentsList.classList?.add("hidden"); + // Hide list via renderer? + // The renderer manages list content, but visibility of the container might be controller concern? + // In old code: this.commentsList.setAttribute("hidden", ""); + // Renderer has listRoot. + if (this.renderer.listRoot) { + this.renderer.listRoot.setAttribute("hidden", ""); + this.renderer.listRoot.classList?.add("hidden"); } - if (this.modal.commentsComposer) { - this.modal.commentsComposer.setAttribute("hidden", ""); - this.modal.commentsComposer.classList?.add("hidden"); + if (this.commentsComposer) { + this.commentsComposer.setAttribute("hidden", ""); + this.commentsComposer.classList?.add("hidden"); } const nextMessage = typeof message === "string" && message.trim() ? message.trim() - : this.modal.commentsDisabledPlaceholderDefaultText || + : this.commentsDisabledPlaceholderDefaultText || "Comments are unavailable right now."; - this.modal.showCommentsDisabledMessage(nextMessage); + this.showCommentsDisabledMessage(nextMessage); logger.dev?.info?.( "[VideoModal:comments] Disabled comments handling", { reason: reason || "unknown" } @@ -188,80 +581,68 @@ export class CommentsController { } update(action = {}) { - if (!this.modal) { - return; - } const { type } = action; switch (type) { case "set-visibility": - this.modal.setCommentsVisibility(action.visible); + this.setCommentsVisibility(action.visible); break; case "render": - this.modal.renderComments(action.snapshot); + this.renderComments(action.snapshot); break; case "set-composer-state": - this.modal.setCommentComposerState(action.state); + this.setCommentComposerState(action.state); break; case "set-thread-context": - this.modal.setCommentThreadContext(action.context); + this.setCommentThreadContext(action.context); break; case "clear": - this.modal.clearComments(); + this.clearComments(); break; default: break; } } - destroy() { - if (!this.modal) { - return; + setCommentThreadContext(context) { + if (context) { + this.commentThreadContext = { ...context }; } - this.modal.detachCommentEventHandlers(); - this.modal.removeCommentRetryButton(); - this.modal.commentsRoot = null; - this.modal.commentsContainer = null; - this.modal.commentsCountLabel = null; - this.modal.commentsEmptyState = null; - this.modal.commentsList = null; - this.modal.commentsLoadMoreButton = null; - this.modal.commentsDisabledPlaceholder = null; - this.modal.commentsComposer = null; - this.modal.commentsInput = null; - this.modal.commentsSubmitButton = null; - this.modal.commentsStatusMessage = null; - this.modal.commentsCharCount = null; - this.modal.commentComposerHint = null; + } + + destroy() { + this.detachCommentEventHandlers(); + this.removeCommentRetryButton(); + this.invokeCommentTeardown(); + + this.renderer.destroy(); + + this.commentsRoot = null; + this.commentsContainer = null; + + this.commentsDisabledPlaceholder = null; + this.commentsComposer = null; + this.commentsInput = null; + this.commentsSubmitButton = null; + this.commentsStatusMessage = null; + this.commentsCharCount = null; + this.commentComposerHint = null; if (this.playerModal) { this.playerModal.removeAttribute("data-comments-disabled"); this.playerModal.classList?.remove("comments-disabled"); } - this.root = null; - } - - getDocument() { - if (this.modal?.document) { - return this.modal.document; - } - if (this.playerModal?.ownerDocument) { - return this.playerModal.ownerDocument; - } - if (typeof document !== "undefined") { - return document; - } - return null; + this.playerModal = null; } ensureCommentsDisabledPlaceholder({ root } = {}) { const existing = - this.modal?.commentsDisabledPlaceholder || + this.commentsDisabledPlaceholder || root?.querySelector?.("[data-comments-disabled-placeholder]") || this.playerModal?.querySelector?.("[data-comments-disabled-placeholder]"); if (existing) { return existing; } - const doc = this.getDocument(); + const doc = this.document; const container = root?.parentElement || this.playerModal || null; if (!doc || !container) { return null; @@ -275,7 +656,7 @@ export class CommentsController { placeholder.classList.add("comment-thread__disabled"); const defaultText = - this.modal?.commentsDisabledPlaceholderDefaultText || + this.commentsDisabledPlaceholderDefaultText || "Comments are unavailable right now."; placeholder.textContent = defaultText; @@ -288,4 +669,3 @@ export class CommentsController { return placeholder; } } - diff --git a/js/ui/components/video-modal/commentsListRenderer.js b/js/ui/components/video-modal/commentsListRenderer.js new file mode 100644 index 00000000..37bd8ab8 --- /dev/null +++ b/js/ui/components/video-modal/commentsListRenderer.js @@ -0,0 +1,652 @@ +import { + normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil, + resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil, +} from "./utils/commentAvatar.js"; +import { HEX64_REGEX } from "../../../utils/hex.js"; +import { sanitizeProfileMediaUrl } from "../../../utils/profileMedia.js"; +import logger from "../../../utils/logger.js"; +import { CommentNodeFactory } from "./commentNodeFactory.js"; + +export class CommentsListRenderer { + constructor({ controller } = {}) { + this.controller = controller; + this.document = controller.document; + this.window = controller.window; + this.logger = controller.logger || logger; + this.helpers = controller.modal?.helpers || {}; // Need helpers for npub format + + this.listRoot = null; + this.loadMoreButton = null; + this.countLabel = null; + this.emptyState = null; + + this.commentProfiles = new Map(); + this.commentNodes = new Map(); + this.commentChildLists = new Map(); + this.commentNodeElements = new Map(); + this.commentNpubEncodingFailures = new Set(); + this.commentAvatarCache = new Map(); + this.commentAvatarFailures = new Set(); + + this.commentThreadContext = { + videoEventId: "", + parentCommentId: null, + }; + + this.DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; + + this.boundCommentLoadMoreHandler = this.handleCommentLoadMore.bind(this); + + this.nodeFactory = new CommentNodeFactory({ + document: this.document, + window: this.window, + logger: this.logger, + dispatch: (type, detail) => this.controller.dispatch(type, detail), + DEFAULT_PROFILE_AVATAR: this.DEFAULT_PROFILE_AVATAR + }); + } + + initialize({ list, loadMoreButton, countLabel, emptyState }) { + this.listRoot = list; + this.loadMoreButton = loadMoreButton; + this.countLabel = countLabel; + this.emptyState = emptyState; + + if (this.loadMoreButton) { + this.loadMoreButton.addEventListener("click", this.boundCommentLoadMoreHandler); + } + } + + destroy() { + if (this.loadMoreButton) { + this.loadMoreButton.removeEventListener("click", this.boundCommentLoadMoreHandler); + } + this.listRoot = null; + this.loadMoreButton = null; + this.countLabel = null; + this.emptyState = null; + this.commentProfiles.clear(); + this.commentNodes.clear(); + this.commentChildLists.clear(); + this.commentNodeElements.clear(); + this.commentAvatarCache.clear(); + this.commentAvatarFailures.clear(); + } + + handleCommentLoadMore(event) { + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + const triggerElement = + (event && event.currentTarget) || this.loadMoreButton || null; + this.controller.dispatch("comment:load-more", { + parentId: this.commentThreadContext.parentCommentId || null, + triggerElement, + }); + } + + render(snapshot) { + if (!this.listRoot || !this.document) { + return; + } + + const commentsMap = this.normalizeCommentMap(snapshot?.commentsById); + const childrenMap = this.normalizeChildrenMap(snapshot?.childrenByParent); + this.commentProfiles = this.normalizeProfileMap(snapshot?.profiles); + + this.commentThreadContext.videoEventId = + typeof snapshot?.videoEventId === "string" + ? snapshot.videoEventId.trim() + : ""; + this.commentThreadContext.parentCommentId = + typeof snapshot?.parentCommentId === "string" && + snapshot.parentCommentId.trim() + ? snapshot.parentCommentId.trim() + : null; + + this.commentNodes.clear(); + this.commentChildLists = new Map(); + this.commentChildLists.set(null, this.listRoot); + + this.listRoot.textContent = ""; + + const fragment = this.document.createDocumentFragment(); + const topLevelIds = this.getChildCommentIds(childrenMap, null); + topLevelIds.forEach((commentId) => { + const node = this.buildCommentTree( + commentId, + commentsMap, + childrenMap, + 0 + ); + if (node) { + fragment.appendChild(node); + } + }); + + this.listRoot.appendChild(fragment); + + this.updateCommentCount(this.commentNodes.size); + this.toggleCommentEmptyState(this.commentNodes.size === 0); + } + + clear() { + this.commentNodes.clear(); + this.commentChildLists.clear(); + this.commentNodeElements.clear(); + this.commentNpubEncodingFailures.clear(); + if (this.listRoot) { + this.listRoot.textContent = ""; + this.commentChildLists.set(null, this.listRoot); + } + this.updateCommentCount(0); + this.toggleCommentEmptyState(true); + } + + append(event) { + if (!event || typeof event !== "object" || !this.listRoot) { + return; + } + + const commentId = + typeof event.id === "string" && event.id.trim() ? event.id.trim() : ""; + if (!commentId || this.commentNodes.has(commentId)) { + return; + } + + if (event.profile && typeof event.pubkey === "string") { + this.mergeCommentProfile(event.pubkey, event.profile); + } + + const parentId = this.extractParentCommentId(event); + const parentNode = + parentId && this.commentNodes.has(parentId) + ? this.commentNodes.get(parentId) + : null; + const depth = parentNode + ? Math.max(0, Number(parentNode.dataset?.commentDepth) || 0) + 1 + : 0; + + const node = this.createCommentNode(event, { depth }); + if (!node) { + return; + } + + const container = this.getCommentChildrenContainer(parentId); + if (!container) { + this.logger.log("[VideoModal] Missing container for appended comment", parentId); + return; + } + + this.insertCommentNodeSorted(container, node); + this.updateCommentCount(this.commentNodes.size); + this.toggleCommentEmptyState(this.commentNodes.size === 0); + } + + insertCommentNodeSorted(container, node) { + if (!container || !node) { + return; + } + + const timestamp = Number(node.dataset.timestamp || 0); + let inserted = false; + + const children = container.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childTimestamp = Number(child.dataset.timestamp || 0); + + if (childTimestamp > timestamp) { + container.insertBefore(node, child); + inserted = true; + break; + } + } + + if (!inserted) { + container.appendChild(node); + } + } + + buildCommentTree(commentId, commentsMap, childrenMap, depth = 0) { + const normalizedId = + typeof commentId === "string" && commentId.trim() + ? commentId.trim() + : ""; + if (!normalizedId) { + return null; + } + + const event = commentsMap.get(normalizedId); + if (!event) { + return null; + } + + const node = this.createCommentNode(event, { depth }); + if (!node) { + return null; + } + + const repliesContainer = this.commentChildLists.get(normalizedId); + if (repliesContainer) { + const childIds = this.getChildCommentIds(childrenMap, normalizedId); + if (childIds.length) { + const fragment = this.document.createDocumentFragment(); + childIds.forEach((childId) => { + const childNode = this.buildCommentTree( + childId, + commentsMap, + childrenMap, + depth + 1 + ); + if (childNode) { + fragment.appendChild(childNode); + } + }); + if (fragment.childNodes.length) { + repliesContainer.appendChild(fragment); + } + } + } + + return node; + } + + createCommentNode(event, { depth = 0 } = {}) { + if (!event || typeof event !== "object") { + return null; + } + + const commentId = + typeof event.id === "string" && event.id.trim() ? event.id.trim() : ""; + if (!commentId) { + return null; + } + + if (this.commentNodes.has(commentId)) { + return this.commentNodes.get(commentId); + } + + const { listItem, replies, elements } = this.nodeFactory.createCommentNode(event, { depth }); + + this.commentNodes.set(commentId, listItem); + this.commentChildLists.set(commentId, replies); + this.commentNodeElements.set(commentId, elements); + + this.updateCommentNodeProfile(commentId, event.pubkey); + + return listItem; + } + + updateCommentNodeProfile(commentId, pubkey) { + if (!(this.commentNodeElements instanceof Map)) { + this.commentNodeElements = new Map(); + } + + if (!commentId || !this.commentNodeElements.has(commentId)) { + return; + } + + const refs = this.commentNodeElements.get(commentId); + const profile = this.getCommentAuthorProfile(pubkey); + + this.nodeFactory.updateCommentNodeProfile( + refs, + profile, + this.commentAvatarCache, + this.commentAvatarFailures + ); + } + + updateCommentNodesForAuthor(pubkey) { + if (typeof pubkey !== "string" || !pubkey.trim()) { + return; + } + const normalized = pubkey.trim().toLowerCase(); + this.commentNodes.forEach((node, id) => { + const author = + typeof node?.dataset?.commentAuthor === "string" + ? node.dataset.commentAuthor.trim().toLowerCase() + : ""; + if (author && author === normalized) { + this.updateCommentNodeProfile(id, pubkey); + } + }); + } + + getCommentAuthorName(pubkey) { + return this.getCommentAuthorProfile(pubkey).displayName; + } + + getCommentAvatarInitial(pubkey) { + return this.getCommentAuthorProfile(pubkey).initial; + } + + getCommentAuthorProfile(pubkey) { + const normalized = + typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; + const profileEntry = normalized + ? this.commentProfiles.get(normalized.toLowerCase()) || null + : null; + + const rawPicture = + profileEntry && typeof profileEntry.picture === "string" + ? profileEntry.picture + : ""; + const sanitizedPicture = sanitizeProfileMediaUrl(rawPicture); + const avatar = this.resolveCommentAvatarAsset(normalized, sanitizedPicture); + + let npub = ""; + if (profileEntry && typeof profileEntry.npub === "string") { + const trimmed = profileEntry.npub.trim(); + if (trimmed) { + if (trimmed.startsWith("npub")) { + npub = trimmed; + } else if (HEX64_REGEX.test(trimmed)) { + npub = this.encodeCommentPubkeyToNpub(trimmed); + } + } + } + + if (!npub && profileEntry && typeof profileEntry.pubkey === "string") { + const trimmed = profileEntry.pubkey.trim(); + if (trimmed) { + npub = this.encodeCommentPubkeyToNpub(trimmed); + } + } + + if (!npub && normalized) { + npub = this.encodeCommentPubkeyToNpub(normalized); + } + + let displayName = ""; + if (profileEntry && typeof profileEntry === "object") { + const candidates = [ + profileEntry.display_name, + profileEntry.name, + profileEntry.username, + ]; + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + displayName = trimmed; + break; + } + } + } + + if (displayName && HEX64_REGEX.test(displayName)) { + displayName = ""; + } + + if (!displayName && npub && this.helpers.formatShortNpub) { + displayName = this.helpers.formatShortNpub(npub); + } + + if (!displayName) { + displayName = "Anonymous"; + } + + const shortNpub = npub && this.helpers.formatShortNpub ? this.helpers.formatShortNpub(npub) : ""; + const initialCandidate = displayName.trim().charAt(0) || ""; + const initial = initialCandidate ? initialCandidate.toUpperCase() : "?"; + + return { + displayName, + avatarUrl: avatar.url, + avatarSource: avatar.source, + npub, + shortNpub, + initial, + }; + } + + resolveCommentAvatarAsset(pubkey, sanitizedPicture) { + return resolveCommentAvatarAssetUtil({ + cache: this.commentAvatarCache, + failures: this.commentAvatarFailures, + defaultAvatar: this.DEFAULT_PROFILE_AVATAR, + pubkey, + sanitizedPicture, + }); + } + + normalizeCommentAvatarKey(value) { + return normalizeCommentAvatarKeyUtil(value); + } + + encodeCommentPubkeyToNpub(pubkey) { + if (typeof pubkey !== "string") { + return ""; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return ""; + } + + if (trimmed.startsWith("npub1")) { + return trimmed; + } + + if (!HEX64_REGEX.test(trimmed)) { + return ""; + } + + if (this.commentNpubEncodingFailures.has(trimmed)) { + return ""; + } + + try { + const encoded = this.window?.NostrTools?.nip19?.npubEncode?.(trimmed); + if (typeof encoded === "string" && encoded) { + return encoded; + } + } catch (error) { + if (!this.commentNpubEncodingFailures.has(trimmed)) { + this.logger.log("[VideoModal] Failed to encode comment author pubkey", error); + } + } + + this.commentNpubEncodingFailures.add(trimmed); + return ""; + } + + normalizeCommentMap(candidate) { + if (candidate instanceof Map) { + return new Map(candidate); + } + if (Array.isArray(candidate)) { + const map = new Map(); + candidate.forEach(([key, value]) => { + if (typeof key === "string" && key.trim()) { + map.set(key.trim(), value); + } + }); + return map; + } + if (candidate && typeof candidate === "object") { + const map = new Map(); + Object.entries(candidate).forEach(([key, value]) => { + if (typeof key === "string" && key.trim()) { + map.set(key.trim(), value); + } + }); + return map; + } + return new Map(); + } + + normalizeChildrenMap(candidate) { + const map = new Map(); + if (candidate instanceof Map) { + candidate.forEach((value, key) => { + map.set(this.normalizeParentKey(key), this.normalizeIdList(value)); + }); + return map; + } + if (Array.isArray(candidate)) { + candidate.forEach(([key, value]) => { + map.set(this.normalizeParentKey(key), this.normalizeIdList(value)); + }); + return map; + } + if (candidate && typeof candidate === "object") { + Object.entries(candidate).forEach(([key, value]) => { + map.set(this.normalizeParentKey(key), this.normalizeIdList(value)); + }); + } + return map; + } + + normalizeProfileMap(candidate) { + const map = new Map(); + if (candidate instanceof Map) { + candidate.forEach((value, key) => { + const normalizedKey = + typeof key === "string" && key.trim() ? key.trim().toLowerCase() : ""; + if (normalizedKey) { + map.set(normalizedKey, value); + } + }); + return map; + } + if (Array.isArray(candidate)) { + candidate.forEach(([key, value]) => { + const normalizedKey = + typeof key === "string" && key.trim() ? key.trim().toLowerCase() : ""; + if (normalizedKey) { + map.set(normalizedKey, value); + } + }); + return map; + } + if (candidate && typeof candidate === "object") { + Object.entries(candidate).forEach(([key, value]) => { + const normalizedKey = + typeof key === "string" && key.trim() ? key.trim().toLowerCase() : ""; + if (normalizedKey) { + map.set(normalizedKey, value); + } + }); + } + return map; + } + + normalizeIdList(value) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => + typeof entry === "string" && entry.trim() ? entry.trim() : "" + ) + .filter(Boolean); + } + + normalizeParentKey(value) { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if ( + !trimmed || + trimmed === "null" || + trimmed === "undefined" || + trimmed === "__root__" + ) { + return null; + } + return trimmed; + } + return this.normalizeParentKey(String(value)); + } + + getChildCommentIds(childrenMap, parentId) { + const key = this.normalizeParentKey(parentId); + const list = childrenMap.get(key); + return Array.isArray(list) ? [...list] : []; + } + + getCommentChildrenContainer(parentId) { + const key = this.normalizeParentKey(parentId); + if (key === null) { + return this.commentChildLists.get(null) || this.listRoot; + } + if (this.commentChildLists.has(key)) { + return this.commentChildLists.get(key); + } + const parentNode = this.commentNodes.get(key); + if (parentNode) { + const existing = parentNode.querySelector("[data-comment-replies]"); + if (existing) { + this.commentChildLists.set(key, existing); + return existing; + } + } + return this.commentChildLists.get(null) || this.listRoot; + } + + extractParentCommentId(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + const values = []; + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const [name, value] = tag; + if (name !== "e") { + continue; + } + const normalized = typeof value === "string" ? value.trim() : ""; + if (!normalized || normalized === this.commentThreadContext.videoEventId) { + continue; + } + values.push(normalized); + } + if (!values.length) { + return null; + } + return values[values.length - 1]; + } + + mergeCommentProfile(pubkey, profile) { + if (typeof pubkey !== "string" || !pubkey.trim()) { + return; + } + const normalized = pubkey.trim().toLowerCase(); + this.commentProfiles.set(normalized, profile); + this.updateCommentNodesForAuthor(pubkey); + } + + updateCommentCount(count) { + if (!this.countLabel) { + return; + } + const numeric = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; + const label = numeric === 1 ? "1 comment" : `${numeric} comments`; + this.countLabel.textContent = label; + } + + toggleCommentEmptyState(isEmpty) { + const shouldShow = Boolean(isEmpty); + if (this.emptyState) { + if (shouldShow) { + this.emptyState.removeAttribute("hidden"); + } else { + this.emptyState.setAttribute("hidden", ""); + } + } + if (this.listRoot) { + if (shouldShow) { + this.listRoot.setAttribute("data-empty", "true"); + } else { + this.listRoot.removeAttribute("data-empty"); + } + } + } +} diff --git a/tests/comment-avatar.test.mjs b/tests/comment-avatar.test.mjs new file mode 100644 index 00000000..4117765c --- /dev/null +++ b/tests/comment-avatar.test.mjs @@ -0,0 +1,160 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeCommentAvatarKey, + resolveCommentAvatarAsset, + registerCommentAvatarFailure, +} from "../js/ui/components/video-modal/utils/commentAvatar.js"; + +describe("commentAvatar utils", () => { + describe("normalizeCommentAvatarKey", () => { + it("should return empty string for non-string inputs", () => { + assert.equal(normalizeCommentAvatarKey(null), ""); + assert.equal(normalizeCommentAvatarKey(undefined), ""); + assert.equal(normalizeCommentAvatarKey(123), ""); + assert.equal(normalizeCommentAvatarKey({}), ""); + }); + + it("should return empty string for empty or whitespace-only strings", () => { + assert.equal(normalizeCommentAvatarKey(""), ""); + assert.equal(normalizeCommentAvatarKey(" "), ""); + assert.equal(normalizeCommentAvatarKey("\t\n"), ""); + }); + + it("should return lowercased string for valid inputs", () => { + assert.equal(normalizeCommentAvatarKey("TestKey"), "testkey"); + assert.equal(normalizeCommentAvatarKey(" TestKey "), "testkey"); + assert.equal(normalizeCommentAvatarKey("TESTKEY"), "testkey"); + }); + }); + + describe("resolveCommentAvatarAsset", () => { + const defaultAvatar = "default.png"; + + it("should return default avatar if source is in failures", () => { + const failures = new Set(["bad-source.jpg"]); + const result = resolveCommentAvatarAsset({ + failures, + defaultAvatar, + pubkey: "pubkey1", + sanitizedPicture: "bad-source.jpg", + }); + assert.deepEqual(result, { url: defaultAvatar, source: "" }); + }); + + it("should return cached data if pubkey is in cache and source matches", () => { + const cache = new Map(); + cache.set("pubkey1", { url: "cached.jpg", source: "source.jpg" }); + const result = resolveCommentAvatarAsset({ + cache, + defaultAvatar, + pubkey: "pubkey1", + sanitizedPicture: "source.jpg", + }); + assert.deepEqual(result, { url: "cached.jpg", source: "source.jpg" }); + }); + + it("should return cached data if source is empty and pubkey is in cache", () => { + const cache = new Map(); + cache.set("pubkey1", { url: "cached.jpg", source: "source.jpg" }); + const result = resolveCommentAvatarAsset({ + cache, + defaultAvatar, + pubkey: "pubkey1", + sanitizedPicture: "", + }); + assert.deepEqual(result, { url: "cached.jpg", source: "source.jpg" }); + }); + + it("should update cache and return new source if cache miss (different source)", () => { + const cache = new Map(); + cache.set("pubkey1", { url: "old.jpg", source: "old-source.jpg" }); + const result = resolveCommentAvatarAsset({ + cache, + defaultAvatar, + pubkey: "pubkey1", + sanitizedPicture: "new-source.jpg", + }); + assert.deepEqual(result, { + url: "new-source.jpg", + source: "new-source.jpg", + }); + assert.equal(cache.get("pubkey1").url, "new-source.jpg"); + assert.equal(cache.get("pubkey1").source, "new-source.jpg"); + }); + + it("should update cache and return new source if cache miss (new pubkey)", () => { + const cache = new Map(); + const result = resolveCommentAvatarAsset({ + cache, + defaultAvatar, + pubkey: "pubkey2", + sanitizedPicture: "source.jpg", + }); + assert.deepEqual(result, { url: "source.jpg", source: "source.jpg" }); + assert.equal(cache.get("pubkey2").url, "source.jpg"); + }); + + it("should return default avatar if source is missing and not cached", () => { + const cache = new Map(); + const result = resolveCommentAvatarAsset({ + cache, + defaultAvatar, + pubkey: "pubkey3", + sanitizedPicture: "", + }); + assert.deepEqual(result, { url: defaultAvatar, source: "" }); + assert.deepEqual(cache.get("pubkey3"), { url: defaultAvatar, source: "" }); + }); + }); + + describe("registerCommentAvatarFailure", () => { + const defaultAvatar = "default.png"; + + it("should ignore invalid inputs", () => { + const failures = new Set(); + registerCommentAvatarFailure({ failures, defaultAvatar, sourceUrl: null }); + registerCommentAvatarFailure({ failures, defaultAvatar, sourceUrl: "" }); + registerCommentAvatarFailure({ + failures, + defaultAvatar, + sourceUrl: defaultAvatar, + }); + assert.equal(failures.size, 0); + }); + + it("should add valid source to failures", () => { + const failures = new Set(); + registerCommentAvatarFailure({ + failures, + defaultAvatar, + sourceUrl: "bad.jpg", + }); + assert.ok(failures.has("bad.jpg")); + }); + + it("should update cache entries matching the failed source", () => { + const failures = new Set(); + const cache = new Map(); + cache.set("pubkey1", { url: "bad.jpg", source: "bad.jpg" }); + cache.set("pubkey2", { url: "good.jpg", source: "good.jpg" }); + + registerCommentAvatarFailure({ + cache, + failures, + defaultAvatar, + sourceUrl: "bad.jpg", + }); + + assert.ok(failures.has("bad.jpg")); + assert.deepEqual(cache.get("pubkey1"), { + url: defaultAvatar, + source: "", + }); + assert.deepEqual(cache.get("pubkey2"), { + url: "good.jpg", + source: "good.jpg", + }); + }); + }); +}); diff --git a/tests/dm-decryptor-order.test.mjs b/tests/dm-decryptor-order.test.mjs new file mode 100644 index 00000000..86c5eafe --- /dev/null +++ b/tests/dm-decryptor-order.test.mjs @@ -0,0 +1,57 @@ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { decryptDM } from "../js/dmDecryptor.js"; + +const SENDER_HEX = "a".repeat(64); +const RECIPIENT_HEX = "b".repeat(64); + +test("decryptDM prefers higher priority decryptor even if slower", async () => { + // Mock event + const event = { + kind: 4, + content: "ciphertext", + pubkey: SENDER_HEX, + tags: [["p", RECIPIENT_HEX]] + }; + + const context = { + actorPubkey: RECIPIENT_HEX, + decryptors: [ + { + scheme: "preferred", + priority: 0, + source: "slow-preferred", + decrypt: async (remote, cipher) => { + // Normalize remote key might be done inside decryptDM + if (remote === SENDER_HEX) { + await new Promise(r => setTimeout(r, 100)); + return "preferred-plaintext"; + } + throw new Error("wrong remote: " + remote); + } + }, + { + scheme: "fallback", + priority: 10, + source: "fast-fallback", + decrypt: async (remote, cipher) => { + if (remote === SENDER_HEX) { + // Fast! + return "fallback-plaintext"; + } + throw new Error("wrong remote: " + remote); + } + } + ] + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "Result should be ok. Errors: " + JSON.stringify(result.errors)); + + // If Promise.any is used, it will likely pick "fallback-plaintext" (fast). + // We WANT "preferred-plaintext". + assert.equal(result.plaintext, "preferred-plaintext", + `Expected preferred-plaintext but got ${result.plaintext}. Current implementation likely uses Promise.any which is sensitive to race conditions.`); +}); diff --git a/tests/link-preview-settings.test.mjs b/tests/link-preview-settings.test.mjs new file mode 100644 index 00000000..b6107690 --- /dev/null +++ b/tests/link-preview-settings.test.mjs @@ -0,0 +1,199 @@ +import test, { beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// Mock Event if not present (unlikely in Node 22, but safe) +if (typeof globalThis.Event === "undefined") { + globalThis.Event = class Event { + constructor(type, options = {}) { + this.type = type; + this.bubbles = !!options.bubbles; + this.cancelable = !!options.cancelable; + } + }; +} + +// Mock CustomEvent if not present (Node environment) +if (typeof globalThis.CustomEvent === "undefined") { + globalThis.CustomEvent = class CustomEvent extends globalThis.Event { + constructor(type, options = {}) { + super(type, options); + this.detail = options.detail || null; + } + }; +} + +// Mock EventTarget methods on window if not present +// setup-localstorage.mjs provides window object but not these methods +if (typeof window.dispatchEvent === "undefined") { + const listeners = new Map(); + + window.addEventListener = (type, handler) => { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + listeners.get(type).add(handler); + }; + + window.removeEventListener = (type, handler) => { + if (listeners.has(type)) { + listeners.get(type).delete(handler); + } + }; + + window.dispatchEvent = (event) => { + const type = event.type; + if (listeners.has(type)) { + for (const handler of listeners.get(type)) { + handler(event); + } + return true; + } + return false; + }; + + // Helper to clear listeners between tests + window._clearListeners = () => listeners.clear(); +} + +// Import the module under test +import { + getLinkPreviewSettings, + setLinkPreviewAutoFetch, + allowLinkPreviewDomain, + isLinkPreviewDomainAllowed, + subscribeToLinkPreviewSettings, +} from "../js/utils/linkPreviewSettings.js"; + +beforeEach(() => { + window.localStorage.clear(); + if (window._clearListeners) window._clearListeners(); +}); + +test("getLinkPreviewSettings returns default settings when storage is empty", () => { + const settings = getLinkPreviewSettings(); + assert.equal(settings.autoFetchUnknownDomains, true); + assert.deepEqual(settings.allowedDomains, []); +}); + +test("getLinkPreviewSettings parses stored settings correctly", () => { + const stored = { + autoFetchUnknownDomains: false, + allowedDomains: ["example.com", "test.org"], + }; + window.localStorage.setItem("bitvid:linkPreviewSettings:v1", JSON.stringify(stored)); + + const settings = getLinkPreviewSettings(); + assert.equal(settings.autoFetchUnknownDomains, false); + assert.deepEqual(settings.allowedDomains, ["example.com", "test.org"]); +}); + +test("getLinkPreviewSettings handles corrupted JSON", () => { + window.localStorage.setItem("bitvid:linkPreviewSettings:v1", "{invalid-json"); + + const settings = getLinkPreviewSettings(); + assert.equal(settings.autoFetchUnknownDomains, true); // Fallback to default + assert.deepEqual(settings.allowedDomains, []); +}); + +test("getLinkPreviewSettings sanitizes invalid types in storage", () => { + const invalidTypes = { + autoFetchUnknownDomains: "invalid-string", // Should be boolean + allowedDomains: "not-an-array", // Should be array + }; + window.localStorage.setItem("bitvid:linkPreviewSettings:v1", JSON.stringify(invalidTypes)); + + const settings = getLinkPreviewSettings(); + assert.equal(settings.autoFetchUnknownDomains, true); // Default + assert.deepEqual(settings.allowedDomains, []); // Default +}); + +test("getLinkPreviewSettings handles non-object JSON", () => { + window.localStorage.setItem("bitvid:linkPreviewSettings:v1", "123"); + + const settings = getLinkPreviewSettings(); + assert.equal(settings.autoFetchUnknownDomains, true); + assert.deepEqual(settings.allowedDomains, []); +}); + +test("setLinkPreviewAutoFetch updates setting and persists", () => { + const newSettings = setLinkPreviewAutoFetch(false); + assert.equal(newSettings.autoFetchUnknownDomains, false); + + const stored = JSON.parse(window.localStorage.getItem("bitvid:linkPreviewSettings:v1")); + assert.equal(stored.autoFetchUnknownDomains, false); +}); + +test("setLinkPreviewAutoFetch emits event", () => { + return new Promise((resolve, reject) => { + subscribeToLinkPreviewSettings((e) => { + try { + assert.equal(e.detail.settings.autoFetchUnknownDomains, false); + resolve(); + } catch (err) { + reject(err); + } + }); + setLinkPreviewAutoFetch(false); + }); +}); + +test("allowLinkPreviewDomain adds domain and persists", () => { + let settings = allowLinkPreviewDomain("example.com"); + assert.deepEqual(settings.allowedDomains, ["example.com"]); + + settings = allowLinkPreviewDomain("TEST.ORG"); // Should normalize + assert.deepEqual(settings.allowedDomains, ["example.com", "test.org"]); + + const stored = JSON.parse(window.localStorage.getItem("bitvid:linkPreviewSettings:v1")); + assert.deepEqual(stored.allowedDomains, ["example.com", "test.org"]); +}); + +test("allowLinkPreviewDomain ignores duplicates", () => { + allowLinkPreviewDomain("example.com"); + const settings = allowLinkPreviewDomain("example.com"); + assert.deepEqual(settings.allowedDomains, ["example.com"]); +}); + +test("allowLinkPreviewDomain respects silent option", () => { + let eventEmitted = false; + subscribeToLinkPreviewSettings(() => { + eventEmitted = true; + }); + + allowLinkPreviewDomain("silent.com", { silent: true }); + assert.equal(eventEmitted, false); + + const settings = getLinkPreviewSettings(); + assert.ok(settings.allowedDomains.includes("silent.com")); +}); + +test("isLinkPreviewDomainAllowed checks against settings", () => { + allowLinkPreviewDomain("allowed.com"); + + assert.equal(isLinkPreviewDomainAllowed("allowed.com"), true); + assert.equal(isLinkPreviewDomainAllowed("ALLOWED.COM"), true); // Case insensitive + assert.equal(isLinkPreviewDomainAllowed("blocked.com"), false); +}); + +test("isLinkPreviewDomainAllowed checks against provided settings", () => { + const customSettings = { + allowedDomains: ["custom.com"], + }; + + assert.equal(isLinkPreviewDomainAllowed("custom.com", customSettings), true); + assert.equal(isLinkPreviewDomainAllowed("other.com", customSettings), false); +}); + +test("subscribeToLinkPreviewSettings returns unsubscribe function", () => { + let callCount = 0; + const unsubscribe = subscribeToLinkPreviewSettings(() => { + callCount++; + }); + + setLinkPreviewAutoFetch(false); + assert.equal(callCount, 1); + + unsubscribe(); + setLinkPreviewAutoFetch(true); + assert.equal(callCount, 1); // Should not increase +}); diff --git a/tests/nostr/nip71-d-tag.test.mjs b/tests/nostr/nip71-d-tag.test.mjs new file mode 100644 index 00000000..08ee87a9 --- /dev/null +++ b/tests/nostr/nip71-d-tag.test.mjs @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getDTagValueFromTags } from "../../js/nostr/nip71.js"; + +test("getDTagValueFromTags handles invalid inputs", (t) => { + assert.equal(getDTagValueFromTags(null), ""); + assert.equal(getDTagValueFromTags(undefined), ""); + assert.equal(getDTagValueFromTags("not an array"), ""); + assert.equal(getDTagValueFromTags({}), ""); +}); + +test("getDTagValueFromTags handles empty tags array", (t) => { + assert.equal(getDTagValueFromTags([]), ""); +}); + +test("getDTagValueFromTags returns empty string if no d-tag present", (t) => { + const tags = [ + ["e", "eventId"], + ["p", "pubkey"], + ["t", "hashtag"], + ]; + assert.equal(getDTagValueFromTags(tags), ""); +}); + +test("getDTagValueFromTags returns correct value for single d-tag", (t) => { + const tags = [ + ["e", "eventId"], + ["d", "my-identifier"], + ["p", "pubkey"], + ]; + assert.equal(getDTagValueFromTags(tags), "my-identifier"); +}); + +test("getDTagValueFromTags returns first value for multiple d-tags", (t) => { + const tags = [ + ["d", "first-identifier"], + ["d", "second-identifier"], + ]; + assert.equal(getDTagValueFromTags(tags), "first-identifier"); +}); + +test("getDTagValueFromTags skips malformed d-tags", (t) => { + const tags = [ + ["d"], // too short + ["d", null], // invalid value type + ["d", 123], // invalid value type + "not an array tag", + ["d", "valid-identifier"], + ]; + assert.equal(getDTagValueFromTags(tags), "valid-identifier"); +}); + +test("getDTagValueFromTags skips empty string d-tag value", (t) => { + // nip71 implementation: if (typeof tag[1] === "string" && tag[1]) { ... } + const tags = [ + ["d", ""], + ["d", "next-valid"], + ]; + assert.equal(getDTagValueFromTags(tags), "next-valid"); +}); diff --git a/tests/static-modal-accessibility.test.mjs b/tests/static-modal-accessibility.test.mjs new file mode 100644 index 00000000..04571f7f --- /dev/null +++ b/tests/static-modal-accessibility.test.mjs @@ -0,0 +1,276 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { + prepareStaticModal, + openStaticModal, + closeStaticModal, +} from "../js/ui/components/staticModalAccessibility.js"; + +function installDom(html) { + const dom = new JSDOM(html, { url: "https://example.com" }); + const { window } = dom; + globalThis.window = window; + globalThis.document = window.document; + globalThis.HTMLElement = window.HTMLElement; + globalThis.Element = window.Element; + globalThis.EventTarget = window.EventTarget; + globalThis.CustomEvent = window.CustomEvent; + globalThis.Event = window.Event; + globalThis.Node = window.Node; + globalThis.DOMParser = window.DOMParser; + Object.defineProperty(globalThis, "navigator", { + value: window.navigator, + writable: true, + configurable: true, + }); + globalThis.location = window.location; + globalThis.KeyboardEvent = window.KeyboardEvent; + globalThis.MouseEvent = window.MouseEvent; + if (typeof window.requestAnimationFrame !== "function") { + window.requestAnimationFrame = (callback) => + setTimeout(() => callback(Date.now()), 0); + } + if (typeof window.cancelAnimationFrame !== "function") { + window.cancelAnimationFrame = (id) => clearTimeout(id); + } + globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window); + globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window); + return () => { + delete globalThis.window; + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.Element; + delete globalThis.EventTarget; + delete globalThis.CustomEvent; + delete globalThis.Event; + delete globalThis.Node; + delete globalThis.DOMParser; + delete globalThis.navigator; + delete globalThis.location; + delete globalThis.KeyboardEvent; + delete globalThis.MouseEvent; + delete globalThis.requestAnimationFrame; + delete globalThis.cancelAnimationFrame; + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +test("prepareStaticModal finds element by ID and initializes data-open", async (t) => { + const cleanup = installDom(` + + + + + + + `); + + const modal = prepareStaticModal({ id: "testModal" }); + assert.ok(modal, "Modal should be found and returned"); + assert.strictEqual(modal.id, "testModal", "Returned element should match ID"); + assert.strictEqual(modal.getAttribute("data-open"), "false", "data-open should be initialized to false for hidden modal"); + + cleanup(); +}); + +test("prepareStaticModal accepts direct element reference", async (t) => { + const cleanup = installDom(` + + + + + + + `); + + const element = document.getElementById("testModal"); + const modal = prepareStaticModal({ root: element }); + assert.ok(modal, "Modal should be returned when passed directly"); + assert.strictEqual(modal, element, "Returned element should be the same instance"); + assert.strictEqual(modal.getAttribute("data-open"), "false", "data-open should be initialized"); + + cleanup(); +}); + +test("prepareStaticModal returns null for missing element", async (t) => { + const cleanup = installDom(``); + + const modal = prepareStaticModal({ id: "nonExistent" }); + assert.strictEqual(modal, null, "Should return null if element not found"); + + cleanup(); +}); + +test("prepareStaticModal initializes data-open to true if modal is visible", async (t) => { + const cleanup = installDom(` + + + +
+
+
+
+ + + `); + + const modal = prepareStaticModal({ id: "testModal" }); + assert.strictEqual(modal.getAttribute("data-open"), "true", "data-open should be true for visible modal"); + + cleanup(); +}); + +test("openStaticModal shows modal and updates state", async (t) => { + const cleanup = installDom(` + + + + + + + + `); + + const modal = document.getElementById("testModal"); + const trigger = document.getElementById("trigger"); + + // Ensure prepared first + prepareStaticModal({ root: modal }); + + const result = openStaticModal(modal, { triggerElement: trigger }); + await flushMicrotasks(); + + assert.strictEqual(result, true, "openStaticModal should return true on success"); + assert.strictEqual(modal.classList.contains("hidden"), false, "hidden class should be removed"); + assert.strictEqual(modal.getAttribute("data-open"), "true", "data-open attribute should be true"); + assert.ok(document.documentElement.classList.contains("modal-open"), "document should have modal-open class"); + + cleanup(); +}); + +test("openStaticModal returns false for invalid target", async (t) => { + const cleanup = installDom(``); + + const result = openStaticModal(null); + assert.strictEqual(result, false, "Should return false for null target"); + + cleanup(); +}); + +test("closeStaticModal hides modal and updates state", async (t) => { + const cleanup = installDom(` + + + + +
+
+
+
+ + + `); + + const modal = document.getElementById("testModal"); + // Ensure prepared + prepareStaticModal({ root: modal }); + + // Set initial state manually just to be sure + document.documentElement.classList.add("modal-open"); + + const result = closeStaticModal(modal); + await flushMicrotasks(); + + assert.strictEqual(result, true, "closeStaticModal should return true on success"); + assert.strictEqual(modal.classList.contains("hidden"), true, "hidden class should be added"); + assert.strictEqual(modal.getAttribute("data-open"), "false", "data-open attribute should be false"); + assert.strictEqual(document.documentElement.classList.contains("modal-open"), false, "modal-open class should be removed from document"); + + cleanup(); +}); + +test("closeStaticModal returns false for invalid target", async (t) => { + const cleanup = installDom(``); + + const result = closeStaticModal(null); + assert.strictEqual(result, false, "Should return false for null target"); + + cleanup(); +}); + +test("modal state synchronization handles multiple open modals", async (t) => { + const cleanup = installDom(` + + + + + + + + `); + + const modal1 = document.getElementById("modal1"); + const modal2 = document.getElementById("modal2"); + + prepareStaticModal({ root: modal1 }); + prepareStaticModal({ root: modal2 }); + + openStaticModal(modal1); + await flushMicrotasks(); + assert.ok(document.documentElement.classList.contains("modal-open"), "modal-open set after opening first modal"); + + openStaticModal(modal2); + await flushMicrotasks(); + assert.ok(document.documentElement.classList.contains("modal-open"), "modal-open remains set after opening second modal"); + + closeStaticModal(modal1); + await flushMicrotasks(); + assert.ok(document.documentElement.classList.contains("modal-open"), "modal-open remains set while one modal is still open"); + + closeStaticModal(modal2); + await flushMicrotasks(); + assert.strictEqual(document.documentElement.classList.contains("modal-open"), false, "modal-open removed after closing last modal"); + + cleanup(); +}); + +test("prepareStaticModal handles missing document gracefully", async (t) => { + // This is hard to test with JSDOM as we rely on global document, + // but we can try passing a null document explicitly if the API supports it. + // The API signature is prepareStaticModal({ id, root, document: providedDocument } = {}) + + const result = prepareStaticModal({ id: "test", document: null }); + // If no global document (which exists due to JSDOM import in other tests context or node environment), it might use global. + // But existing code: const doc = resolveDocument(providedDocument); -> returns global document if provided is null. + // So we can't easily simulate "no document" unless we don't install DOM first. + + // Let's rely on the fact that if we don't call installDom, global.document is undefined. + // But JSDOM might be polluting global scope from other tests running in the same process? + // Node test runner runs files in isolation usually, or we can assume clean slate. + + // However, since we are using JSDOM in this file, let's just test passing a dummy object as document that doesn't have getElementById + + const mockDoc = { getElementById: () => null }; + const result2 = prepareStaticModal({ id: "test", document: mockDoc }); + assert.strictEqual(result2, null); +}); diff --git a/tests/ui/components/DeleteModal.test.mjs b/tests/ui/components/DeleteModal.test.mjs new file mode 100644 index 00000000..605e4959 --- /dev/null +++ b/tests/ui/components/DeleteModal.test.mjs @@ -0,0 +1,129 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; + +// Manual DOM Mocks +class MockElement { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.textContent = ""; + this.dataset = {}; + this.classList = { + add: () => {}, + remove: () => {}, + contains: () => false, + }; + this.style = {}; + } + querySelector() { + return null; + } + querySelectorAll() { + return []; + } + appendChild(child) { + this.children.push(child); + } + replaceChildren() { + this.children = []; + } + getAttribute() { + return null; + } + setAttribute() {} + get ownerDocument() { + return globalThis.document; + } + get nodeType() { + return 1; + } // ELEMENT_NODE + addEventListener() {} + removeEventListener() {} +} + +globalThis.EventTarget = class EventTarget { + addEventListener() {} + removeEventListener() {} + dispatchEvent() {} +}; +globalThis.CustomEvent = class CustomEvent {}; +globalThis.DOMParser = class DOMParser { + parseFromString() { + return { body: { children: [] } }; + } +}; +globalThis.document = { + getElementById: () => null, + createElement: (tag) => new MockElement(tag), + body: new MockElement("BODY"), + activeElement: null, + addEventListener: () => {}, + removeEventListener: () => {}, + contains: () => false, +}; + +// Dynamic import to ensure mocks are ready before module execution +const { DeleteModal } = await import("../../../js/ui/components/DeleteModal.js"); + +describe("DeleteModal", () => { + let modal; + let mockModalElement; + let subtitle; + let metadataList; + + beforeEach(() => { + // Setup mock elements + mockModalElement = new MockElement("DIV"); + subtitle = new MockElement("DIV"); + subtitle.id = "deleteModalSubtitle"; + metadataList = new MockElement("DL"); + metadataList.id = "deleteModalMetadata"; + + // Mock querySelector for cacheElements + mockModalElement.querySelector = (selector) => { + if (selector === "#deleteModalSubtitle") return subtitle; + if (selector === "#deleteModalMetadata") return metadataList; + return new MockElement("DIV"); // Return generic element for others + }; + + modal = new DeleteModal({ container: new MockElement("DIV") }); + modal.cacheElements(mockModalElement); + }); + + it("should extract d-tag and display it in subtitle", () => { + const video = { + tags: [["d", "test-d-tag"]], + }; + modal.setVideo(video); + + // Check if subtitle text content contains the d-tag + assert.match(subtitle.textContent, /d=test-d-tag/); + }); + + it("should extract d-tag and display it in metadata", () => { + const video = { + tags: [["d", "test-d-tag"]], + }; + modal.setVideo(video); + + // Metadata list populates children + // We can check if any child (or grandchild) has the text + const hasDTag = metadataList.children.some((child) => { + // child is wrapper, wrapper has dt/dd + return child.children.some((grandchild) => + grandchild.textContent.includes("test-d-tag"), + ); + }); + + assert.ok(hasDTag, "Metadata should contain d-tag"); + }); + + it("should handle missing d-tag", () => { + const video = { + tags: [["t", "hashtag"]], + }; + modal.setVideo(video); + + assert.ok(!subtitle.textContent.includes("d=")); + }); +}); diff --git a/tests/ui/components/revertModalRenderers.test.mjs b/tests/ui/components/revertModalRenderers.test.mjs new file mode 100644 index 00000000..d9cb45a7 --- /dev/null +++ b/tests/ui/components/revertModalRenderers.test.mjs @@ -0,0 +1,208 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHeader, + createDescription, + createAudienceFlags, + createEventMetadata, + createNotePointers, +} from '../../../js/ui/components/revertModalRenderers.js'; + +// --- Mocks --- + +class MockHTMLElement { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.className = ''; + this.textContent = ''; + this.children = []; + this.attributes = {}; + this.dataset = {}; + } + + appendChild(child) { + if (typeof child === 'string') { + // Text node simulation + this.children.push({ nodeType: 3, textContent: child }); + } else { + this.children.push(child); + } + return child; + } + + setAttribute(name, value) { + this.attributes[name] = value; + } + + hasChildNodes() { + return this.children.length > 0; + } +} + +const documentMock = { + createElement: (tagName) => new MockHTMLElement(tagName), + createDocumentFragment: () => { + const fragment = { + nodeType: 11, + children: [], + appendChild(child) { + this.children.push(child); + } + }; + return fragment; + }, + createTextNode: (text) => ({ nodeType: 3, textContent: text }), +}; + +// Global document mock +globalThis.document = documentMock; + +const utilsMock = { + formatAbsoluteTimestamp: (ts) => `ABS:${ts}`, + formatTimeAgo: (ts) => `AGO:${ts}`, + truncateMiddle: (str) => `TRUNC:${str}`, + createPlaceholder: (text) => { + const s = new MockHTMLElement('span'); + s.textContent = text || 'PLACEHOLDER'; + return s; + }, + createLinkMarkup: (url) => { + const a = new MockHTMLElement('a'); + a.attributes.href = url; + a.textContent = url; + return a; + }, + createListEmpty: (text) => { + const p = new MockHTMLElement('p'); + p.textContent = text; + return p; + }, + formatDurationSeconds: (s) => `DUR:${s}`, + fallbackThumbnailSrc: 'fallback.jpg', +}; + +// --- Tests --- + +test('createHeader renders correctly', () => { + const version = { + created_at: 1234567890, + title: 'Test Title', + thumbnail: 'thumb.jpg', + url: 'https://example.com', + magnet: 'magnet:?xt=urn:btih:123', + isNsfw: true, + version: 2 + }; + const nip71Metadata = {}; + + const header = createHeader(version, nip71Metadata, utilsMock); + + assert.equal(header.tagName, 'DIV'); + assert.ok(header.className.includes('space-y-6')); + + // Find title + const h3 = findElement(header, 'H3'); + assert.equal(h3.textContent, 'Test Title'); + + // Find timestamp + // We expect "ABS:1234567890 (AGO:1234567890)" + const p = findElement(header, 'P', (el) => el.textContent.includes('ABS:')); + assert.ok(p); + + // Find NSFW pill + const nsfwPill = findElement(header, 'SPAN', (el) => el.textContent === 'Marked NSFW'); + assert.ok(nsfwPill); + + // Find Schema pill + const schemaPill = findElement(header, 'SPAN', (el) => el.textContent === 'Schema v2'); + assert.ok(schemaPill); +}); + +test('createDescription renders description', () => { + const desc = "My Description"; + const section = createDescription(desc, utilsMock); + + assert.equal(section.tagName, 'SECTION'); + // Check title (from createSection mock) + const h4 = findElement(section, 'H4'); + assert.equal(h4.textContent, 'Description'); + + const p = findElement(section, 'P', (el) => el.textContent === desc); + assert.ok(p); +}); + +test('createAudienceFlags renders flags', () => { + const version = { isNsfw: true, isForKids: false }; + const section = createAudienceFlags(version, utilsMock); + + const nsfw = findElement(section, 'SPAN', (el) => el.textContent === 'Yes — marked NSFW'); + assert.ok(nsfw); + + const kids = findElement(section, 'SPAN', (el) => el.textContent === 'No — not marked for kids'); + assert.ok(kids); +}); + +test('createEventMetadata renders metadata', () => { + const metadata = { + kind: 21, + summary: 'Summary Text', + contentWarning: 'Warning', + publishedAtSeconds: 1000, + durationSeconds: 60, + alt: 'Alt Text' + }; + + const section = createEventMetadata(metadata, utilsMock); + + // Kind + const kindCode = findElement(section, 'CODE', (el) => el.textContent === 'kind 21'); + assert.ok(kindCode); + + // Summary + const summary = findElement(section, 'P', (el) => el.textContent === 'Summary Text'); + assert.ok(summary); + + // Duration + const duration = findElement(section, 'SPAN', (el) => el.textContent === 'DUR:60'); + assert.ok(duration); +}); + +test('createNotePointers renders pointers', () => { + const version = { + mode: 'live', + videoRootId: 'root123', + id: 'event123' + }; + const dTag = 'd-tag-val'; + + const section = createNotePointers(version, dTag, utilsMock); + + const dTagCode = findElement(section, 'CODE', (el) => el.textContent === 'd-tag-val'); + assert.ok(dTagCode); + + const rootCode = findElement(section, 'CODE', (el) => el.textContent === 'TRUNC:root123'); + assert.ok(rootCode); +}); + + +// Helper to recursively find element +function findElement(root, tagName, predicate) { + if (!root || !root.children) return null; + + if (root.tagName === tagName) { + if (!predicate || predicate(root)) return root; + } + + for (const child of root.children) { + if (child.nodeType === 3) continue; // Skip text nodes + if (child.nodeType === 11) { // Fragment + const found = findElement(child, tagName, predicate); + if (found) return found; + continue; + } + + const found = findElement(child, tagName, predicate); + if (found) return found; + } + return null; +} diff --git a/tests/utils/linkPreviewSettings.test.mjs b/tests/utils/linkPreviewSettings.test.mjs index 252de889..f5b7bdd6 100644 --- a/tests/utils/linkPreviewSettings.test.mjs +++ b/tests/utils/linkPreviewSettings.test.mjs @@ -1,10 +1,44 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import "../test-setup.mjs"; -// Ensure JSDOM Event constructors are available globally so the module uses them -globalThis.CustomEvent = window.CustomEvent; -globalThis.Event = window.Event; +// Note: We avoid importing "../test-setup.mjs" because it depends on "jsdom" +// which is missing in some environments, causing immediate test failures. +// Instead, we provide minimal polyfills required for these tests. + +// Polyfill window if missing (e.g. running test file directly without setup) +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +// Polyfill EventTarget methods on window if missing +if (!globalThis.window.dispatchEvent) { + const et = new EventTarget(); + globalThis.window.dispatchEvent = et.dispatchEvent.bind(et); + globalThis.window.addEventListener = et.addEventListener.bind(et); + globalThis.window.removeEventListener = et.removeEventListener.bind(et); +} + +// Ensure CustomEvent is available (Node 22 has it natively) +if (typeof globalThis.CustomEvent === "undefined") { + globalThis.CustomEvent = class CustomEvent extends Event { + constructor(message, data) { + super(message, data); + this.detail = data.detail; + } + }; +} + +// Ensure localStorage is available (Node doesn't have it natively) +// This polyfill mirrors what setup-localstorage.mjs does, ensuring tests run self-contained +if (typeof globalThis.window.localStorage === "undefined") { + const store = new Map(); + globalThis.window.localStorage = { + getItem: (key) => store.get(key) || null, + setItem: (key, value) => store.set(String(key), String(value)), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + }; +} import { getLinkPreviewSettings, @@ -63,6 +97,22 @@ describe("linkPreviewSettings", () => { assert.equal(settings.autoFetchUnknownDomains, true); assert.deepEqual(settings.allowedDomains, ["example.com"]); }); + + it("handles null/undefined gracefully (simulating storage issues)", () => { + const originalGetItem = window.localStorage.getItem; + // Simulate storage.getItem returning null explicitly (already covered by defaults test, but explicit) + window.localStorage.getItem = () => null; + try { + const settings = getLinkPreviewSettings(); + assert.deepEqual(settings, { + autoFetchUnknownDomains: true, + allowedDomains: [], + }); + } finally { + // Restore + window.localStorage.getItem = originalGetItem; + } + }); }); describe("setLinkPreviewAutoFetch", () => { @@ -191,5 +241,12 @@ describe("linkPreviewSettings", () => { setLinkPreviewAutoFetch(true); assert.equal(callCount, 1); }); + + it("handles non-function callback gracefully", () => { + // Should not throw + const unsubscribe = subscribeToLinkPreviewSettings(null); + setLinkPreviewAutoFetch(false); + unsubscribe(); + }); }); }); diff --git a/tests/video-menu-renderers.test.mjs b/tests/video-menu-renderers.test.mjs new file mode 100644 index 00000000..972a77c5 --- /dev/null +++ b/tests/video-menu-renderers.test.mjs @@ -0,0 +1,253 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { + createVideoMoreMenuPanel, + createVideoShareMenuPanel, + createChannelProfileMenuPanel, + createVideoSettingsMenuPanel, +} from '../js/ui/components/videoMenuRenderers.js'; + +// Setup JSDOM environment for tests +const dom = new JSDOM(''); +const { window } = dom; +const { document } = window; + +// Helper to check if an element has a specific class +function hasClass(element, className) { + return element.classList.contains(className); +} + +// Helper to find a button by action dataset +function findButtonByAction(container, action) { + return container.querySelector(`button[data-action="${action}"]`); +} + +test('createVideoMoreMenuPanel - basic structure', () => { + const panel = createVideoMoreMenuPanel({ + document, + video: { id: 'test-id', pubkey: 'test-pubkey' }, + context: 'test-context', + }); + + assert.ok(panel, 'Panel should be created'); + assert.equal(panel.tagName, 'DIV'); + assert.equal(panel.dataset.menu, 'video-more'); + assert.ok(hasClass(panel, 'popover__panel')); + + const list = panel.querySelector('.menu'); + assert.ok(list, 'Menu list should exist'); + + // Check standard actions + const openChannelBtn = findButtonByAction(panel, 'open-channel'); + assert.ok(openChannelBtn, 'Open channel button should exist'); + assert.equal(openChannelBtn.dataset.author, 'test-pubkey'); + + const copyLinkBtn = findButtonByAction(panel, 'copy-link'); + assert.ok(copyLinkBtn, 'Copy link button should exist'); + assert.equal(copyLinkBtn.dataset.eventId, 'test-id'); + + // Check moderation actions that are always present + const muteBtn = findButtonByAction(panel, 'mute-author'); + assert.ok(muteBtn, 'Mute author button should exist'); + + const unmuteBtn = findButtonByAction(panel, 'unmute-author'); + assert.ok(unmuteBtn, 'Unmute author button should exist'); + + const blockBtn = findButtonByAction(panel, 'block-author'); + assert.ok(blockBtn, 'Block author button should exist'); + assert.equal(blockBtn.dataset.variant, 'critical'); + + const reportBtn = findButtonByAction(panel, 'report'); + assert.ok(reportBtn, 'Report button should exist'); + + const detailsBtn = findButtonByAction(panel, 'event-details'); + assert.ok(detailsBtn, 'Event details button should exist'); +}); + +test('createVideoMoreMenuPanel - null document returns null', () => { + const panel = createVideoMoreMenuPanel({ document: null }); + assert.equal(panel, null); +}); + +test('createVideoMoreMenuPanel - boost on nostr section', () => { + const panel = createVideoMoreMenuPanel({ + document, + video: { id: 'test-id', pubkey: 'test-pubkey', kind: 30009 }, + }); + + const heading = panel.querySelector('.menu__heading'); + assert.ok(heading, 'Heading should exist'); + assert.match(heading.textContent, /Boost on Nostr/); + + const repostBtn = findButtonByAction(panel, 'repost-event'); + assert.ok(repostBtn, 'Repost button should exist'); + assert.equal(repostBtn.dataset.eventId, 'test-id'); + assert.equal(repostBtn.dataset.kind, '30009'); +}); + +test('createVideoMoreMenuPanel - playbackUrl adds mirror action', () => { + const panel = createVideoMoreMenuPanel({ + document, + video: { id: 'test-id', pubkey: 'test-pubkey', isPrivate: false }, + playbackUrl: 'https://example.com/video.mp4', + }); + + const mirrorBtn = findButtonByAction(panel, 'mirror-video'); + assert.ok(mirrorBtn, 'Mirror button should exist'); + assert.equal(mirrorBtn.dataset.url, 'https://example.com/video.mp4'); + assert.equal(mirrorBtn.dataset.isPrivate, 'false'); +}); + +test('createVideoMoreMenuPanel - pointerInfo adds remove history action', () => { + const pointerInfo = { + pointer: ['e', 'event-id', 'relay-url'], + key: 'test-key', + }; + + const panel = createVideoMoreMenuPanel({ + document, + video: { id: 'test-id', pubkey: 'test-pubkey' }, + pointerInfo, + }); + + const removeBtn = findButtonByAction(panel, 'remove-history'); + assert.ok(removeBtn, 'Remove from history button should exist'); + assert.equal(removeBtn.dataset.pointerKey, 'test-key'); + assert.equal(removeBtn.dataset.pointerValue, 'event-id'); +}); + +test('createVideoMoreMenuPanel - canManageBlacklist adds blacklist action', () => { + const panel = createVideoMoreMenuPanel({ + document, + video: { id: 'test-id', pubkey: 'test-pubkey' }, + canManageBlacklist: true, + }); + + const blacklistBtn = findButtonByAction(panel, 'blacklist-author'); + assert.ok(blacklistBtn, 'Blacklist button should exist'); + assert.equal(blacklistBtn.dataset.variant, 'critical'); +}); + +test('createVideoShareMenuPanel - share actions state', () => { + const panel = createVideoShareMenuPanel({ + document, + video: { id: 'test-id' }, + isLoggedIn: true, + hasSigner: true, + hasMagnet: true, + hasCdn: true, + }); + + assert.equal(panel.dataset.menu, 'video-share'); + + const copyUrlBtn = findButtonByAction(panel, 'share'); + assert.ok(copyUrlBtn, 'Copy URL button should exist'); + + const shareNostrBtn = findButtonByAction(panel, 'share-nostr'); + assert.ok(shareNostrBtn, 'Share on Nostr button should exist'); + assert.equal(shareNostrBtn.disabled, false); + + const copyMagnetBtn = findButtonByAction(panel, 'copy-magnet'); + assert.ok(copyMagnetBtn, 'Copy Magnet button should exist'); + assert.equal(copyMagnetBtn.disabled, false); + + const copyCdnBtn = findButtonByAction(panel, 'copy-cdn'); + assert.ok(copyCdnBtn, 'Copy CDN button should exist'); + assert.equal(copyCdnBtn.disabled, false); +}); + +test('createVideoShareMenuPanel - share actions disabled state', () => { + const panel = createVideoShareMenuPanel({ + document, + video: { id: 'test-id' }, + isLoggedIn: false, + hasSigner: false, + hasMagnet: false, + hasCdn: false, + }); + + const shareNostrBtn = findButtonByAction(panel, 'share-nostr'); + assert.equal(shareNostrBtn.disabled, true); + assert.ok(hasClass(shareNostrBtn, 'cursor-not-allowed')); + + const copyMagnetBtn = findButtonByAction(panel, 'copy-magnet'); + assert.equal(copyMagnetBtn.disabled, true); + + const copyCdnBtn = findButtonByAction(panel, 'copy-cdn'); + assert.equal(copyCdnBtn.disabled, true); +}); + +test('createChannelProfileMenuPanel - structure and actions', () => { + const panel = createChannelProfileMenuPanel({ + document, + context: 'custom-context', + }); + + assert.equal(panel.dataset.menu, 'channel-profile'); + assert.equal(panel.dataset.menuContext, 'custom-context'); + + const actions = [ + 'copy-npub', + 'mute-author', + 'unmute-author', + 'blacklist-author', + 'block-author', + 'report', + ]; + + actions.forEach(action => { + const btn = findButtonByAction(panel, action); + assert.ok(btn, `Button for ${action} should exist`); + assert.equal(btn.dataset.context, 'custom-context'); + }); +}); + +test('createVideoSettingsMenuPanel - edit and conditional actions', () => { + const capabilities = { + canEdit: true, + canRevert: true, + canDelete: true, + }; + + const panel = createVideoSettingsMenuPanel({ + document, + video: { id: 'test-id' }, + index: 5, + capabilities, + }); + + assert.equal(panel.dataset.menu, 'video-settings'); + + const editBtn = findButtonByAction(panel, 'edit'); + assert.ok(editBtn, 'Edit button should exist'); + assert.equal(editBtn.dataset.index, '5'); + + const revertBtn = findButtonByAction(panel, 'revert'); + assert.ok(revertBtn, 'Revert button should exist'); + assert.equal(revertBtn.dataset.variant, 'critical'); + + const deleteBtn = findButtonByAction(panel, 'delete'); + assert.ok(deleteBtn, 'Delete button should exist'); + assert.equal(deleteBtn.dataset.variant, 'critical'); +}); + +test('createVideoSettingsMenuPanel - missing capabilities', () => { + const capabilities = { + canEdit: true, + canRevert: false, + canDelete: false, + }; + + const panel = createVideoSettingsMenuPanel({ + document, + video: { id: 'test-id' }, + capabilities, + }); + + const revertBtn = findButtonByAction(panel, 'revert'); + assert.equal(revertBtn, null, 'Revert button should not exist'); + + const deleteBtn = findButtonByAction(panel, 'delete'); + assert.equal(deleteBtn, null, 'Delete button should not exist'); +}); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 88b14b15..530d6cc4 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1591,9 +1591,7 @@ async function testWatchHistoryServiceIntegration() { reason: "integration", }); assert.ok(snapshotResult.ok, "snapshot should publish queued pointers"); - // snapshotResult now contains { items: flatItems, results: [monthResults...] } - // or items might be flatItems? - const snapshotItems = snapshotResult.items || (snapshotResult.results || []).flatMap(r => r.items || []); + const snapshotItems = snapshotResult.items || []; // Check if item exists first const snapshotItem = snapshotItems.find( diff --git a/torch/package-lock.json b/torch/package-lock.json index b7ddc49c..f41c37be 100644 --- a/torch/package-lock.json +++ b/torch/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "nostr-tools": "2.23.1", + "nostr-tools": "^2.23.1", "ws": "8.19.0" }, "bin": { diff --git a/torch/package.json b/torch/package.json index 13272ae5..1c83a2bf 100644 --- a/torch/package.json +++ b/torch/package.json @@ -78,7 +78,7 @@ "torch:remove": "node bin/torch-lock.mjs remove" }, "dependencies": { - "nostr-tools": "2.23.1", + "nostr-tools": "^2.23.1", "ws": "8.19.0" }, "devDependencies": { diff --git a/torch/task-logs/daily/2026-02-26T19-12-43Z__scheduler-update-agent__completed.md b/torch/task-logs/daily/2026-02-26T19-12-43Z__scheduler-update-agent__completed.md new file mode 100644 index 00000000..06dca5a9 --- /dev/null +++ b/torch/task-logs/daily/2026-02-26T19-12-43Z__scheduler-update-agent__completed.md @@ -0,0 +1,22 @@ +--- +agent: scheduler-update-agent +cadence: daily +status: completed +platform: linux +timestamp: 2026-02-26T19-12-43Z +--- + +# Scheduler Update Agent - Completed + +## Summary +The `scheduler-update-agent` executed successfully. It verified that the canonical roster source (`torch/src/prompts/roster.json`) and the scheduler markdown files (`daily-scheduler.md`, `weekly-scheduler.md`) are perfectly aligned with the actual prompt files on disk. + +## Actions +- Verified 23 daily agent prompts. +- Verified 21 weekly agent prompts. +- Confirmed zero discrepancies between rosters and file system. +- No changes were required. + +## Verification +- Roster counts match file counts. +- `npm run lint` passed. diff --git a/torch/task-logs/daily/2026-02-26T19-35-26Z__scheduler-update-agent__completed.md b/torch/task-logs/daily/2026-02-26T19-35-26Z__scheduler-update-agent__completed.md new file mode 100644 index 00000000..b5a5fa26 --- /dev/null +++ b/torch/task-logs/daily/2026-02-26T19-35-26Z__scheduler-update-agent__completed.md @@ -0,0 +1,26 @@ +# Scheduler Update Agent - Completion Report + +**Status:** Success +**Timestamp:** 2026-02-26T19:35:26Z +**Agent:** scheduler-update-agent + +## Summary + +The agent roster and scheduler markdown files were verified against the prompt files on disk. No discrepancies were found, so no changes were applied. + +## Verification + +- **Daily Prompts:** 23 files found. +- **Weekly Prompts:** 21 files found. +- **Roster JSON:** Matches disk inventory exactly. +- **Scheduler Markdowns:** Tables match roster JSON exactly. +- **Validation Script:** `torch/scripts/validate-scheduler-roster.mjs` passed successfully. + +## Changes + +- None required. + +## Learnings + +- The `scheduler-update-agent` should always verify the existence of `nostr-tools` in `torch/node_modules` before attempting lock operations. +- The `validate-scheduler-roster.mjs` script is the authoritative source for verifying roster sync. diff --git a/verification/channel_profile.png b/verification/channel_profile.png new file mode 100644 index 00000000..ce2ade6c Binary files /dev/null and b/verification/channel_profile.png differ diff --git a/views/channel-profile.html b/views/channel-profile.html index f4b55d4b..32939924 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -135,6 +135,7 @@ id="channelWebsite" href="#" target="_blank" + rel="noopener noreferrer" class="mb-2 block text-info-strong hover:underline" >Website