diff --git a/js/nostr/client.js b/js/nostr/client.js index 7e9d1f2b..f6ef3176 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -61,6 +61,7 @@ import { removeWatchHistoryItem as removeWatchHistoryItemWithManager, fetchWatchHistory as fetchWatchHistoryWithManager, resolveWatchHistory as resolveWatchHistoryWithManager, + getWatchHistoryDeferredChunkCount as getWatchHistoryDeferredChunkCountFromManager, } from "./watchHistory.js"; import { buildVideoPostEvent, @@ -978,6 +979,12 @@ export class NostrClient { options, ); } + getWatchHistoryDeferredChunkCount(actorInput) { + return getWatchHistoryDeferredChunkCountFromManager( + this.watchHistory, + actorInput, + ); + } async listVideoViewEvents(pointer, options = {}) { return listVideoViewEventsForClient(this, pointer, options); } diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 66767194..a1a177cd 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -962,6 +962,10 @@ class WatchHistoryManager { this.cacheTtlMs = 0; this.fingerprints = new Map(); this.lastCreatedAt = 0; + // Per-actor count of older chunks left undecrypted by the last bounded + // fetch (see fetch()'s chunkDecryptLimit). Read by the service to decide + // whether to offer "load older". + this.deferredChunkCounts = new Map(); profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -2314,7 +2318,18 @@ class WatchHistoryManager { const fetchResult = await this.fetch(resolvedActor, { forceRefresh: options.forceRefresh || false, + ...(options.chunkDecryptLimit !== undefined + ? { chunkDecryptLimit: options.chunkDecryptLimit } + : {}), }); + // Surface how many older chunks were left undecrypted so the service can + // decide whether to offer "load older". Stored per-actor side-channel so + // resolve() can keep returning a plain items array (its sole caller relies + // on that contract). + this.setDeferredChunkCount( + actorKey, + Number(fetchResult?.deferredChunkCount) || 0, + ); const merged = mergeWatchHistoryItemsWithFallback( { version: 2, @@ -2386,9 +2401,27 @@ class WatchHistoryManager { this.cache.clear(); this.fingerprints.clear(); this.refreshPromises.clear(); + this.deferredChunkCounts.clear(); this.lastCreatedAt = 0; this.storage = null; } + + setDeferredChunkCount(actorInput, count) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return; + } + const normalized = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + this.deferredChunkCounts.set(actorKey, normalized); + } + + getDeferredChunkCount(actorInput) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return 0; + } + return this.deferredChunkCounts.get(actorKey) || 0; + } } export function getWatchHistoryCacheTtlMs(manager) { @@ -2448,6 +2481,10 @@ export function resolveWatchHistory(manager, actorInput, options = {}) { return manager.resolve(actorInput, options); } +export function getWatchHistoryDeferredChunkCount(manager, actorInput) { + return manager.getDeferredChunkCount(actorInput); +} + /** * Create a watch-history manager. Callers supply the nostr client hooks needed * to sign, encrypt, fetch, and publish events. All dependencies are optional; diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index 9ffc5ec3..7bca0ce1 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -7,7 +7,9 @@ import { createWatchHistoryManager, fetchWatchHistory, getWatchHistoryCacheTtlMs, + getWatchHistoryDeferredChunkCount, getWatchHistoryStorage, + resolveWatchHistory, } from "../../js/nostr/watchHistory.js"; import { WATCH_HISTORY_KIND } from "../../js/config.js"; import { NOTE_TYPES } from "../../js/nostrEventSchemas.js"; @@ -559,6 +561,110 @@ test("fetchWatchHistory caps uncached chunk decryption to chunkDecryptLimit, new } }); +test("resolveWatchHistory threads chunkDecryptLimit and records the deferred chunk count", async () => { + // resolve() returns a bare items array (its sole caller depends on that), so + // the "more older chunks available" signal is exposed via a per-actor + // side-channel the service reads to decide whether to offer "load older". + const actorHex = "8".repeat(64); + const newCipher = "cmVzb2x2ZS1uZXctY2lwaGVy"; // base64, no "?iv=" => nip44 + const oldCipher = "cmVzb2x2ZS1vbGQtY2lwaGVy"; + + const pointerEvent = { + id: "pointer-resolve-cap", + pubkey: actorHex, + created_at: 1_700_600_000, + content: JSON.stringify({ + version: 2, + snapshot: "snap-resolve", + items: [], + chunkIndex: 0, + totalChunks: 2, + }), + tags: [ + ["snapshot", "snap-resolve"], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-r-new`], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-r-old`], + ], + }; + const newChunk = { + id: "chunk-r-new-id", + pubkey: actorHex, + created_at: 1_700_600_200, + content: newCipher, + tags: [["d", "chunk-r-new"], ["encrypted", "nip44"]], + }; + const oldChunk = { + id: "chunk-r-old-id", + pubkey: actorHex, + created_at: 1_700_500_000, + content: oldCipher, + tags: [["d", "chunk-r-old"], ["encrypted", "nip44"]], + }; + + const signer = { + nip44Decrypt: async (_pubkey, payload) => + JSON.stringify({ + version: 2, + items: [ + { + type: "e", + value: payload === newCipher ? "r-new" : "r-old", + watchedAt: payload === newCipher ? 200 : 100, + }, + ], + }), + }; + + let listCall = 0; + const pool = { + async list() { + listCall += 1; + if (listCall === 1) { + return [pointerEvent]; + } + return [oldChunk, newChunk]; + }, + }; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorHex, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + getPool: () => pool, + getReadRelays: () => ["wss://relay.example"], + }); + + try { + assert.equal( + getWatchHistoryDeferredChunkCount(manager, actorHex), + 0, + "no deferred chunks before any fetch", + ); + const items = await resolveWatchHistory(manager, actorHex, { + forceRefresh: true, + chunkDecryptLimit: 1, + }); + assert.ok(Array.isArray(items), "resolve must still return a bare items array"); + assert.deepStrictEqual( + items.map((item) => item.value), + ["r-new"], + "only the newest chunk's items should resolve under the cap", + ); + assert.equal( + getWatchHistoryDeferredChunkCount(manager, actorHex), + 1, + "the deferred side-channel should report the older chunk", + ); + } finally { + manager.clear(); + assert.equal( + getWatchHistoryDeferredChunkCount(manager, actorHex), + 0, + "clear() must reset the deferred chunk side-channel", + ); + } +}); + test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => { const actorPubkey = "f".repeat(64); const sessionPrivateKey = "a".repeat(64);