mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Merge pull request #1427 from PR0M3TH3AN/ai/refactor-similar-content-20231026-2020523280376033819
Refactor Similar Content Logic to Controller
This commit is contained in:
3 files changed
+544
-260
No files matched your search
@@ -150,6 +150,7 @@ import {
|
||||
urlHealthConstants,
|
||||
} from "./state/cache.js";
|
||||
import ApplicationBootstrap from "./ui/applicationBootstrap.js";
|
||||
import SimilarContentController from "./ui/similarContentController.js";
|
||||
import VideoModalCommentController from "./ui/videoModalCommentController.js";
|
||||
import TorrentStatusController from "./ui/torrentStatusController.js";
|
||||
import ModerationActionController from "./services/moderationActionController.js";
|
||||
@@ -285,6 +286,7 @@ class Application {
|
||||
});
|
||||
|
||||
this.initializeModerationActionController();
|
||||
this.initializeSimilarContentController();
|
||||
}
|
||||
|
||||
get modalVideo() {
|
||||
@@ -1136,7 +1138,40 @@ class Application {
|
||||
};
|
||||
}
|
||||
|
||||
initializeSimilarContentController() {
|
||||
if (this.similarContentController) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.similarContentController = new SimilarContentController({
|
||||
services: {
|
||||
nostrClient,
|
||||
},
|
||||
callbacks: {
|
||||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||||
decorateVideoModeration: (video) => this.decorateVideoModeration(video),
|
||||
decorateVideoCreatorIdentity: (video) => this.decorateVideoCreatorIdentity(video),
|
||||
},
|
||||
ui: {
|
||||
videoModal: this.videoModal,
|
||||
},
|
||||
state: {
|
||||
getVideoListView: () => this.videoListView,
|
||||
getVideosMap: () => this.videosMap,
|
||||
},
|
||||
helpers: {
|
||||
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
||||
buildShareUrlFromEventId: (id) => this.buildShareUrlFromEventId(id),
|
||||
formatTimeAgo: (ts) => this.formatTimeAgo(ts),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
extractDTagValue(tags) {
|
||||
if (this.similarContentController) {
|
||||
return this.similarContentController.extractDTagValue(tags);
|
||||
}
|
||||
// Fallback if controller not initialized (though it should be)
|
||||
if (!Array.isArray(tags)) {
|
||||
return "";
|
||||
}
|
||||
@@ -1152,6 +1187,10 @@ class Application {
|
||||
}
|
||||
|
||||
deriveVideoPointerInfo(video) {
|
||||
if (this.similarContentController) {
|
||||
return this.similarContentController.deriveVideoPointerInfo(video);
|
||||
}
|
||||
// Fallback if controller not initialized
|
||||
if (!video || typeof video !== "object") {
|
||||
return null;
|
||||
}
|
||||
@@ -1168,271 +1207,20 @@ class Application {
|
||||
});
|
||||
}
|
||||
|
||||
computeSimilarContentCandidates({ activeVideo = this.currentVideo, maxItems = 5 } = {}) {
|
||||
const decorateCandidate = (video) => {
|
||||
if (!video || typeof video !== "object") {
|
||||
return video;
|
||||
}
|
||||
let decoratedVideo = video;
|
||||
if (typeof this.decorateVideoModeration === "function") {
|
||||
try {
|
||||
const decorated = this.decorateVideoModeration(video);
|
||||
if (decorated && typeof decorated === "object") {
|
||||
decoratedVideo = decorated;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to decorate similar content candidate",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof this.decorateVideoCreatorIdentity === "function") {
|
||||
try {
|
||||
const identityDecorated = this.decorateVideoCreatorIdentity(
|
||||
decoratedVideo,
|
||||
);
|
||||
if (identityDecorated && typeof identityDecorated === "object") {
|
||||
decoratedVideo = identityDecorated;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[Application] 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 = [];
|
||||
if (Array.isArray(this.videoListView?.currentVideos) && this.videoListView.currentVideos.length) {
|
||||
candidateSource = this.videoListView.currentVideos;
|
||||
} else if (this.videosMap instanceof Map && this.videosMap.size) {
|
||||
candidateSource = Array.from(this.videosMap.values());
|
||||
} else if (nostrClient && typeof nostrClient.getActiveVideos === "function") {
|
||||
try {
|
||||
const activeVideos = nostrClient.getActiveVideos();
|
||||
if (Array.isArray(activeVideos)) {
|
||||
candidateSource = activeVideos;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] 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 && this.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);
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidate.displayTags) || candidate.displayTags.length === 0) {
|
||||
candidate.displayTags = Array.isArray(candidateTagsSource)
|
||||
? candidateTagsSource.slice()
|
||||
: [];
|
||||
}
|
||||
|
||||
let postedAt = this.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) {
|
||||
shareUrl = this.buildShareUrlFromEventId(candidateId) || "";
|
||||
}
|
||||
|
||||
results.push({
|
||||
video: decoratedCandidate,
|
||||
pointerInfo: pointerInfo || null,
|
||||
shareUrl,
|
||||
postedAt,
|
||||
sharedTagCount: sharedCount,
|
||||
computeSimilarContentCandidates(options = {}) {
|
||||
if (this.similarContentController) {
|
||||
return this.similarContentController.computeCandidates({
|
||||
activeVideo: options.activeVideo || this.currentVideo,
|
||||
maxItems: options.maxItems,
|
||||
});
|
||||
}
|
||||
|
||||
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 ? this.formatTimeAgo(normalizedPostedAt) : "";
|
||||
return {
|
||||
video: entry.video,
|
||||
pointerInfo: entry.pointerInfo,
|
||||
shareUrl: entry.shareUrl,
|
||||
postedAt: normalizedPostedAt,
|
||||
timeAgo,
|
||||
sharedTagCount: entry.sharedTagCount,
|
||||
};
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
updateModalSimilarContent({ activeVideo = this.currentVideo, maxItems } = {}) {
|
||||
if (!this.videoModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null;
|
||||
if (!target) {
|
||||
if (typeof this.videoModal.clearSimilarContent === "function") {
|
||||
this.videoModal.clearSimilarContent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = this.computeSimilarContentCandidates({ activeVideo: target, maxItems });
|
||||
if (matches.length > 0) {
|
||||
if (typeof this.videoModal.setSimilarContent === "function") {
|
||||
this.videoModal.setSimilarContent(matches);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.videoModal.clearSimilarContent === "function") {
|
||||
this.videoModal.clearSimilarContent();
|
||||
if (this.similarContentController) {
|
||||
this.similarContentController.ui.videoModal = this.videoModal;
|
||||
this.similarContentController.updateModal({ activeVideo, maxItems });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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);
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidate.displayTags) || candidate.displayTags.length === 0) {
|
||||
candidate.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
|
||||
import { describe, it, mock, beforeEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import SimilarContentController from "../../js/ui/similarContentController.js";
|
||||
|
||||
describe("SimilarContentController", () => {
|
||||
let controller;
|
||||
let services;
|
||||
let callbacks;
|
||||
let ui;
|
||||
let state;
|
||||
let helpers;
|
||||
|
||||
beforeEach(() => {
|
||||
services = {
|
||||
nostrClient: {
|
||||
getActiveVideos: mock.fn(() => []),
|
||||
},
|
||||
};
|
||||
callbacks = {
|
||||
isAuthorBlocked: mock.fn(() => false),
|
||||
decorateVideoModeration: mock.fn((v) => v),
|
||||
decorateVideoCreatorIdentity: mock.fn((v) => v),
|
||||
};
|
||||
ui = {
|
||||
videoModal: {
|
||||
setSimilarContent: mock.fn(),
|
||||
clearSimilarContent: mock.fn(),
|
||||
},
|
||||
};
|
||||
state = {
|
||||
getVideoListView: mock.fn(() => ({ currentVideos: [] })),
|
||||
getVideosMap: mock.fn(() => new Map()),
|
||||
};
|
||||
helpers = {
|
||||
getKnownVideoPostedAt: mock.fn(() => Date.now()),
|
||||
buildShareUrlFromEventId: mock.fn((id) => `https://example.com/v/${id}`),
|
||||
formatTimeAgo: mock.fn(() => "just now"),
|
||||
};
|
||||
|
||||
controller = new SimilarContentController({
|
||||
services,
|
||||
callbacks,
|
||||
ui,
|
||||
state,
|
||||
helpers,
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDTagValue", () => {
|
||||
it("should return the 'd' tag value", () => {
|
||||
const tags = [["e", "123"], ["d", "my-d-tag"]];
|
||||
const result = controller.extractDTagValue(tags);
|
||||
assert.strictEqual(result, "my-d-tag");
|
||||
});
|
||||
|
||||
it("should return empty string if no 'd' tag", () => {
|
||||
const tags = [["e", "123"]];
|
||||
const result = controller.extractDTagValue(tags);
|
||||
assert.strictEqual(result, "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCandidates", () => {
|
||||
it("should return empty array if no active video", () => {
|
||||
const result = controller.computeCandidates();
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
it("should compute similar candidates based on tags", () => {
|
||||
const activeVideo = {
|
||||
id: "active",
|
||||
displayTags: ["fun", "music"],
|
||||
pubkey: "abc"
|
||||
};
|
||||
const candidate1 = {
|
||||
id: "c1",
|
||||
displayTags: ["fun", "other"],
|
||||
pubkey: "def"
|
||||
};
|
||||
const candidate2 = {
|
||||
id: "c2",
|
||||
displayTags: ["boring"],
|
||||
pubkey: "ghi"
|
||||
};
|
||||
|
||||
state.getVideoListView.mock.mockImplementationOnce(() => ({
|
||||
currentVideos: [candidate1, candidate2]
|
||||
}));
|
||||
|
||||
const result = controller.computeCandidates({ activeVideo });
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].video.id, "c1");
|
||||
assert.strictEqual(result[0].sharedTagCount, 1);
|
||||
});
|
||||
|
||||
it("should exclude blocked authors", () => {
|
||||
const activeVideo = { id: "active", displayTags: ["fun"] };
|
||||
const candidate1 = { id: "c1", displayTags: ["fun"], pubkey: "bad-guy" };
|
||||
|
||||
state.getVideoListView.mock.mockImplementationOnce(() => ({
|
||||
currentVideos: [candidate1]
|
||||
}));
|
||||
|
||||
callbacks.isAuthorBlocked.mock.mockImplementationOnce((pubkey) => pubkey === "bad-guy");
|
||||
|
||||
const result = controller.computeCandidates({ activeVideo });
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
it("should prioritize higher shared tag count", () => {
|
||||
const activeVideo = { id: "active", displayTags: ["a", "b", "c"] };
|
||||
const candidate1 = { id: "c1", displayTags: ["a"] };
|
||||
const candidate2 = { id: "c2", displayTags: ["a", "b"] };
|
||||
|
||||
state.getVideoListView.mock.mockImplementationOnce(() => ({
|
||||
currentVideos: [candidate1, candidate2]
|
||||
}));
|
||||
|
||||
const result = controller.computeCandidates({ activeVideo });
|
||||
assert.strictEqual(result.length, 2);
|
||||
assert.strictEqual(result[0].video.id, "c2"); // 2 shared tags
|
||||
assert.strictEqual(result[1].video.id, "c1"); // 1 shared tag
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateModal", () => {
|
||||
it("should clear similar content if no active video", () => {
|
||||
controller.updateModal();
|
||||
assert.strictEqual(ui.videoModal.clearSimilarContent.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("should set similar content if matches found", () => {
|
||||
const activeVideo = { id: "active", displayTags: ["fun"] };
|
||||
const candidate1 = { id: "c1", displayTags: ["fun"] };
|
||||
|
||||
state.getVideoListView.mock.mockImplementationOnce(() => ({
|
||||
currentVideos: [candidate1]
|
||||
}));
|
||||
|
||||
controller.updateModal({ activeVideo });
|
||||
|
||||
assert.strictEqual(ui.videoModal.setSimilarContent.mock.callCount(), 1);
|
||||
const args = ui.videoModal.setSimilarContent.mock.calls[0].arguments;
|
||||
assert.strictEqual(args[0].length, 1);
|
||||
assert.strictEqual(args[0][0].video.id, "c1");
|
||||
});
|
||||
|
||||
it("should clear similar content if no matches found", () => {
|
||||
const activeVideo = { id: "active", displayTags: ["fun"] };
|
||||
const candidate1 = { id: "c1", displayTags: ["boring"] };
|
||||
|
||||
state.getVideoListView.mock.mockImplementationOnce(() => ({
|
||||
currentVideos: [candidate1]
|
||||
}));
|
||||
|
||||
controller.updateModal({ activeVideo });
|
||||
|
||||
assert.strictEqual(ui.videoModal.clearSimilarContent.mock.callCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user