diff --git a/src/nostr/event/comparator.test.ts b/src/nostr/event/comparator.test.ts new file mode 100644 index 0000000..2a52b7c --- /dev/null +++ b/src/nostr/event/comparator.test.ts @@ -0,0 +1,132 @@ +import assert from 'assert'; + +import { Event as NostrEvent } from 'nostr-tools'; +import { describe, it } from 'vitest'; + +import { compareEvents, pickLatestEvent } from '@/nostr/event/comparator'; + +describe('compareEvents', () => { + it('should return negative value if first event was made earlier than second one', () => { + const result = compareEvents( + { + id: 'b39d39a256ea0824436f1604830030c78f8a15c42b97036fe68124edc3452cc5', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169670, + kind: 1, + tags: [], + content: 'hello', + sig: '90787036c2cdb31125b7b6ef0ca746458d68e0a1d151243a7f29c7272e7be7ec14a72e425e2cf1cb0d9552e3d5acf97d20d4445ba71a710c5354153eeb9848ae', + }, + { + id: 'ec6a3a0a1061a45502b66df8fa4f3454a32fe11a6ce79bfc4b2affafa0346da9', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169675, + kind: 1, + tags: [], + content: 'hello', + sig: '65ac58f314c99274304c540221e9ff603c26472f91cbb59bf5203e397a686917bb777c6e4c7ac43b27b0b519fc490e91b383d22b9ee8f1efc88175b2c6108c79', + }, + ); + assert(result < 0); + }); + + it('should return positive number if first event was made later than second one', () => { + const result = compareEvents( + { + id: 'ec6a3a0a1061a45502b66df8fa4f3454a32fe11a6ce79bfc4b2affafa0346da9', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169675, + kind: 1, + tags: [], + content: 'hello', + sig: '65ac58f314c99274304c540221e9ff603c26472f91cbb59bf5203e397a686917bb777c6e4c7ac43b27b0b519fc490e91b383d22b9ee8f1efc88175b2c6108c79', + }, + { + id: 'b39d39a256ea0824436f1604830030c78f8a15c42b97036fe68124edc3452cc5', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169670, + kind: 1, + tags: [], + content: 'hello', + sig: '90787036c2cdb31125b7b6ef0ca746458d68e0a1d151243a7f29c7272e7be7ec14a72e425e2cf1cb0d9552e3d5acf97d20d4445ba71a710c5354153eeb9848ae', + }, + ); + assert(result > 0); + }); + + it('should return negative number if both are made at the same time and first id is smaller than second one', () => { + const result = compareEvents( + { + id: '2b4f598e0586b8e54c7fecfd2a1686ef7d91f0e55236bf53d437b100a29c07ea', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169680, + kind: 1, + tags: [], + content: 'hello', + sig: 'a6d20521d9c141852d1a85bd139b13f26457d0cb878909fa92357e8d47b9bdfa3199fdb7c5a1cf1c1f9e38a0450e453577ae8325b5036fdf75ecfa7077e775d9', + }, + { + id: '945a7a4df86e69eeafaba5b4025acb175f67e56a58ad3b8c8ce6532e6aad49a8', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169680, + kind: 1, + tags: [], + content: 'hello world', + sig: 'ca57fbea0f6a0e6a9a93920c204d4b829d0dcc71f0cff9b5e7d91dfe1af2245178d88c34acf9ff72d1afe2d799ef3f4b51a37a8ad2e8a27a9859e58fe984f6ee', + }, + ); + assert(result < 0); + }); +}); + +describe('pickLatestEvent', () => { + it('should return the first event if there is only a single event', () => { + const events: NostrEvent[] = [ + { + id: '2b4f598e0586b8e54c7fecfd2a1686ef7d91f0e55236bf53d437b100a29c07ea', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169680, + kind: 1, + tags: [], + content: 'hello', + sig: 'a6d20521d9c141852d1a85bd139b13f26457d0cb878909fa92357e8d47b9bdfa3199fdb7c5a1cf1c1f9e38a0450e453577ae8325b5036fdf75ecfa7077e775d9', + }, + ]; + const result = pickLatestEvent(events); + assert.deepStrictEqual(result, events[0]); + }); + + it('should return latest event', () => { + const events: NostrEvent[] = [ + { + id: 'b39d39a256ea0824436f1604830030c78f8a15c42b97036fe68124edc3452cc5', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169670, + kind: 1, + tags: [], + content: 'hello', + sig: '90787036c2cdb31125b7b6ef0ca746458d68e0a1d151243a7f29c7272e7be7ec14a72e425e2cf1cb0d9552e3d5acf97d20d4445ba71a710c5354153eeb9848ae', + }, + { + id: 'ec6a3a0a1061a45502b66df8fa4f3454a32fe11a6ce79bfc4b2affafa0346da9', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169675, + kind: 1, + tags: [], + content: 'hello', + sig: '65ac58f314c99274304c540221e9ff603c26472f91cbb59bf5203e397a686917bb777c6e4c7ac43b27b0b519fc490e91b383d22b9ee8f1efc88175b2c6108c79', + }, + { + id: '2b4f598e0586b8e54c7fecfd2a1686ef7d91f0e55236bf53d437b100a29c07ea', + pubkey: '6e62e578bdf608e250e93c25dc0cbadbda8db17e6fc3a28cdce8a2f56db7d106', + created_at: 1696169680, + kind: 1, + tags: [], + content: 'hello', + sig: 'a6d20521d9c141852d1a85bd139b13f26457d0cb878909fa92357e8d47b9bdfa3199fdb7c5a1cf1c1f9e38a0450e453577ae8325b5036fdf75ecfa7077e775d9', + }, + ]; + const result = pickLatestEvent(events); + assert.deepStrictEqual(result, events[2]); + }); +}); diff --git a/src/nostr/event/comparator.ts b/src/nostr/event/comparator.ts new file mode 100644 index 0000000..30ca973 --- /dev/null +++ b/src/nostr/event/comparator.ts @@ -0,0 +1,22 @@ +import { Event as NostrEvent } from 'nostr-tools'; + +/** + * compareEvents compares events by created_at and id. + * + * Comparison by id is defined in NIP-01 for parameterized replaceable events + * but it is used here to ensure consistent results for sorting. + */ +export const compareEvents = (a: NostrEvent, b: NostrEvent): number => { + const diff = a.created_at - b.created_at; + if (diff !== 0) return diff; + return a.id > b.id ? 1 : -1; +}; + +export const pickLatestEvent = (events: NostrEvent[]): NostrEvent | undefined => { + if (events.length === 0) return undefined; + if (events.length === 1) return events[0]; + return events.reduce((a, b) => (compareEvents(a, b) > 0 ? a : b)); +}; + +export const sortEvents = (events: NostrEvent[]) => + Array.from(events).sort((a, b) => -compareEvents(a, b)); diff --git a/src/nostr/query.ts b/src/nostr/query.ts index 3252bdf..480c5be 100644 --- a/src/nostr/query.ts +++ b/src/nostr/query.ts @@ -1,7 +1,9 @@ import { QueryClient, QueryKey } from '@tanstack/solid-query'; +import { uniqBy } from 'lodash'; import { Event as NostrEvent } from 'nostr-tools'; -import { BatchedEventsTask, pickLatestEvent, registerTask } from '@/nostr/useBatchedEvents'; +import { pickLatestEvent, sortEvents } from '@/nostr/event/comparator'; +import { BatchedEventsTask, registerTask } from '@/nostr/useBatchedEvents'; import timeout from '@/utils/timeout'; export const latestEventQuery = @@ -20,7 +22,11 @@ export const latestEventQuery = }); task.onUpdate((events) => { const latest = pickLatestEvent(events); - queryClient.setQueryData(queryKey, latest); + queryClient.setQueryData(queryKey, (prev: NostrEvent | undefined) => + prev == null || (latest != null && latest.created_at > prev.created_at) + ? latest + : undefined, + ); }); registerTask({ task, signal }); return timeout(15000, `${JSON.stringify(queryKey)}`)(promise); @@ -39,7 +45,12 @@ export const eventsQuery = if (task == null) return Promise.resolve([]); const promise = task.toUpdatePromise().catch(() => []); task.onUpdate((events) => { - queryClient.setQueryData(queryKey, events); + // TODO consider kind:5 deletion + queryClient.setQueryData(queryKey, (prev: NostrEvent[] | undefined) => { + if (prev == null) return events; + const deduped = uniqBy([...prev, ...events], (e) => e.id); + return sortEvents(deduped); + }); }); registerTask({ task, signal }); return timeout(15000, `${JSON.stringify(queryKey)}`)(promise); diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index b86f62a..4bdb5d0 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -1,7 +1,8 @@ -import { type Event as NostrEvent, type Filter, Kind } from 'nostr-tools'; +import { type Event as NostrEvent, type Filter, Kind, utils } from 'nostr-tools'; import useConfig from '@/core/useConfig'; import { genericEvent } from '@/nostr/event'; +import { pickLatestEvent } from '@/nostr/event/comparator'; import usePool from '@/nostr/usePool'; import useStats from '@/nostr/useStats'; import ObservableTask from '@/utils/batch/ObservableTask'; @@ -29,19 +30,9 @@ type TaskArg = | RepostsTask | ParameterizedReplaceableEventTask; -export const pickLatestEvent = (events: NostrEvent[]): NostrEvent | null => { - if (events.length === 0) return null; - return events.reduce((a, b) => { - const diff = a.created_at - b.created_at; - if (diff > 0) return a; - if (diff < 0) return b; - return a.id < b.id ? a : b; - }); -}; - export class BatchedEventsTask extends ObservableTask { addEvent(event: NostrEvent) { - this.updateWith((current) => [...(current ?? []), event]); + this.updateWith((current) => utils.insertEventIntoDescendingList(current ?? [], event)); } firstEventPromise(): Promise { diff --git a/src/nostr/useParameterizedReplaceableEvent.ts b/src/nostr/useParameterizedReplaceableEvent.ts index f6c5dbd..beba3f1 100644 --- a/src/nostr/useParameterizedReplaceableEvent.ts +++ b/src/nostr/useParameterizedReplaceableEvent.ts @@ -3,7 +3,8 @@ import { createMemo, observable } from 'solid-js'; import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; import { Event as NostrEvent } from 'nostr-tools'; -import { registerTask, BatchedEventsTask, pickLatestEvent } from '@/nostr/useBatchedEvents'; +import { pickLatestEvent } from '@/nostr/event/comparator'; +import { registerTask, BatchedEventsTask } from '@/nostr/useBatchedEvents'; import timeout from '@/utils/timeout'; // Parameterized Replaceable Event diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index 70b89f0..e5f5392 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -4,6 +4,7 @@ import uniqBy from 'lodash/uniqBy'; import { utils } from 'nostr-tools'; import useConfig from '@/core/useConfig'; +import { sortEvents } from '@/nostr/event/comparator'; import usePool from '@/nostr/usePool'; import useStats from '@/nostr/useStats'; @@ -29,9 +30,6 @@ export type UseSubscriptionProps = { debugId?: string; }; -const sortEvents = (events: NostrEvent[]) => - Array.from(events).sort((a, b) => b.created_at - a.created_at); - let count = 0; const { setActiveSubscriptions } = useStats();