diff --git a/js/subscriptions.js b/js/subscriptions.js index 6a3e1d64..0ac83a2b 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -339,7 +339,14 @@ class SubscriptionsManager { } if (!this.loaded) { - await this.loadSubscriptions(userPubkey); + try { + await this.loadSubscriptions(userPubkey); + } catch (error) { + userLogger.error( + "[SubscriptionsManager] Failed to load subscriptions while rendering feed:", + error, + ); + } } const channelHexes = this.getSubscribedAuthors(); @@ -422,6 +429,22 @@ class SubscriptionsManager { ? result.items.map((item) => item?.video).filter(Boolean) : []; + const metadata = result && typeof result.metadata === "object" + ? { ...result.metadata } + : {}; + + if (!metadata.feed) { + metadata.feed = "subscriptions"; + } + if (limit) { + metadata.limit = limit; + } + if (reason) { + metadata.reason = reason; + } + + const enrichedResult = { ...result, metadata }; + if (app?.videosMap instanceof Map) { videos.forEach((video) => { if (video && typeof video.id === "string" && video.id) { @@ -430,10 +453,15 @@ class SubscriptionsManager { }); } - this.lastResult = result; - this.renderSameGridStyle(result, containerId, { limit, reason }); + this.lastResult = enrichedResult; + this.renderSameGridStyle(enrichedResult, containerId, { + limit, + reason, + emptyMessage: + "No playable subscription videos found yet. We'll keep watching for new posts.", + }); this.hasRenderedOnce = true; - return result; + return enrichedResult; } catch (error) { userLogger.error( "[SubscriptionsManager] Failed to run subscriptions feed:", @@ -587,6 +615,10 @@ class SubscriptionsManager { ? { ...result.metadata } : {}; + if (!metadata.feed) { + metadata.feed = "subscriptions"; + } + const limitCandidate = Number(options?.limit); const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 @@ -599,10 +631,15 @@ class SubscriptionsManager { .filter((video) => video && typeof video === "object"); if (!videos.length) { - container.innerHTML = ` -
- No videos available yet. -
`; + const reasonDetail = + typeof metadata.reason === "string" && metadata.reason + ? metadata.reason + : "empty"; + this.renderEmptyState(container, { + message: options?.emptyMessage, + reason: reasonDetail, + metadata, + }); return; } @@ -636,6 +673,39 @@ class SubscriptionsManager { listView.render(videos, enrichedMetadata); } + renderEmptyState(container, { message, reason, metadata } = {}) { + if (!container) { + return; + } + + const copy = + typeof message === "string" && message.trim() + ? message.trim() + : "No playable subscription videos found yet. We'll keep watching for new posts."; + + container.innerHTML = getSidebarLoadingMarkup(copy, { showSpinner: false }); + + if (this.subscriptionListView && this.subscriptionListView.state) { + const currentMetadata = + this.subscriptionListView.state.feedMetadata && + typeof this.subscriptionListView.state.feedMetadata === "object" + ? { ...this.subscriptionListView.state.feedMetadata } + : {}; + + if (metadata && typeof metadata === "object") { + Object.assign(currentMetadata, metadata); + } + + if (reason && typeof reason === "string") { + currentMetadata.reason = reason; + } else if (!currentMetadata.reason) { + currentMetadata.reason = "empty"; + } + + this.subscriptionListView.state.feedMetadata = currentMetadata; + } + } + getListView(container, app) { if (this.subscriptionListView) { return this.subscriptionListView; diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 63b6b9c1..5de69640 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -203,6 +203,11 @@ test( assert.equal(runCalls.length, 1, "engine.run should execute after warm-up"); assert.equal(renderCalls.length, 1, "initial render should occur once"); + assert.equal( + renderCalls[0].options.emptyMessage.includes("No playable subscription videos"), + true, + "empty state should provide a descriptive message", + ); nostrService.emit("videos:updated", { reason: "subscription", @@ -226,6 +231,11 @@ test( "video-1", "updated render should receive fresh videos", ); + assert.equal( + renderCalls[1].options.emptyMessage.includes("No playable subscription videos"), + true, + "subsequent renders should retain the empty state message for metadata", + ); if (typeof manager.unsubscribeFromNostrUpdates === "function") { manager.unsubscribeFromNostrUpdates(); @@ -257,3 +267,51 @@ test( }, ); +test("renderSameGridStyle shows empty state message", async () => { + const SubscriptionsManager = subscriptions.constructor; + + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + const dom = new JSDOM( + "", + { url: "https://example.test/" }, + ); + + globalThis.window = dom.window; + globalThis.document = dom.window.document; + + const manager = new SubscriptionsManager(); + + manager.renderSameGridStyle( + { items: [] }, + "subscriptionsVideoList", + { emptyMessage: "Custom empty state copy." }, + ); + + const container = dom.window.document.getElementById("subscriptionsVideoList"); + assert.match( + container.textContent, + /Custom empty state copy\./, + "empty state should render the provided copy", + ); + assert.ok( + !container.textContent.includes("Fetching subscriptions"), + "loading spinner should be removed when rendering the empty state", + ); + + 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; + } +}); +