diff --git a/js/app.js b/js/app.js index 60b89dfb..63c18f5e 100644 --- a/js/app.js +++ b/js/app.js @@ -1879,6 +1879,9 @@ class Application { if (candidate.isPrivate === true) { continue; } + if (candidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) { + continue; + } const candidatePubkey = typeof candidate.pubkey === "string" ? candidate.pubkey : ""; if (candidatePubkey && this.isAuthorBlocked(candidatePubkey)) { diff --git a/tests/app/similar-content.test.mjs b/tests/app/similar-content.test.mjs new file mode 100644 index 00000000..0f0858f2 --- /dev/null +++ b/tests/app/similar-content.test.mjs @@ -0,0 +1,200 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import Application from "../../js/app.js"; + +const computeSimilarContent = Application.prototype.computeSimilarContentCandidates; + +function createAppStub({ videos = [] } = {}) { + const videosMap = new Map(); + for (const video of videos) { + if (video && typeof video.id === "string") { + videosMap.set(video.id, video); + } + } + + return { + videoListView: { currentVideos: videos }, + videosMap, + getKnownVideoPostedAt(video) { + if (Number.isFinite(video?.rootCreatedAt)) { + return Math.floor(video.rootCreatedAt); + } + if (Number.isFinite(video?.created_at)) { + return Math.floor(video.created_at); + } + return null; + }, + buildShareUrlFromEventId(id) { + return id ? `https://bitvid.invalid/${id}` : ""; + }, + formatTimeAgo(timestamp) { + return Number.isFinite(timestamp) ? `${timestamp}s` : ""; + }, + deriveVideoPointerInfo(video) { + if (video?.pointerInfo) { + return video.pointerInfo; + } + if (typeof video?.pointerKey === "string" && video.pointerKey.trim()) { + return { key: video.pointerKey.trim() }; + } + return null; + }, + isAuthorBlocked() { + return false; + }, + }; +} + +function createVideo({ + id, + pubkey = "author", + displayTags, + tags, + rootCreatedAt, + created_at, + pointerKey, + pointerInfo, + shareUrl, + isPrivate = false, + isNsfw = false, +} = {}) { + const video = { + id, + pubkey, + isPrivate, + isNsfw, + }; + + if (Array.isArray(displayTags)) { + video.displayTags = displayTags.slice(); + } + if (Array.isArray(tags)) { + video.tags = tags.slice(); + } + if (Number.isFinite(rootCreatedAt)) { + video.rootCreatedAt = Math.floor(rootCreatedAt); + } + if (Number.isFinite(created_at)) { + video.created_at = Math.floor(created_at); + } + if (typeof pointerKey === "string") { + video.pointerKey = pointerKey; + } + if (pointerInfo) { + video.pointerInfo = pointerInfo; + } + if (typeof shareUrl === "string") { + video.shareUrl = shareUrl; + } + + return video; +} + +test("orders similar videos by shared tags then timestamp", () => { + const activeVideo = createVideo({ + id: "root-video", + displayTags: ["Art", "Music", "News"], + }); + + const candidateA = createVideo({ + id: "video-a", + displayTags: ["art", "music"], + rootCreatedAt: 100, + }); + const candidateB = createVideo({ + id: "video-b", + displayTags: ["art"], + rootCreatedAt: 200, + }); + const candidateC = createVideo({ + id: "video-c", + displayTags: ["art", "music"], + rootCreatedAt: 50, + }); + + const app = createAppStub({ + videos: [candidateA, candidateB, candidateC], + }); + + const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 }); + assert.equal(matches.length, 3); + assert.deepEqual( + matches.map((entry) => entry.video.id), + ["video-a", "video-c", "video-b"], + ); + assert.deepEqual( + matches.map((entry) => entry.sharedTagCount), + [2, 2, 1], + ); +}); + +test("returns no matches when the active video lacks tags", () => { + const activeVideo = createVideo({ + id: "root-video", + }); + + const candidate = createVideo({ + id: "video-a", + displayTags: ["art"], + }); + + const app = createAppStub({ videos: [candidate] }); + const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 }); + assert.deepEqual(matches, []); +}); + +test("skips candidates without tag metadata", () => { + const activeVideo = createVideo({ + id: "root-video", + displayTags: ["art"], + }); + + const candidateWithTags = createVideo({ + id: "video-a", + displayTags: ["art"], + }); + const candidateWithoutTags = createVideo({ + id: "video-b", + }); + + const app = createAppStub({ + videos: [candidateWithTags, candidateWithoutTags], + }); + + const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 }); + assert.equal(matches.length, 1); + assert.equal(matches[0].video.id, "video-a"); +}); + +test("filters NSFW and private videos when NSFW content is disabled", () => { + const activeVideo = createVideo({ + id: "root-video", + displayTags: ["art"], + }); + + const publicVideo = createVideo({ + id: "video-public", + displayTags: ["art"], + }); + const nsfwVideo = createVideo({ + id: "video-nsfw", + displayTags: ["art"], + isNsfw: true, + }); + const privateVideo = createVideo({ + id: "video-private", + displayTags: ["art"], + isPrivate: true, + }); + + const app = createAppStub({ + videos: [publicVideo, nsfwVideo, privateVideo], + }); + + const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 }); + assert.deepEqual( + matches.map((entry) => entry.video.id), + ["video-public"], + ); +}); diff --git a/tests/test-helpers/setup-localstorage.mjs b/tests/test-helpers/setup-localstorage.mjs index ff110ed8..cfda0f66 100644 --- a/tests/test-helpers/setup-localstorage.mjs +++ b/tests/test-helpers/setup-localstorage.mjs @@ -2,6 +2,26 @@ if (typeof globalThis.window === "undefined") { globalThis.window = {}; } +if (typeof globalThis.self === "undefined") { + globalThis.self = globalThis; +} + +if (typeof globalThis.WebSocket === "undefined") { + globalThis.WebSocket = class WebSocket {}; +} + +if (typeof globalThis.navigator === "undefined") { + globalThis.navigator = { userAgent: "node" }; +} + +if (typeof globalThis.window.WebSocket === "undefined") { + globalThis.window.WebSocket = globalThis.WebSocket; +} + +if (typeof globalThis.window.navigator === "undefined") { + globalThis.window.navigator = globalThis.navigator; +} + if (typeof globalThis.localStorage === "undefined") { const store = new Map(); globalThis.localStorage = {