From 85258e27ea8e21c04718efa07ca419a864ab7ce8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:58:07 -0400 Subject: [PATCH] Respect NSFW flag while letting authors review their uploads --- config/instance-config.js | 9 ++++++ css/style.css | 19 ++++++++++++ css/tailwind.source.css | 14 +++++++++ js/app.js | 2 ++ js/channelProfile.js | 22 ++++++++++++-- js/config.js | 2 ++ js/services/nostrService.js | 55 ++++++++++++++++++++++++++--------- js/subscriptions.js | 2 ++ js/ui/components/VideoCard.js | 39 +++++++++++++++++++++++++ js/ui/views/VideoListView.js | 31 ++++++++++++++++---- 10 files changed, 174 insertions(+), 21 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 3773da10..ac5fe344 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -106,6 +106,15 @@ export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode"; */ export const DEFAULT_WHITELIST_MODE_ENABLED = true; +/** + * Whether the public client should display content flagged as NSFW. + * + * When set to `false`, BitVid filters out any video notes marked with the + * `isNsfw` flag so they never appear in feeds or video lists. Toggle to `true` + * only if your deployment allows NSFW media to surface publicly. + */ +export const ALLOW_NSFW_CONTENT = false; + /** * Nostr kind used when persisting watch history events. * diff --git a/css/style.css b/css/style.css index 45c2d397..650e8d64 100644 --- a/css/style.css +++ b/css/style.css @@ -171,6 +171,25 @@ header img { var(--shadow-lg); } +.video-card.video-card--nsfw-owner { + background-color: #000; + box-shadow: + 0 0 0 0.0625rem rgba(239, 68, 68, 0.4), + var(--shadow-md); +} + +.video-card.video-card--nsfw-owner:hover { + box-shadow: + 0 0 0 0.0625rem rgba(239, 68, 68, 0.55), + var(--shadow-lg); +} + +.video-card img.video-card__thumbnail--blurred { + filter: blur(18px); + transform: scale(1.05); + transition: filter 200ms ease, transform 200ms ease; +} + .video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] { box-shadow: 0 0 0 0.0625rem rgba(239, 68, 68, 0.35), diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 09ffe42e..9eea7dc4 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -134,6 +134,20 @@ @apply bg-card-private; } + .video-card.video-card--nsfw-owner { + @apply border border-critical border-opacity-40 bg-black; + } + + .video-card.video-card--nsfw-owner:hover { + @apply border-opacity-60 shadow-lg; + } + + .video-card img.video-card__thumbnail--blurred { + @apply transition duration-base ease-in-out; + filter: blur(18px); + transform: scale(1.05); + } + .video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] { @apply border border-critical border-opacity-40 bg-card-critical; } diff --git a/js/app.js b/js/app.js index 95a83d2e..e15a751f 100644 --- a/js/app.js +++ b/js/app.js @@ -8,6 +8,7 @@ import { ADMIN_DM_IMAGE_URL, BITVID_WEBSITE_URL, MAX_WALLET_DEFAULT_ZAP, + ALLOW_NSFW_CONTENT, } from "./config.js"; import { accessControl } from "./accessControl.js"; import { safeDecodeMagnet } from "./magnetUtils.js"; @@ -849,6 +850,7 @@ class Application { renderers: { getLoadingMarkup: (message) => getSidebarLoadingMarkup(message), }, + allowNsfw: ALLOW_NSFW_CONTENT === true, }; this.videoListView = (typeof ui.videoListView === "function" diff --git a/js/channelProfile.js b/js/channelProfile.js index 39790921..ec589ef7 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -12,6 +12,7 @@ import { accessControl } from "./accessControl.js"; import { getApplication } from "./applicationContext.js"; import { escapeHTML } from "./utils/domUtils.js"; import { VideoCard } from "./ui/components/VideoCard.js"; +import { ALLOW_NSFW_CONTENT } from "./config.js"; import { calculateZapShares, describeShareType, @@ -1797,6 +1798,8 @@ function renderChannelVideosFromList({ } }; + const allowNsfw = ALLOW_NSFW_CONTENT === true; + videos.forEach((video) => { if (!video || !video.id || !video.title) { return; @@ -1812,6 +1815,10 @@ function renderChannelVideosFromList({ return; } + if (!allowNsfw && video?.isNsfw === true && !canEdit) { + return; + } + app?.videosMap?.set(video.id, video); const index = renderIndex++; @@ -1837,8 +1844,14 @@ function renderChannelVideosFromList({ ? app.formatTimeAgo(video.created_at) : new Date(video.created_at * 1000).toLocaleString(); - const highlightClass = - canEdit && video.isPrivate ? "video-card--owner-private" : ""; + const highlightClasses = []; + if (canEdit && video.isPrivate) { + highlightClasses.push("video-card--owner-private"); + } + if (!allowNsfw && video?.isNsfw === true && canEdit) { + highlightClasses.push("video-card--nsfw-owner"); + } + const highlightClass = highlightClasses.join(" "); const videoCard = new VideoCard({ document, @@ -1857,6 +1870,11 @@ function renderChannelVideosFromList({ ? app.canCurrentUserManageBlacklist() : false, }, + nsfwContext: { + isNsfw: video?.isNsfw === true, + allowNsfw, + viewerIsOwner: canEdit, + }, helpers: { escapeHtml: (value) => escapeHTML(value), isMagnetSupported: (magnet) => app?.isMagnetUriSupported?.(magnet), diff --git a/js/config.js b/js/config.js index 330819b1..ce79dc44 100644 --- a/js/config.js +++ b/js/config.js @@ -10,6 +10,7 @@ import { DEFAULT_RELAY_URLS_OVERRIDE, ADMIN_WHITELIST_MODE_STORAGE_KEY, DEFAULT_WHITELIST_MODE_ENABLED, + ALLOW_NSFW_CONTENT, WATCH_HISTORY_KIND, WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS, @@ -41,6 +42,7 @@ export { PLATFORM_LUD16_OVERRIDE }; export { DEFAULT_RELAY_URLS_OVERRIDE }; export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional) export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists +export { ALLOW_NSFW_CONTENT }; const WHITELIST_MODE_KEY = ADMIN_WHITELIST_MODE_STORAGE_KEY; const DEFAULT_WHITELIST_ENABLED = DEFAULT_WHITELIST_MODE_ENABLED; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 0a57236a..56299ab7 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -3,6 +3,7 @@ import { convertEventToVideo, } from "../nostr.js"; import { accessControl } from "../accessControl.js"; +import { ALLOW_NSFW_CONTENT } from "../config.js"; import { getVideosMap as getStoredVideosMap, setVideosMap as setStoredVideosMap, @@ -192,6 +193,12 @@ export class NostrService { return false; } + const viewerIsAuthor = this.isViewerVideoAuthor(video); + + if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true && !viewerIsAuthor) { + return false; + } + if (blacklistedEventIds.has(video.id)) { return false; } @@ -206,19 +213,8 @@ export class NostrService { } } - if (video.isPrivate === true) { - const normalizedVideoPubkey = normalizeHexPubkey(video.pubkey); - const normalizedViewerPubkey = normalizeHexPubkey( - this.nostrClient?.pubkey - ); - - if ( - !normalizedViewerPubkey || - !normalizedVideoPubkey || - normalizedVideoPubkey !== normalizedViewerPubkey - ) { - return false; - } + if (video.isPrivate === true && !viewerIsAuthor) { + return false; } if (this.accessControl && typeof this.accessControl.canAccess === "function") { @@ -482,12 +478,26 @@ export class NostrService { async getOldEventById(eventId) { const map = this.ensureVideosMap(); + const isBlockedNsfw = (video) => + ALLOW_NSFW_CONTENT !== true && + video?.isNsfw === true && + !this.isViewerVideoAuthor(video); + if (map.has(eventId)) { - return map.get(eventId); + const existing = map.get(eventId); + if (isBlockedNsfw(existing)) { + map.delete(eventId); + setStoredVideosMap(map); + return null; + } + return existing; } const cached = this.nostrClient.allEvents.get(eventId); if (cached && !cached.deleted) { + if (isBlockedNsfw(cached)) { + return null; + } map.set(eventId, cached); setStoredVideosMap(map); return cached; @@ -495,6 +505,9 @@ export class NostrService { const fetched = await this.nostrClient.getEventById(eventId); if (fetched && !fetched.deleted) { + if (isBlockedNsfw(fetched)) { + return null; + } map.set(eventId, fetched); setStoredVideosMap(map); return fetched; @@ -502,6 +515,20 @@ export class NostrService { return null; } + + isViewerVideoAuthor(video) { + if (!video || typeof video !== "object") { + return false; + } + + const viewerPubkey = normalizeHexPubkey(this.nostrClient?.pubkey); + if (!viewerPubkey) { + return false; + } + + const videoPubkey = normalizeHexPubkey(video.pubkey); + return !!videoPubkey && videoPubkey === viewerPubkey; + } } const nostrService = new NostrService(); diff --git a/js/subscriptions.js b/js/subscriptions.js index 110d8fd2..1c7a2ad1 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -14,6 +14,7 @@ import { } from "./nostrPublish.js"; import { getApplication } from "./applicationContext.js"; import { VideoListView } from "./ui/views/VideoListView.js"; +import { ALLOW_NSFW_CONTENT } from "./config.js"; const getApp = () => getApplication(); @@ -590,6 +591,7 @@ class SubscriptionsManager { renderers: { getLoadingMarkup: (message) => getSidebarLoadingMarkup(message), }, + allowNsfw: ALLOW_NSFW_CONTENT === true, }; const listView = new VideoListView(listViewConfig); diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index d741d224..1b28ac5d 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -16,6 +16,7 @@ export class VideoCard { state = {}, ensureGlobalMoreMenuHandlers, onRequestCloseAllMenus, + nsfwContext = null, } = {}) { if (!doc) { throw new Error("VideoCard requires a document reference."); @@ -72,6 +73,16 @@ export class VideoCard { ? onRequestCloseAllMenus : null; + 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.callbacks = { onPlay: null, onEdit: null, @@ -220,6 +231,7 @@ export class VideoCard { root.dataset.videoId = this.video.id; } + this.applyNsfwContext(); this.applyPointerDataset(); this.applyOwnerDataset(); this.applySourceDatasets(); @@ -340,6 +352,10 @@ export class VideoCard { this.thumbnailEl = img; + if (this.shouldMaskNsfwForOwner) { + img.classList.add("video-card__thumbnail--blurred"); + } + const markThumbnailAsLoaded = () => { if (!thumbnailUrl) { return; @@ -1257,6 +1273,29 @@ export class VideoCard { return container; } + applyNsfwContext() { + if (!this.root) { + return; + } + + if (!this.nsfwContext?.isNsfw) { + if (this.root.dataset.nsfwVisibility) { + delete this.root.dataset.nsfwVisibility; + } + return; + } + + if (this.shouldMaskNsfwForOwner) { + this.root.dataset.nsfwVisibility = "owner-only"; + this.root.classList.add("video-card--nsfw-owner"); + return; + } + + this.root.dataset.nsfwVisibility = this.nsfwContext.allowNsfw + ? "allowed" + : "hidden"; + } + applyPointerDataset() { if (!this.root) { return; diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 72b68e10..5e6f3223 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -19,6 +19,7 @@ export class VideoListView { state = {}, utils = {}, renderers = {}, + allowNsfw = true, } = options; this.document = doc; @@ -163,6 +164,8 @@ export class VideoListView { blacklist: null, }; + this.allowNsfw = allowNsfw !== false; + this.emitter = typeof EventTarget !== "undefined" ? new EventTarget() : null; this._boundClickHandler = this.handleContainerClick.bind(this); @@ -270,9 +273,14 @@ export class VideoListView { const source = Array.isArray(videos) ? videos : []; const dedupedVideos = this.utils.dedupeVideos(source); - const displayVideos = dedupedVideos.filter( - (video) => this.utils.canEditVideo(video) || !video?.isPrivate - ); + const displayVideos = dedupedVideos.filter((video) => { + const canEdit = this.utils.canEditVideo(video); + const isPrivate = video?.isPrivate === true; + if (!this.allowNsfw && video?.isNsfw === true && !canEdit) { + return false; + } + return canEdit || !isPrivate; + }); this.syncViewCountSubscriptions(displayVideos); this.cleanupThumbnailCache(displayVideos); @@ -340,8 +348,16 @@ export class VideoListView { const shareUrl = this.buildShareUrl(video, shareBase); const canEdit = this.utils.canEditVideo(video); const canDelete = this.utils.canDeleteVideo(video); - const highlightClass = - canEdit && video.isPrivate ? "video-card--owner-private" : ""; + const highlightClasses = []; + if (canEdit && video.isPrivate) { + highlightClasses.push("video-card--owner-private"); + } + const viewerSeesBlockedNsfw = + !this.allowNsfw && video?.isNsfw === true && canEdit; + if (viewerSeesBlockedNsfw) { + highlightClasses.push("video-card--nsfw-owner"); + } + const highlightClass = highlightClasses.join(" "); const isNewlyRendered = !previouslyRenderedIds.has(video.id); const animationClass = isNewlyRendered ? "video-card--enter" : ""; const knownPostedAt = this.utils.getKnownVideoPostedAt(video); @@ -379,6 +395,11 @@ export class VideoListView { postedAt: normalizedPostedAt, highlightClass, animationClass, + nsfwContext: { + isNsfw: video?.isNsfw === true, + allowNsfw: this.allowNsfw, + viewerIsOwner: canEdit, + }, capabilities: { canEdit, canDelete,