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 { UseSubscriptionProps } from '@/clients/useSubscription';
import { type UseSubscriptionProps } from '@/clients/useSubscription';
import type { Event as NostrEvent } from 'nostr-tools/event';
import type { Filter } from 'nostr-tools/filter';
import type { SimplePool } from 'nostr-tools/pool';
@@ -10,6 +10,8 @@ type GetEventsArgs = {
pool: SimplePool;
relayUrls: string[];
filters: Filter[];
// TODO 継続的に取得する場合、Promiseでは無理なので、無理やりキャッシュにストアする仕組みを使う
continuous?: boolean;
options?: SubscriptionOptions;
signal?: AbortSignal;
};
@@ -52,12 +54,12 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
return createQuery(
() => {
const { relayUrls, filters, options } = propsProvider();
return ['useCachedEvents', relayUrls, filters, options] as const;
const { relayUrls, filters, continuous, options } = propsProvider();
return ['useCachedEvents', relayUrls, filters, continuous, options] as const;
},
({ queryKey, signal }) => {
const [, relayUrls, filters, options] = queryKey;
return getEvents({ pool: pool(), relayUrls, filters, options, signal });
const [, relayUrls, filters, continuous, options] = queryKey;
return getEvents({ pool: pool(), relayUrls, filters, options, continuous, signal });
},
{
// 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 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 = {
relayUrls: string[];
@@ -11,25 +13,57 @@ export type UseEventProps = {
export type UseEvent = {
event: Accessor<NostrEvent | undefined>;
query: CreateQueryResult<NostrEvent[]>;
query: CreateQueryResult<NostrEvent>;
};
const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
const query = useCachedEvents(() => {
const { relayUrls, eventId } = propsProvider();
return {
relayUrls,
filters: [
{
ids: [eventId],
kinds: [1],
limit: 1,
},
],
};
});
const { exec } = useBatch<UseEventProps, NostrEvent>(() => {
return {
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());
const event = () => query.data?.[0];
useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
ids: eventIds,
kinds: [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 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 };
};

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 CreateQueryResult } from '@tanstack/solid-query';
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import useCachedEvents from '@/clients/useCachedEvents';
type UseProfileProps = {
relayUrls: string[];
pubkey: string;
};
import useConfig from '@/clients/useConfig';
import useBatch, { type Task } from '@/clients/useBatch';
import useSubscription from '@/clients/useSubscription';
// TODO zodにする
// deleted等の特殊なもの
@@ -27,28 +24,65 @@ type NonStandardProfile = {
type Profile = StandardProfile & NonStandardProfile;
type UseProfile = {
profile: Accessor<Profile | undefined>;
query: CreateQueryResult<NostrEvent[]>;
type UseProfileProps = {
relayUrls: string[];
pubkey: string;
};
const useProfile = (propsProvider: () => UseProfileProps): UseProfile => {
const query = useCachedEvents(() => {
const { relayUrls, pubkey } = propsProvider();
return {
relayUrls,
filters: [
{
kinds: [0],
authors: [pubkey],
limit: 1,
type UseProfile = {
profile: Accessor<Profile | undefined>;
query: CreateQueryResult<NostrEvent>;
};
const { exec } = useBatch<UseProfileProps, NostrEvent>(() => {
return {
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: [
{
kinds: [0],
authors: pubkeys,
},
],
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 maybeProfile = query.data?.[0];
const maybeProfile = query.data;
if (maybeProfile == null) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック

View File

@@ -8,6 +8,11 @@ export type UseSubscriptionProps = {
relayUrls: string[];
filters: Filter[];
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[]) =>
@@ -21,7 +26,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
const props = propsProvider();
if (props == null) return;
const { relayUrls, filters, options } = props;
const { relayUrls, filters, options, onEvent, continuous = true } = props;
const sub = pool().sub(relayUrls, filters, options);
let pushed = false;
@@ -29,6 +34,9 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
const storedEvents: NostrEvent[] = [];
sub.on('event', (event: NostrEvent) => {
if (onEvent != null) {
onEvent(event);
}
if (!eose) {
pushed = true;
storedEvents.push(event);
@@ -41,6 +49,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
sub.on('eose', () => {
eose = true;
setEvents(sortEvents(storedEvents));
if (!continuous) {
sub.unsub();
}
});
// avoid updating an array too rapidly while this is fetching stored events

View File

@@ -37,7 +37,10 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
{' Reposted'}
</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()} />
</Show>
</div>

View File

@@ -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,6 +33,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
pubkey: props.event.pubkey,
}));
/*
const {
reactions,
isReactedBy,
@@ -43,6 +44,8 @@ const TextNote: Component<TextNoteProps> = (props) => {
}));
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
*/
const isReactedByMe = () => false;
const replyingToPubKeys = createMemo(() =>
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) => {
/*
if (isReactedByMe()) {
// TODO remove reaction
return;
}
*/
ev.preventDefault();
commands
.publishReaction({
@@ -75,7 +80,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
notifyPubkey: props.event.pubkey,
})
.then(() => {
reactionsQuery.refetch();
// reactionsQuery.refetch();
});
};
@@ -141,7 +146,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
<HeartSolid />
</Show>
</button>
<div class="text-sm text-zinc-400">{reactions().length}</div>
{/* <div class="text-sm text-zinc-400">{reactions().length}</div> */}
</div>
<button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal />

View File

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

View File

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

View File

@@ -32,6 +32,21 @@ useShortcutKeys({
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 [config] = useConfig();
const commands = useCommands();
@@ -47,6 +62,7 @@ const Home: Component = () => {
kinds: [1, 6],
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
],
}));
@@ -117,18 +133,6 @@ const Home: Component = () => {
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
<div class="flex flex-row overflow-y-hidden overflow-x-scroll">
<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()} />
</Column>
<Column name="通知" width="medium">