mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
feat: add similar content card for video modal
This commit is contained in:
1 parent
ff89a9866c
commit
5f1c4ad0bb
4 files changed
+767
-45
No files matched your search
@@ -728,6 +728,16 @@ class Application {
|
||||
log: (message, ...args) => this.log(message, ...args),
|
||||
},
|
||||
mediaLoader: this.mediaLoader,
|
||||
assets: {
|
||||
fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC,
|
||||
},
|
||||
state: {
|
||||
loadedThumbnails: this.loadedThumbnails,
|
||||
},
|
||||
helpers: {
|
||||
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
|
||||
formatShortNpub: (value) => formatShortNpub(value),
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
|
||||
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.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);
|
||||
|
||||
if (this.authorNameEl) {
|
||||
this.authorNameEl.textContent =
|
||||
this.identity.name || this.identity.shortNpub || this.identity.npub || "";
|
||||
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) {
|
||||
const label = this.identity.shortNpub || this.identity.npub || "";
|
||||
this.authorNpubEl.textContent = label;
|
||||
if (!label) {
|
||||
this.authorNpubEl.setAttribute("aria-hidden", "true");
|
||||
} else {
|
||||
this.authorNpubEl.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
if (this.identity.pubkey) {
|
||||
this.authorNpubEl.dataset.pubkey = this.identity.pubkey;
|
||||
} else if (this.authorNpubEl.dataset?.pubkey) {
|
||||
delete this.authorNpubEl.dataset.pubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, npub];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "string" && entry.trim()) {
|
||||
return entry.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
if (!name) {
|
||||
name = shortNpub || npub || "";
|
||||
}
|
||||
|
||||
return { name, npub, shortNpub, 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;
|
||||
|
||||
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 nameEl = this.document.createElement("span");
|
||||
nameEl.classList.add("author-name", "player-modal__similar-card-author-name");
|
||||
nameEl.textContent =
|
||||
this.identity.name || this.identity.shortNpub || this.identity.npub || "";
|
||||
if (this.identity.pubkey) {
|
||||
nameEl.dataset.pubkey = this.identity.pubkey;
|
||||
}
|
||||
|
||||
const npubEl = this.document.createElement("span");
|
||||
npubEl.classList.add("author-npub", "player-modal__similar-card-author-npub");
|
||||
const npubLabel = this.identity.shortNpub || this.identity.npub || "";
|
||||
npubEl.textContent = npubLabel;
|
||||
if (npubLabel) {
|
||||
npubEl.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
npubEl.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
if (this.identity.pubkey) {
|
||||
npubEl.dataset.pubkey = this.identity.pubkey;
|
||||
}
|
||||
|
||||
wrapper.appendChild(nameEl);
|
||||
wrapper.appendChild(npubEl);
|
||||
|
||||
this.authorNameEl = nameEl;
|
||||
this.authorNpubEl = npubEl;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
-45
@@ -5,7 +5,7 @@ import { attachAmbientBackground } from "../ambientBackground.js";
|
||||
import { applyDesignSystemAttributes } from "../../designSystem.js";
|
||||
import { devLogger } from "../../utils/logger.js";
|
||||
import { renderTagPillStrip } from "./tagPillList.js";
|
||||
import { VideoCard } from "./VideoCard.js";
|
||||
import { SimilarContentCard } from "./SimilarContentCard.js";
|
||||
import {
|
||||
subscribeToVideoViewCount,
|
||||
unsubscribeFromVideoViewCount,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
formatAbsoluteTimestamp,
|
||||
formatTimeAgo,
|
||||
formatShortNpub,
|
||||
formatShortNpub as defaultFormatShortNpub,
|
||||
} from "../../utils/formatters.js";
|
||||
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
|
||||
|
||||
@@ -27,6 +27,9 @@ export class VideoModal {
|
||||
document: doc,
|
||||
logger,
|
||||
mediaLoader,
|
||||
assets = {},
|
||||
state = {},
|
||||
helpers = {},
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("VideoModal requires a document reference.");
|
||||
@@ -54,6 +57,37 @@ export class VideoModal {
|
||||
|
||||
this.mediaLoader = null;
|
||||
|
||||
const assetsConfig = assets && typeof assets === "object" ? assets : {};
|
||||
this.fallbackThumbnailSrc =
|
||||
typeof assetsConfig.fallbackThumbnailSrc === "string"
|
||||
? assetsConfig.fallbackThumbnailSrc
|
||||
: "";
|
||||
|
||||
const stateConfig = state && typeof state === "object" ? state : {};
|
||||
this.thumbnailCache =
|
||||
stateConfig.loadedThumbnails instanceof Map
|
||||
? stateConfig.loadedThumbnails
|
||||
: null;
|
||||
|
||||
const helperConfig = helpers && typeof helpers === "object" ? helpers : {};
|
||||
const fallbackFormatShort = (value) => {
|
||||
try {
|
||||
return defaultFormatShortNpub(value);
|
||||
} catch {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
};
|
||||
this.helpers = {
|
||||
safeEncodeNpub:
|
||||
typeof helperConfig.safeEncodeNpub === "function"
|
||||
? helperConfig.safeEncodeNpub
|
||||
: () => "",
|
||||
formatShortNpub:
|
||||
typeof helperConfig.formatShortNpub === "function"
|
||||
? helperConfig.formatShortNpub
|
||||
: fallbackFormatShort,
|
||||
};
|
||||
|
||||
this.loaded = false;
|
||||
|
||||
this.playerModal = null;
|
||||
@@ -1593,14 +1627,14 @@ export class VideoModal {
|
||||
}
|
||||
|
||||
if (!displayName && npub) {
|
||||
displayName = formatShortNpub(npub);
|
||||
displayName = this.helpers.formatShortNpub(npub);
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
displayName = "Anonymous";
|
||||
}
|
||||
|
||||
const shortNpub = npub ? formatShortNpub(npub) : "";
|
||||
const shortNpub = npub ? this.helpers.formatShortNpub(npub) : "";
|
||||
const initialCandidate = displayName.trim().charAt(0) || "";
|
||||
const initial = initialCandidate ? initialCandidate.toUpperCase() : "?";
|
||||
|
||||
@@ -3923,10 +3957,6 @@ export class VideoModal {
|
||||
? Math.floor(baseVideo.created_at)
|
||||
: null;
|
||||
|
||||
const capabilities =
|
||||
item && typeof item.capabilities === "object" && item.capabilities
|
||||
? { ...item.capabilities }
|
||||
: {};
|
||||
const nsfwContext =
|
||||
item && typeof item.nsfwContext === "object" && item.nsfwContext
|
||||
? { ...item.nsfwContext }
|
||||
@@ -3938,48 +3968,38 @@ export class VideoModal {
|
||||
Boolean(item?.capabilities?.canEdit),
|
||||
};
|
||||
|
||||
const cardOptions = {
|
||||
document: this.document,
|
||||
video: baseVideo,
|
||||
index: position,
|
||||
shareUrl,
|
||||
pointerInfo,
|
||||
timeAgo,
|
||||
postedAt: postedAtCandidate,
|
||||
capabilities,
|
||||
nsfwContext,
|
||||
variant: "compact",
|
||||
};
|
||||
|
||||
if (typeof item.cardState === "string" && item.cardState) {
|
||||
cardOptions.cardState = item.cardState;
|
||||
}
|
||||
if (typeof item.motionState === "string" && item.motionState) {
|
||||
cardOptions.motionState = item.motionState;
|
||||
}
|
||||
if (item.helpers && typeof item.helpers === "object") {
|
||||
cardOptions.helpers = item.helpers;
|
||||
}
|
||||
if (item.assets && typeof item.assets === "object") {
|
||||
cardOptions.assets = item.assets;
|
||||
}
|
||||
if (item.state && typeof item.state === "object") {
|
||||
cardOptions.state = item.state;
|
||||
}
|
||||
if (item.designSystem) {
|
||||
cardOptions.designSystem = item.designSystem;
|
||||
}
|
||||
const identity = this.buildSimilarCardIdentity(
|
||||
baseVideo,
|
||||
item?.identity || null
|
||||
);
|
||||
|
||||
let card;
|
||||
try {
|
||||
card = new VideoCard(cardOptions);
|
||||
card = new SimilarContentCard({
|
||||
document: this.document,
|
||||
video: baseVideo,
|
||||
index: position,
|
||||
shareUrl,
|
||||
pointerInfo,
|
||||
timeAgo,
|
||||
postedAt: postedAtCandidate,
|
||||
identity,
|
||||
nsfwContext,
|
||||
designSystem: item?.designSystem || null,
|
||||
thumbnailCache: this.thumbnailCache,
|
||||
fallbackThumbnailSrc: this.fallbackThumbnailSrc,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log("[VideoModal] Failed to render similar content card", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const cardIndex = renderedCards.length;
|
||||
this.prepareSimilarVideoCard(card, cardOptions, cardIndex);
|
||||
this.prepareSimilarVideoCard(
|
||||
card,
|
||||
{ video: baseVideo, pointerInfo, shareUrl },
|
||||
cardIndex
|
||||
);
|
||||
|
||||
const root = card.getRoot();
|
||||
if (!root) {
|
||||
@@ -4377,19 +4397,23 @@ export class VideoModal {
|
||||
return "";
|
||||
}
|
||||
|
||||
prepareSimilarVideoCard(card, cardOptions, index) {
|
||||
prepareSimilarVideoCard(card, meta, index) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerInfo = meta?.pointerInfo || null;
|
||||
const shareUrl = typeof meta?.shareUrl === "string" ? meta.shareUrl : "#";
|
||||
const fallbackVideo = meta?.video || null;
|
||||
|
||||
card.onPlay = ({ event, video: selectedVideo, card: sourceCard }) => {
|
||||
this.dispatch("similar:select", {
|
||||
event,
|
||||
video: selectedVideo || cardOptions.video,
|
||||
video: selectedVideo || fallbackVideo,
|
||||
card: sourceCard || card,
|
||||
index,
|
||||
pointerInfo: cardOptions.pointerInfo || null,
|
||||
shareUrl: cardOptions.shareUrl || "#",
|
||||
pointerInfo: pointerInfo,
|
||||
shareUrl,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4408,6 +4432,118 @@ export class VideoModal {
|
||||
}
|
||||
}
|
||||
|
||||
buildSimilarCardIdentity(video, overrides) {
|
||||
const override = overrides && typeof overrides === "object" ? overrides : {};
|
||||
|
||||
const pubkeyCandidates = [
|
||||
override.pubkey,
|
||||
video?.pubkey,
|
||||
video?.author?.pubkey,
|
||||
video?.creator?.pubkey,
|
||||
];
|
||||
let pubkey = "";
|
||||
for (const candidate of pubkeyCandidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) {
|
||||
pubkey = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const npubCandidates = [
|
||||
override.npub,
|
||||
video?.npub,
|
||||
video?.authorNpub,
|
||||
video?.creatorNpub,
|
||||
video?.profile?.npub,
|
||||
];
|
||||
let npub = "";
|
||||
for (const candidate of npubCandidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) {
|
||||
npub = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!npub && pubkey) {
|
||||
try {
|
||||
const encoded = this.helpers.safeEncodeNpub(pubkey);
|
||||
if (typeof encoded === "string" && encoded.trim()) {
|
||||
npub = encoded.trim();
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
const shortNpubCandidates = [override.shortNpub, video?.shortNpub];
|
||||
let shortNpub = "";
|
||||
for (const candidate of shortNpubCandidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) {
|
||||
shortNpub = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shortNpub && npub) {
|
||||
shortNpub = this.helpers.formatShortNpub(npub) || npub;
|
||||
}
|
||||
|
||||
const nameCandidates = [
|
||||
override.name,
|
||||
override.displayName,
|
||||
override.username,
|
||||
video?.authorName,
|
||||
video?.creatorName,
|
||||
video?.creator?.name,
|
||||
video?.author?.name,
|
||||
video?.profile?.display_name,
|
||||
video?.profile?.name,
|
||||
video?.profile?.username,
|
||||
];
|
||||
|
||||
let name = "";
|
||||
for (const candidate of nameCandidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
name = trimmed;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!name && shortNpub) {
|
||||
name = shortNpub;
|
||||
}
|
||||
if (!name && npub) {
|
||||
name = npub;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
npub,
|
||||
shortNpub,
|
||||
pubkey,
|
||||
};
|
||||
}
|
||||
|
||||
attachSimilarCardViewCounter(card, pointerInfo) {
|
||||
if (!card || typeof card.getViewCountElement !== "function") {
|
||||
return null;
|
||||
|
||||
@@ -172,6 +172,16 @@ export async function setupModal({ lazyLoad = false } = {}) {
|
||||
setGlobalModalState: () => {},
|
||||
document,
|
||||
logger: console,
|
||||
assets: {
|
||||
fallbackThumbnailSrc: "",
|
||||
},
|
||||
state: {
|
||||
loadedThumbnails: new Map(),
|
||||
},
|
||||
helpers: {
|
||||
safeEncodeNpub: (pubkey) => `npub:${pubkey}`,
|
||||
formatShortNpub: (value) => value,
|
||||
},
|
||||
});
|
||||
|
||||
let playerModal = document.querySelector("#playerModal");
|
||||
|
||||
Reference in new issue
Block a user