From 61815ba289cf4d4348a3e76aed9586ffe0680f4e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:41:44 -0400 Subject: [PATCH] Add nostr service layer for video operations --- js/app.js | 185 +++++----------- js/historyView.js | 27 ++- js/nostr.js | 16 +- js/services/nostrService.js | 407 ++++++++++++++++++++++++++++++++++++ js/state/appState.js | 31 +++ 5 files changed, 518 insertions(+), 148 deletions(-) create mode 100644 js/services/nostrService.js diff --git a/js/app.js b/js/app.js index 2ba99973..70e6992d 100644 --- a/js/app.js +++ b/js/app.js @@ -23,6 +23,7 @@ import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import PlaybackService from "./services/playbackService.js"; import AuthService from "./services/authService.js"; +import nostrService from "./services/nostrService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; @@ -583,7 +584,7 @@ class bitvidApp { this.currentVideoPointerKey = null; this.playbackTelemetryState = null; this.loggedViewPointerKeys = new Set(); - this.videoSubscription = null; + this.videoSubscription = nostrService.getVideoSubscription() || null; this.videoList = null; this._videoListElement = null; this._videoListClickHandler = null; @@ -592,7 +593,10 @@ class bitvidApp { this.videoViewCountEl = null; // Videos stored as a Map (key=event.id) - this.videosMap = new Map(); + this.videosMap = nostrService.getVideosMap(); + nostrService.on("subscription:changed", ({ subscription }) => { + this.videoSubscription = subscription || null; + }); this.moreMenuGlobalHandlerBound = false; this.boundMoreMenuDocumentClick = null; this.boundMoreMenuDocumentKeydown = null; @@ -5394,7 +5398,7 @@ class bitvidApp { } try { - await nostrClient.publishVideo(formData, this.pubkey); + await nostrService.publishVideoNote(formData, this.pubkey); if (typeof onSuccess === "function") { await onSuccess(); } @@ -5532,7 +5536,11 @@ class bitvidApp { }; try { - await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); + await nostrService.handleEditVideoSubmit({ + originalEvent, + updatedData, + pubkey: this.pubkey, + }); await this.loadVideos(); this.videosMap.clear(); this.showSuccess("Video updated successfully!"); @@ -5845,16 +5853,9 @@ class bitvidApp { this.pruneDetachedViewCountElements(); } - if (!preserveSubscriptions && this.videoSubscription) { - try { - if (typeof this.videoSubscription.unsub === "function") { - this.videoSubscription.unsub(); - } - } catch (err) { - console.error("Failed to unsubscribe from video feed:", err); - } finally { - this.videoSubscription = null; - } + if (!preserveSubscriptions) { + nostrService.clearVideoSubscription(); + this.videoSubscription = nostrService.getVideoSubscription() || null; } // If there's a small inline player @@ -6951,104 +6952,43 @@ class bitvidApp { async loadVideos(forceFetch = false) { console.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); - try { - await accessControl.ensureReady(); - } catch (error) { - console.warn("Failed to ensure admin lists were loaded before fetching videos:", error); - } - - const shouldIncludeVideo = (video) => { - if (!video || typeof video !== "object") { - return false; - } - - if (this.blacklistedEventIds.has(video.id)) { - return false; - } - - if (this.isAuthorBlocked(video.pubkey)) { - return false; - } - - if (!accessControl.canAccess(video)) { - return false; - } - - return true; - }; - - // If forceFetch is true, unsubscribe from the old subscription to start fresh - if (forceFetch && this.videoSubscription) { - // Call unsubscribe on the subscription object directly. - this.videoSubscription.unsub(); - this.videoSubscription = null; - } - if (this.videoList) { - // Always reset the cached signature before showing the loading state so the - // next render isn't skipped when the incoming payload matches the last - // successful render. Without this, the spinner could remain visible for - // returning users who already have the latest list cached. this.lastRenderedVideoSignature = null; this.videoList.innerHTML = getSidebarLoadingMarkup( "Fetching recent videos…" ); } - // The rest of your existing logic: - if (!this.videoSubscription) { + const videos = await nostrService.loadVideos({ + forceFetch, + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + onVideos: (payload) => this.renderVideoList(payload), + }); - // Create a new subscription - this.videoSubscription = nostrClient.subscribeVideos(() => { - const updatedAll = nostrClient.getActiveVideos(); - - // Filter out blacklisted authors & blacklisted event IDs - const filteredVideos = updatedAll.filter(shouldIncludeVideo); - - this.renderVideoList(filteredVideos); - }); - - const cachedActiveVideos = nostrClient.getActiveVideos(); - const cachedFiltered = cachedActiveVideos.filter(shouldIncludeVideo); - if (cachedFiltered.length) { - this.renderVideoList(cachedFiltered); - } - - if (this.videoSubscription) { - console.log( - "[loadVideos] subscription remains open to get live updates." - ); - } - } else { - // Already subscribed: just show what's cached - const allCached = nostrClient.getActiveVideos(); - - const filteredCached = allCached.filter(shouldIncludeVideo); - - this.renderVideoList(filteredCached); + if (!Array.isArray(videos) || videos.length === 0) { + this.renderVideoList([]); } + + this.videoSubscription = nostrService.getVideoSubscription() || null; + this.videosMap = nostrService.getVideosMap(); } async loadOlderVideos(lastTimestamp) { - // 1) Use nostrClient to fetch older slices - const olderVideos = await nostrClient.fetchOlderVideos(lastTimestamp); + const olderVideos = await nostrService.loadOlderVideos(lastTimestamp, { + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + }); - if (!olderVideos || olderVideos.length === 0) { + if (!Array.isArray(olderVideos) || olderVideos.length === 0) { this.showSuccess("No more older videos found."); return; } - // 2) Merge them into the client’s allEvents / activeMap - for (const v of olderVideos) { - nostrClient.allEvents.set(v.id, v); - // If it’s the newest version for its root, update activeMap - const rootKey = v.videoRootId || v.id; - // You can call getActiveKey(v) if you want to match your code’s approach. - // Then re-check if this one is newer than what’s stored, etc. - } - - // 3) Re-render - const all = nostrClient.getActiveVideos(); + const all = nostrService.getFilteredActiveVideos({ + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + }); this.renderVideoList(all); } @@ -8943,7 +8883,10 @@ class bitvidApp { async handleEditVideo(target) { try { const normalizedTarget = this.normalizeActionTarget(target); - const latestVideos = await nostrClient.fetchVideos(); + const latestVideos = await nostrService.fetchVideos({ + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + }); const video = await this.resolveVideoActionTarget({ ...normalizedTarget, preloadedList: latestVideos, @@ -8981,7 +8924,10 @@ class bitvidApp { async handleRevertVideo(target) { try { const normalizedTarget = this.normalizeActionTarget(target); - const activeVideos = await nostrClient.fetchVideos(); + const activeVideos = await nostrService.fetchVideos({ + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + }); const video = await this.resolveVideoActionTarget({ ...normalizedTarget, preloadedList: activeVideos, @@ -9022,7 +8968,10 @@ class bitvidApp { async handleFullDeleteVideo(target) { try { const normalizedTarget = this.normalizeActionTarget(target); - const all = await nostrClient.fetchVideos(); + const all = nostrService.getFilteredActiveVideos({ + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + }); const video = await this.resolveVideoActionTarget({ ...normalizedTarget, preloadedList: all, @@ -9050,7 +8999,11 @@ class bitvidApp { // We assume video.videoRootId is not empty, or fallback to video.id if needed const rootId = video.videoRootId || video.id; - await nostrClient.deleteAllVersions(rootId, this.pubkey); + await nostrService.handleFullDeleteVideo({ + videoRootId: rootId, + pubkey: this.pubkey, + confirm: false, + }); // Reload await this.loadVideos(); @@ -9952,39 +9905,7 @@ class bitvidApp { * this.videosMap or from a bulk fetch. Uses nostrClient.getEventById. */ async getOldEventById(eventId) { - // 1) Already in our local videosMap? - let video = this.videosMap.get(eventId); - if (video) { - return video; - } - - // 2) Already in nostrClient.allEvents? - // (assuming nostrClient.allEvents is a Map of id => video) - const fromAll = nostrClient.allEvents.get(eventId); - if (fromAll && !fromAll.deleted) { - this.videosMap.set(eventId, fromAll); - return fromAll; - } - - // 3) Direct single-event fetch (fewer resources than full fetchVideos) - const single = await nostrClient.getEventById(eventId); - if (single && !single.deleted) { - this.videosMap.set(single.id, single); - return single; - } - - // 4) If you wanted a final fallback, you could do it here: - // But it's typically better to avoid repeated full fetches - // console.log("Falling back to full fetchVideos..."); - // const allFetched = await nostrClient.fetchVideos(); - // video = allFetched.find(v => v.id === eventId && !v.deleted); - // if (video) { - // this.videosMap.set(video.id, video); - // return video; - // } - - // Not found or was deleted - return null; + return nostrService.getOldEventById(eventId); } /** diff --git a/js/historyView.js b/js/historyView.js index ffd321a2..b8f25c0c 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -7,6 +7,7 @@ import { nostrClient, updateWatchHistoryList, } from "./nostr.js"; +import nostrService from "./services/nostrService.js"; import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_BATCH_PAGE_SIZE, @@ -409,11 +410,15 @@ async function resolveVideoFromPointer(pointer, caches) { } } + const filterOptions = { + blacklistedEventIds: app?.blacklistedEventIds, + isAuthorBlocked: (pubkey) => + (typeof app?.isAuthorBlocked === "function" && app.isAuthorBlocked(pubkey)) || false, + }; + const activeVideos = Array.isArray(caches?.activeVideos) ? caches.activeVideos - : typeof nostrClient?.getActiveVideos === "function" - ? nostrClient.getActiveVideos() - : []; + : nostrService.getFilteredActiveVideos(filterOptions); for (const candidate of activeVideos) { if (compareAddress(candidate)) { const normalized = normalizeVideo(candidate); @@ -423,13 +428,15 @@ async function resolveVideoFromPointer(pointer, caches) { } } - if (!caches?.catalogPromise && typeof nostrClient?.fetchVideos === "function") { - caches.catalogPromise = nostrClient.fetchVideos().catch((error) => { - if (isDevEnv) { - console.warn("[historyView] Failed to fetch video catalog:", error); - } - return []; - }); + if (!caches?.catalogPromise) { + caches.catalogPromise = nostrService + .fetchVideos(filterOptions) + .catch((error) => { + if (isDevEnv) { + console.warn("[historyView] Failed to fetch video catalog:", error); + } + return []; + }); } if (caches?.catalogPromise) { try { diff --git a/js/nostr.js b/js/nostr.js index 621fb452..c57d4ec1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -4611,17 +4611,21 @@ class NostrClient { * * This version now asks for confirmation before proceeding. */ - async deleteAllVersions(videoRootId, pubkey) { + async deleteAllVersions(videoRootId, pubkey, options = {}) { if (!pubkey) { throw new Error("Not logged in to delete all versions."); } - // Ask for confirmation before proceeding - if ( - !window.confirm( + const shouldConfirm = options?.confirm !== false; + let confirmed = true; + + if (shouldConfirm && typeof window?.confirm === "function") { + confirmed = window.confirm( "Are you sure you want to delete all versions of this video? This action cannot be undone." - ) - ) { + ); + } + + if (!confirmed) { console.log("Deletion cancelled by user."); return null; // Cancel deletion if user clicks "Cancel" } diff --git a/js/services/nostrService.js b/js/services/nostrService.js new file mode 100644 index 00000000..99a6fbba --- /dev/null +++ b/js/services/nostrService.js @@ -0,0 +1,407 @@ +import { + nostrClient, + convertEventToVideo, +} from "../nostr.js"; +import { accessControl } from "../accessControl.js"; +import { + getVideosMap as getStoredVideosMap, + setVideosMap as setStoredVideosMap, + getVideoSubscription as getStoredVideoSubscription, + setVideoSubscription as setStoredVideoSubscription, +} from "../state/appState.js"; + +const VIDEO_KIND = 30078; + +class SimpleEventEmitter { + constructor(logger = null) { + this.logger = typeof logger === "function" ? logger : null; + this.listeners = new Map(); + } + + on(eventName, handler) { + if (typeof handler !== "function") { + return () => {}; + } + + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + + const handlers = this.listeners.get(eventName); + handlers.add(handler); + + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, detail) { + const handlers = this.listeners.get(eventName); + if (!handlers || !handlers.size) { + return; + } + + for (const handler of Array.from(handlers)) { + try { + handler(detail); + } catch (error) { + if (this.logger) { + try { + this.logger(`nostrService listener for "${eventName}" threw`, error); + } catch (logError) { + console.warn("[nostrService] listener logger threw", logError); + } + } + } + } + } +} + +function normalizeLogger(logger) { + if (typeof logger === "function") { + return logger; + } + if (logger && typeof logger.log === "function") { + return (...args) => logger.log(...args); + } + return () => {}; +} + +function ensureSet(value) { + if (value instanceof Set) { + return value; + } + if (Array.isArray(value)) { + return new Set(value); + } + return new Set(); +} + +function normalizeUntil(timestamp) { + const value = Number(timestamp); + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + const seconds = Math.floor(value); + return seconds > 0 ? seconds : 0; +} + +export class NostrService { + constructor({ logger } = {}) { + this.nostrClient = nostrClient; + this.accessControl = accessControl; + this.logger = normalizeLogger(logger); + this.emitter = new SimpleEventEmitter((message, error) => { + try { + this.logger(message, error); + } catch (logError) { + console.warn("[nostrService] logger threw", logError); + } + }); + this.videosMap = null; + } + + log(...args) { + try { + this.logger(...args); + } catch (error) { + console.warn("[nostrService] logger threw", error); + } + } + + on(eventName, handler) { + return this.emitter.on(eventName, handler); + } + + emit(eventName, detail) { + this.emitter.emit(eventName, detail); + } + + ensureVideosMap() { + if (this.videosMap instanceof Map) { + return this.videosMap; + } + + let map = getStoredVideosMap(); + if (!(map instanceof Map)) { + map = new Map(); + setStoredVideosMap(map); + } + + this.videosMap = map; + return this.videosMap; + } + + getVideosMap() { + return this.ensureVideosMap(); + } + + getVideoSubscription() { + return getStoredVideoSubscription(); + } + + setVideoSubscription(subscription) { + setStoredVideoSubscription(subscription || null); + this.emit("subscription:changed", { subscription: subscription || null }); + } + + clearVideoSubscription() { + const current = getStoredVideoSubscription(); + if (current && typeof current.unsub === "function") { + try { + current.unsub(); + } catch (error) { + console.warn("[nostrService] Failed to unsubscribe from video feed:", error); + } + } + this.setVideoSubscription(null); + } + + cacheVideos(videos = []) { + if (!Array.isArray(videos) || !videos.length) { + return; + } + + const map = this.ensureVideosMap(); + for (const video of videos) { + if (video && typeof video.id === "string" && video.id) { + map.set(video.id, video); + } + } + setStoredVideosMap(map); + this.emit("videos:cache", { size: map.size }); + } + + shouldIncludeVideo(video, { + blacklistedEventIds = new Set(), + isAuthorBlocked = () => false, + } = {}) { + if (!video || typeof video !== "object") { + return false; + } + + if (blacklistedEventIds.has(video.id)) { + return false; + } + + if (typeof video.pubkey === "string" && video.pubkey) { + try { + if (isAuthorBlocked(video.pubkey)) { + return false; + } + } catch (error) { + console.warn("[nostrService] isAuthorBlocked handler threw", error); + } + } + + if (this.accessControl && typeof this.accessControl.canAccess === "function") { + try { + if (!this.accessControl.canAccess(video)) { + return false; + } + } catch (error) { + console.warn("[nostrService] access control check failed", error); + return false; + } + } + + return true; + } + + filterVideos(videos = [], options = {}) { + const blacklist = ensureSet(options.blacklistedEventIds); + const isAuthorBlocked = + typeof options.isAuthorBlocked === "function" + ? options.isAuthorBlocked + : () => false; + + return videos.filter((video) => + this.shouldIncludeVideo(video, { + blacklistedEventIds: blacklist, + isAuthorBlocked, + }) + ); + } + + async ensureAccessControlReady() { + if (!this.accessControl || typeof this.accessControl.ensureReady !== "function") { + return; + } + + try { + await this.accessControl.ensureReady(); + } catch (error) { + console.warn( + "[nostrService] Failed to ensure access control lists are ready:", + error + ); + } + } + + getFilteredActiveVideos(options = {}) { + const all = this.nostrClient.getActiveVideos(); + return this.filterVideos(all, options); + } + + async loadVideos({ + forceFetch = false, + blacklistedEventIds, + isAuthorBlocked, + onVideos, + } = {}) { + await this.ensureAccessControlReady(); + + if (forceFetch) { + this.clearVideoSubscription(); + } + + const applyAndNotify = (videos, reason) => { + const filtered = this.filterVideos(videos, { + blacklistedEventIds, + isAuthorBlocked, + }); + this.cacheVideos(filtered); + if (typeof onVideos === "function") { + try { + onVideos(filtered, { reason }); + } catch (error) { + console.warn("[nostrService] onVideos handler threw", error); + } + } + this.emit("videos:updated", { videos: filtered, reason }); + return filtered; + }; + + const cached = this.nostrClient.getActiveVideos(); + const initial = applyAndNotify(cached, "cache"); + + if (!getStoredVideoSubscription()) { + const subscription = this.nostrClient.subscribeVideos(() => { + const updated = this.nostrClient.getActiveVideos(); + applyAndNotify(updated, "subscription"); + }); + this.setVideoSubscription(subscription); + this.emit("subscription:started", { subscription }); + } + + return initial; + } + + async fetchVideos(options = {}) { + try { + const videos = await this.nostrClient.fetchVideos(); + const filtered = this.filterVideos(videos, options); + this.cacheVideos(filtered); + this.emit("videos:fetched", { videos: filtered }); + return filtered; + } catch (error) { + console.error("[nostrService] Failed to fetch videos:", error); + return []; + } + } + + async loadOlderVideos(lastTimestamp, { + blacklistedEventIds, + isAuthorBlocked, + limit = 150, + } = {}) { + const until = normalizeUntil(lastTimestamp) - 1; + if (until <= 0 || !this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) { + return []; + } + + const filter = { + kinds: [VIDEO_KIND], + "#t": ["video"], + until, + limit, + }; + + const collected = new Map(); + + try { + const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]); + for (const event of Array.isArray(events) ? events : []) { + try { + const video = convertEventToVideo(event); + if (video.invalid) { + continue; + } + if (collected.has(video.id)) { + continue; + } + collected.set(video.id, video); + this.nostrClient.allEvents.set(video.id, video); + } catch (error) { + console.warn("[nostrService] Failed to convert older event", error); + } + } + } catch (error) { + console.error("[nostrService] Failed to load older videos:", error); + return []; + } + + const videos = Array.from(collected.values()); + const filtered = this.filterVideos(videos, { + blacklistedEventIds, + isAuthorBlocked, + }); + + this.cacheVideos(filtered); + this.emit("videos:older", { videos: filtered, until }); + + return filtered; + } + + async publishVideoNote(formData, pubkey) { + const result = await this.nostrClient.publishVideo(formData, pubkey); + this.emit("videos:published", { formData, pubkey, result }); + return result; + } + + async handleEditVideoSubmit({ originalEvent, updatedData, pubkey }) { + const result = await this.nostrClient.editVideo(originalEvent, updatedData, pubkey); + this.emit("videos:edited", { originalEvent, updatedData, pubkey, result }); + return result; + } + + async handleFullDeleteVideo({ videoRootId, pubkey, confirm = true } = {}) { + const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, { + confirm, + }); + if (result) { + this.emit("videos:deleted", { videoRootId, pubkey }); + } + return result; + } + + async getOldEventById(eventId) { + const map = this.ensureVideosMap(); + if (map.has(eventId)) { + return map.get(eventId); + } + + const cached = this.nostrClient.allEvents.get(eventId); + if (cached && !cached.deleted) { + map.set(eventId, cached); + setStoredVideosMap(map); + return cached; + } + + const fetched = await this.nostrClient.getEventById(eventId); + if (fetched && !fetched.deleted) { + map.set(eventId, fetched); + setStoredVideosMap(map); + return fetched; + } + + return null; + } +} + +const nostrService = new NostrService(); + +export default nostrService; diff --git a/js/state/appState.js b/js/state/appState.js index 03d4a86f..efde8cd2 100644 --- a/js/state/appState.js +++ b/js/state/appState.js @@ -3,6 +3,8 @@ const state = { currentUserNpub: null, currentVideo: null, modals: Object.create(null), + videosMap: null, + videoSubscription: null, }; const globalSubscribers = new Set(); @@ -15,6 +17,8 @@ function createSnapshot() { currentUserNpub: state.currentUserNpub, currentVideo: state.currentVideo, modals: { ...state.modals }, + videosMap: state.videosMap, + videoSubscription: state.videoSubscription, }; } @@ -118,6 +122,25 @@ export function setCurrentVideo(value) { return updateKey("currentVideo", value ?? null); } +export function getVideosMap() { + return state.videosMap; +} + +export function setVideosMap(value) { + if (value instanceof Map) { + return updateKey("videosMap", value); + } + return updateKey("videosMap", value ?? null); +} + +export function getVideoSubscription() { + return state.videoSubscription; +} + +export function setVideoSubscription(value) { + return updateKey("videoSubscription", value ?? null); +} + export function getModalState(name) { if (typeof name !== "string" || !name) { return false; @@ -183,8 +206,16 @@ export function resetAppState() { state.currentUserNpub = null; state.currentVideo = null; state.modals = Object.create(null); + state.videosMap = null; + state.videoSubscription = null; notifyKey("pubkey", state.pubkey, previous.pubkey); notifyKey("currentUserNpub", state.currentUserNpub, previous.currentUserNpub); notifyKey("currentVideo", state.currentVideo, previous.currentVideo); notifyKey("modals", { ...state.modals }, previous.modals); + notifyKey("videosMap", state.videosMap, previous.videosMap); + notifyKey( + "videoSubscription", + state.videoSubscription, + previous.videoSubscription + ); }