mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Move comment-related logic and state from VideoModal.js to CommentsController.js. Split CommentsController into CommentsController (orchestration), CommentsListRenderer (DOM management), and CommentNodeFactory (element creation) to maintain modularity and adhere to file size limits. Update VideoModal to initialize and destroy CommentsController correctly. Fix minor lint issues. Co-authored-by: PR0M3TH3AN <[email protected]>
653 lines
18 KiB
JavaScript
653 lines
18 KiB
JavaScript
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");
|
|
}
|
|
}
|
|
}
|
|
}
|