From 51249ab6f6f1541939af650ab927c42ea855c79a Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Fri, 3 Mar 2023 12:14:25 +0900 Subject: [PATCH] update --- src/clients/useBatch.ts | 64 ++++++++++++++------ src/clients/useBatchedEvent.ts | 12 +++- src/clients/useConfig.ts | 3 +- src/clients/useEvent.ts | 4 +- src/clients/useProfile.ts | 12 +++- src/clients/useReactions.ts | 101 ++++++++++++++++++++++++------- src/clients/useSubscription.ts | 7 ++- src/components/ReplyPostForm.tsx | 6 ++ src/components/TextNote.tsx | 20 ++---- src/pages/Home.tsx | 6 +- src/utils/timeout.ts | 14 +++++ 11 files changed, 183 insertions(+), 66 deletions(-) create mode 100644 src/components/ReplyPostForm.tsx create mode 100644 src/utils/timeout.ts diff --git a/src/clients/useBatch.ts b/src/clients/useBatch.ts index d5b02f7..972e1cf 100644 --- a/src/clients/useBatch.ts +++ b/src/clients/useBatch.ts @@ -1,4 +1,4 @@ -import { createSignal, createEffect, onCleanup } from 'solid-js'; +import { createSignal, createMemo } from 'solid-js'; export type Task = { id: number; @@ -10,7 +10,7 @@ export type Task = { export type UseBatchProps = { executor: (task: Task[]) => void; interval?: number; - // batchSize: number; + batchSize?: number; }; export type PromiseWithCallbacks = { @@ -38,24 +38,26 @@ const promiseWithCallbacks = (): PromiseWithCallbacks => { const useBatch = ( propsProvider: () => UseBatchProps, ) => { + const props = createMemo(propsProvider); + const batchSize = createMemo(() => props().batchSize ?? 100); + const interval = createMemo(() => props().interval ?? 1000); + const [seqId, setSeqId] = createSignal(0); const [taskQueue, setTaskQueue] = createSignal[]>([]); - createEffect(() => { - const { executor, interval = 1000 } = propsProvider(); - let timeoutId: ReturnType | undefined; + let timeoutId: ReturnType | undefined; - if (timeoutId == null && taskQueue().length > 0) { - timeoutId = setTimeout(() => { - const currentTaskQueue = taskQueue(); - if (currentTaskQueue.length > 0) { - setTaskQueue([]); - executor(currentTaskQueue); - } - timeoutId = undefined; - }, interval); + const executeTasks = () => { + const { executor } = props(); + const currentTaskQueue = taskQueue(); + + if (currentTaskQueue.length > 0) { + setTaskQueue([]); + executor(currentTaskQueue); } - }); + if (timeoutId != null) clearTimeout(timeoutId); + timeoutId = undefined; + }; const nextId = (): number => { const id = seqId(); @@ -63,18 +65,40 @@ const useBatch = ( return id; }; + const launchTimer = () => { + if (timeoutId == null) { + timeoutId = setTimeout(() => { + executeTasks(); + }, interval()); + } + }; + + const addTask = (task: Task) => { + if (taskQueue().length < batchSize()) { + setTaskQueue((currentTaskQueue) => [...currentTaskQueue, task]); + } else { + executeTasks(); + setTaskQueue([task]); + } + }; + + const removeTask = (id: number) => { + setTaskQueue((currentTaskQueue) => currentTaskQueue.filter((task) => task.id !== id)); + }; + // enqueue task and wait response const exec = async (args: TaskArgs, signal?: AbortSignal): Promise => { const { promise, resolve, reject } = promiseWithCallbacks(); const id = nextId(); const newTask: Task = { id, args, resolve, reject }; - signal?.addEventListener('abort', () => { - reject(new Error('AbortError')); - setTaskQueue((currentTaskQueue) => currentTaskQueue.filter((task) => task.id !== newTask.id)); - }); + addTask(newTask); + launchTimer(); - setTaskQueue((currentTaskQueue) => [...currentTaskQueue, newTask]); + signal?.addEventListener('abort', () => { + removeTask(id); + reject(new Error('AbortError')); + }); return promise; }; diff --git a/src/clients/useBatchedEvent.ts b/src/clients/useBatchedEvent.ts index fb574fb..7bf5555 100644 --- a/src/clients/useBatchedEvent.ts +++ b/src/clients/useBatchedEvent.ts @@ -1,3 +1,4 @@ +import { createMemo } from 'solid-js'; import { type Event as NostrEvent } from 'nostr-tools/event'; import { type Filter } from 'nostr-tools/filter'; @@ -6,16 +7,20 @@ import useBatch, { type Task } from '@/clients/useBatch'; import useSubscription from '@/clients/useSubscription'; export type UseBatchedEventProps = { + interval?: number; generateKey: (args: TaskArgs) => string | number; mergeFilters: (args: TaskArgs[]) => Filter[]; extractKey: (event: NostrEvent) => string | number | undefined; }; const useBatchedEvent = (propsProvider: () => UseBatchedEventProps) => { + const props = createMemo(propsProvider); + return useBatch(() => { return { + interval: props().interval, executor: (tasks) => { - const { generateKey, mergeFilters, extractKey } = propsProvider(); + const { generateKey, mergeFilters, extractKey } = props(); // TODO relayUrlsを考慮する const [config] = useConfig(); @@ -36,6 +41,11 @@ const useBatchedEvent = (propsProvider: () => UseBatchedEventProps { + tasks.forEach((task) => { + task.reject(new Error('NotFound')); + }); + }, })); }, }; diff --git a/src/clients/useConfig.ts b/src/clients/useConfig.ts index 40f9aa8..9c00201 100644 --- a/src/clients/useConfig.ts +++ b/src/clients/useConfig.ts @@ -17,7 +17,8 @@ const InitialConfig: Config = { 'wss://relay.snort.social', 'wss://relay.current.fyi', 'wss://relay.nostr.wirednet.jp', - 'wss://relay.mostr.pub', + 'wss://nostr-relay.nokotaro.com', + 'wss://nostr.holybea.com', ], }; diff --git a/src/clients/useEvent.ts b/src/clients/useEvent.ts index 7cb9ed1..e324037 100644 --- a/src/clients/useEvent.ts +++ b/src/clients/useEvent.ts @@ -1,6 +1,7 @@ import { createMemo, type Accessor } from 'solid-js'; import { type Event as NostrEvent } from 'nostr-tools/event'; import { createQuery, type CreateQueryResult } from '@tanstack/solid-query'; +import timeout from '@/utils/timeout'; import useBatchedEvent from '@/clients/useBatchedEvent'; @@ -26,12 +27,11 @@ const { exec } = useBatchedEvent(() => ({ const useEvent = (propsProvider: () => UseEventProps): UseEvent => { const props = createMemo(propsProvider); - const query = createQuery( () => ['useEvent', props()] as const, ({ queryKey, signal }) => { const [, currentProps] = queryKey; - return exec(currentProps, signal); + return timeout(15000, `useEvent: ${currentProps.eventId}`)(exec(currentProps, signal)); }, { // 5 minutes diff --git a/src/clients/useProfile.ts b/src/clients/useProfile.ts index 084b30b..ffa7941 100644 --- a/src/clients/useProfile.ts +++ b/src/clients/useProfile.ts @@ -4,7 +4,7 @@ import { type Filter } from 'nostr-tools/filter'; import { createQuery, type CreateQueryResult } from '@tanstack/solid-query'; import useBatchedEvent from '@/clients/useBatchedEvent'; -import { Task } from './useBatch'; +import timeout from '@/utils/timeout'; // TODO zodにする // deleted等の特殊なもの @@ -49,7 +49,8 @@ const useProfile = (propsProvider: () => UseProfileProps): UseProfile => { () => ['useProfile', props()] as const, ({ queryKey, signal }) => { const [, currentProps] = queryKey; - return exec(currentProps, signal); + // TODO timeoutと同時にsignalでキャンセルするようにしたい + return timeout(15000, `useProfile: ${currentProps.pubkey}`)(exec(currentProps, signal)); }, { // 5 minutes @@ -61,7 +62,12 @@ const useProfile = (propsProvider: () => UseProfileProps): UseProfile => { const profile = () => { if (query.data == null) return undefined; // TODO 大きすぎたりしないかどうか、JSONかどうかのチェック - return JSON.parse(query.data.content) as Profile; + try { + return JSON.parse(query.data.content) as Profile; + } catch (e) { + console.error(e); + return undefined; + } }; return { profile, query }; diff --git a/src/clients/useReactions.ts b/src/clients/useReactions.ts index e65b677..97e04df 100644 --- a/src/clients/useReactions.ts +++ b/src/clients/useReactions.ts @@ -1,36 +1,88 @@ -import { type Accessor } from 'solid-js'; +import { createSignal, createMemo, type Signal, type Accessor } from 'solid-js'; import { type Event as NostrEvent } from 'nostr-tools/event'; -import { type CreateQueryResult } from '@tanstack/solid-query'; +import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; -import useCachedEvents from '@/clients/useCachedEvents'; +import useConfig from '@/clients/useConfig'; +import useBatch, { type Task } from '@/clients/useBatch'; +import useSubscription from '@/clients/useSubscription'; +import timeout from '@/utils/timeout'; -export type UseEventProps = { +export type UseReactionsProps = { relayUrls: string[]; eventId: string; }; -export type UseEvent = { +export type UseReactions = { reactions: Accessor; reactionsGroupedByContent: Accessor>; - isReactedBy(pubkey: string): boolean; - query: CreateQueryResult; + isReactedBy: (pubkey: string) => boolean; + invalidateReactions: () => Promise; + query: CreateQueryResult>; }; -const useReactions = (propsProvider: () => UseEventProps): UseEvent => { - const query = useCachedEvents(() => { - const { relayUrls, eventId } = propsProvider(); - return { - relayUrls, - filters: [ - { - '#e': [eventId], - kinds: [7], - }, - ], - }; - }); +const { exec } = useBatch>(() => { + return { + interval: 2500, + executor: (tasks) => { + // TODO relayUrlsを考慮する + const [config] = useConfig(); - const reactions = () => query.data ?? []; + const eventIdTaskMap = new Map>>( + tasks.map((task) => [task.args.eventId, task]), + ); + const eventIds = Array.from(eventIdTaskMap.keys()); + const eventIdReactionsMap = new Map>(); + + useSubscription(() => ({ + relayUrls: config().relayUrls, + filters: [{ kinds: [7], '#e': eventIds }], + continuous: false, + onEvent(event: NostrEvent) { + const reactTo = event.tags.find((e) => e[0] === 'e')?.[1]; + if (reactTo == null) return; + const task = eventIdTaskMap.get(reactTo); + // possibly, the new event received + if (task == null) return; + + const reactionsSignal = + eventIdReactionsMap.get(reactTo) ?? createSignal([]); + eventIdReactionsMap.set(reactTo, reactionsSignal); + + const [reactions, setReactions] = reactionsSignal; + + setReactions((currentReactions) => [...currentReactions, event]); + + // 初回のresolveのみが有効 + task.resolve(reactions); + }, + onEOSE() { + tasks.forEach((task) => { + task.resolve(() => []); + }); + }, + })); + }, + }; +}); + +const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => { + const props = createMemo(propsProvider); + const queryKey = createMemo(() => ['useReactions', props()] as const); + + const query = createQuery( + () => queryKey(), + ({ queryKey, signal }) => { + const [, currentProps] = queryKey; + return timeout(15000, `useReactions: ${currentProps.eventId}`)(exec(currentProps, signal)); + }, + { + // 1 minutes + staleTime: 1 * 60 * 1000, + cacheTime: 1 * 60 * 1000, + }, + ); + + const reactions = () => query.data?.() ?? []; const reactionsGroupedByContent = () => { const result = new Map(); @@ -45,7 +97,12 @@ const useReactions = (propsProvider: () => UseEventProps): UseEvent => { const isReactedBy = (pubkey: string): boolean => reactions().findIndex((event) => event.pubkey === pubkey) !== -1; - return { reactions, reactionsGroupedByContent, isReactedBy, query }; + const invalidateReactions = (): Promise => { + const queryClient = useQueryClient(); + return queryClient.invalidateQueries(queryKey()); + }; + + return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query }; }; export default useReactions; diff --git a/src/clients/useSubscription.ts b/src/clients/useSubscription.ts index 187de45..c00f6e8 100644 --- a/src/clients/useSubscription.ts +++ b/src/clients/useSubscription.ts @@ -12,6 +12,7 @@ export type UseSubscriptionProps = { // default is true continuous?: boolean; onEvent?: (event: NostrEvent) => void; + onEOSE?: () => void; signal?: AbortSignal; }; @@ -26,7 +27,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined) const props = propsProvider(); if (props == null) return; - const { relayUrls, filters, options, onEvent, continuous = true } = props; + const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props; const sub = pool().sub(relayUrls, filters, options); let pushed = false; @@ -47,6 +48,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined) }); sub.on('eose', () => { + if (onEOSE != null) { + onEOSE(); + } + eose = true; setEvents(sortEvents(storedEvents)); diff --git a/src/components/ReplyPostForm.tsx b/src/components/ReplyPostForm.tsx new file mode 100644 index 0000000..1443f05 --- /dev/null +++ b/src/components/ReplyPostForm.tsx @@ -0,0 +1,6 @@ +import { type Component } from 'solid-js'; + +const ReplyPostForm = () => { +}; + +export default ReplyPostForm; diff --git a/src/components/TextNote.tsx b/src/components/TextNote.tsx index 0a6bd08..4705de8 100644 --- a/src/components/TextNote.tsx +++ b/src/components/TextNote.tsx @@ -11,7 +11,7 @@ import useProfile from '@/clients/useProfile'; import useConfig from '@/clients/useConfig'; import usePubkey from '@/clients/usePubkey'; import useCommands from '@/clients/useCommands'; -// import useReactions from '@/clients/useReactions'; +import useReactions from '@/clients/useReactions'; import useDatePulser from '@/hooks/useDatePulser'; import { formatRelative } from '@/utils/formatDate'; import ColumnItem from '@/components/ColumnItem'; @@ -33,19 +33,12 @@ const TextNote: Component = (props) => { pubkey: props.event.pubkey, })); - /* - const { - reactions, - isReactedBy, - query: reactionsQuery, - } = useReactions(() => ({ + const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({ relayUrls: config().relayUrls, eventId: props.event.id, })); const isReactedByMe = createMemo(() => isReactedBy(pubkey())); - */ - const isReactedByMe = () => false; const replyingToPubKeys = createMemo(() => props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]), @@ -64,13 +57,12 @@ const TextNote: Component = (props) => { }; const handleReaction: JSX.EventHandler = (ev) => { - /* if (isReactedByMe()) { // TODO remove reaction return; } - */ ev.preventDefault(); + commands .publishReaction({ relayUrls: config().relayUrls, @@ -79,9 +71,7 @@ const TextNote: Component = (props) => { eventId: props.event.id, notifyPubkey: props.event.pubkey, }) - .then(() => { - // reactionsQuery.refetch(); - }); + .then(() => invalidateReactions()); }; return ( @@ -146,7 +136,7 @@ const TextNote: Component = (props) => { - {/*
{reactions().length}
*/} +
{reactions().length}