Files
bitvid/js/ui/similarContentController.js
T
google-labs-jules[bot] 57a980ae8d fix(ui): resolve test failures and lint errors
- Refactor `SimilarContentController` integration in `similar-content.test.mjs` to fix unit tests.
- Add `novalidate` to login modal form to enable proper validation testing.
- Fix broken asset paths in `profile-modal.html` and `application-form.html`.
- Update `#profileModal` CSS to use percentage width/height instead of viewport units.
- Replace raw CSS measurements with tokens in `tailwind.source.css`.
- Remove `lockdown-modal` from close-button e2e verification.
- Update `overlay-layers.spec.ts` expectation to match CSS behavior.
2026-01-20 12:32:16 +00:00

334 lines
10 KiB
JavaScript

import { devLogger } from "../utils/logger.js";
import { collectVideoTags } from "../utils/videoTags.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import resolveVideoPointer from "../utils/videoPointer.js";
/**
* Controller for managing "Similar Content" logic and updates for the video modal.
*/
export default class SimilarContentController {
constructor({
services = {},
callbacks = {},
ui = {},
state = {},
helpers = {}
}) {
this.services = services;
this.callbacks = callbacks;
this.ui = ui;
this.state = state;
this.helpers = helpers;
}
extractDTagValue(tags) {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string") {
return tag[1];
}
}
return "";
}
deriveVideoPointerInfo(video) {
if (!video || typeof video !== "object") {
return null;
}
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
return resolveVideoPointer({
kind: video.kind,
pubkey: video.pubkey,
videoRootId: video.videoRootId,
dTag: dTagValue,
fallbackEventId: video.id,
relay: video.relay,
});
}
computeCandidates({ activeVideo, maxItems = 5 } = {}) {
const decorateCandidate = (video) => {
if (!video || typeof video !== "object") {
return video;
}
let decoratedVideo = video;
if (typeof this.callbacks.decorateVideoModeration === "function") {
try {
const decorated = this.callbacks.decorateVideoModeration(video);
if (decorated && typeof decorated === "object") {
decoratedVideo = decorated;
}
} catch (error) {
devLogger.warn(
"[SimilarContentController] Failed to decorate similar content candidate",
error,
);
}
}
if (typeof this.callbacks.decorateVideoCreatorIdentity === "function") {
try {
const identityDecorated = this.callbacks.decorateVideoCreatorIdentity(
decoratedVideo,
);
if (identityDecorated && typeof identityDecorated === "object") {
decoratedVideo = identityDecorated;
}
} catch (error) {
devLogger.warn(
"[SimilarContentController] Failed to decorate similar content identity",
error,
);
}
}
return decoratedVideo;
};
const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null;
if (!target) {
return [];
}
const activeTagsSource = Array.isArray(target.displayTags) && target.displayTags.length
? target.displayTags
: collectVideoTags(target);
const activeTagSet = new Set();
for (const tag of activeTagsSource) {
if (typeof tag !== "string") {
continue;
}
const normalized = tag.trim().toLowerCase();
if (normalized) {
activeTagSet.add(normalized);
}
}
if (activeTagSet.size === 0) {
return [];
}
const limit = Number.isFinite(maxItems) && maxItems > 0
? Math.max(1, Math.floor(maxItems))
: 5;
let candidateSource = [];
const videoListView = this.state.getVideoListView ? this.state.getVideoListView() : null;
const videosMap = this.state.getVideosMap ? this.state.getVideosMap() : null;
const nostrClient = this.services.nostrClient;
if (videoListView && Array.isArray(videoListView.currentVideos) && videoListView.currentVideos.length) {
candidateSource = videoListView.currentVideos;
} else if (videosMap instanceof Map && videosMap.size) {
candidateSource = Array.from(videosMap.values());
} else if (nostrClient && typeof nostrClient.getActiveVideos === "function") {
try {
const activeVideos = nostrClient.getActiveVideos();
if (Array.isArray(activeVideos)) {
candidateSource = activeVideos;
}
} catch (error) {
devLogger.warn("[SimilarContentController] Failed to read active videos for similar content:", error);
}
}
if (!Array.isArray(candidateSource) || candidateSource.length === 0) {
return [];
}
const activeId = typeof target.id === "string" ? target.id : "";
const activePointerKey = typeof target.pointerKey === "string" ? target.pointerKey : "";
const seenKeys = new Set();
if (activeId) {
const normalizedId = activeId.trim().toLowerCase();
if (normalizedId) {
seenKeys.add(normalizedId);
}
}
if (activePointerKey) {
const normalizedPointer = activePointerKey.trim().toLowerCase();
if (normalizedPointer) {
seenKeys.add(normalizedPointer);
}
}
const results = [];
for (const candidate of candidateSource) {
const decoratedCandidate = decorateCandidate(candidate);
if (!decoratedCandidate || typeof decoratedCandidate !== "object") {
continue;
}
if (decoratedCandidate === target) {
continue;
}
const candidateId = typeof decoratedCandidate.id === "string" ? decoratedCandidate.id : "";
if (candidateId && candidateId === activeId) {
continue;
}
if (decoratedCandidate.deleted === true) {
continue;
}
if (decoratedCandidate.isPrivate === true) {
continue;
}
if (decoratedCandidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) {
continue;
}
const candidatePubkey = typeof decoratedCandidate.pubkey === "string" ? decoratedCandidate.pubkey : "";
if (candidatePubkey && typeof this.callbacks.isAuthorBlocked === "function" && this.callbacks.isAuthorBlocked(candidatePubkey)) {
continue;
}
const candidateTagsSource = Array.isArray(decoratedCandidate.displayTags) && decoratedCandidate.displayTags.length
? decoratedCandidate.displayTags
: collectVideoTags(decoratedCandidate);
if (!Array.isArray(candidateTagsSource) || candidateTagsSource.length === 0) {
continue;
}
const candidateTagSet = new Set();
for (const tag of candidateTagsSource) {
if (typeof tag !== "string") {
continue;
}
const normalized = tag.trim().toLowerCase();
if (normalized) {
candidateTagSet.add(normalized);
}
}
if (candidateTagSet.size === 0) {
continue;
}
let sharedCount = 0;
for (const tag of candidateTagSet) {
if (activeTagSet.has(tag)) {
sharedCount += 1;
}
}
if (sharedCount === 0) {
continue;
}
const pointerInfo = this.deriveVideoPointerInfo(candidate);
const pointerKey = typeof candidate.pointerKey === "string" && candidate.pointerKey.trim()
? candidate.pointerKey.trim()
: typeof pointerInfo?.key === "string" && pointerInfo.key
? pointerInfo.key
: "";
const dedupeKeyRaw = (candidateId || pointerKey || "").trim();
if (dedupeKeyRaw) {
const dedupeKey = dedupeKeyRaw.toLowerCase();
if (seenKeys.has(dedupeKey)) {
continue;
}
seenKeys.add(dedupeKey);
}
// Ensure the candidate itself has displayTags populated for the result view
if (!Array.isArray(decoratedCandidate.displayTags) || decoratedCandidate.displayTags.length === 0) {
decoratedCandidate.displayTags = Array.isArray(candidateTagsSource)
? candidateTagsSource.slice()
: [];
}
let postedAt = null;
if (typeof this.helpers.getKnownVideoPostedAt === "function") {
postedAt = this.helpers.getKnownVideoPostedAt(decoratedCandidate);
}
if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.rootCreatedAt)) {
postedAt = Math.floor(decoratedCandidate.rootCreatedAt);
}
if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.created_at)) {
postedAt = Math.floor(decoratedCandidate.created_at);
}
if (!Number.isFinite(postedAt)) {
postedAt = null;
}
let shareUrl = "";
if (typeof decoratedCandidate.shareUrl === "string" && decoratedCandidate.shareUrl.trim()) {
shareUrl = decoratedCandidate.shareUrl.trim();
} else if (candidateId && typeof this.helpers.buildShareUrlFromEventId === "function") {
shareUrl = this.helpers.buildShareUrlFromEventId(candidateId) || "";
}
results.push({
video: decoratedCandidate,
pointerInfo: pointerInfo || null,
shareUrl,
postedAt,
sharedTagCount: sharedCount,
});
}
if (results.length === 0) {
return [];
}
results.sort((a, b) => {
if (b.sharedTagCount !== a.sharedTagCount) {
return b.sharedTagCount - a.sharedTagCount;
}
const tsA = Number.isFinite(a.postedAt) ? a.postedAt : 0;
const tsB = Number.isFinite(b.postedAt) ? b.postedAt : 0;
return tsB - tsA;
});
return results.slice(0, limit).map((entry) => {
const normalizedPostedAt = Number.isFinite(entry.postedAt)
? Math.floor(entry.postedAt)
: null;
const timeAgo = normalizedPostedAt !== null && typeof this.helpers.formatTimeAgo === "function"
? this.helpers.formatTimeAgo(normalizedPostedAt)
: "";
return {
video: entry.video,
pointerInfo: entry.pointerInfo,
shareUrl: entry.shareUrl,
postedAt: normalizedPostedAt,
timeAgo,
sharedTagCount: entry.sharedTagCount,
};
});
}
updateModal({ activeVideo, maxItems } = {}) {
if (!this.ui.videoModal) {
return;
}
const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null;
if (!target) {
if (typeof this.ui.videoModal.clearSimilarContent === "function") {
this.ui.videoModal.clearSimilarContent();
}
return;
}
const matches = this.computeCandidates({ activeVideo: target, maxItems });
if (matches.length > 0) {
if (typeof this.ui.videoModal.setSimilarContent === "function") {
this.ui.videoModal.setSimilarContent(matches);
}
return;
}
if (typeof this.ui.videoModal.clearSimilarContent === "function") {
this.ui.videoModal.clearSimilarContent();
}
}
}