From e49483d34db6bb012d249e476a36544858905792 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:04:52 -0400 Subject: [PATCH 1/5] Optimize subscription feed retrieval and rendering --- js/feedEngine/sources.js | 17 ++++++++- js/services/nostrService.js | 42 +++++++++++++++++++++-- js/subscriptions.js | 34 ++++++++++++++---- tests/subscriptions-feed.test.mjs | 57 +++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 11 deletions(-) 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"); From c1d8a8b9dc22619635558958fed52b7864619c12 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:15:47 -0400 Subject: [PATCH 2/5] Normalize subscription authors to fix empty feed --- js/feedEngine/sources.js | 2 +- js/subscriptions.js | 41 +++++++++++++++++++----- tests/subscriptions-feed.test.mjs | 52 +++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/js/feedEngine/sources.js b/js/feedEngine/sources.js index 6cf89cb9..8c3aa03d 100644 --- a/js/feedEngine/sources.js +++ b/js/feedEngine/sources.js @@ -16,7 +16,7 @@ function normalizeAuthor(value) { return ""; } const trimmed = value.trim(); - return trimmed; + return trimmed ? trimmed.toLowerCase() : ""; } function normalizeActorCandidate(...values) { diff --git a/js/subscriptions.js b/js/subscriptions.js index 46ae9e16..6a3e1d64 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -20,6 +20,14 @@ import { devLogger, userLogger } from "./utils/logger.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; +function normalizeHexPubkey(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + const getApp = () => getApplication(); /** @@ -122,7 +130,10 @@ class SubscriptionsManager { const subArray = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; - this.subscribedPubkeys = new Set(subArray); + const normalized = subArray + .map((value) => normalizeHexPubkey(value)) + .filter((value) => Boolean(value)); + this.subscribedPubkeys = new Set(normalized); this.loaded = true; } catch (err) { @@ -131,7 +142,11 @@ class SubscriptionsManager { } isSubscribed(channelHex) { - return this.subscribedPubkeys.has(channelHex); + const normalized = normalizeHexPubkey(channelHex); + if (!normalized) { + return false; + } + return this.subscribedPubkeys.has(normalized); } getSubscribedAuthors() { @@ -139,14 +154,19 @@ class SubscriptionsManager { } async addChannel(channelHex, userPubkey) { + const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); } - if (this.subscribedPubkeys.has(channelHex)) { + if (!normalizedChannel) { + devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex); + return; + } + if (this.subscribedPubkeys.has(normalizedChannel)) { devLogger.log("Already subscribed to", channelHex); return; } - this.subscribedPubkeys.add(channelHex); + this.subscribedPubkeys.add(normalizedChannel); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( @@ -157,14 +177,19 @@ class SubscriptionsManager { } async removeChannel(channelHex, userPubkey) { + const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { throw new Error("No user pubkey => cannot removeChannel."); } - if (!this.subscribedPubkeys.has(channelHex)) { + if (!normalizedChannel) { + devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex); + return; + } + if (!this.subscribedPubkeys.has(normalizedChannel)) { devLogger.log("Channel not found in subscription list:", channelHex); return; } - this.subscribedPubkeys.delete(channelHex); + this.subscribedPubkeys.delete(normalizedChannel); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( @@ -515,7 +540,9 @@ class SubscriptionsManager { buildFeedRuntime({ app, authors = [], limit = null } = {}) { const normalizedAuthors = Array.isArray(authors) - ? authors.filter((author) => typeof author === "string" && author) + ? authors + .map((author) => normalizeHexPubkey(author)) + .filter((author) => Boolean(author)) : []; const blacklist = diff --git a/tests/subscriptions-feed.test.mjs b/tests/subscriptions-feed.test.mjs index aa669294..086a4243 100644 --- a/tests/subscriptions-feed.test.mjs +++ b/tests/subscriptions-feed.test.mjs @@ -255,9 +255,61 @@ async function testLimitApplied() { ); } +async function testAuthorNormalization() { + const engine = createFeedEngine(); + const feedName = "subscriptions-author-normalization"; + + const videos = [ + { id: "video-mixed", pubkey: "pubkey-lower", created_at: 123 }, + ]; + + const calls = []; + const service = { + async getActiveVideosByAuthors(authors, options) { + calls.push({ authors, options }); + return videos; + }, + async getFilteredActiveVideos() { + throw new Error("case normalization run should not fall back to filtered lookup"); + }, + }; + + engine.registerFeed(feedName, { + source: createSubscriptionAuthorsSource({ service }), + stages: [ + createBlacklistFilterStage({ shouldIncludeVideo: () => true }), + createDedupeByRootStage(), + ], + sorter: createChronologicalSorter(), + }); + + const result = await engine.runFeed(feedName, { + runtime: { + subscriptionAuthors: [" PUBKEY-LOWER "], + authors: ["PUBKEY-LOWER"], + blacklistedEventIds: new Set(), + isAuthorBlocked: () => false, + }, + }); + + assert.equal(calls.length, 1, "case normalization run should call targeted lookup once"); + assert.deepEqual( + calls[0].authors, + ["pubkey-lower"], + "authors passed to the service should be lowercased", + ); + + assert.deepEqual( + result.videos.map((video) => video.id), + ["video-mixed"], + "videos should be retained when the service pubkey casing differs", + ); +} + await testBlockedCreatorsFiltered(); await testDuplicateRootsFiltered(); await testNoAuthorsSkipsLookup(); await testLimitApplied(); +await testAuthorNormalization(); console.log("subscriptions-feed tests passed"); From 2322b19a3f9fb7c36d0ad7ad706217418b69dd86 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:39:08 -0400 Subject: [PATCH 3/5] Improve subscriptions empty state rendering --- js/subscriptions.js | 86 +++++++++++++++++++++++++--- tests/subscriptions-manager.test.mjs | 58 +++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) 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; + } +}); + From d7d12c3b744b48016578730014c7fd63f105eb53 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:56:18 -0400 Subject: [PATCH 4/5] Support nip44 subscription list decryption --- js/subscriptions.js | 139 +++++++++++++++++++++++++-- tests/subscriptions-manager.test.mjs | 85 ++++++++++++++++ 2 files changed, 217 insertions(+), 7 deletions(-) diff --git a/js/subscriptions.js b/js/subscriptions.js index 0ac83a2b..7d2f092c 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -28,6 +28,70 @@ function normalizeHexPubkey(value) { return trimmed ? trimmed.toLowerCase() : ""; } +function extractEncryptionHints(event) { + if (!event || typeof event !== "object") { + return []; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const hints = []; + + const pushUnique = (scheme) => { + if (!scheme || hints.includes(scheme)) { + return; + } + hints.push(scheme); + }; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + if (label !== "encrypted" && label !== "encryption") { + continue; + } + const rawValue = typeof tag[1] === "string" ? tag[1] : ""; + if (!rawValue) { + continue; + } + const parts = rawValue + .split(/[\s,]+/) + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + for (const part of parts) { + if (part.startsWith("nip44")) { + pushUnique("nip44"); + } else if (part === "nip04" || part === "nip-04") { + pushUnique("nip04"); + } + } + } + + return hints; +} + +function determineDecryptionOrder(event, availableSchemes) { + const available = Array.isArray(availableSchemes) ? availableSchemes : []; + const availableSet = new Set(available); + const prioritized = []; + + const hints = extractEncryptionHints(event); + for (const hint of hints) { + if (availableSet.has(hint) && !prioritized.includes(hint)) { + prioritized.push(hint); + } + } + + for (const fallback of ["nip04", "nip44"]) { + if (availableSet.has(fallback) && !prioritized.includes(fallback)) { + prioritized.push(fallback); + } + } + + return prioritized.length ? prioritized : available; +} + const getApp = () => getApplication(); /** @@ -112,20 +176,20 @@ class SubscriptionsManager { return; } - let decryptedStr = ""; - try { - decryptedStr = await window.nostr.nip04.decrypt( - userPubkey, - newest.content + const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey); + if (!decryptResult.ok) { + userLogger.error( + "[SubscriptionsManager] Failed to decrypt subscription list:", + decryptResult.error, ); - } catch (errDecrypt) { - userLogger.error("[SubscriptionsManager] Decryption failed:", errDecrypt); this.subscribedPubkeys.clear(); this.subsEventId = null; this.loaded = true; return; } + const decryptedStr = decryptResult.plaintext; + const parsed = JSON.parse(decryptedStr); const subArray = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys @@ -153,6 +217,67 @@ class SubscriptionsManager { return Array.from(this.subscribedPubkeys); } + async decryptSubscriptionEvent(event, userPubkey) { + const ciphertext = typeof event?.content === "string" ? event.content : ""; + if (!ciphertext) { + const error = new Error("Subscription event is missing ciphertext content."); + error.code = "subscriptions-empty-ciphertext"; + return { ok: false, error }; + } + + const nostrApi = + typeof window !== "undefined" && window && window.nostr ? window.nostr : null; + if (!nostrApi) { + const error = new Error("Nostr extension is unavailable for decrypting subscriptions."); + error.code = "nostr-extension-missing"; + return { ok: false, error }; + } + + const decryptors = new Map(); + if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { + decryptors.set("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload)); + } + if (nostrApi.nip44 && typeof nostrApi.nip44.decrypt === "function") { + decryptors.set("nip44", (payload) => nostrApi.nip44.decrypt(userPubkey, payload)); + } + + if (!decryptors.size) { + const error = new Error("No compatible decryption helpers are available."); + error.code = "subscriptions-no-decryptors"; + return { ok: false, error }; + } + + const availableSchemes = Array.from(decryptors.keys()); + const order = determineDecryptionOrder(event, availableSchemes); + const attemptErrors = []; + + for (const scheme of order) { + const decryptFn = decryptors.get(scheme); + if (!decryptFn) { + continue; + } + try { + const plaintext = await decryptFn(ciphertext); + if (typeof plaintext !== "string") { + const error = new Error("Decryption returned a non-string payload."); + error.code = "subscriptions-invalid-plaintext"; + attemptErrors.push({ scheme, error }); + continue; + } + return { ok: true, plaintext, scheme }; + } catch (error) { + attemptErrors.push({ scheme, error }); + } + } + + const error = new Error("Failed to decrypt subscription list with available schemes."); + error.code = "subscriptions-decrypt-failed"; + if (attemptErrors.length) { + error.cause = attemptErrors; + } + return { ok: false, error, errors: attemptErrors }; + } + async addChannel(channelHex, userPubkey) { const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 5de69640..1751f1d3 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -125,6 +125,91 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => } }); +test("loadSubscriptions falls back to nip44 when hinted", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) { + globalThis.window = {}; + } + const originalWindowNostr = globalThis.window.nostr; + + const relayUrls = ["wss://relay-nip44.example"]; + nostrClient.relays = relayUrls; + + const event = { + id: "event-nip44", + created_at: 400, + content: "cipher-nip44", + tags: [["encrypted", "nip44_v2"]], + }; + + nostrClient.pool = { + list() { + return Promise.resolve([event]); + }, + }; + + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const decryptCalls = { nip04: 0, nip44: 0 }; + globalThis.window.nostr = { + nip04: { + async decrypt() { + decryptCalls.nip04 += 1; + throw new Error("nip04 unavailable"); + }, + }, + nip44: { + async decrypt(_pubkey, ciphertext) { + decryptCalls.nip44 += 1; + return JSON.stringify({ subPubkeys: ["pub-nip44"], hint: ciphertext }); + }, + }, + }; + + try { + await manager.loadSubscriptions("user-pubkey-123"); + + assert.deepEqual( + Array.from(manager.subscribedPubkeys), + ["pub-nip44"], + "nip44 decrypted subscriptions should populate the set", + ); + assert.equal(manager.subsEventId, "event-nip44"); + assert.equal(manager.loaded, true); + assert.equal( + decryptCalls.nip44, + 1, + "nip44 decrypt should be invoked once when hinted", + ); + assert.equal( + decryptCalls.nip04, + 0, + "nip04 decrypt should be skipped when nip44 hint is present", + ); + } finally { + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + if (typeof originalWindowNostr === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = originalWindowNostr; + } + if (!hadWindow) { + delete globalThis.window; + } + } +}); + test( "showSubscriptionVideos waits for nostrService warm-up and refreshes after updates", async () => { From c48a138785957e983e9d4c163ede3b3503fd72b0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:56:30 -0400 Subject: [PATCH 5/5] Handle nip44.v2 subscription lists --- js/nostr.js | 2 + js/subscriptions.js | 47 ++++++++++++--- tests/subscriptions-manager.test.mjs | 86 ++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 0b636275..ac41990f 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -202,6 +202,8 @@ export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([ "nip04.decrypt", "nip44.encrypt", "nip44.decrypt", + "nip44.v2.encrypt", + "nip44.v2.decrypt", ]); export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ diff --git a/js/subscriptions.js b/js/subscriptions.js index 7d2f092c..4b32767f 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -61,7 +61,11 @@ function extractEncryptionHints(event) { .filter(Boolean); for (const part of parts) { if (part.startsWith("nip44")) { - pushUnique("nip44"); + if (/^nip44(?:[_-]?v2|v2)/.test(part)) { + pushUnique("nip44_v2"); + } else { + pushUnique("nip44"); + } } else if (part === "nip04" || part === "nip-04") { pushUnique("nip04"); } @@ -77,13 +81,23 @@ function determineDecryptionOrder(event, availableSchemes) { const prioritized = []; const hints = extractEncryptionHints(event); + const aliasMap = { + nip04: ["nip04"], + nip44: ["nip44", "nip44_v2"], + nip44_v2: ["nip44_v2", "nip44"], + }; + for (const hint of hints) { - if (availableSet.has(hint) && !prioritized.includes(hint)) { - prioritized.push(hint); + const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; + for (const candidate of candidates) { + if (availableSet.has(candidate) && !prioritized.includes(candidate)) { + prioritized.push(candidate); + break; + } } } - for (const fallback of ["nip04", "nip44"]) { + for (const fallback of ["nip04", "nip44", "nip44_v2"]) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } @@ -234,11 +248,30 @@ class SubscriptionsManager { } const decryptors = new Map(); + const registerDecryptor = (scheme, handler) => { + if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { + return; + } + decryptors.set(scheme, handler); + }; + if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { - decryptors.set("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload)); + registerDecryptor("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload)); } - if (nostrApi.nip44 && typeof nostrApi.nip44.decrypt === "function") { - decryptors.set("nip44", (payload) => nostrApi.nip44.decrypt(userPubkey, payload)); + + const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; + if (nip44) { + if (typeof nip44.decrypt === "function") { + registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload)); + } + + const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; + if (nip44v2 && typeof nip44v2.decrypt === "function") { + registerDecryptor("nip44_v2", (payload) => nip44v2.decrypt(userPubkey, payload)); + if (!decryptors.has("nip44")) { + registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload)); + } + } } if (!decryptors.size) { diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 1751f1d3..5024132f 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -210,6 +210,92 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => { } }); +test("loadSubscriptions handles nip44.v2 decryptors", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) { + globalThis.window = {}; + } + const originalWindowNostr = globalThis.window.nostr; + + const relayUrls = ["wss://relay-nip44-v2.example"]; + nostrClient.relays = relayUrls; + + const event = { + id: "event-nip44-v2", + created_at: 500, + content: "cipher-nip44-v2", + tags: [["encrypted", "nip44_v2"]], + }; + + nostrClient.pool = { + list() { + return Promise.resolve([event]); + }, + }; + + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const decryptCalls = { nip04: 0, nip44: 0, nip44_v2: 0 }; + globalThis.window.nostr = { + nip44: { + v2: { + async decrypt(_pubkey, ciphertext) { + decryptCalls.nip44_v2 += 1; + return JSON.stringify({ subPubkeys: ["pub-nip44-v2"], hint: ciphertext }); + }, + }, + }, + }; + + try { + await manager.loadSubscriptions("user-pubkey-123"); + + assert.deepEqual( + Array.from(manager.subscribedPubkeys), + ["pub-nip44-v2"], + "nip44.v2 decrypted subscriptions should populate the set", + ); + assert.equal(manager.subsEventId, "event-nip44-v2"); + assert.equal(manager.loaded, true); + assert.equal( + decryptCalls.nip44_v2, + 1, + "nip44.v2 decrypt should be invoked once when provided", + ); + assert.equal( + decryptCalls.nip44, + 0, + "no legacy nip44 decryptors should be invoked when only v2 exists", + ); + assert.equal( + decryptCalls.nip04, + 0, + "nip04 decrypt should not be attempted when unavailable", + ); + } finally { + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + if (typeof originalWindowNostr === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = originalWindowNostr; + } + if (!hadWindow) { + delete globalThis.window; + } + } +}); + test( "showSubscriptionVideos waits for nostrService warm-up and refreshes after updates", async () => {