mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into refactor-revert-modal-dtag-18208308647871133769
This commit is contained in:
17 files changed
+3348
-2585
No files matched your search
+51
-1
@@ -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);
|
||||
|
||||
@@ -130,6 +130,7 @@ export class VideoEventBuffer {
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedVideos.push(video);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
+31
-414
@@ -1,6 +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,
|
||||
@@ -8,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
|
||||
@@ -608,420 +615,39 @@ export class RevertModal {
|
||||
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");
|
||||
@@ -1039,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);
|
||||
@@ -1490,4 +1108,3 @@ export class RevertModal {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2049
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-9
@@ -336,7 +336,9 @@ if (hasDocument) {
|
||||
var nCount = oQRCode.getModuleCount();
|
||||
var nWidth = Math.floor(_htOption.width / nCount);
|
||||
var nHeight = Math.floor(_htOption.height / nCount);
|
||||
var aHTML = ['<table class="qr-code__table">'];
|
||||
|
||||
var table = document.createElement("table");
|
||||
table.className = "qr-code__table";
|
||||
|
||||
updateQrCodeContainerStyles(_el, {
|
||||
colorDark: _htOption.colorDark,
|
||||
@@ -347,23 +349,27 @@ if (hasDocument) {
|
||||
});
|
||||
|
||||
for (var row = 0; row < nCount; row++) {
|
||||
aHTML.push('<tr>');
|
||||
var tr = document.createElement("tr");
|
||||
|
||||
for (var col = 0; col < nCount; col++) {
|
||||
var cellClass = oQRCode.isDark(row, col)
|
||||
var td = document.createElement("td");
|
||||
td.className = oQRCode.isDark(row, col)
|
||||
? "qr-code__cell qr-code__cell--dark"
|
||||
: "qr-code__cell";
|
||||
aHTML.push('<td class="' + cellClass + '"></td>');
|
||||
tr.appendChild(td);
|
||||
}
|
||||
|
||||
aHTML.push('</tr>');
|
||||
table.appendChild(tr);
|
||||
}
|
||||
|
||||
aHTML.push('</table>');
|
||||
_el.innerHTML = aHTML.join('');
|
||||
// Clear previous content
|
||||
while (_el.firstChild) {
|
||||
_el.removeChild(_el.firstChild);
|
||||
}
|
||||
_el.appendChild(table);
|
||||
|
||||
// Fix the margin values as real size.
|
||||
var elTable = _el.childNodes[0];
|
||||
var elTable = table;
|
||||
var nLeftMarginTable = (_htOption.width - (nCount * nWidth)) / 2;
|
||||
var nTopMarginTable = (_htOption.height - (nCount * nHeight)) / 2;
|
||||
|
||||
@@ -378,7 +384,9 @@ if (hasDocument) {
|
||||
* Clear the QRCode
|
||||
*/
|
||||
Drawing.prototype.clear = function () {
|
||||
this._el.innerHTML = '';
|
||||
while (this._el.firstChild) {
|
||||
this._el.removeChild(this._el.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
return Drawing;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
normalizeCommentAvatarKey,
|
||||
resolveCommentAvatarAsset,
|
||||
registerCommentAvatarFailure,
|
||||
} from "../js/ui/components/video-modal/utils/commentAvatar.js";
|
||||
|
||||
test("normalizeCommentAvatarKey returns empty string for non-string inputs", () => {
|
||||
assert.equal(normalizeCommentAvatarKey(null), "");
|
||||
assert.equal(normalizeCommentAvatarKey(undefined), "");
|
||||
assert.equal(normalizeCommentAvatarKey(123), "");
|
||||
assert.equal(normalizeCommentAvatarKey({}), "");
|
||||
assert.equal(normalizeCommentAvatarKey([]), "");
|
||||
});
|
||||
|
||||
test("normalizeCommentAvatarKey returns empty string for empty or whitespace-only strings", () => {
|
||||
assert.equal(normalizeCommentAvatarKey(""), "");
|
||||
assert.equal(normalizeCommentAvatarKey(" "), "");
|
||||
assert.equal(normalizeCommentAvatarKey("\t\n"), "");
|
||||
});
|
||||
|
||||
test("normalizeCommentAvatarKey returns normalized lowercased string", () => {
|
||||
assert.equal(normalizeCommentAvatarKey("FooBar"), "foobar");
|
||||
assert.equal(normalizeCommentAvatarKey(" BazQux "), "bazqux");
|
||||
assert.equal(normalizeCommentAvatarKey("ABC"), "abc");
|
||||
});
|
||||
|
||||
test("resolveCommentAvatarAsset returns default avatar if source is in failures", () => {
|
||||
const failures = new Set(["https://example.com/failed.jpg"]);
|
||||
const result = resolveCommentAvatarAsset({
|
||||
cache: new Map(),
|
||||
failures,
|
||||
defaultAvatar: "default.png",
|
||||
pubkey: "somekey",
|
||||
sanitizedPicture: "https://example.com/failed.jpg",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { url: "default.png", source: "" });
|
||||
});
|
||||
|
||||
test("resolveCommentAvatarAsset returns cached asset if pubkey matches and source matches", () => {
|
||||
const cache = new Map();
|
||||
cache.set("somekey", { url: "cached.jpg", source: "https://example.com/pic.jpg" });
|
||||
|
||||
const result = resolveCommentAvatarAsset({
|
||||
cache,
|
||||
failures: new Set(),
|
||||
defaultAvatar: "default.png",
|
||||
pubkey: "somekey",
|
||||
sanitizedPicture: "https://example.com/pic.jpg",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { url: "cached.jpg", source: "https://example.com/pic.jpg" });
|
||||
});
|
||||
|
||||
test("resolveCommentAvatarAsset returns cached asset if source is empty", () => {
|
||||
const cache = new Map();
|
||||
cache.set("somekey", { url: "cached.jpg", source: "https://example.com/pic.jpg" });
|
||||
|
||||
const result = resolveCommentAvatarAsset({
|
||||
cache,
|
||||
failures: new Set(),
|
||||
defaultAvatar: "default.png",
|
||||
pubkey: "somekey",
|
||||
sanitizedPicture: "",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { url: "cached.jpg", source: "https://example.com/pic.jpg" });
|
||||
});
|
||||
|
||||
test("resolveCommentAvatarAsset updates cache and returns new asset if not cached", () => {
|
||||
const cache = new Map();
|
||||
const result = resolveCommentAvatarAsset({
|
||||
cache,
|
||||
failures: new Set(),
|
||||
defaultAvatar: "default.png",
|
||||
pubkey: "newkey",
|
||||
sanitizedPicture: "https://example.com/new.jpg",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { url: "https://example.com/new.jpg", source: "https://example.com/new.jpg" });
|
||||
assert.ok(cache.has("newkey"));
|
||||
assert.deepEqual(cache.get("newkey"), { url: "https://example.com/new.jpg", source: "https://example.com/new.jpg" });
|
||||
});
|
||||
|
||||
test("resolveCommentAvatarAsset updates cache when source changes for existing key", () => {
|
||||
const cache = new Map();
|
||||
cache.set("somekey", { url: "old.jpg", source: "https://example.com/old.jpg" });
|
||||
|
||||
const result = resolveCommentAvatarAsset({
|
||||
cache,
|
||||
failures: new Set(),
|
||||
defaultAvatar: "default.png",
|
||||
pubkey: "somekey",
|
||||
sanitizedPicture: "https://example.com/new.jpg",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { url: "https://example.com/new.jpg", source: "https://example.com/new.jpg" });
|
||||
assert.deepEqual(cache.get("somekey"), { url: "https://example.com/new.jpg", source: "https://example.com/new.jpg" });
|
||||
});
|
||||
|
||||
test("resolveCommentAvatarAsset uses default avatar when no source provided", () => {
|
||||
const cache = new Map();
|
||||
const result = resolveCommentAvatarAsset({
|
||||
cache,
|
||||
failures: new Set(),
|
||||
defaultAvatar: "default.png",
|
||||
pubkey: "somekey",
|
||||
sanitizedPicture: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { url: "default.png", source: "" });
|
||||
assert.deepEqual(cache.get("somekey"), { url: "default.png", source: "" });
|
||||
});
|
||||
|
||||
test("registerCommentAvatarFailure ignores invalid or default source urls", () => {
|
||||
const failures = new Set();
|
||||
const cache = new Map();
|
||||
const defaultAvatar = "default.png";
|
||||
|
||||
registerCommentAvatarFailure({ cache, failures, defaultAvatar, sourceUrl: null });
|
||||
assert.equal(failures.size, 0);
|
||||
|
||||
registerCommentAvatarFailure({ cache, failures, defaultAvatar, sourceUrl: "" });
|
||||
assert.equal(failures.size, 0);
|
||||
|
||||
registerCommentAvatarFailure({ cache, failures, defaultAvatar, sourceUrl: "default.png" });
|
||||
assert.equal(failures.size, 0);
|
||||
});
|
||||
|
||||
test("registerCommentAvatarFailure adds source to failures", () => {
|
||||
const failures = new Set();
|
||||
const cache = new Map();
|
||||
const defaultAvatar = "default.png";
|
||||
const failedUrl = "https://example.com/fail.jpg";
|
||||
|
||||
registerCommentAvatarFailure({ cache, failures, defaultAvatar, sourceUrl: failedUrl });
|
||||
assert.ok(failures.has(failedUrl));
|
||||
});
|
||||
|
||||
test("registerCommentAvatarFailure updates cache entries matching the failed source", () => {
|
||||
const failures = new Set();
|
||||
const cache = new Map();
|
||||
const defaultAvatar = "default.png";
|
||||
const failedUrl = "https://example.com/fail.jpg";
|
||||
|
||||
cache.set("user1", { url: failedUrl, source: failedUrl });
|
||||
cache.set("user2", { url: "other.jpg", source: "other.jpg" });
|
||||
|
||||
registerCommentAvatarFailure({ cache, failures, defaultAvatar, sourceUrl: failedUrl });
|
||||
|
||||
assert.ok(failures.has(failedUrl));
|
||||
assert.deepEqual(cache.get("user1"), { url: defaultAvatar, source: "" });
|
||||
assert.deepEqual(cache.get("user2"), { url: "other.jpg", source: "other.jpg" });
|
||||
});
|
||||
@@ -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.`);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
describe('QRCode Table Fallback', () => {
|
||||
test('should generate table when Canvas is not supported', async () => {
|
||||
const dom = new JSDOM(`<!DOCTYPE html><body><div id="qrcode"></div></body>`);
|
||||
|
||||
// Set up global environment for the library
|
||||
global.document = dom.window.document;
|
||||
global.window = dom.window;
|
||||
global.HTMLStyleElement = dom.window.HTMLStyleElement;
|
||||
global.HTMLElement = dom.window.HTMLElement;
|
||||
global.WeakMap = dom.window.WeakMap || global.WeakMap;
|
||||
global.Map = dom.window.Map || global.Map;
|
||||
|
||||
// Ensure CanvasRenderingContext2D is NOT defined to trigger table fallback
|
||||
delete global.CanvasRenderingContext2D;
|
||||
delete global.window.CanvasRenderingContext2D;
|
||||
|
||||
// Ensure we are not in SVG mode (JSDOM default is HTML)
|
||||
|
||||
// Import the module dynamically
|
||||
const { createQrCode } = await import('../js/utils/qrcode.js');
|
||||
|
||||
const el = global.document.getElementById('qrcode');
|
||||
|
||||
// Create QR code
|
||||
createQrCode(el, { text: "test", width: 100, height: 100 });
|
||||
|
||||
// Verify table is used
|
||||
const table = el.querySelector('table');
|
||||
assert.ok(table, "Table element should be created");
|
||||
assert.ok(table.classList.contains('qr-code__table'), "Table should have correct class");
|
||||
|
||||
// Check for table structure
|
||||
const rows = table.querySelectorAll('tr');
|
||||
assert.ok(rows.length > 0, "Should have rows");
|
||||
|
||||
const cells = table.querySelectorAll('td');
|
||||
assert.ok(cells.length > 0, "Should have cells");
|
||||
});
|
||||
});
|
||||
@@ -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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="testModal" class="bv-modal hidden">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="testModal" class="bv-modal hidden">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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(`<!DOCTYPE html><html><body></body></html>`);
|
||||
|
||||
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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="testModal" class="bv-modal">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<button id="trigger">Open</button>
|
||||
<div id="testModal" class="bv-modal hidden" data-open="false">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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(`<!DOCTYPE html><html><body></body></html>`);
|
||||
|
||||
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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body class="modal-open">
|
||||
<button id="trigger">Open</button>
|
||||
<div id="testModal" class="bv-modal" data-open="true">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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(`<!DOCTYPE html><html><body></body></html>`);
|
||||
|
||||
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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="modal1" class="bv-modal hidden">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
<div id="modal2" class="bv-modal hidden">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="bv-modal__panel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('<!DOCTYPE html><html><body></body></html>');
|
||||
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');
|
||||
});
|
||||
Reference in new issue
Block a user