Add nostr service layer for video operations

This commit is contained in:
thePR0M3TH3AN committed 2025-10-03 13:41:44 -04:00
1 parent 9fa359f9b5
commit 61815ba289
5 files changed
+518 -148

No files matched your search

+53 -132
View File
@@ -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);
}
/**
+17 -10
View File
@@ -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 {
+10 -6
View File
@@ -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"
}
+407
View File
@@ -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;
+31
View File
@@ -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
);
}