This commit is contained in:
Shusui MOYATANI
2023-03-03 21:27:06 +09:00
parent 51249ab6f6
commit 94c51d76c4
6 changed files with 203 additions and 61 deletions

View File

@@ -30,7 +30,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
### 日本語
このプログラムは自由ソフトウェアです。あなたはこれを、Free Software Foundationによって発行された
GNUアフェロー一般公衆利用許諾書バージョン3か、それ以降のいずれかのバージョンが定める条件の下で再頒布または改変することができます。
GNUアフェロー一般公衆利用許諾書バージョン3か、それ以降のいずれかのバージョン
が定める条件の下で再頒布または改変することができます。
このプログラムは有用であることを願って頒布されますが、 *全くの無保証* です。
*商業可能性**特定目的への適合性* に対する保証は言外に示されたものも含め、全く存在しません。

View File

@@ -0,0 +1,90 @@
import { createSignal, createMemo, type Signal, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import { type Filter } from 'nostr-tools/filter';
import useConfig from '@/clients/useConfig';
import useBatch, { type Task } from '@/clients/useBatch';
import useSubscription from '@/clients/useSubscription';
export type UseBatchedEventsProps<TaskArgs> = {
interval?: number;
generateKey: (args: TaskArgs) => string | number;
mergeFilters: (args: TaskArgs[]) => Filter[];
extractKey: (event: NostrEvent) => string | number | undefined;
};
export type BatchedEvents = {
events: NostrEvent[];
completed: boolean;
};
const useBatchedEvents = <TaskArgs>(propsProvider: () => UseBatchedEventsProps<TaskArgs>) => {
const props = createMemo(propsProvider);
return useBatch<TaskArgs, Accessor<BatchedEvents>>(() => {
return {
interval: props().interval,
executor: (tasks) => {
const { generateKey, mergeFilters, extractKey } = props();
// TODO relayUrlsを考慮する
const [config] = useConfig();
const keyTaskMap = new Map<string | number, Task<TaskArgs, Accessor<BatchedEvents>>>(
tasks.map((task) => [generateKey(task.args), task]),
);
const filters = mergeFilters(tasks.map((task) => task.args));
const keyEventSignalsMap = new Map<string | number, Signal<BatchedEvents>>();
const getSignalForKey = (key: string | number): Signal<BatchedEvents> => {
const eventsSignal =
keyEventSignalsMap.get(key) ??
createSignal<BatchedEvents>({
events: [],
completed: false,
});
keyEventSignalsMap.set(key, eventsSignal);
return eventsSignal;
};
const didReceivedEventsForKey = (key: string | number): boolean =>
keyEventSignalsMap.has(key);
useSubscription(() => ({
relayUrls: config().relayUrls,
filters,
continuous: false,
onEvent: (event: NostrEvent) => {
const key = extractKey(event);
if (key == null) return;
const task = keyTaskMap.get(key);
if (task == null) return;
const [events, setEvents] = getSignalForKey(key);
setEvents((currentEvents) => ({
...currentEvents,
events: [...currentEvents.events, event],
}));
task.resolve(events);
},
onEOSE: () => {
tasks.forEach((task) => {
const key = generateKey(task.args);
if (didReceivedEventsForKey(key)) {
const [, setEvents] = getSignalForKey(key);
setEvents((currentEvents) => ({
...currentEvents,
completed: true,
}));
} else {
task.reject(new Error(`NotFound: ${key}`));
}
});
},
}));
},
};
});
};
export default useBatchedEvents;

View File

@@ -0,0 +1,66 @@
import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import useBatchedEvents, { type BatchedEvents } from '@/clients/useBatchedEvents';
import timeout from '@/utils/timeout';
export type UseDeprecatedRepostsProps = {
relayUrls: string[];
eventId: string;
};
export type UseDeprecatedReposts = {
reposts: Accessor<NostrEvent[]>;
isRepostedBy: (pubkey: string) => boolean;
invalidateDeprecatedReposts: () => Promise<void>;
query: CreateQueryResult<Accessor<BatchedEvents>>;
};
const { exec } = useBatchedEvents<UseDeprecatedRepostsProps>(() => ({
generateKey: ({ eventId }) => eventId,
mergeFilters: (args) => {
const eventIds = args.map((arg) => arg.eventId);
return [{ kinds: [6], '#e': eventIds }];
},
extractKey: (event: NostrEvent) => {
return event.tags.find((e) => e[0] === 'e')?.[1];
},
}));
const useDeprecatedReposts = (
propsProvider: () => UseDeprecatedRepostsProps,
): UseDeprecatedReposts => {
const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useDeprecatedReposts', props()] as const);
const query = createQuery(
() => queryKey(),
({ queryKey: currentQueryKey, signal }) => {
const [, currentProps] = currentQueryKey;
return timeout(
15000,
`useDeprecatedReposts: ${currentProps.eventId}`,
)(exec(currentProps, signal));
},
{
// 1 minutes
staleTime: 1 * 60 * 1000,
cacheTime: 1 * 60 * 1000,
},
);
const reposts = () => query.data?.()?.events ?? [];
const isRepostedBy = (pubkey: string): boolean =>
reposts().findIndex((event) => event.pubkey === pubkey) !== -1;
const invalidateDeprecatedReposts = (): Promise<void> => {
const queryClient = useQueryClient();
return queryClient.invalidateQueries(queryKey());
};
return { reposts, isRepostedBy, invalidateDeprecatedReposts, query };
};
export default useDeprecatedReposts;

View File

@@ -1,10 +1,8 @@
import { createSignal, createMemo, type Signal, type Accessor } from 'solid-js';
import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import useConfig from '@/clients/useConfig';
import useBatch, { type Task } from '@/clients/useBatch';
import useSubscription from '@/clients/useSubscription';
import useBatchedEvents, { type BatchedEvents } from '@/clients/useBatchedEvents';
import timeout from '@/utils/timeout';
export type UseReactionsProps = {
@@ -17,53 +15,19 @@ export type UseReactions = {
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
isReactedBy: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>;
query: CreateQueryResult<Accessor<NostrEvent[]>>;
query: CreateQueryResult<Accessor<BatchedEvents>>;
};
const { exec } = useBatch<UseReactionsProps, Accessor<NostrEvent[]>>(() => {
return {
interval: 2500,
executor: (tasks) => {
// TODO relayUrlsを考慮する
const [config] = useConfig();
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);
const { exec } = useBatchedEvents<UseReactionsProps>(() => ({
generateKey: ({ eventId }) => eventId,
mergeFilters: (args) => {
const eventIds = args.map((arg) => arg.eventId);
return [{ kinds: [7], '#e': eventIds }];
},
onEOSE() {
tasks.forEach((task) => {
task.resolve(() => []);
});
extractKey: (event: NostrEvent) => {
return event.tags.find((e) => e[0] === 'e')?.[1];
},
}));
},
};
});
}));
const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
const props = createMemo(propsProvider);
@@ -71,8 +35,8 @@ const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
const query = createQuery(
() => queryKey(),
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
({ queryKey: currentQueryKey, signal }) => {
const [, currentProps] = currentQueryKey;
return timeout(15000, `useReactions: ${currentProps.eventId}`)(exec(currentProps, signal));
},
{
@@ -82,7 +46,7 @@ const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
},
);
const reactions = () => query.data?.() ?? [];
const reactions = () => query.data?.()?.events ?? [];
const reactionsGroupedByContent = () => {
const result = new Map<string, NostrEvent[]>();

View File

@@ -5,11 +5,11 @@ import TextNote from '@/components/TextNote';
import Reaction from '@/components/notification/Reaction';
import DeprecatedRepost from '@/components/DeprecatedRepost';
export type TimelineProps = {
export type NotificationProps = {
events: NostrEvent[];
};
const Timeline: Component<TimelineProps> = (props) => {
const Notification: Component<NotificationProps> = (props) => {
return (
<For each={props.events}>
{(event) => (
@@ -21,7 +21,7 @@ const Timeline: Component<TimelineProps> = (props) => {
<Reaction event={event} />
</Match>
{/* TODO ちゃんとnotification用のコンポーネント使う */}
<Match when={event.kind === 1}>
<Match when={event.kind === 6}>
<DeprecatedRepost event={event} />
</Match>
</Switch>
@@ -30,4 +30,4 @@ const Timeline: Component<TimelineProps> = (props) => {
);
};
export default Timeline;
export default Notification;

View File

@@ -12,6 +12,7 @@ import useConfig from '@/clients/useConfig';
import usePubkey from '@/clients/usePubkey';
import useCommands from '@/clients/useCommands';
import useReactions from '@/clients/useReactions';
import useDeprecatedReposts from '@/clients/useDeprecatedReposts';
import useDatePulser from '@/hooks/useDatePulser';
import { formatRelative } from '@/utils/formatDate';
import ColumnItem from '@/components/ColumnItem';
@@ -38,7 +39,13 @@ const TextNote: Component<TextNoteProps> = (props) => {
eventId: props.event.id,
}));
const { reposts, isRepostedBy } = useDeprecatedReposts(() => ({
relayUrls: config().relayUrls,
eventId: props.event.id,
}));
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
const isRepostedByMe = createMemo(() => isRepostedBy(pubkey()));
const replyingToPubKeys = createMemo(() =>
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
@@ -79,6 +86,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
<ColumnItem>
<div class="author-icon h-10 w-10 shrink-0">
<Show when={author()?.picture}>
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
<img
src={author()?.picture}
alt="icon"
@@ -121,9 +129,20 @@ const TextNote: Component<TextNoteProps> = (props) => {
<button class="h-4 w-4 text-zinc-400">
<ChatBubbleLeft />
</button>
<button class="h-4 w-4 text-zinc-400" onClick={handleRepost}>
<div
class="flex items-center gap-1"
classList={{
'text-zinc-400': !isRepostedByMe(),
'text-green-400': isRepostedByMe(),
}}
>
<button class="h-4 w-4" onClick={handleReaction}>
<ArrowPathRoundedSquare />
</button>
<Show when={reposts().length > 0}>
<div class="text-sm text-zinc-400">{reposts().length}</div>
</Show>
</div>
<div
class="flex items-center gap-1"
classList={{
@@ -136,7 +155,9 @@ const TextNote: Component<TextNoteProps> = (props) => {
<HeartSolid />
</Show>
</button>
<Show when={reactions().length > 0}>
<div class="text-sm text-zinc-400">{reactions().length}</div>
</Show>
</div>
<button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal />