diff --git a/js/nostr/client.js b/js/nostr/client.js index c66becd8..83335dfc 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -3482,7 +3482,14 @@ export class NostrClient { * @param {function} [params.fetchFn] - Custom fetch function (mocks or specialized logic). Defaults to `pool.list`. * @returns {Promise} Deduplicated list of events found across all relays. */ - async fetchListIncrementally({ kind, pubkey, dTag, relayUrls, fetchFn } = {}) { + async fetchListIncrementally({ + kind, + pubkey, + dTag, + relayUrls, + fetchFn, + since, + } = {}) { if (!kind || !pubkey) { throw new Error("fetchListIncrementally requires kind and pubkey"); } @@ -3514,7 +3521,23 @@ export class NostrClient { for (const chunk of chunks) { const promises = chunk.map(async (relayUrl) => { - const lastSeen = this.getSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl); + let lastSeen = this.getSyncLastSeen( + kind, + normalizedPubkey, + dTag, + relayUrl, + ); + + // If an explicit 'since' override is provided, use it instead of storage. + // This allows services to anchor fetching to their own known state. + if (since !== undefined) { + const overrideSince = Number(since); + lastSeen = + Number.isFinite(overrideSince) && overrideSince >= 0 + ? Math.floor(overrideSince) + : 0; + } + const filter = { kinds: [kind], authors: [normalizedPubkey], diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 4eba4f25..f1535d77 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -477,12 +477,20 @@ class HashtagPreferencesService { // fetchListIncrementally takes a single kind. // kinds is array of [canonicalKind, legacyKind] if different. - const promises = kinds.map(kind => nostrClient.fetchListIncrementally({ - kind, - pubkey: normalized, - dTag: HASHTAG_IDENTIFIER, - relayUrls: relays - })); + // Anchor the fetch to the service's current state to prevent desync. + // If we have cached data, we ask for updates since that timestamp. + // If we have no data, we force a full fetch (since=0). + const since = wasLoadedForUser ? Number(this.eventCreatedAt) || 0 : 0; + + const promises = kinds.map((kind) => + nostrClient.fetchListIncrementally({ + kind, + pubkey: normalized, + dTag: HASHTAG_IDENTIFIER, + relayUrls: relays, + since, + }), + ); const results = await Promise.all(promises); events = results.flat(); diff --git a/tests/nostr/client.test.mjs b/tests/nostr/client.test.mjs index f64f8e8f..97e84cbd 100644 --- a/tests/nostr/client.test.mjs +++ b/tests/nostr/client.test.mjs @@ -85,6 +85,40 @@ describe("NostrClient", () => { // pool.list is called with ([url], [filter]), so arguments[1] is an array assert.equal(filter[0].since, 101); }); + + it("should override lastSeen with explicit since parameter", async () => { + // Mock syncMetadataStore to return something high + client.syncMetadataStore.getLastSeen = () => 500; + + await client.fetchListIncrementally({ + kind: 1, + pubkey: "abc", + relayUrls: ["wss://relay.example.com"], + since: 100 // Explicit override, should be used instead of 500 + }); + + // Verify filter used 'since + 1' + const call = mockPool.list.mock.calls[0]; + const filter = call.arguments[1]; + assert.equal(filter[0].since, 101); + }); + + it("should force full fetch if since is 0", async () => { + // Mock syncMetadataStore to return something + client.syncMetadataStore.getLastSeen = () => 500; + + await client.fetchListIncrementally({ + kind: 1, + pubkey: "abc", + relayUrls: ["wss://relay.example.com"], + since: 0 // Force full fetch + }); + + const call = mockPool.list.mock.calls[0]; + const filter = call.arguments[1]; + // Should NOT have 'since' property + assert.equal(filter[0].since, undefined); + }); }); describe("subscribeVideos", () => {