mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
1303 lines
38 KiB
JavaScript
1303 lines
38 KiB
JavaScript
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||
import { formatShortNpub } from "../../utils/formatters.js";
|
||
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
|
||
import {
|
||
getModerationOverrideActionLabels,
|
||
normalizeVideoModerationContext,
|
||
} from "../moderationUiHelpers.js";
|
||
import { buildModerationBadgeText } from "../moderationCopy.js";
|
||
|
||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
|
||
let similarCardIdCounter = 0;
|
||
const similarCardStyleRegistry = new WeakMap();
|
||
|
||
function getSimilarCardStyleState(doc) {
|
||
if (!doc || typeof doc.createElement !== "function") {
|
||
return null;
|
||
}
|
||
const existing = similarCardStyleRegistry.get(doc);
|
||
if (existing) {
|
||
return existing;
|
||
}
|
||
let styleNode = doc.getElementById?.("similarContentCardStyles") || null;
|
||
if (!(styleNode instanceof HTMLStyleElement)) {
|
||
styleNode = doc.createElement("style");
|
||
styleNode.id = "similarContentCardStyles";
|
||
doc.head?.appendChild(styleNode);
|
||
}
|
||
const state = {
|
||
styleNode,
|
||
rules: new Map(),
|
||
};
|
||
similarCardStyleRegistry.set(doc, state);
|
||
return state;
|
||
}
|
||
|
||
function updateSimilarCardBackdrop(doc, cardId, url) {
|
||
const state = getSimilarCardStyleState(doc);
|
||
if (!state || !cardId) {
|
||
return;
|
||
}
|
||
|
||
if (url) {
|
||
state.rules.set(
|
||
cardId,
|
||
`[data-similar-card-id="${cardId}"] { --similar-card-thumb-url: url("${url}"); }`,
|
||
);
|
||
} else {
|
||
state.rules.delete(cardId);
|
||
}
|
||
|
||
state.styleNode.textContent = [...state.rules.values()].join("\n");
|
||
}
|
||
|
||
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,
|
||
onModerationOverride: null,
|
||
onModerationBlock: null,
|
||
onModerationHide: null,
|
||
};
|
||
|
||
this.root = null;
|
||
this.cardStyleId = "";
|
||
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.discussionCountEl = null;
|
||
|
||
this.moderationBadgeEl = null;
|
||
this.moderationBadgeLabelEl = null;
|
||
this.moderationBadgeTextEl = null;
|
||
this.moderationActionsContainer = null;
|
||
this.moderationActionButton = null;
|
||
this.moderationActionButtonMode = "";
|
||
this.moderationBlockButton = null;
|
||
this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event);
|
||
this.boundModerationHideHandler = (event) =>
|
||
this.handleModerationHideClick(event);
|
||
this.boundModerationBlockHandler = (event) =>
|
||
this.handleModerationBlockClick(event);
|
||
|
||
this.build();
|
||
}
|
||
|
||
set onPlay(fn) {
|
||
this.callbacks.onPlay = typeof fn === "function" ? fn : null;
|
||
}
|
||
|
||
set onModerationOverride(fn) {
|
||
this.callbacks.onModerationOverride = typeof fn === "function" ? fn : null;
|
||
}
|
||
|
||
set onModerationBlock(fn) {
|
||
this.callbacks.onModerationBlock = typeof fn === "function" ? fn : null;
|
||
}
|
||
|
||
set onModerationHide(fn) {
|
||
this.callbacks.onModerationHide = typeof fn === "function" ? fn : null;
|
||
}
|
||
|
||
getRoot() {
|
||
return this.root;
|
||
}
|
||
|
||
getViewCountElement() {
|
||
return this.viewCountEl;
|
||
}
|
||
|
||
getDiscussionCountElement() {
|
||
return this.discussionCountEl;
|
||
}
|
||
|
||
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() {
|
||
// Modified: Ensure 'group' class for hover effects
|
||
const root = this.document.createElement("article");
|
||
root.classList.add("player-modal__similar-card", "card", "group");
|
||
root.dataset.component = "similar-content-card";
|
||
root.dataset.index = String(this.index);
|
||
if (this.video.id) {
|
||
root.dataset.videoId = this.video.id;
|
||
}
|
||
similarCardIdCounter += 1;
|
||
this.cardStyleId = `similar-card-${similarCardIdCounter}`;
|
||
root.dataset.similarCardId = this.cardStyleId;
|
||
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.refreshModerationUi();
|
||
this.applyPointerDatasets();
|
||
this.bindEvents();
|
||
}
|
||
|
||
buildMediaSection() {
|
||
const anchor = this.document.createElement("a");
|
||
anchor.classList.add(
|
||
"player-modal__similar-card-media",
|
||
"block",
|
||
"relative",
|
||
"overflow-hidden",
|
||
"rounded"
|
||
);
|
||
anchor.href = this.shareUrl;
|
||
anchor.setAttribute("data-primary-action", "play");
|
||
|
||
const thumbnail = this.buildThumbnail();
|
||
if (thumbnail) {
|
||
anchor.appendChild(thumbnail);
|
||
}
|
||
|
||
this.mediaLinkEl = anchor;
|
||
return anchor;
|
||
}
|
||
|
||
getModerationContext() {
|
||
return normalizeVideoModerationContext(this.video?.moderation);
|
||
}
|
||
|
||
shouldShowModerationBlockButton(context = this.getModerationContext()) {
|
||
if (!context || !context.trustedMuted) {
|
||
return false;
|
||
}
|
||
if (context.activeHidden && !context.overrideActive) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
createModerationOverrideButton() {
|
||
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
|
||
overrideActive: false,
|
||
});
|
||
const button = this.document.createElement("button");
|
||
button.classList.add(
|
||
"moderation-badge__action",
|
||
"flex-shrink-0",
|
||
"text-xs",
|
||
"py-1",
|
||
"px-2"
|
||
);
|
||
button.type = "button";
|
||
button.dataset.moderationAction = "override";
|
||
button.setAttribute("aria-pressed", "false");
|
||
button.setAttribute("aria-label", ariaLabel);
|
||
button.textContent = "Show";
|
||
button.addEventListener("click", this.boundShowAnywayHandler);
|
||
// Explicit pointer events since container is none
|
||
button.classList.add("pointer-events-auto");
|
||
return button;
|
||
}
|
||
|
||
createModerationHideButton() {
|
||
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
|
||
overrideActive: true,
|
||
});
|
||
const button = this.document.createElement("button");
|
||
button.classList.add(
|
||
"moderation-badge__action",
|
||
"flex-shrink-0",
|
||
"text-xs",
|
||
"py-1",
|
||
"px-2"
|
||
);
|
||
button.type = "button";
|
||
button.dataset.moderationAction = "hide";
|
||
button.setAttribute("aria-label", ariaLabel);
|
||
button.textContent = "Hide";
|
||
button.addEventListener("click", this.boundModerationHideHandler);
|
||
button.classList.add("pointer-events-auto");
|
||
return button;
|
||
}
|
||
|
||
createModerationBlockButton() {
|
||
const button = this.document.createElement("button");
|
||
button.classList.add(
|
||
"moderation-badge__action",
|
||
"flex-shrink-0",
|
||
"text-xs",
|
||
"py-1",
|
||
"px-2"
|
||
);
|
||
button.type = "button";
|
||
button.dataset.moderationAction = "block";
|
||
button.textContent = "Block";
|
||
button.addEventListener("click", this.boundModerationBlockHandler);
|
||
button.classList.add("pointer-events-auto");
|
||
return button;
|
||
}
|
||
|
||
handleShowAnywayClick(event) {
|
||
event?.preventDefault?.();
|
||
event?.stopPropagation?.();
|
||
|
||
if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = true;
|
||
this.moderationActionButton.setAttribute("aria-busy", "true");
|
||
}
|
||
|
||
if (!this.callbacks.onModerationOverride) {
|
||
if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = false;
|
||
this.moderationActionButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
let result;
|
||
try {
|
||
result = this.callbacks.onModerationOverride({
|
||
event,
|
||
video: this.video,
|
||
card: this,
|
||
});
|
||
} catch (error) {
|
||
if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = false;
|
||
this.moderationActionButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
Promise.resolve(result).then((handled) => {
|
||
if (handled !== false) {
|
||
this.refreshModerationUi();
|
||
} else if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = false;
|
||
this.moderationActionButton.removeAttribute("aria-busy");
|
||
}
|
||
});
|
||
}
|
||
|
||
handleModerationHideClick(event) {
|
||
event?.preventDefault?.();
|
||
event?.stopPropagation?.();
|
||
|
||
if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = true;
|
||
this.moderationActionButton.setAttribute("aria-busy", "true");
|
||
}
|
||
|
||
if (!this.callbacks.onModerationHide) {
|
||
if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = false;
|
||
this.moderationActionButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
let result;
|
||
try {
|
||
result = this.callbacks.onModerationHide({
|
||
event,
|
||
video: this.video,
|
||
card: this,
|
||
});
|
||
} catch (error) {
|
||
if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = false;
|
||
this.moderationActionButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
Promise.resolve(result).then((handled) => {
|
||
if (handled !== false) {
|
||
this.refreshModerationUi();
|
||
} else if (this.moderationActionButton) {
|
||
this.moderationActionButton.disabled = false;
|
||
this.moderationActionButton.removeAttribute("aria-busy");
|
||
}
|
||
});
|
||
}
|
||
|
||
handleModerationBlockClick(event) {
|
||
event?.preventDefault?.();
|
||
event?.stopPropagation?.();
|
||
|
||
if (this.moderationBlockButton) {
|
||
this.moderationBlockButton.disabled = true;
|
||
this.moderationBlockButton.setAttribute("aria-busy", "true");
|
||
}
|
||
|
||
if (!this.callbacks.onModerationBlock) {
|
||
if (this.moderationBlockButton) {
|
||
this.moderationBlockButton.disabled = false;
|
||
this.moderationBlockButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
let result;
|
||
try {
|
||
result = this.callbacks.onModerationBlock({
|
||
event,
|
||
video: this.video,
|
||
card: this,
|
||
});
|
||
} catch (error) {
|
||
if (this.moderationBlockButton) {
|
||
this.moderationBlockButton.disabled = false;
|
||
this.moderationBlockButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
Promise.resolve(result).then((handled) => {
|
||
if (handled !== false) {
|
||
this.refreshModerationUi();
|
||
} else if (this.moderationBlockButton) {
|
||
this.moderationBlockButton.disabled = false;
|
||
this.moderationBlockButton.removeAttribute("aria-busy");
|
||
}
|
||
});
|
||
}
|
||
|
||
buildModerationBadge(context = this.getModerationContext()) {
|
||
if (!context.shouldShow || !context.trustedMuted) {
|
||
this.moderationBadgeEl = null;
|
||
this.moderationActionsContainer = null;
|
||
this.moderationActionButton = null;
|
||
this.moderationActionButtonMode = "";
|
||
this.moderationBlockButton = null;
|
||
return null;
|
||
}
|
||
|
||
const badge = this.document.createElement("div");
|
||
badge.className = "moderation-badge moderation-badge--interactive opacity-95";
|
||
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
||
badge.dataset.moderationBadge = "true";
|
||
badge.dataset.moderationState = context.overrideActive
|
||
? "override"
|
||
: "trusted-mute";
|
||
|
||
// Allow interaction with children
|
||
badge.classList.add("pointer-events-auto");
|
||
|
||
const label = this.document.createElement("span");
|
||
label.className = "moderation-badge__label inline-flex items-center gap-xs";
|
||
|
||
const iconWrapper = this.document.createElement("span");
|
||
iconWrapper.className = "moderation-badge__icon";
|
||
iconWrapper.setAttribute("aria-hidden", "true");
|
||
|
||
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
|
||
svg.setAttribute("viewBox", "0 0 20 20");
|
||
svg.setAttribute("focusable", "false");
|
||
svg.classList.add("moderation-badge__icon-mark");
|
||
|
||
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
|
||
path.setAttribute("fill", "currentColor");
|
||
|
||
if (context.overrideActive) {
|
||
path.setAttribute("d", "M10 18a8 8 0 100-16 8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L9 11.94l-1.72-1.72a.75.75 0 10-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l3.25-3.25z");
|
||
} else {
|
||
path.setAttribute("d", "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z");
|
||
}
|
||
|
||
path.setAttribute("fill-rule", "evenodd");
|
||
path.setAttribute("clip-rule", "evenodd");
|
||
|
||
svg.appendChild(path);
|
||
iconWrapper.appendChild(svg);
|
||
label.appendChild(iconWrapper);
|
||
|
||
const text = this.document.createElement("span");
|
||
text.className = "moderation-badge__text text-xs";
|
||
text.textContent = "Blocked by trusted";
|
||
|
||
label.appendChild(text);
|
||
badge.appendChild(label);
|
||
|
||
const actions = this.document.createElement("div");
|
||
actions.className = "moderation-badge__actions flex items-center gap-1 mt-1";
|
||
|
||
if (context.allowOverride) {
|
||
if (context.overrideActive) {
|
||
const hideButton = this.createModerationHideButton();
|
||
actions.appendChild(hideButton);
|
||
this.moderationActionButton = hideButton;
|
||
this.moderationActionButtonMode = "hide";
|
||
} else {
|
||
const showButton = this.createModerationOverrideButton();
|
||
actions.appendChild(showButton);
|
||
this.moderationActionButton = showButton;
|
||
this.moderationActionButtonMode = "override";
|
||
}
|
||
}
|
||
|
||
if (this.shouldShowModerationBlockButton(context)) {
|
||
const blockButton = this.createModerationBlockButton();
|
||
actions.appendChild(blockButton);
|
||
this.moderationBlockButton = blockButton;
|
||
}
|
||
|
||
if (actions.childElementCount > 0) {
|
||
badge.appendChild(actions);
|
||
this.moderationActionsContainer = actions;
|
||
}
|
||
|
||
this.moderationBadgeEl = badge;
|
||
return badge;
|
||
}
|
||
|
||
refreshModerationUi() {
|
||
const context = this.getModerationContext();
|
||
|
||
if (this.moderationBadgeEl) {
|
||
this.moderationBadgeEl.remove();
|
||
this.moderationBadgeEl = null;
|
||
}
|
||
this.moderationActionsContainer = null;
|
||
this.moderationActionButton = null;
|
||
this.moderationActionButtonMode = "";
|
||
this.moderationBlockButton = null;
|
||
|
||
// Apply blurring class if needed
|
||
if (this.thumbnailEl) {
|
||
const shouldBlur =
|
||
(this.shouldMaskNsfwForOwner ||
|
||
this.video?.moderation?.blurThumbnail) &&
|
||
!context.overrideActive;
|
||
|
||
if (shouldBlur) {
|
||
this.thumbnailEl.dataset.thumbnailState = "blurred";
|
||
this.thumbnailEl.classList.add("blur-xl");
|
||
// Force blur style in case class utility is missing or overridden,
|
||
// and clear the backdrop so the sharp background doesn't show through.
|
||
// Scale up to hide feathered edges of the blur.
|
||
this.thumbnailEl.classList.add("scale-[1.2]");
|
||
this.setCardBackdropImage("");
|
||
} else {
|
||
delete this.thumbnailEl.dataset.thumbnailState;
|
||
this.thumbnailEl.classList.remove("blur-xl");
|
||
this.thumbnailEl.classList.remove("scale-[1.2]");
|
||
// Restore backdrop if src is available
|
||
if (this.thumbnailEl.src) {
|
||
this.setCardBackdropImage(this.thumbnailEl.src);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check specifically for muted/blurred state where we want the overlay
|
||
if (context.shouldShow && context.trustedMuted) {
|
||
if (!this.mediaLinkEl) return;
|
||
|
||
const badge = this.buildModerationBadge(context);
|
||
if (badge) {
|
||
const container = this.document.createElement("div");
|
||
container.className =
|
||
"absolute inset-0 flex flex-col items-center justify-center p-2 z-10 pointer-events-none";
|
||
container.appendChild(badge);
|
||
|
||
this.mediaLinkEl.appendChild(container);
|
||
this.moderationBadgeEl = container;
|
||
}
|
||
}
|
||
}
|
||
|
||
buildThumbnail() {
|
||
const img = this.document.createElement("img");
|
||
img.decoding = "async";
|
||
img.loading = "lazy";
|
||
img.alt = this.video.title || "";
|
||
img.dataset.videoThumbnail = "true";
|
||
|
||
// Modified: Add transition classes for hover zoom
|
||
img.classList.add(
|
||
"transition-transform",
|
||
"duration-300",
|
||
"ease-out",
|
||
"group-hover:scale-105"
|
||
);
|
||
|
||
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";
|
||
img.classList.add("blur-xl");
|
||
// Pre-apply style to avoid flash and crop edges
|
||
img.classList.add("scale-[1.2]");
|
||
}
|
||
|
||
const handleLoad = () => {
|
||
const currentSrc = img.currentSrc || img.src || "";
|
||
if (!currentSrc) {
|
||
return;
|
||
}
|
||
|
||
// If currently blurred, do not restore the backdrop to avoid transparency bleed-through.
|
||
const isBlurred = img.dataset.thumbnailState === "blurred";
|
||
|
||
const fallbackAttr =
|
||
(typeof img.dataset.fallbackSrc === "string"
|
||
? img.dataset.fallbackSrc.trim()
|
||
: "") ||
|
||
fallbackSrc ||
|
||
"";
|
||
const isFallback =
|
||
!!fallbackAttr &&
|
||
(currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr));
|
||
|
||
if (!isFallback) {
|
||
if (isBlurred) {
|
||
this.setCardBackdropImage("");
|
||
} else {
|
||
this.setCardBackdropImage(currentSrc);
|
||
}
|
||
} else if (fallbackAttr) {
|
||
if (isBlurred) {
|
||
this.setCardBackdropImage("");
|
||
} else {
|
||
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 currently blurred, do not set the backdrop.
|
||
const isBlurred = img.dataset.thumbnailState === "blurred";
|
||
|
||
if (fallbackAttr) {
|
||
if (isBlurred) {
|
||
this.setCardBackdropImage("");
|
||
} else {
|
||
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");
|
||
|
||
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";
|
||
|
||
const authorStack = this.buildAuthorStack();
|
||
const metaRow = this.buildMetaRow();
|
||
const engagement = this.buildEngagementSection();
|
||
|
||
content.appendChild(titleLink);
|
||
if (authorStack) {
|
||
content.appendChild(authorStack);
|
||
}
|
||
if (metaRow) {
|
||
content.appendChild(metaRow);
|
||
}
|
||
if (engagement) {
|
||
content.appendChild(engagement);
|
||
}
|
||
|
||
this.contentEl = content;
|
||
this.titleEl = titleLink;
|
||
return content;
|
||
}
|
||
|
||
buildAuthorStack() {
|
||
const wrapper = this.document.createElement("div");
|
||
wrapper.classList.add("player-modal__similar-card-author");
|
||
|
||
const textWrapper = this.document.createElement("span");
|
||
textWrapper.classList.add("player-modal__similar-card-author-meta");
|
||
|
||
const nameEl = this.document.createElement("span");
|
||
nameEl.classList.add("author-name", "player-modal__similar-card-author-name");
|
||
textWrapper.appendChild(nameEl);
|
||
|
||
// Removed npubEl appending here to simplify visual noise in compact card
|
||
// but keeping element reference if needed for logic
|
||
const npubEl = this.document.createElement("span");
|
||
npubEl.classList.add("author-npub", "player-modal__similar-card-author-npub");
|
||
// textWrapper.appendChild(npubEl);
|
||
|
||
// Reordered: Content -> Meta (Avatar logic removed from stack to clean up UI,
|
||
// or kept minimal if desired. For now, we keep structure but can style via CSS order if needed)
|
||
|
||
// Actually, keeping the avatar structure but CSS will handle sizing/layout
|
||
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 = "";
|
||
if (!avatarImg.dataset.avatarFallbackBound) {
|
||
avatarImg.addEventListener("error", () => {
|
||
if (avatarImg.getAttribute("src") !== DEFAULT_PROFILE_AVATAR) {
|
||
avatarImg.src = DEFAULT_PROFILE_AVATAR;
|
||
}
|
||
avatarImg.dataset.avatarLoadFailed = "true";
|
||
});
|
||
avatarImg.dataset.avatarFallbackBound = "true";
|
||
}
|
||
avatarWrapper.appendChild(avatarImg);
|
||
|
||
// Append avatar then text
|
||
wrapper.appendChild(textWrapper);
|
||
|
||
// Note: To match modern style, we might want text first or no avatar.
|
||
// But let's keep avatar for now but maybe hide it via CSS if needed,
|
||
// or just show name.
|
||
// Let's re-append avatar if we want it visible
|
||
// wrapper.appendChild(avatarWrapper);
|
||
|
||
// Let's stick to the previous logical structure but rely on CSS flex order or just
|
||
// simplified HTML order:
|
||
// [Name]
|
||
// [Meta]
|
||
|
||
// Cleaning up: We want Title -> Name -> Meta
|
||
// This wrapper is for the "Author" line.
|
||
|
||
// Let's clear wrapper and re-append in clean order
|
||
while (wrapper.firstChild) { wrapper.removeChild(wrapper.firstChild); }
|
||
|
||
wrapper.appendChild(textWrapper);
|
||
|
||
// We preserve refs
|
||
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");
|
||
|
||
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;
|
||
|
||
return row;
|
||
}
|
||
|
||
createEyeIcon(classNames = []) {
|
||
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
|
||
svg.setAttribute("viewBox", "0 0 24 24");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("aria-hidden", "true");
|
||
svg.setAttribute("focusable", "false");
|
||
svg.setAttribute("stroke", "currentColor");
|
||
svg.setAttribute("stroke-width", "2");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
|
||
classNames.forEach((className) => {
|
||
if (className) {
|
||
svg.classList.add(className);
|
||
}
|
||
});
|
||
|
||
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
|
||
path.setAttribute("d", "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z");
|
||
svg.appendChild(path);
|
||
|
||
const circle = this.document.createElementNS(SVG_NAMESPACE, "circle");
|
||
circle.setAttribute("cx", "12");
|
||
circle.setAttribute("cy", "12");
|
||
circle.setAttribute("r", "3");
|
||
svg.appendChild(circle);
|
||
|
||
return svg;
|
||
}
|
||
|
||
createMessageIcon(classNames = []) {
|
||
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
|
||
svg.setAttribute("viewBox", "0 0 24 24");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("aria-hidden", "true");
|
||
svg.setAttribute("focusable", "false");
|
||
svg.setAttribute("stroke", "currentColor");
|
||
svg.setAttribute("stroke-width", "2");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
|
||
classNames.forEach((className) => {
|
||
if (className) {
|
||
svg.classList.add(className);
|
||
}
|
||
});
|
||
|
||
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
|
||
path.setAttribute(
|
||
"d",
|
||
"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
|
||
);
|
||
svg.appendChild(path);
|
||
|
||
return svg;
|
||
}
|
||
|
||
buildEngagementSection() {
|
||
const hasPointer = this.pointerInfo && this.pointerInfo.key;
|
||
const hasDiscussion =
|
||
this.video.enableComments !== false &&
|
||
(typeof this.video.discussionCount === "number" ||
|
||
(typeof this.video.discussionCount === "string" &&
|
||
this.video.discussionCount.trim()));
|
||
|
||
if (!hasPointer && !hasDiscussion) {
|
||
return null;
|
||
}
|
||
|
||
const container = this.document.createElement("div");
|
||
container.classList.add(
|
||
"flex",
|
||
"items-center",
|
||
"gap-4",
|
||
"text-xs",
|
||
"text-muted-strong",
|
||
"mt-1.5",
|
||
"video-card__engagement" // Reuse this class for consistent styling if global, otherwise it's just a marker
|
||
);
|
||
// Add specific class for scoping if needed, but Tailwind classes usually suffice.
|
||
|
||
// Views
|
||
if (hasPointer) {
|
||
const wrapper = this.document.createElement("div");
|
||
wrapper.classList.add("flex", "items-center", "gap-1.5");
|
||
wrapper.setAttribute("title", "Views");
|
||
|
||
const icon = this.createEyeIcon(["w-3.5", "h-3.5"]);
|
||
wrapper.appendChild(icon);
|
||
|
||
const view = this.document.createElement("span");
|
||
view.classList.add("view-count-text");
|
||
view.textContent = "–";
|
||
view.dataset.viewCount = "";
|
||
view.dataset.viewPointer = this.pointerInfo.key;
|
||
|
||
wrapper.appendChild(view);
|
||
container.appendChild(wrapper);
|
||
|
||
this.viewCountEl = view;
|
||
}
|
||
|
||
// Discussion
|
||
if (hasDiscussion) {
|
||
let initialCount = 0;
|
||
if (typeof this.video.discussionCount === "number") {
|
||
initialCount = this.video.discussionCount;
|
||
} else if (typeof this.video.discussionCount === "string") {
|
||
const parsed = Number.parseInt(this.video.discussionCount.trim(), 10);
|
||
if (Number.isFinite(parsed)) {
|
||
initialCount = parsed;
|
||
}
|
||
}
|
||
|
||
if (Number.isFinite(initialCount) && initialCount >= 0) {
|
||
const wrapper = this.document.createElement("div");
|
||
wrapper.classList.add("flex", "items-center", "gap-1.5");
|
||
wrapper.dataset.discussionCount = this.video.id;
|
||
wrapper.dataset.countState = "ready";
|
||
wrapper.setAttribute("title", "Comments");
|
||
|
||
const icon = this.createMessageIcon(["w-3.5", "h-3.5"]);
|
||
wrapper.appendChild(icon);
|
||
|
||
const displayValue = initialCount.toLocaleString();
|
||
|
||
const valueEl = this.document.createElement("span");
|
||
valueEl.textContent = displayValue;
|
||
valueEl.dataset.discussionCountValue = "";
|
||
|
||
wrapper.appendChild(valueEl);
|
||
container.appendChild(wrapper);
|
||
|
||
this.discussionCountEl = wrapper;
|
||
}
|
||
}
|
||
|
||
return container;
|
||
}
|
||
|
||
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.cardStyleId) {
|
||
return;
|
||
}
|
||
|
||
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");
|
||
updateSimilarCardBackdrop(this.document, this.cardStyleId, escaped);
|
||
} else {
|
||
updateSimilarCardBackdrop(this.document, this.cardStyleId, "");
|
||
}
|
||
}
|
||
}
|