import { normalizeDesignSystemContext } from "../../designSystem.js"; import { formatShortNpub } from "../../utils/formatters.js"; import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; export class SimilarContentCard { constructor({ document: doc, video, index = 0, shareUrl = "#", pointerInfo = null, timeAgo = "", postedAt = null, identity = null, nsfwContext = null, designSystem = null, thumbnailCache = null, fallbackThumbnailSrc = "", } = {}) { if (!doc) { throw new Error("SimilarContentCard requires a document reference."); } if (!video || typeof video !== "object" || !video.id || !video.title) { throw new Error("SimilarContentCard requires a video with id and title."); } this.document = doc; this.window = doc.defaultView || globalThis; this.video = video; this.index = Number.isFinite(index) ? Number(index) : 0; this.shareUrl = typeof shareUrl === "string" && shareUrl.trim() ? shareUrl.trim() : "#"; this.pointerInfo = this.normalizePointerInfo(pointerInfo); this.timeAgo = typeof timeAgo === "string" ? timeAgo : ""; this.postedAt = this.normalizeTimestamp(postedAt); this.identity = this.normalizeIdentity(identity); this.nsfwContext = { isNsfw: Boolean(nsfwContext?.isNsfw), allowNsfw: nsfwContext?.allowNsfw !== false, viewerIsOwner: nsfwContext?.viewerIsOwner === true, }; this.shouldMaskNsfwForOwner = this.nsfwContext.isNsfw && !this.nsfwContext.allowNsfw && this.nsfwContext.viewerIsOwner; this.designSystem = normalizeDesignSystemContext(designSystem); this.thumbnailCache = thumbnailCache instanceof Map ? thumbnailCache : null; this.fallbackThumbnailSrc = typeof fallbackThumbnailSrc === "string" ? fallbackThumbnailSrc.trim() : ""; this.callbacks = { onPlay: null }; this.root = null; this.mediaLinkEl = null; this.thumbnailEl = null; this.contentEl = null; this.titleEl = null; this.avatarEl = null; this.authorNameEl = null; this.authorNpubEl = null; this.timeEl = null; this.viewCountEl = null; this.build(); } set onPlay(fn) { this.callbacks.onPlay = typeof fn === "function" ? fn : null; } getRoot() { return this.root; } getViewCountElement() { return this.viewCountEl; } closeMoreMenu() {} closeSettingsMenu() {} updateIdentity(nextIdentity = {}) { this.identity = this.normalizeIdentity(nextIdentity, this.identity); this.applyIdentityToElements(); } normalizePointerInfo(info) { if (!info || typeof info !== "object") { return null; } const key = typeof info.key === "string" && info.key.trim() ? info.key.trim() : ""; const pointer = Array.isArray(info.pointer) ? info.pointer.slice(0, 3) : null; if (!key && !pointer) { return null; } return { key, pointer }; } normalizeTimestamp(candidate) { if (!Number.isFinite(candidate)) { return null; } const value = Number(candidate); const seconds = value > 1e12 ? Math.floor(value / 1000) : Math.floor(value); return seconds; } normalizeIdentity(nextIdentity = {}, fallback = null) { const baseline = fallback && typeof fallback === "object" ? fallback : {}; const candidate = nextIdentity && typeof nextIdentity === "object" ? nextIdentity : {}; const pubkey = (() => { if (typeof candidate.pubkey === "string" && candidate.pubkey.trim()) { return candidate.pubkey.trim(); } if (typeof baseline.pubkey === "string" && baseline.pubkey.trim()) { return baseline.pubkey.trim(); } if (typeof this.video?.pubkey === "string" && this.video.pubkey.trim()) { return this.video.pubkey.trim(); } return ""; })(); const nameCandidates = [ candidate.name, candidate.displayName, candidate.username, baseline.name, baseline.displayName, baseline.username, ]; let name = ""; for (const entry of nameCandidates) { if (typeof entry !== "string") { continue; } const trimmed = entry.trim(); if (trimmed) { name = trimmed; break; } } const npub = (() => { const entries = [candidate.npub, baseline.npub]; for (const entry of entries) { if (typeof entry === "string" && entry.trim()) { return entry.trim(); } } return ""; })(); const shortNpub = (() => { const entries = [candidate.shortNpub, baseline.shortNpub]; let resolved = ""; for (const entry of entries) { if (typeof entry !== "string") { continue; } const trimmed = entry.trim(); if (trimmed) { resolved = trimmed; break; } } if (npub) { const formatted = formatShortNpub(npub); if (formatted) { resolved = formatted; } } return resolved; })(); if (!name) { name = shortNpub || npub || ""; } const picture = (() => { const entries = [ candidate.picture, candidate.image, candidate.photo, baseline.picture, baseline.image, baseline.photo, this.video?.author?.picture, this.video?.creator?.picture, this.video?.profile?.picture, this.video?.authorPicture, this.video?.creatorPicture, ]; for (const entry of entries) { if (typeof entry !== "string") { continue; } const trimmed = entry.trim(); if (!trimmed) { continue; } const sanitized = sanitizeProfileMediaUrl(trimmed); if (sanitized) { return sanitized; } } const fallback = typeof baseline.picture === "string" && baseline.picture.trim() ? sanitizeProfileMediaUrl(baseline.picture) || baseline.picture.trim() : ""; return fallback; })(); const resolvedPicture = picture || DEFAULT_PROFILE_AVATAR; return { name, npub, shortNpub, pubkey, picture: resolvedPicture }; } applyIdentityToElements() { const nameLabel = this.identity.name || this.identity.shortNpub || this.identity.npub || ""; const npubLabel = this.identity.shortNpub || this.identity.npub || ""; if (this.authorNameEl) { this.authorNameEl.textContent = nameLabel; if (this.identity.pubkey) { this.authorNameEl.dataset.pubkey = this.identity.pubkey; } else if (this.authorNameEl.dataset?.pubkey) { delete this.authorNameEl.dataset.pubkey; } } if (this.authorNpubEl) { this.authorNpubEl.textContent = npubLabel; const hasNpub = Boolean(npubLabel); const normalizedName = nameLabel.trim().toLowerCase(); const normalizedNpub = npubLabel.trim().toLowerCase(); const isDuplicate = hasNpub && normalizedName && normalizedName === normalizedNpub; this.authorNpubEl.hidden = !hasNpub || isDuplicate; this.authorNpubEl.setAttribute( "aria-hidden", !hasNpub || isDuplicate ? "true" : "false", ); if (this.identity.npub && !isDuplicate) { this.authorNpubEl.setAttribute("title", this.identity.npub); } else { this.authorNpubEl.removeAttribute("title"); } if (this.identity.npub) { this.authorNpubEl.dataset.npub = this.identity.npub; } else if (this.authorNpubEl.dataset?.npub) { delete this.authorNpubEl.dataset.npub; } const resolvedPubkey = this.identity.pubkey || (typeof this.video?.pubkey === "string" ? this.video.pubkey.trim() : ""); if (resolvedPubkey) { this.authorNpubEl.dataset.pubkey = resolvedPubkey; } else if (this.authorNpubEl.dataset?.pubkey) { delete this.authorNpubEl.dataset.pubkey; } } if (this.avatarEl) { const picture = this.identity.picture || DEFAULT_PROFILE_AVATAR; if (this.avatarEl.getAttribute("src") !== picture) { this.avatarEl.src = picture; } const altLabel = nameLabel || this.identity.shortNpub || this.identity.npub || "Channel"; this.avatarEl.alt = `${altLabel}'s avatar`; if (this.identity.pubkey) { this.avatarEl.dataset.pubkey = this.identity.pubkey; } else if (this.avatarEl.dataset?.pubkey) { delete this.avatarEl.dataset.pubkey; } } } build() { const root = this.document.createElement("article"); root.classList.add("player-modal__similar-card", "card"); root.dataset.component = "similar-content-card"; root.dataset.index = String(this.index); if (this.video.id) { root.dataset.videoId = this.video.id; } const dsMode = this.designSystem?.getMode?.(); if (dsMode) { root.setAttribute("data-ds", dsMode); } this.root = root; this.root.__bitvidSimilarContentCard = this; const media = this.buildMediaSection(); const content = this.buildContentSection(); if (media) { root.appendChild(media); } if (content) { root.appendChild(content); } this.applyPointerDatasets(); this.bindEvents(); } buildMediaSection() { const anchor = this.document.createElement("a"); anchor.classList.add("player-modal__similar-card-media"); anchor.href = this.shareUrl; anchor.setAttribute("data-primary-action", "play"); const thumbnail = this.buildThumbnail(); if (thumbnail) { anchor.appendChild(thumbnail); } this.mediaLinkEl = anchor; return anchor; } buildThumbnail() { const img = this.document.createElement("img"); img.decoding = "async"; img.loading = "lazy"; img.alt = this.video.title || ""; img.dataset.videoThumbnail = "true"; const rawThumbnail = typeof this.video.thumbnail === "string" ? this.video.thumbnail.trim() : ""; const thumbnailUrl = rawThumbnail; const fallbackSrc = this.fallbackThumbnailSrc; const cachedValue = this.thumbnailCache?.get(this.video.id) || ""; const shouldLazyLoad = !!thumbnailUrl && cachedValue !== thumbnailUrl; if (shouldLazyLoad) { if (fallbackSrc) { img.src = fallbackSrc; img.dataset.fallbackSrc = fallbackSrc; this.setCardBackdropImage(fallbackSrc); } else { this.setCardBackdropImage(""); } img.dataset.lazy = thumbnailUrl; } else { const initialSrc = thumbnailUrl || fallbackSrc; if (initialSrc) { img.src = initialSrc; if (fallbackSrc) { img.dataset.fallbackSrc = fallbackSrc; } this.setCardBackdropImage(initialSrc); } else { this.setCardBackdropImage(""); } } if (this.shouldMaskNsfwForOwner || this.video?.moderation?.blurThumbnail) { img.dataset.thumbnailState = "blurred"; } const handleLoad = () => { const currentSrc = img.currentSrc || img.src || ""; if (!currentSrc) { return; } const fallbackAttr = (typeof img.dataset.fallbackSrc === "string" ? img.dataset.fallbackSrc.trim() : "") || fallbackSrc || ""; const isFallback = !!fallbackAttr && (currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr)); if (!isFallback) { this.setCardBackdropImage(currentSrc); } else if (fallbackAttr) { this.setCardBackdropImage(fallbackAttr); } if (thumbnailUrl && !isFallback && this.thumbnailCache) { this.thumbnailCache.set(this.video.id, thumbnailUrl); } }; const handleError = () => { const fallbackAttr = (typeof img.dataset.fallbackSrc === "string" ? img.dataset.fallbackSrc.trim() : "") || fallbackSrc || ""; if (fallbackAttr) { this.setCardBackdropImage(fallbackAttr); if (!img.src || img.src === thumbnailUrl) { img.src = fallbackAttr; } } else { this.setCardBackdropImage(""); } if (thumbnailUrl && this.thumbnailCache) { const cached = this.thumbnailCache.get(this.video.id); if (cached === thumbnailUrl) { this.thumbnailCache.delete(this.video.id); } } }; img.addEventListener("load", handleLoad); img.addEventListener("error", handleError, { once: true }); if (img.complete) { handleLoad(); } this.thumbnailEl = img; return img; } buildContentSection() { const content = this.document.createElement("div"); content.classList.add("player-modal__similar-card-content"); content.style.minWidth = "0"; const titleLink = this.document.createElement("a"); titleLink.classList.add("player-modal__similar-card-title"); titleLink.href = this.shareUrl; titleLink.textContent = this.video.title || "Untitled"; titleLink.title = this.video.title || "Untitled"; titleLink.style.minWidth = "0"; const authorStack = this.buildAuthorStack(); const metaRow = this.buildMetaRow(); content.appendChild(titleLink); if (authorStack) { content.appendChild(authorStack); } if (metaRow) { content.appendChild(metaRow); } this.contentEl = content; this.titleEl = titleLink; return content; } buildAuthorStack() { const wrapper = this.document.createElement("div"); wrapper.classList.add("player-modal__similar-card-author"); wrapper.style.minWidth = "0"; const avatarWrapper = this.document.createElement("span"); avatarWrapper.classList.add("player-modal__similar-card-avatar"); const avatarImg = this.document.createElement("img"); avatarImg.classList.add("player-modal__similar-card-avatar-img"); avatarImg.decoding = "async"; avatarImg.loading = "lazy"; avatarImg.alt = ""; avatarWrapper.appendChild(avatarImg); const textWrapper = this.document.createElement("span"); textWrapper.classList.add("player-modal__similar-card-author-meta"); textWrapper.style.minWidth = "0"; const nameEl = this.document.createElement("span"); nameEl.classList.add("author-name", "player-modal__similar-card-author-name"); textWrapper.appendChild(nameEl); const npubEl = this.document.createElement("span"); npubEl.classList.add("author-npub", "player-modal__similar-card-author-npub"); textWrapper.appendChild(npubEl); wrapper.appendChild(avatarWrapper); wrapper.appendChild(textWrapper); this.avatarEl = avatarImg; this.authorNameEl = nameEl; this.authorNpubEl = npubEl; this.applyIdentityToElements(); return wrapper; } buildMetaRow() { const row = this.document.createElement("div"); row.classList.add("player-modal__similar-card-meta"); row.style.minWidth = "0"; const timeEl = this.document.createElement("time"); timeEl.classList.add("player-modal__similar-card-timestamp"); if (this.postedAt !== null) { try { const iso = new Date(this.postedAt * 1000).toISOString(); timeEl.setAttribute("datetime", iso); timeEl.title = new Date(this.postedAt * 1000).toLocaleString(); } catch { /* noop */ } } timeEl.textContent = this.timeAgo || ""; row.appendChild(timeEl); this.timeEl = timeEl; const shouldShowViews = Boolean(this.pointerInfo?.key); if (shouldShowViews) { const separator = this.document.createElement("span"); separator.classList.add("player-modal__similar-card-separator"); separator.setAttribute("aria-hidden", "true"); separator.textContent = "•"; row.appendChild(separator); } const viewEl = this.document.createElement("span"); viewEl.classList.add("player-modal__similar-card-views", "view-count-text"); viewEl.dataset.viewCount = ""; viewEl.textContent = "– views"; if (this.pointerInfo?.key) { viewEl.dataset.viewPointer = this.pointerInfo.key; } row.appendChild(viewEl); this.viewCountEl = viewEl; return row; } applyPointerDatasets() { if (!this.root || !this.pointerInfo) { return; } const { key, pointer } = this.pointerInfo; if (key) { this.root.dataset.pointerKey = key; } if (Array.isArray(pointer)) { const [type, value, relay] = pointer; if (typeof type === "string" && type) { this.root.dataset.pointerType = type; } if (typeof value === "string" && value) { this.root.dataset.pointerValue = value; } if (typeof relay === "string" && relay) { this.root.dataset.pointerRelay = relay; } } } bindEvents() { const MouseEventCtor = this.window?.MouseEvent || globalThis.MouseEvent; const handler = (event) => { if (!this.callbacks.onPlay) { return; } const isMouseEvent = typeof MouseEventCtor !== "undefined" && event instanceof MouseEventCtor; if (isMouseEvent) { const isPrimaryClick = typeof event.button !== "number" || event.button === 0; const hasModifier = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; if (!isPrimaryClick || hasModifier) { return; } } event.preventDefault?.(); event.stopPropagation?.(); this.callbacks.onPlay({ event, video: this.video, card: this }); }; [this.mediaLinkEl, this.titleEl].forEach((el) => { if (!el) { return; } el.addEventListener("click", handler); }); } setCardBackdropImage(src) { if (!this.root || !this.root.style) { return; } const style = this.root.style; const normalizeSource = (raw) => { if (typeof raw !== "string") { return ""; } const trimmed = raw.trim(); if (!trimmed) { return ""; } if (/^javascript:/i.test(trimmed) || /^vbscript:/i.test(trimmed)) { return ""; } if (/^(?:https?:|data:|blob:)/i.test(trimmed)) { return trimmed; } if ( trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("assets/") ) { return trimmed; } try { const base = typeof this.document?.baseURI === "string" && this.document.baseURI ? this.document.baseURI : this.window?.location?.href || ""; if (!base) { return ""; } const resolved = new URL(trimmed, base); if (/^(?:https?:|data:|blob:)/i.test(resolved.protocol)) { return resolved.href; } } catch { return ""; } return ""; }; const sanitized = normalizeSource(src); if (sanitized) { const escaped = sanitized.replace(/(["\\])/g, "\\$1"); style.setProperty( "--similar-card-thumb-url", `url("${escaped}")` ); } else { style.removeProperty("--similar-card-thumb-url"); } } }