feat: add similar content card for video modal

This commit is contained in:
thePR0M3TH3AN committed 2025-10-24 21:15:52 -04:00
1 parent ff89a9866c
commit 5f1c4ad0bb
4 files changed
+767 -45

No files matched your search

+10
View File
@@ -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 (
+566
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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");