mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Gate subscriptions feed on nostr warmup
This commit is contained in:
1 parent
6e32486ed0
commit
34ef65db7b
3 files changed
+306
-30
No files matched your search
+79
-30
@@ -117,6 +117,9 @@ export class NostrService {
|
||||
this.videosByAuthorIndex = null;
|
||||
this.authorIndexDirty = false;
|
||||
this.moderationService = moderationService || null;
|
||||
this.initialLoadPromise = null;
|
||||
this.initialLoadResolved = false;
|
||||
this.initialLoadResolve = null;
|
||||
try {
|
||||
if (this.moderationService && typeof this.moderationService.setNostrClient === "function") {
|
||||
this.moderationService.setNostrClient(this.nostrClient);
|
||||
@@ -142,6 +145,43 @@ export class NostrService {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
ensureInitialLoadDeferred() {
|
||||
if (!this.initialLoadPromise) {
|
||||
this.initialLoadResolved = false;
|
||||
this.initialLoadPromise = new Promise((resolve) => {
|
||||
this.initialLoadResolve = (value) => {
|
||||
if (this.initialLoadResolved) {
|
||||
return;
|
||||
}
|
||||
this.initialLoadResolved = true;
|
||||
this.initialLoadResolve = null;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return this.initialLoadPromise;
|
||||
}
|
||||
|
||||
resolveInitialLoad(value) {
|
||||
if (this.initialLoadResolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.initialLoadResolve === "function") {
|
||||
try {
|
||||
this.initialLoadResolve(value);
|
||||
} finally {
|
||||
this.initialLoadResolve = null;
|
||||
this.initialLoadResolved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
awaitInitialLoad() {
|
||||
return this.ensureInitialLoadDeferred();
|
||||
}
|
||||
|
||||
getModerationService() {
|
||||
if (!this.moderationService) {
|
||||
return null;
|
||||
@@ -396,42 +436,51 @@ export class NostrService {
|
||||
isAuthorBlocked,
|
||||
onVideos,
|
||||
} = {}) {
|
||||
await this.ensureAccessControlReady();
|
||||
this.ensureInitialLoadDeferred();
|
||||
|
||||
if (forceFetch) {
|
||||
this.clearVideoSubscription();
|
||||
}
|
||||
try {
|
||||
await this.ensureAccessControlReady();
|
||||
|
||||
const applyAndNotify = (videos, reason) => {
|
||||
const filtered = this.filterVideos(videos, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
});
|
||||
this.cacheVideos(filtered);
|
||||
if (typeof onVideos === "function") {
|
||||
try {
|
||||
onVideos(filtered, { reason });
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] onVideos handler threw", error);
|
||||
}
|
||||
if (forceFetch) {
|
||||
this.clearVideoSubscription();
|
||||
}
|
||||
this.emit("videos:updated", { videos: filtered, reason });
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const cached = this.nostrClient.getActiveVideos();
|
||||
const initial = applyAndNotify(cached, "cache");
|
||||
const applyAndNotify = (videos, reason) => {
|
||||
const filtered = this.filterVideos(videos, {
|
||||
blacklistedEventIds,
|
||||
isAuthorBlocked,
|
||||
});
|
||||
this.cacheVideos(filtered);
|
||||
if (typeof onVideos === "function") {
|
||||
try {
|
||||
onVideos(filtered, { reason });
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] onVideos handler threw", error);
|
||||
}
|
||||
}
|
||||
this.emit("videos:updated", { videos: filtered, reason });
|
||||
return filtered;
|
||||
};
|
||||
|
||||
if (!getStoredVideoSubscription()) {
|
||||
const subscription = this.nostrClient.subscribeVideos(() => {
|
||||
const updated = this.nostrClient.getActiveVideos();
|
||||
applyAndNotify(updated, "subscription");
|
||||
});
|
||||
this.setVideoSubscription(subscription);
|
||||
this.emit("subscription:started", { subscription });
|
||||
const cached = this.nostrClient.getActiveVideos();
|
||||
const initial = applyAndNotify(cached, "cache");
|
||||
|
||||
this.resolveInitialLoad({ videos: initial, reason: "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;
|
||||
} catch (error) {
|
||||
this.resolveInitialLoad({ videos: [], reason: "error", error });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return initial;
|
||||
}
|
||||
|
||||
async fetchVideos(options = {}) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { VideoListView } from "./ui/views/VideoListView.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "./config.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import moderationService from "./services/moderationService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
|
||||
const getApp = () => getApplication();
|
||||
|
||||
@@ -36,6 +37,12 @@ class SubscriptionsManager {
|
||||
this.lastRunOptions = null;
|
||||
this.lastResult = null;
|
||||
this.lastContainerId = null;
|
||||
this.unsubscribeFromNostrUpdates = null;
|
||||
this.pendingRefreshPromise = null;
|
||||
this.scheduledRefreshDetail = null;
|
||||
this.isRunningFeed = false;
|
||||
this.hasRenderedOnce = false;
|
||||
this.ensureNostrServiceListener();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +309,7 @@ class SubscriptionsManager {
|
||||
}
|
||||
this.lastRunOptions = null;
|
||||
this.lastResult = null;
|
||||
this.hasRenderedOnce = Boolean(container);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -316,6 +324,7 @@ class SubscriptionsManager {
|
||||
limit,
|
||||
containerId
|
||||
};
|
||||
this.hasRenderedOnce = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -328,6 +337,7 @@ class SubscriptionsManager {
|
||||
containerId
|
||||
};
|
||||
this.lastResult = { items: [], metadata: { reason: "no-subscriptions" } };
|
||||
this.hasRenderedOnce = true;
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
@@ -341,10 +351,24 @@ class SubscriptionsManager {
|
||||
};
|
||||
|
||||
this.ensureFeedRegistered();
|
||||
this.ensureNostrServiceListener();
|
||||
|
||||
if (typeof nostrService?.awaitInitialLoad === "function") {
|
||||
try {
|
||||
await nostrService.awaitInitialLoad();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[SubscriptionsManager] Failed to await nostrService initial load:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const engine = this.getFeedEngine();
|
||||
if (!engine || typeof engine.run !== "function") {
|
||||
container.innerHTML =
|
||||
"<p class='text-muted-strong'>Subscriptions are unavailable right now.</p>";
|
||||
this.hasRenderedOnce = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -362,6 +386,7 @@ class SubscriptionsManager {
|
||||
};
|
||||
|
||||
try {
|
||||
this.isRunningFeed = true;
|
||||
const result = await engine.run("subscriptions", runOptions);
|
||||
|
||||
const videos = Array.isArray(result?.items)
|
||||
@@ -378,6 +403,7 @@ class SubscriptionsManager {
|
||||
|
||||
this.lastResult = result;
|
||||
this.renderSameGridStyle(result, containerId, { limit, reason });
|
||||
this.hasRenderedOnce = true;
|
||||
return result;
|
||||
} catch (error) {
|
||||
userLogger.error(
|
||||
@@ -387,8 +413,72 @@ class SubscriptionsManager {
|
||||
container.innerHTML =
|
||||
"<p class='text-muted-strong'>Unable to load subscriptions right now.</p>";
|
||||
this.lastResult = null;
|
||||
this.hasRenderedOnce = Boolean(container);
|
||||
return null;
|
||||
} finally {
|
||||
this.isRunningFeed = false;
|
||||
this.processScheduledRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
ensureNostrServiceListener() {
|
||||
if (this.unsubscribeFromNostrUpdates || typeof nostrService?.on !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeFromNostrUpdates = nostrService.on(
|
||||
"videos:updated",
|
||||
(detail) => {
|
||||
this.handleNostrVideosUpdated(detail);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
handleNostrVideosUpdated(detail) {
|
||||
if (!detail || !Array.isArray(detail.videos) || !detail.videos.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduledRefreshDetail = detail;
|
||||
this.processScheduledRefresh();
|
||||
}
|
||||
|
||||
processScheduledRefresh() {
|
||||
if (!this.lastRunOptions || !this.hasRenderedOnce) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.scheduledRefreshDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isRunningFeed || this.pendingRefreshPromise) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = this.scheduledRefreshDetail;
|
||||
this.scheduledRefreshDetail = null;
|
||||
|
||||
const refreshReason =
|
||||
typeof detail?.reason === "string" && detail.reason
|
||||
? `nostr:${detail.reason}`
|
||||
: "nostr:update";
|
||||
|
||||
this.pendingRefreshPromise = this.refreshActiveFeed({ reason: refreshReason })
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[SubscriptionsManager] Failed to refresh after nostrService update:",
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingRefreshPromise = null;
|
||||
if (this.scheduledRefreshDetail) {
|
||||
this.processScheduledRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
return this.pendingRefreshPromise;
|
||||
}
|
||||
|
||||
ensureFeedRegistered() {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { nostrClient } from "../js/nostr.js";
|
||||
import { subscriptions } from "../js/subscriptions.js";
|
||||
import { setApplication } from "../js/applicationContext.js";
|
||||
import nostrService from "../js/services/nostrService.js";
|
||||
import moderationService from "../js/services/moderationService.js";
|
||||
|
||||
test("loadSubscriptions aggregates relay results when one rejects", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
@@ -120,3 +125,135 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"showSubscriptionVideos waits for nostrService warm-up and refreshes after updates",
|
||||
async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalNavigator = globalThis.navigator;
|
||||
|
||||
const dom = new JSDOM(
|
||||
"<!doctype html><div id=\"subscriptionsVideoList\"></div>",
|
||||
{ url: "https://example.test/" }
|
||||
);
|
||||
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.navigator = dom.window.navigator;
|
||||
|
||||
const manager = new SubscriptionsManager();
|
||||
|
||||
const originalAwaitInitialLoad = nostrService.awaitInitialLoad;
|
||||
const originalAwaitUserBlockRefresh = moderationService.awaitUserBlockRefresh;
|
||||
|
||||
let resolveInitialLoad;
|
||||
const initialLoadPromise = new Promise((resolve) => {
|
||||
resolveInitialLoad = resolve;
|
||||
});
|
||||
nostrService.awaitInitialLoad = () => initialLoadPromise;
|
||||
|
||||
moderationService.awaitUserBlockRefresh = async () => {};
|
||||
|
||||
const renderCalls = [];
|
||||
manager.renderSameGridStyle = (result, containerId, options) => {
|
||||
renderCalls.push({ result, containerId, options });
|
||||
};
|
||||
|
||||
manager.loadSubscriptions = async () => {
|
||||
manager.loaded = true;
|
||||
manager.subscribedPubkeys = new Set(["pub-1"]);
|
||||
};
|
||||
|
||||
const feedResults = [
|
||||
{ items: [] },
|
||||
{ items: [{ video: { id: "video-1", pubkey: "pub-1" } }] },
|
||||
];
|
||||
const runCalls = [];
|
||||
const feedEngine = {
|
||||
async run(name, options) {
|
||||
runCalls.push({ name, options });
|
||||
return feedResults.shift() ?? { items: [] };
|
||||
},
|
||||
};
|
||||
|
||||
setApplication({
|
||||
feedEngine,
|
||||
videosMap: new Map(),
|
||||
registerSubscriptionsFeed: () => {},
|
||||
isAuthorBlocked: () => false,
|
||||
blacklistedEventIds: new Set(),
|
||||
});
|
||||
|
||||
const showPromise = manager.showSubscriptionVideos(
|
||||
"viewer-pub",
|
||||
"subscriptionsVideoList"
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(
|
||||
runCalls.length,
|
||||
0,
|
||||
"engine.run should not execute before nostrService initial load resolves",
|
||||
);
|
||||
|
||||
resolveInitialLoad();
|
||||
await showPromise;
|
||||
|
||||
assert.equal(runCalls.length, 1, "engine.run should execute after warm-up");
|
||||
assert.equal(renderCalls.length, 1, "initial render should occur once");
|
||||
|
||||
nostrService.emit("videos:updated", {
|
||||
reason: "subscription",
|
||||
videos: [{ id: "video-1", pubkey: "pub-1" }],
|
||||
});
|
||||
|
||||
if (manager.pendingRefreshPromise) {
|
||||
await manager.pendingRefreshPromise;
|
||||
} else {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
runCalls.length,
|
||||
2,
|
||||
"engine.run should execute again after nostrService update",
|
||||
);
|
||||
assert.equal(renderCalls.length, 2, "render should run again after update");
|
||||
assert.equal(
|
||||
renderCalls[1].result.items[0].video.id,
|
||||
"video-1",
|
||||
"updated render should receive fresh videos",
|
||||
);
|
||||
|
||||
if (typeof manager.unsubscribeFromNostrUpdates === "function") {
|
||||
manager.unsubscribeFromNostrUpdates();
|
||||
}
|
||||
|
||||
nostrService.awaitInitialLoad = originalAwaitInitialLoad;
|
||||
moderationService.awaitUserBlockRefresh = originalAwaitUserBlockRefresh;
|
||||
setApplication(null);
|
||||
|
||||
dom.window.close();
|
||||
|
||||
if (typeof originalWindow === "undefined") {
|
||||
delete globalThis.window;
|
||||
} else {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
|
||||
if (typeof originalDocument === "undefined") {
|
||||
delete globalThis.document;
|
||||
} else {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
|
||||
if (typeof originalNavigator === "undefined") {
|
||||
delete globalThis.navigator;
|
||||
} else {
|
||||
globalThis.navigator = originalNavigator;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user