diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index 3704cd6..5e80e60 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -19,6 +19,11 @@ export type ReactionsTask = { type: 'Reactions'; mentionedEventId: string }; export type ZapReceiptsTask = { type: 'ZapReceipts'; mentionedEventId: string }; export type RepostsTask = { type: 'Reposts'; mentionedEventId: string }; export type FollowingsTask = { type: 'Followings'; pubkey: string }; +export type ReplaceableEventTask = { + type: 'ReplaceableEvent'; + kind: number; + author: string; +}; export type ParameterizedReplaceableEventTask = { type: 'ParameterizedReplaceableEvent'; kind: number; @@ -33,6 +38,7 @@ export type TaskArgs = [ ReactionsTask, RepostsTask, ZapReceiptsTask, + ReplaceableEventTask, ParameterizedReplaceableEventTask, ]; @@ -69,6 +75,9 @@ setInterval(() => { setActiveBatchSubscriptions(count); }, 1000); +const keyForReplaceableEvent = ({ kind, author }: { kind: number; author: string }) => + `${kind}:${author}`; + const keyForParameterizedReplaceableEvent = ({ kind, author, @@ -164,6 +173,20 @@ export const tasksRequestBuilder = (tasks: BatchedEventsTask[]) => { filtersBuilder: (ids) => [{ kinds: [Kind.Zap], '#e': ids }], eventKeyExtractor: (ev) => genericEvent(ev).lastTaggedEventId(), }); + const replaceableEventsTasks = createTasks({ + keyExtractor: keyForReplaceableEvent, + filtersBuilder: (keys) => { + const result: Filter[] = []; + keys.forEach((key) => { + const task = replaceableEventsTasks.tasks.get(key)?.[0]; + if (task == null) return; + const { kind, author } = task.req; + result.push({ kinds: [kind], authors: [author] }); + }); + return result; + }, + eventKeyExtractor: (ev) => keyForReplaceableEvent({ kind: ev.kind, author: ev.pubkey }), + }); const parameterizedReplaceableEventsTasks = createTasks({ keyExtractor: keyForParameterizedReplaceableEvent, filtersBuilder: (keys) => { @@ -200,6 +223,8 @@ export const tasksRequestBuilder = (tasks: BatchedEventsTask[]) => { reactionsTasks.add(task); } else if (isBatchedEventsTaskOf('ZapReceipts')(task)) { zapReceiptsTasks.add(task); + } else if (isBatchedEventsTaskOf('ReplaceableEvent')(task)) { + replaceableEventsTasks.add(task); } else if ( isBatchedEventsTaskOf('ParameterizedReplaceableEvent')( task, @@ -218,6 +243,7 @@ export const tasksRequestBuilder = (tasks: BatchedEventsTask[]) => { ...repostsTasks.buildFilter(), ...reactionsTasks.buildFilter(), ...zapReceiptsTasks.buildFilter(), + ...replaceableEventsTasks.buildFilter(), ...parameterizedReplaceableEventsTasks.buildFilter(), ]; @@ -237,6 +263,9 @@ export const tasksRequestBuilder = (tasks: BatchedEventsTask[]) => { if (event.kind === Kind.Zap) { if (zapReceiptsTasks.resolve(event)) return; } + if (Kind.isReplaceableKind(event.kind)) { + if (replaceableEventsTasks.resolve(event)) return; + } if (Kind.isParameterizedReplaceableKind(event.kind)) { if (parameterizedReplaceableEventsTasks.resolve(event)) return; } @@ -255,6 +284,7 @@ export const tasksRequestBuilder = (tasks: BatchedEventsTask[]) => { repostsTasks, reactionsTasks, zapReceiptsTasks, + replaceableEventsTasks, parameterizedReplaceableEventsTasks, }, add, diff --git a/src/nostr/useReplaceableEvent.ts b/src/nostr/useReplaceableEvent.ts new file mode 100644 index 0000000..5e23c86 --- /dev/null +++ b/src/nostr/useReplaceableEvent.ts @@ -0,0 +1,56 @@ +import { createMemo } from 'solid-js'; + +import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; +import { type Event as NostrEvent } from 'nostr-tools/pure'; + +import { pickLatestEvent } from '@/nostr/event/comparator'; +import { registerTask, BatchedEventsTask, ReplaceableEventTask } from '@/nostr/useBatchedEvents'; +import timeout from '@/utils/timeout'; + +export type UseReplacableEventProps = { + kind: number; + author: string; +}; + +export type UseReplaceableEvent = { + event: () => NostrEvent | null; + query: CreateQueryResult; +}; + +const useReplaceableEvent = ( + propsProvider: () => UseReplacableEventProps | null, +): UseReplaceableEvent => { + const queryClient = useQueryClient(); + const props = createMemo(propsProvider); + + const query = createQuery(() => ({ + queryKey: ['useReplaceableEvent', props()] as const, + queryFn: ({ queryKey, signal }) => { + const [, currentProps] = queryKey; + if (currentProps == null) return null; + const { kind, author } = currentProps; + const task = new BatchedEventsTask({ + type: 'ReplaceableEvent', + kind, + author, + }); + const promise = task.firstEventPromise().catch(() => { + throw new Error(`event not found: ${kind}:${author}`); + }); + task.onUpdate((events) => { + const latest = pickLatestEvent(events); + queryClient.setQueryData(queryKey, latest); + }); + registerTask({ task, signal }); + return timeout(15000, `useReplaceableEvent: ${kind}:${author}`)(promise); + }, + staleTime: 5 * 60 * 1000, // 5 min + gcTime: 4 * 60 * 60 * 1000, // 4 hour + })); + + const event = () => query.data ?? null; + + return { event, query }; +}; + +export default useReplaceableEvent;