diff --git a/src/nostr/query.ts b/src/nostr/query.ts new file mode 100644 index 0000000..3252bdf --- /dev/null +++ b/src/nostr/query.ts @@ -0,0 +1,46 @@ +import { QueryClient, QueryKey } from '@tanstack/solid-query'; +import { Event as NostrEvent } from 'nostr-tools'; + +import { BatchedEventsTask, pickLatestEvent, registerTask } from '@/nostr/useBatchedEvents'; +import timeout from '@/utils/timeout'; + +export const latestEventQuery = + ({ + taskProvider, + queryClient, + }: { + taskProvider: (arg: K) => BatchedEventsTask | undefined | null; + queryClient: QueryClient; + }) => + ({ queryKey, signal }: { queryKey: K; signal?: AbortSignal }): Promise => { + const task = taskProvider(queryKey); + if (task == null) return Promise.resolve(null); + const promise = task.firstEventPromise().catch(() => { + throw new Error(`event not found: ${JSON.stringify(queryKey)}`); + }); + task.onUpdate((events) => { + const latest = pickLatestEvent(events); + queryClient.setQueryData(queryKey, latest); + }); + registerTask({ task, signal }); + return timeout(15000, `${JSON.stringify(queryKey)}`)(promise); + }; + +export const eventsQuery = + ({ + taskProvider, + queryClient, + }: { + taskProvider: (arg: K) => BatchedEventsTask | undefined | null; + queryClient: QueryClient; + }) => + ({ queryKey, signal }: { queryKey: K; signal?: AbortSignal }): Promise => { + const task = taskProvider(queryKey); + if (task == null) return Promise.resolve([]); + const promise = task.toUpdatePromise().catch(() => []); + task.onUpdate((events) => { + queryClient.setQueryData(queryKey, events); + }); + registerTask({ task, signal }); + return timeout(15000, `${JSON.stringify(queryKey)}`)(promise); + }; diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index 79b77da..b86f62a 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -29,6 +29,16 @@ 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]); @@ -37,6 +47,14 @@ export class BatchedEventsTask extends ObservableTask { firstEventPromise(): Promise { return this.toUpdatePromise().then((events) => events[0]); } + + latestEventPromise(): Promise { + return this.toCompletePromise().then((events) => { + const latest = pickLatestEvent(events); + if (latest == null) throw new Error('event not found'); + return latest; + }); + } } let count = 0; @@ -219,13 +237,3 @@ export const registerTask = ({ addTask(task); signal?.addEventListener('abort', () => removeTask(task)); }; - -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; - }); -}; diff --git a/src/nostr/useFollowers.ts b/src/nostr/useFollowers.ts index 7cab8ce..5c98eb0 100644 --- a/src/nostr/useFollowers.ts +++ b/src/nostr/useFollowers.ts @@ -1,4 +1,4 @@ -import { createMemo, createSignal } from 'solid-js'; +import { createMemo } from 'solid-js'; import uniq from 'lodash/uniq'; import { Kind } from 'nostr-tools'; diff --git a/src/nostr/useFollowings.ts b/src/nostr/useFollowings.ts index 3103fdb..afc9b35 100644 --- a/src/nostr/useFollowings.ts +++ b/src/nostr/useFollowings.ts @@ -1,11 +1,11 @@ -import { createMemo, observable } from 'solid-js'; +import { createMemo } from 'solid-js'; import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; import { Event as NostrEvent } from 'nostr-tools'; import { genericEvent } from '@/nostr/event'; -import { registerTask, BatchedEventsTask, pickLatestEvent } from '@/nostr/useBatchedEvents'; -import timeout from '@/utils/timeout'; +import { latestEventQuery } from '@/nostr/query'; +import { BatchedEventsTask, registerTask } from '@/nostr/useBatchedEvents'; type Following = { pubkey: string; @@ -24,6 +24,15 @@ export type UseFollowings = { query: CreateQueryResult; }; +export const fetchLatestFollowings = ( + { pubkey }: UseFollowingsProps, + signal?: AbortSignal, +): Promise => { + const task = new BatchedEventsTask({ type: 'Followings', pubkey }); + registerTask({ task, signal }); + return task.latestEventPromise(); +}; + const useFollowings = (propsProvider: () => UseFollowingsProps | null): UseFollowings => { const queryClient = useQueryClient(); const props = createMemo(propsProvider); @@ -31,22 +40,14 @@ const useFollowings = (propsProvider: () => UseFollowingsProps | null): UseFollo const query = createQuery( genQueryKey, - ({ queryKey, signal }) => { - console.debug('useFollowings'); - const [, currentProps] = queryKey; - if (currentProps == null) return Promise.resolve(null); - const { pubkey } = currentProps; - const task = new BatchedEventsTask({ type: 'Followings', pubkey }); - const promise = task.firstEventPromise().catch(() => { - throw new Error(`followings not found: ${pubkey}`); - }); - task.onUpdate((events) => { - const latest = pickLatestEvent(events); - queryClient.setQueryData(queryKey, latest); - }); - registerTask({ task, signal }); - return timeout(15000, `useFollowings: ${pubkey}`)(promise); - }, + latestEventQuery({ + taskProvider: ([, currentProps]) => { + if (currentProps == null) return null; + const { pubkey } = currentProps; + return new BatchedEventsTask({ type: 'Followings', pubkey }); + }, + queryClient, + }), { staleTime: 5 * 60 * 1000, // 5 min cacheTime: 24 * 60 * 60 * 1000, // 24 hour diff --git a/src/nostr/useProfile.ts b/src/nostr/useProfile.ts index c86d611..827f0d8 100644 --- a/src/nostr/useProfile.ts +++ b/src/nostr/useProfile.ts @@ -1,16 +1,11 @@ -import { createMemo, observable } from 'solid-js'; +import { createMemo } from 'solid-js'; -import { - createQuery, - useQueryClient, - type CreateQueryResult, - QueryClient, -} from '@tanstack/solid-query'; +import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; import { Event as NostrEvent } from 'nostr-tools'; import { Profile, ProfileWithOtherProperties, safeParseProfile } from '@/nostr/event/Profile'; -import { BatchedEventsTask, pickLatestEvent, registerTask } from '@/nostr/useBatchedEvents'; -import timeout from '@/utils/timeout'; +import { latestEventQuery } from '@/nostr/query'; +import { BatchedEventsTask } from '@/nostr/useBatchedEvents'; export type UseProfileProps = { pubkey: string; @@ -40,21 +35,14 @@ const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => const query = createQuery( genQueryKey, - ({ queryKey, signal }) => { - const [, currentProps] = queryKey; - if (currentProps == null) return null; - const { pubkey } = currentProps; - const task = new BatchedEventsTask({ type: 'Profile', pubkey }); - const promise = task.firstEventPromise().catch(() => { - throw new Error(`profile not found: ${pubkey}`); - }); - task.onUpdate((events) => { - const latest = pickLatestEvent(events); - queryClient.setQueryData(queryKey, latest); - }); - registerTask({ task, signal }); - return timeout(3000, `useProfile: ${pubkey}`)(promise); - }, + latestEventQuery({ + taskProvider: ([, currentProps]) => { + if (currentProps == null) return null; + const { pubkey } = currentProps; + return new BatchedEventsTask({ type: 'Profile', pubkey }); + }, + queryClient, + }), { // Profiles are updated occasionally, so a short staleTime is used here. // cacheTime is long so that the user see profiles instantly. diff --git a/src/nostr/useReactions.ts b/src/nostr/useReactions.ts index 30f716e..f8c1369 100644 --- a/src/nostr/useReactions.ts +++ b/src/nostr/useReactions.ts @@ -1,11 +1,11 @@ -import { createMemo, observable } from 'solid-js'; +import { createMemo } from 'solid-js'; import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; import { Event as NostrEvent } from 'nostr-tools'; import useConfig from '@/core/useConfig'; -import { registerTask, BatchedEventsTask } from '@/nostr/useBatchedEvents'; -import timeout from '@/utils/timeout'; +import { eventsQuery } from '@/nostr/query'; +import { BatchedEventsTask } from '@/nostr/useBatchedEvents'; export type UseReactionsProps = { eventId: string; @@ -30,18 +30,14 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio const query = createQuery( genQueryKey, - ({ queryKey, signal }) => { - const [, currentProps] = queryKey; - if (currentProps == null) return []; - const { eventId: mentionedEventId } = currentProps; - const task = new BatchedEventsTask({ type: 'Reactions', mentionedEventId }); - const promise = task.toUpdatePromise().catch(() => []); - task.onUpdate((events) => { - queryClient.setQueryData(queryKey, events); - }); - registerTask({ task, signal }); - return timeout(15000, `useReactions: ${mentionedEventId}`)(promise); - }, + eventsQuery({ + taskProvider: ([, currentProps]) => { + if (currentProps == null) return null; + const { eventId: mentionedEventId } = currentProps; + return new BatchedEventsTask({ type: 'Reactions', mentionedEventId }); + }, + queryClient, + }), { staleTime: 1 * 60 * 1000, // 1 min cacheTime: 4 * 60 * 60 * 1000, // 4 hour diff --git a/src/nostr/useReposts.ts b/src/nostr/useReposts.ts index aa4abdd..f5f4bc2 100644 --- a/src/nostr/useReposts.ts +++ b/src/nostr/useReposts.ts @@ -1,11 +1,11 @@ -import { createMemo, observable } from 'solid-js'; +import { createMemo } from 'solid-js'; import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; import { Event as NostrEvent } from 'nostr-tools'; import useConfig from '@/core/useConfig'; -import { BatchedEventsTask, registerTask } from '@/nostr/useBatchedEvents'; -import timeout from '@/utils/timeout'; +import { eventsQuery } from '@/nostr/query'; +import { BatchedEventsTask } from '@/nostr/useBatchedEvents'; export type UseRepostsProps = { eventId: string; @@ -26,18 +26,14 @@ const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => { const query = createQuery( genQueryKey, - ({ queryKey, signal }) => { - const [, currentProps] = queryKey; - if (currentProps == null) return []; - const { eventId: mentionedEventId } = currentProps; - const task = new BatchedEventsTask({ type: 'Reposts', mentionedEventId }); - const promise = task.toUpdatePromise().catch(() => []); - task.onUpdate((events) => { - queryClient.setQueryData(queryKey, events); - }); - registerTask({ task, signal }); - return timeout(15000, `useReposts: ${mentionedEventId}`)(promise); - }, + eventsQuery({ + taskProvider: ([, currentProps]) => { + if (currentProps == null) return null; + const { eventId: mentionedEventId } = currentProps; + return new BatchedEventsTask({ type: 'Reposts', mentionedEventId }); + }, + queryClient, + }), { staleTime: 1 * 60 * 1000, // 1 min cacheTime: 4 * 60 * 60 * 1000, // 4 hour diff --git a/src/utils/timeout.ts b/src/utils/timeout.ts index ade53cb..b2eec3b 100644 --- a/src/utils/timeout.ts +++ b/src/utils/timeout.ts @@ -1,10 +1,12 @@ +export class TimeoutError extends Error {} + const timeout = (ms: number, info?: string) => (promise: Promise): Promise => { const timeoutPromise = new Promise((_resolve, reject) => { setTimeout(() => { const message = info != null ? `TimeoutError: ${info}` : 'TimeoutError'; - reject(new Error(message)); + reject(new TimeoutError(message)); }, ms); });