This commit is contained in:
Shusui MOYATANI
2023-03-03 12:14:25 +09:00
parent 3ce64a449d
commit 51249ab6f6
11 changed files with 183 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
import { createSignal, createEffect, onCleanup } from 'solid-js'; import { createSignal, createMemo } from 'solid-js';
export type Task<TaskArgs, TaskResult> = { export type Task<TaskArgs, TaskResult> = {
id: number; id: number;
@@ -10,7 +10,7 @@ export type Task<TaskArgs, TaskResult> = {
export type UseBatchProps<TaskArgs, TaskResult> = { export type UseBatchProps<TaskArgs, TaskResult> = {
executor: (task: Task<TaskArgs, TaskResult>[]) => void; executor: (task: Task<TaskArgs, TaskResult>[]) => void;
interval?: number; interval?: number;
// batchSize: number; batchSize?: number;
}; };
export type PromiseWithCallbacks<T> = { export type PromiseWithCallbacks<T> = {
@@ -38,24 +38,26 @@ const promiseWithCallbacks = <T>(): PromiseWithCallbacks<T> => {
const useBatch = <TaskArgs, TaskResult>( const useBatch = <TaskArgs, TaskResult>(
propsProvider: () => UseBatchProps<TaskArgs, TaskResult>, propsProvider: () => UseBatchProps<TaskArgs, TaskResult>,
) => { ) => {
const props = createMemo(propsProvider);
const batchSize = createMemo(() => props().batchSize ?? 100);
const interval = createMemo(() => props().interval ?? 1000);
const [seqId, setSeqId] = createSignal<number>(0); const [seqId, setSeqId] = createSignal<number>(0);
const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]); const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]);
createEffect(() => { let timeoutId: ReturnType<typeof setTimeout> | undefined;
const { executor, interval = 1000 } = propsProvider();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timeoutId == null && taskQueue().length > 0) { const executeTasks = () => {
timeoutId = setTimeout(() => { const { executor } = props();
const currentTaskQueue = taskQueue(); const currentTaskQueue = taskQueue();
if (currentTaskQueue.length > 0) {
setTaskQueue([]); if (currentTaskQueue.length > 0) {
executor(currentTaskQueue); setTaskQueue([]);
} executor(currentTaskQueue);
timeoutId = undefined;
}, interval);
} }
}); if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = undefined;
};
const nextId = (): number => { const nextId = (): number => {
const id = seqId(); const id = seqId();
@@ -63,18 +65,40 @@ const useBatch = <TaskArgs, TaskResult>(
return id; return id;
}; };
const launchTimer = () => {
if (timeoutId == null) {
timeoutId = setTimeout(() => {
executeTasks();
}, interval());
}
};
const addTask = (task: Task<TaskArgs, TaskResult>) => {
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 // enqueue task and wait response
const exec = async (args: TaskArgs, signal?: AbortSignal): Promise<TaskResult> => { const exec = async (args: TaskArgs, signal?: AbortSignal): Promise<TaskResult> => {
const { promise, resolve, reject } = promiseWithCallbacks<TaskResult>(); const { promise, resolve, reject } = promiseWithCallbacks<TaskResult>();
const id = nextId(); const id = nextId();
const newTask: Task<TaskArgs, TaskResult> = { id, args, resolve, reject }; const newTask: Task<TaskArgs, TaskResult> = { id, args, resolve, reject };
signal?.addEventListener('abort', () => { addTask(newTask);
reject(new Error('AbortError')); launchTimer();
setTaskQueue((currentTaskQueue) => currentTaskQueue.filter((task) => task.id !== newTask.id));
});
setTaskQueue((currentTaskQueue) => [...currentTaskQueue, newTask]); signal?.addEventListener('abort', () => {
removeTask(id);
reject(new Error('AbortError'));
});
return promise; return promise;
}; };

View File

@@ -1,3 +1,4 @@
import { createMemo } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event'; import { type Event as NostrEvent } from 'nostr-tools/event';
import { type Filter } from 'nostr-tools/filter'; import { type Filter } from 'nostr-tools/filter';
@@ -6,16 +7,20 @@ import useBatch, { type Task } from '@/clients/useBatch';
import useSubscription from '@/clients/useSubscription'; import useSubscription from '@/clients/useSubscription';
export type UseBatchedEventProps<TaskArgs> = { export type UseBatchedEventProps<TaskArgs> = {
interval?: number;
generateKey: (args: TaskArgs) => string | number; generateKey: (args: TaskArgs) => string | number;
mergeFilters: (args: TaskArgs[]) => Filter[]; mergeFilters: (args: TaskArgs[]) => Filter[];
extractKey: (event: NostrEvent) => string | number | undefined; extractKey: (event: NostrEvent) => string | number | undefined;
}; };
const useBatchedEvent = <TaskArgs>(propsProvider: () => UseBatchedEventProps<TaskArgs>) => { const useBatchedEvent = <TaskArgs>(propsProvider: () => UseBatchedEventProps<TaskArgs>) => {
const props = createMemo(propsProvider);
return useBatch<TaskArgs, NostrEvent>(() => { return useBatch<TaskArgs, NostrEvent>(() => {
return { return {
interval: props().interval,
executor: (tasks) => { executor: (tasks) => {
const { generateKey, mergeFilters, extractKey } = propsProvider(); const { generateKey, mergeFilters, extractKey } = props();
// TODO relayUrlsを考慮する // TODO relayUrlsを考慮する
const [config] = useConfig(); const [config] = useConfig();
@@ -36,6 +41,11 @@ const useBatchedEvent = <TaskArgs>(propsProvider: () => UseBatchedEventProps<Tas
if (task == null) return; if (task == null) return;
task.resolve(event); task.resolve(event);
}, },
onEOSE: () => {
tasks.forEach((task) => {
task.reject(new Error('NotFound'));
});
},
})); }));
}, },
}; };

View File

@@ -17,7 +17,8 @@ const InitialConfig: Config = {
'wss://relay.snort.social', 'wss://relay.snort.social',
'wss://relay.current.fyi', 'wss://relay.current.fyi',
'wss://relay.nostr.wirednet.jp', 'wss://relay.nostr.wirednet.jp',
'wss://relay.mostr.pub', 'wss://nostr-relay.nokotaro.com',
'wss://nostr.holybea.com',
], ],
}; };

View File

@@ -1,6 +1,7 @@
import { createMemo, type Accessor } from 'solid-js'; import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event'; import { type Event as NostrEvent } from 'nostr-tools/event';
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query'; import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import timeout from '@/utils/timeout';
import useBatchedEvent from '@/clients/useBatchedEvent'; import useBatchedEvent from '@/clients/useBatchedEvent';
@@ -26,12 +27,11 @@ const { exec } = useBatchedEvent<UseEventProps>(() => ({
const useEvent = (propsProvider: () => UseEventProps): UseEvent => { const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
const props = createMemo(propsProvider); const props = createMemo(propsProvider);
const query = createQuery( const query = createQuery(
() => ['useEvent', props()] as const, () => ['useEvent', props()] as const,
({ queryKey, signal }) => { ({ queryKey, signal }) => {
const [, currentProps] = queryKey; const [, currentProps] = queryKey;
return exec(currentProps, signal); return timeout(15000, `useEvent: ${currentProps.eventId}`)(exec(currentProps, signal));
}, },
{ {
// 5 minutes // 5 minutes

View File

@@ -4,7 +4,7 @@ import { type Filter } from 'nostr-tools/filter';
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query'; import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import useBatchedEvent from '@/clients/useBatchedEvent'; import useBatchedEvent from '@/clients/useBatchedEvent';
import { Task } from './useBatch'; import timeout from '@/utils/timeout';
// TODO zodにする // TODO zodにする
// deleted等の特殊なもの // deleted等の特殊なもの
@@ -49,7 +49,8 @@ const useProfile = (propsProvider: () => UseProfileProps): UseProfile => {
() => ['useProfile', props()] as const, () => ['useProfile', props()] as const,
({ queryKey, signal }) => { ({ queryKey, signal }) => {
const [, currentProps] = queryKey; const [, currentProps] = queryKey;
return exec(currentProps, signal); // TODO timeoutと同時にsignalでキャンセルするようにしたい
return timeout(15000, `useProfile: ${currentProps.pubkey}`)(exec(currentProps, signal));
}, },
{ {
// 5 minutes // 5 minutes
@@ -61,7 +62,12 @@ const useProfile = (propsProvider: () => UseProfileProps): UseProfile => {
const profile = () => { const profile = () => {
if (query.data == null) return undefined; if (query.data == null) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック // 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 }; return { profile, query };

View File

@@ -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 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[]; relayUrls: string[];
eventId: string; eventId: string;
}; };
export type UseEvent = { export type UseReactions = {
reactions: Accessor<NostrEvent[]>; reactions: Accessor<NostrEvent[]>;
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>; reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
isReactedBy(pubkey: string): boolean; isReactedBy: (pubkey: string) => boolean;
query: CreateQueryResult<NostrEvent[]>; invalidateReactions: () => Promise<void>;
query: CreateQueryResult<Accessor<NostrEvent[]>>;
}; };
const useReactions = (propsProvider: () => UseEventProps): UseEvent => { const { exec } = useBatch<UseReactionsProps, Accessor<NostrEvent[]>>(() => {
const query = useCachedEvents(() => { return {
const { relayUrls, eventId } = propsProvider(); interval: 2500,
return { executor: (tasks) => {
relayUrls, // TODO relayUrlsを考慮する
filters: [ const [config] = useConfig();
{
'#e': [eventId],
kinds: [7],
},
],
};
});
const reactions = () => query.data ?? []; const eventIdTaskMap = new Map<string, Task<UseReactionsProps, Accessor<NostrEvent[]>>>(
tasks.map((task) => [task.args.eventId, task]),
);
const eventIds = Array.from(eventIdTaskMap.keys());
const eventIdReactionsMap = new Map<string, Signal<NostrEvent[]>>();
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<NostrEvent[]>([]);
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 reactionsGroupedByContent = () => {
const result = new Map<string, NostrEvent[]>(); const result = new Map<string, NostrEvent[]>();
@@ -45,7 +97,12 @@ const useReactions = (propsProvider: () => UseEventProps): UseEvent => {
const isReactedBy = (pubkey: string): boolean => const isReactedBy = (pubkey: string): boolean =>
reactions().findIndex((event) => event.pubkey === pubkey) !== -1; reactions().findIndex((event) => event.pubkey === pubkey) !== -1;
return { reactions, reactionsGroupedByContent, isReactedBy, query }; const invalidateReactions = (): Promise<void> => {
const queryClient = useQueryClient();
return queryClient.invalidateQueries(queryKey());
};
return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query };
}; };
export default useReactions; export default useReactions;

View File

@@ -12,6 +12,7 @@ export type UseSubscriptionProps = {
// default is true // default is true
continuous?: boolean; continuous?: boolean;
onEvent?: (event: NostrEvent) => void; onEvent?: (event: NostrEvent) => void;
onEOSE?: () => void;
signal?: AbortSignal; signal?: AbortSignal;
}; };
@@ -26,7 +27,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
const props = propsProvider(); const props = propsProvider();
if (props == null) return; 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); const sub = pool().sub(relayUrls, filters, options);
let pushed = false; let pushed = false;
@@ -47,6 +48,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
}); });
sub.on('eose', () => { sub.on('eose', () => {
if (onEOSE != null) {
onEOSE();
}
eose = true; eose = true;
setEvents(sortEvents(storedEvents)); setEvents(sortEvents(storedEvents));

View File

@@ -0,0 +1,6 @@
import { type Component } from 'solid-js';
const ReplyPostForm = () => {
};
export default ReplyPostForm;

View File

@@ -11,7 +11,7 @@ import useProfile from '@/clients/useProfile';
import useConfig from '@/clients/useConfig'; import useConfig from '@/clients/useConfig';
import usePubkey from '@/clients/usePubkey'; import usePubkey from '@/clients/usePubkey';
import useCommands from '@/clients/useCommands'; import useCommands from '@/clients/useCommands';
// import useReactions from '@/clients/useReactions'; import useReactions from '@/clients/useReactions';
import useDatePulser from '@/hooks/useDatePulser'; import useDatePulser from '@/hooks/useDatePulser';
import { formatRelative } from '@/utils/formatDate'; import { formatRelative } from '@/utils/formatDate';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
@@ -33,19 +33,12 @@ const TextNote: Component<TextNoteProps> = (props) => {
pubkey: props.event.pubkey, pubkey: props.event.pubkey,
})); }));
/* const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({
const {
reactions,
isReactedBy,
query: reactionsQuery,
} = useReactions(() => ({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
eventId: props.event.id, eventId: props.event.id,
})); }));
const isReactedByMe = createMemo(() => isReactedBy(pubkey())); const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
*/
const isReactedByMe = () => false;
const replyingToPubKeys = createMemo(() => const replyingToPubKeys = createMemo(() =>
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]), props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
@@ -64,13 +57,12 @@ const TextNote: Component<TextNoteProps> = (props) => {
}; };
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
/*
if (isReactedByMe()) { if (isReactedByMe()) {
// TODO remove reaction // TODO remove reaction
return; return;
} }
*/
ev.preventDefault(); ev.preventDefault();
commands commands
.publishReaction({ .publishReaction({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
@@ -79,9 +71,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
eventId: props.event.id, eventId: props.event.id,
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}) })
.then(() => { .then(() => invalidateReactions());
// reactionsQuery.refetch();
});
}; };
return ( return (
@@ -146,7 +136,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
<HeartSolid /> <HeartSolid />
</Show> </Show>
</button> </button>
{/* <div class="text-sm text-zinc-400">{reactions().length}</div> */} <div class="text-sm text-zinc-400">{reactions().length}</div>
</div> </div>
<button class="h-4 w-4 text-zinc-400"> <button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal /> <EllipsisHorizontal />

View File

@@ -63,7 +63,11 @@ const Home: Component = () => {
})); }));
const { events: localTimeline } = useSubscription(() => ({ const { events: localTimeline } = useSubscription(() => ({
relayUrls: ['wss://relay-jp.nostr.wirednet.jp', 'wss://nostr.h3z.jp/'], relayUrls: [
'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp/',
'wss://nostr.holybea.com',
],
filters: [ filters: [
{ {
kinds: [1, 6], kinds: [1, 6],

14
src/utils/timeout.ts Normal file
View File

@@ -0,0 +1,14 @@
const timeout =
(ms: number, info?: string) =>
<T>(promise: Promise<T>): Promise<T> => {
const timeoutPromise = new Promise<T>((_resolve, reject) => {
setTimeout(() => {
const message = info != null ? `TimeoutError: ${info}` : 'TimeoutError';
reject(new Error(message));
}, ms);
});
return Promise.race([promise, timeoutPromise]);
};
export default timeout;