mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- 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.
334 lines
10 KiB
JavaScript
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();
|
|
}
|
|
}
|
|
}
|