diff --git a/js/feedEngine/sources.js b/js/feedEngine/sources.js index 418d7af7..6cf89cb9 100644 --- a/js/feedEngine/sources.js +++ b/js/feedEngine/sources.js @@ -92,12 +92,19 @@ export function createSubscriptionAuthorsSource({ service } = {}) { return []; } + const limitCandidate = Number(context?.runtime?.limit); + const limit = + Number.isFinite(limitCandidate) && limitCandidate > 0 + ? Math.floor(limitCandidate) + : null; + const options = { blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds), isAuthorBlocked: typeof context?.runtime?.isAuthorBlocked === "function" ? context.runtime.isAuthorBlocked : () => false, + limit, }; const authorList = Array.from(authors); @@ -129,7 +136,15 @@ export function createSubscriptionAuthorsSource({ service } = {}) { return author && authors.has(author); }); - return filtered.map((video) => ({ + const sorted = filtered + .slice() + .sort( + (a, b) => (Number(b?.created_at) || 0) - (Number(a?.created_at) || 0) + ); + + const limited = limit ? sorted.slice(0, limit) : sorted; + + return limited.map((video) => ({ video, metadata: { source: "nostr:subscriptions", diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 10a72fc1..e9f76122 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -297,6 +297,24 @@ export class NostrService { index.get(author).push(video); } + for (const [author, videos] of index.entries()) { + if (!Array.isArray(videos) || videos.length <= 1) { + continue; + } + videos.sort((a, b) => { + const aCreatedCandidate = Number(a?.created_at); + const bCreatedCandidate = Number(b?.created_at); + const aCreated = Number.isFinite(aCreatedCandidate) + ? aCreatedCandidate + : 0; + const bCreated = Number.isFinite(bCreatedCandidate) + ? bCreatedCandidate + : 0; + return bCreated - aCreated; + }); + index.set(author, videos); + } + this.videosByAuthorIndex = index; this.authorIndexDirty = false; return this.videosByAuthorIndex; @@ -400,13 +418,25 @@ export class NostrService { const index = this.ensureVideosByAuthorIndex(); const collected = []; const seen = new Set(); + const limitCandidate = Number(options?.limit); + const limit = + Number.isFinite(limitCandidate) && limitCandidate > 0 + ? Math.floor(limitCandidate) + : null; + const perAuthorLimit = limit + ? Math.max(limit * 2, limit + 5) + : null; for (const author of normalizedAuthors) { const entries = index.get(author); - if (!Array.isArray(entries)) { + if (!Array.isArray(entries) || entries.length === 0) { continue; } - for (const video of entries) { + const sliceCount = perAuthorLimit + ? Math.min(perAuthorLimit, entries.length) + : entries.length; + for (let idx = 0; idx < sliceCount; idx += 1) { + const video = entries[idx]; if (!video || typeof video !== "object") { continue; } @@ -421,13 +451,19 @@ export class NostrService { const filtered = this.filterVideos(collected, options); - return filtered.sort((a, b) => { + const sorted = filtered.sort((a, b) => { const aCreatedCandidate = Number(a?.created_at); const bCreatedCandidate = Number(b?.created_at); const aCreated = Number.isFinite(aCreatedCandidate) ? aCreatedCandidate : 0; const bCreated = Number.isFinite(bCreatedCandidate) ? bCreatedCandidate : 0; return bCreated - aCreated; }); + + if (limit) { + return sorted.slice(0, limit); + } + + return sorted; } async loadVideos({ diff --git a/js/subscriptions.js b/js/subscriptions.js index 5aa77358..46ae9e16 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -373,7 +373,11 @@ class SubscriptionsManager { } const app = getApp(); - const runtime = this.buildFeedRuntime({ app, authors: channelHexes }); + const runtime = this.buildFeedRuntime({ + app, + authors: channelHexes, + limit, + }); const runOptions = { actorPubkey: userPubkey, limit, @@ -410,11 +414,20 @@ class SubscriptionsManager { "[SubscriptionsManager] Failed to run subscriptions feed:", error ); - container.innerHTML = - "
Unable to load subscriptions right now.
"; - this.lastResult = null; + if (container && this.lastResult) { + const fallbackReason = reason + ? `${reason}:cached` + : "cached-result"; + this.renderSameGridStyle(this.lastResult, containerId, { + limit, + reason: fallbackReason, + }); + } else if (container) { + container.innerHTML = + "Unable to load subscriptions right now.
"; + } this.hasRenderedOnce = Boolean(container); - return null; + return this.lastResult; } finally { this.isRunningFeed = false; this.processScheduledRefresh(); @@ -500,7 +513,7 @@ class SubscriptionsManager { return app?.feedEngine || null; } - buildFeedRuntime({ app, authors = [] } = {}) { + buildFeedRuntime({ app, authors = [], limit = null } = {}) { const normalizedAuthors = Array.isArray(authors) ? authors.filter((author) => typeof author === "string" && author) : []; @@ -515,11 +528,18 @@ class SubscriptionsManager { ? (pubkey) => app.isAuthorBlocked(pubkey) : () => false; + const limitCandidate = Number(limit); + const normalizedLimit = + Number.isFinite(limitCandidate) && limitCandidate > 0 + ? Math.floor(limitCandidate) + : null; + return { subscriptionAuthors: normalizedAuthors, authors: normalizedAuthors, blacklistedEventIds: blacklist, - isAuthorBlocked + isAuthorBlocked, + limit: normalizedLimit }; } diff --git a/tests/subscriptions-feed.test.mjs b/tests/subscriptions-feed.test.mjs index a4ace784..aa669294 100644 --- a/tests/subscriptions-feed.test.mjs +++ b/tests/subscriptions-feed.test.mjs @@ -199,8 +199,65 @@ async function testNoAuthorsSkipsLookup() { assert.deepEqual(result.videos, [], "no authors should yield an empty feed"); } +async function testLimitApplied() { + const engine = createFeedEngine(); + const feedName = "subscriptions-limit"; + + const videos = [ + { id: "video-1", pubkey: "pub1", created_at: 100 }, + { id: "video-2", pubkey: "pub1", created_at: 200 }, + { id: "video-3", pubkey: "pub1", created_at: 300 }, + { id: "video-4", pubkey: "pub1", created_at: 400 }, + { id: "video-5", pubkey: "pub1", created_at: 500 }, + ]; + + const calls = []; + const service = { + async getActiveVideosByAuthors(authors, options) { + calls.push({ authors, options }); + return videos; + }, + async getFilteredActiveVideos() { + throw new Error("limit run should not use fallback lookup"); + }, + }; + + engine.registerFeed(feedName, { + source: createSubscriptionAuthorsSource({ service }), + stages: [ + createBlacklistFilterStage({ shouldIncludeVideo: () => true }), + createDedupeByRootStage(), + ], + sorter: createChronologicalSorter(), + }); + + const result = await engine.runFeed(feedName, { + runtime: { + subscriptionAuthors: ["pub1"], + authors: ["pub1"], + blacklistedEventIds: new Set(), + isAuthorBlocked: () => false, + limit: 3, + }, + }); + + assert.equal(calls.length, 1, "limit run should request targeted lookup once"); + assert.equal( + calls[0]?.options?.limit, + 3, + "limit option should be forwarded to the service lookup", + ); + + assert.deepEqual( + result.videos.map((video) => video.id), + ["video-5", "video-4", "video-3"], + "feed should only include the newest videos within the limit", + ); +} + await testBlockedCreatorsFiltered(); await testDuplicateRootsFiltered(); await testNoAuthorsSkipsLookup(); +await testLimitApplied(); console.log("subscriptions-feed tests passed");