This commit is contained in:
Shusui MOYATANI
2023-03-01 19:36:41 +09:00
parent 471b03eb1d
commit b1aa63d6a3
10 changed files with 247 additions and 68 deletions

85
src/clients/useBatch.ts Normal file
View File

@@ -0,0 +1,85 @@
import { createSignal, createEffect, onCleanup } from 'solid-js';
export type Task<TaskArgs, TaskResult> = {
id: number;
args: TaskArgs;
resolve: (result: TaskResult) => void;
reject: (error: any) => void;
};
export type UseBatchProps<TaskArgs, TaskResult> = {
executor: (task: Task<TaskArgs, TaskResult>[]) => void;
interval?: number;
// batchSize: number;
};
export type PromiseWithCallbacks<T> = {
promise: Promise<T>;
resolve: (e: T) => void;
reject: (e: any) => void;
};
const promiseWithCallbacks = <T>(): PromiseWithCallbacks<T> => {
let resolve: ((e: T) => void) | undefined;
let reject: ((e: any) => void) | undefined;
const promise = new Promise<T>((resolveFn, rejectFn) => {
resolve = resolveFn;
reject = rejectFn;
});
if (resolve == null || reject == null) {
throw new Error('PromiseWithCallbacks failed to extract callbacks');
}
return { promise, resolve, reject };
};
const useBatch = <TaskArgs, TaskResult>(
propsProvider: () => UseBatchProps<TaskArgs, TaskResult>,
) => {
const [seqId, setSeqId] = createSignal<number>(0);
const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]);
createEffect(() => {
const { executor, interval = 1000 } = propsProvider();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timeoutId == null && taskQueue().length > 0) {
timeoutId = setTimeout(() => {
const currentTaskQueue = taskQueue();
if (currentTaskQueue.length > 0) {
setTaskQueue([]);
executor(currentTaskQueue);
}
timeoutId = undefined;
}, interval);
}
});
const nextId = (): number => {
const id = seqId();
setSeqId((currentId) => currentId + 1);
return id;
};
// enqueue task and wait response
const exec = async (args: TaskArgs, signal?: AbortSignal): Promise<TaskResult> => {
const { promise, resolve, reject } = promiseWithCallbacks<TaskResult>();
const id = nextId();
const newTask: Task<TaskArgs, TaskResult> = { id, args, resolve, reject };
signal?.addEventListener('abort', () => {
reject(new Error('AbortError'));
setTaskQueue((currentTaskQueue) => currentTaskQueue.filter((task) => task.id !== newTask.id));
});
setTaskQueue((currentTaskQueue) => [...currentTaskQueue, newTask]);
return promise;
};
return { exec };
};
export default useBatch;

View File

@@ -1,5 +1,5 @@
import { createQuery } from '@tanstack/solid-query'; import { createQuery } from '@tanstack/solid-query';
import { UseSubscriptionProps } from '@/clients/useSubscription'; import { type UseSubscriptionProps } from '@/clients/useSubscription';
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';
import type { SimplePool } from 'nostr-tools/pool'; import type { SimplePool } from 'nostr-tools/pool';
@@ -10,6 +10,8 @@ type GetEventsArgs = {
pool: SimplePool; pool: SimplePool;
relayUrls: string[]; relayUrls: string[];
filters: Filter[]; filters: Filter[];
// TODO 継続的に取得する場合、Promiseでは無理なので、無理やりキャッシュにストアする仕組みを使う
continuous?: boolean;
options?: SubscriptionOptions; options?: SubscriptionOptions;
signal?: AbortSignal; signal?: AbortSignal;
}; };
@@ -52,12 +54,12 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
return createQuery( return createQuery(
() => { () => {
const { relayUrls, filters, options } = propsProvider(); const { relayUrls, filters, continuous, options } = propsProvider();
return ['useCachedEvents', relayUrls, filters, options] as const; return ['useCachedEvents', relayUrls, filters, continuous, options] as const;
}, },
({ queryKey, signal }) => { ({ queryKey, signal }) => {
const [, relayUrls, filters, options] = queryKey; const [, relayUrls, filters, continuous, options] = queryKey;
return getEvents({ pool: pool(), relayUrls, filters, options, signal }); return getEvents({ pool: pool(), relayUrls, filters, options, continuous, signal });
}, },
{ {
// 5 minutes // 5 minutes

View File

@@ -1,8 +1,10 @@
import { 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 { type CreateQueryResult } from '@tanstack/solid-query'; import { createQuery, 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';
export type UseEventProps = { export type UseEventProps = {
relayUrls: string[]; relayUrls: string[];
@@ -11,25 +13,57 @@ export type UseEventProps = {
export type UseEvent = { export type UseEvent = {
event: Accessor<NostrEvent | undefined>; event: Accessor<NostrEvent | undefined>;
query: CreateQueryResult<NostrEvent[]>; query: CreateQueryResult<NostrEvent>;
}; };
const useEvent = (propsProvider: () => UseEventProps): UseEvent => { const { exec } = useBatch<UseEventProps, NostrEvent>(() => {
const query = useCachedEvents(() => {
const { relayUrls, eventId } = propsProvider();
return { return {
relayUrls, executor: (tasks) => {
// TODO relayUrlsを考慮する
const [config] = useConfig();
const eventIdTaskMap = new Map<string, Task<UseEventProps, NostrEvent>>(
tasks.map((task) => [task.args.eventId, task]),
);
const eventIds = Array.from(eventIdTaskMap.keys());
useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [ filters: [
{ {
ids: [eventId], ids: eventIds,
kinds: [1], kinds: [1],
limit: 1,
}, },
], ],
continuous: false,
onEvent: (event: NostrEvent) => {
if (event.id == null) return;
const task = eventIdTaskMap.get(event.id);
// possibly, the new event received
if (task == null) return;
task.resolve(event);
},
}));
},
}; };
}); });
const event = () => query.data?.[0]; 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);
},
{
// 5 minutes
staleTime: 5 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
},
);
const event = () => query.data;
return { event, query }; return { event, query };
}; };

View File

@@ -1,13 +1,10 @@
import { 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 { type CreateQueryResult } from '@tanstack/solid-query'; import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import useCachedEvents from '@/clients/useCachedEvents'; import useConfig from '@/clients/useConfig';
import useBatch, { type Task } from '@/clients/useBatch';
type UseProfileProps = { import useSubscription from '@/clients/useSubscription';
relayUrls: string[];
pubkey: string;
};
// TODO zodにする // TODO zodにする
// deleted等の特殊なもの // deleted等の特殊なもの
@@ -27,28 +24,65 @@ type NonStandardProfile = {
type Profile = StandardProfile & NonStandardProfile; type Profile = StandardProfile & NonStandardProfile;
type UseProfile = { type UseProfileProps = {
profile: Accessor<Profile | undefined>; relayUrls: string[];
query: CreateQueryResult<NostrEvent[]>; pubkey: string;
}; };
const useProfile = (propsProvider: () => UseProfileProps): UseProfile => { type UseProfile = {
const query = useCachedEvents(() => { profile: Accessor<Profile | undefined>;
const { relayUrls, pubkey } = propsProvider(); query: CreateQueryResult<NostrEvent>;
};
const { exec } = useBatch<UseProfileProps, NostrEvent>(() => {
return { return {
relayUrls, executor: (tasks) => {
// TODO relayUrlsを考慮する
const [config] = useConfig();
const pubkeyTaskMap = new Map<string, Task<UseProfileProps, NostrEvent>>(
tasks.map((task) => [task.args.pubkey, task]),
);
const pubkeys = Array.from(pubkeyTaskMap.keys());
useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [ filters: [
{ {
kinds: [0], kinds: [0],
authors: [pubkey], authors: pubkeys,
limit: 1,
}, },
], ],
continuous: false,
onEvent: (event: NostrEvent) => {
if (event.id == null) return;
const task = pubkeyTaskMap.get(event.pubkey);
// possibly, the new event received
if (task == null) return;
task.resolve(event);
},
}));
},
}; };
}); });
const useProfile = (propsProvider: () => UseProfileProps): UseProfile => {
const props = createMemo(propsProvider);
const query = createQuery(
() => ['useProfile', props()] as const,
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
return exec(currentProps, signal);
},
{
// 5 minutes
staleTime: 5 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
},
);
const profile = () => { const profile = () => {
const maybeProfile = query.data?.[0]; const maybeProfile = query.data;
if (maybeProfile == null) return undefined; if (maybeProfile == null) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック // TODO 大きすぎたりしないかどうか、JSONかどうかのチェック

View File

@@ -8,6 +8,11 @@ export type UseSubscriptionProps = {
relayUrls: string[]; relayUrls: string[];
filters: Filter[]; filters: Filter[];
options?: SubscriptionOptions; options?: SubscriptionOptions;
// subscribe not only stored events but also new events published after the subscription
// default is true
continuous?: boolean;
onEvent?: (event: NostrEvent) => void;
signal?: AbortSignal;
}; };
const sortEvents = (events: NostrEvent[]) => const sortEvents = (events: NostrEvent[]) =>
@@ -21,7 +26,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
const props = propsProvider(); const props = propsProvider();
if (props == null) return; if (props == null) return;
const { relayUrls, filters, options } = props; const { relayUrls, filters, options, onEvent, continuous = true } = props;
const sub = pool().sub(relayUrls, filters, options); const sub = pool().sub(relayUrls, filters, options);
let pushed = false; let pushed = false;
@@ -29,6 +34,9 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
const storedEvents: NostrEvent[] = []; const storedEvents: NostrEvent[] = [];
sub.on('event', (event: NostrEvent) => { sub.on('event', (event: NostrEvent) => {
if (onEvent != null) {
onEvent(event);
}
if (!eose) { if (!eose) {
pushed = true; pushed = true;
storedEvents.push(event); storedEvents.push(event);
@@ -41,6 +49,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
sub.on('eose', () => { sub.on('eose', () => {
eose = true; eose = true;
setEvents(sortEvents(storedEvents)); setEvents(sortEvents(storedEvents));
if (!continuous) {
sub.unsub();
}
}); });
// avoid updating an array too rapidly while this is fetching stored events // avoid updating an array too rapidly while this is fetching stored events

View File

@@ -37,7 +37,10 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
{' Reposted'} {' Reposted'}
</div> </div>
</div> </div>
<Show when={event() != null} fallback={<Show when={eventQuery.isLoading}>loading</Show>}> <Show
when={event() != null}
fallback={<Show when={eventQuery.isLoading}>loading {eventId()}</Show>}
>
<TextNote event={event()} /> <TextNote event={event()} />
</Show> </Show>
</div> </div>

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,6 +33,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
pubkey: props.event.pubkey, pubkey: props.event.pubkey,
})); }));
/*
const { const {
reactions, reactions,
isReactedBy, isReactedBy,
@@ -43,6 +44,8 @@ const TextNote: Component<TextNoteProps> = (props) => {
})); }));
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]),
@@ -61,10 +64,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({
@@ -75,7 +80,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}) })
.then(() => { .then(() => {
reactionsQuery.refetch(); // reactionsQuery.refetch();
}); });
}; };
@@ -141,7 +146,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

@@ -61,7 +61,7 @@ const Reaction: Component<ReactionProps> = (props) => {
</div> </div>
</div> </div>
<div class="notification-event"> <div class="notification-event">
<Show when={reactedEvent() != null} fallback="loading"> <Show when={reactedEvent() != null} fallback={<>loading {eventId()}</>}>
<TextNote event={reactedEvent()} /> <TextNote event={reactedEvent()} />
</Show> </Show>
</div> </div>

View File

@@ -4,7 +4,7 @@ const [currentDate, setCurrentDate] = createSignal(new Date());
setInterval(() => { setInterval(() => {
setCurrentDate(new Date()); setCurrentDate(new Date());
}, 10000); }, 7000);
const useDatePulser = (): Accessor<Date> => { const useDatePulser = (): Accessor<Date> => {
return currentDate; return currentDate;

View File

@@ -32,6 +32,21 @@ useShortcutKeys({
onShortcut: (s) => console.log(s), onShortcut: (s) => console.log(s),
}); });
const dummyTextNote = (
<TextNote
event={
{
id: 12345,
kind: 1,
pubkey: pubkeyHex,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
} as NostrEvent
}
/>
);
const Home: Component = () => { const Home: Component = () => {
const [config] = useConfig(); const [config] = useConfig();
const commands = useCommands(); const commands = useCommands();
@@ -47,6 +62,7 @@ const Home: Component = () => {
kinds: [1, 6], kinds: [1, 6],
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex], authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
limit: 25, limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
}, },
], ],
})); }));
@@ -117,18 +133,6 @@ const Home: Component = () => {
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} /> <SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
<div class="flex flex-row overflow-y-hidden overflow-x-scroll"> <div class="flex flex-row overflow-y-hidden overflow-x-scroll">
<Column name="ホーム" width="widest"> <Column name="ホーム" width="widest">
<TextNote
event={
{
id: 12345,
kind: 1,
pubkey: pubkeyHex,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
} as NostrEvent
}
/>
<Timeline events={followingsPosts()} /> <Timeline events={followingsPosts()} />
</Column> </Column>
<Column name="通知" width="medium"> <Column name="通知" width="medium">