mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 14:34:25 +01:00
update
This commit is contained in:
@@ -30,7 +30,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
### 日本語
|
### 日本語
|
||||||
|
|
||||||
このプログラムは自由ソフトウェアです。あなたはこれを、Free Software Foundationによって発行された
|
このプログラムは自由ソフトウェアです。あなたはこれを、Free Software Foundationによって発行された
|
||||||
GNUアフェロー一般公衆利用許諾書(バージョン3か、それ以降のいずれかのバージョン)が定める条件の下で再頒布または改変することができます。
|
GNUアフェロー一般公衆利用許諾書(バージョン3か、それ以降のいずれかのバージョン)
|
||||||
|
が定める条件の下で再頒布または改変することができます。
|
||||||
|
|
||||||
このプログラムは有用であることを願って頒布されますが、 *全くの無保証* です。
|
このプログラムは有用であることを願って頒布されますが、 *全くの無保証* です。
|
||||||
*商業可能性* や *特定目的への適合性* に対する保証は言外に示されたものも含め、全く存在しません。
|
*商業可能性* や *特定目的への適合性* に対する保証は言外に示されたものも含め、全く存在しません。
|
||||||
|
|||||||
90
src/clients/useBatchedEvents.ts
Normal file
90
src/clients/useBatchedEvents.ts
Normal 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;
|
||||||
66
src/clients/useDeprecatedReposts.ts
Normal file
66
src/clients/useDeprecatedReposts.ts
Normal 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;
|
||||||
@@ -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 { type Event as NostrEvent } from 'nostr-tools/event';
|
||||||
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
|
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
|
||||||
|
|
||||||
import useConfig from '@/clients/useConfig';
|
import useBatchedEvents, { type BatchedEvents } from '@/clients/useBatchedEvents';
|
||||||
import useBatch, { type Task } from '@/clients/useBatch';
|
|
||||||
import useSubscription from '@/clients/useSubscription';
|
|
||||||
import timeout from '@/utils/timeout';
|
import timeout from '@/utils/timeout';
|
||||||
|
|
||||||
export type UseReactionsProps = {
|
export type UseReactionsProps = {
|
||||||
@@ -17,53 +15,19 @@ export type UseReactions = {
|
|||||||
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
|
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
|
||||||
isReactedBy: (pubkey: string) => boolean;
|
isReactedBy: (pubkey: string) => boolean;
|
||||||
invalidateReactions: () => Promise<void>;
|
invalidateReactions: () => Promise<void>;
|
||||||
query: CreateQueryResult<Accessor<NostrEvent[]>>;
|
query: CreateQueryResult<Accessor<BatchedEvents>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { exec } = useBatch<UseReactionsProps, Accessor<NostrEvent[]>>(() => {
|
const { exec } = useBatchedEvents<UseReactionsProps>(() => ({
|
||||||
return {
|
generateKey: ({ eventId }) => eventId,
|
||||||
interval: 2500,
|
mergeFilters: (args) => {
|
||||||
executor: (tasks) => {
|
const eventIds = args.map((arg) => arg.eventId);
|
||||||
// TODO relayUrlsを考慮する
|
return [{ kinds: [7], '#e': eventIds }];
|
||||||
const [config] = useConfig();
|
},
|
||||||
|
extractKey: (event: NostrEvent) => {
|
||||||
const eventIdTaskMap = new Map<string, Task<UseReactionsProps, Accessor<NostrEvent[]>>>(
|
return event.tags.find((e) => e[0] === 'e')?.[1];
|
||||||
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 useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
|
||||||
const props = createMemo(propsProvider);
|
const props = createMemo(propsProvider);
|
||||||
@@ -71,8 +35,8 @@ const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
|
|||||||
|
|
||||||
const query = createQuery(
|
const query = createQuery(
|
||||||
() => queryKey(),
|
() => queryKey(),
|
||||||
({ queryKey, signal }) => {
|
({ queryKey: currentQueryKey, signal }) => {
|
||||||
const [, currentProps] = queryKey;
|
const [, currentProps] = currentQueryKey;
|
||||||
return timeout(15000, `useReactions: ${currentProps.eventId}`)(exec(currentProps, signal));
|
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 reactionsGroupedByContent = () => {
|
||||||
const result = new Map<string, NostrEvent[]>();
|
const result = new Map<string, NostrEvent[]>();
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import TextNote from '@/components/TextNote';
|
|||||||
import Reaction from '@/components/notification/Reaction';
|
import Reaction from '@/components/notification/Reaction';
|
||||||
import DeprecatedRepost from '@/components/DeprecatedRepost';
|
import DeprecatedRepost from '@/components/DeprecatedRepost';
|
||||||
|
|
||||||
export type TimelineProps = {
|
export type NotificationProps = {
|
||||||
events: NostrEvent[];
|
events: NostrEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const Timeline: Component<TimelineProps> = (props) => {
|
const Notification: Component<NotificationProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<For each={props.events}>
|
<For each={props.events}>
|
||||||
{(event) => (
|
{(event) => (
|
||||||
@@ -21,7 +21,7 @@ const Timeline: Component<TimelineProps> = (props) => {
|
|||||||
<Reaction event={event} />
|
<Reaction event={event} />
|
||||||
</Match>
|
</Match>
|
||||||
{/* TODO ちゃんとnotification用のコンポーネント使う */}
|
{/* TODO ちゃんとnotification用のコンポーネント使う */}
|
||||||
<Match when={event.kind === 1}>
|
<Match when={event.kind === 6}>
|
||||||
<DeprecatedRepost event={event} />
|
<DeprecatedRepost event={event} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -30,4 +30,4 @@ const Timeline: Component<TimelineProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Timeline;
|
export default Notification;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 useDeprecatedReposts from '@/clients/useDeprecatedReposts';
|
||||||
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';
|
||||||
@@ -38,7 +39,13 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
eventId: props.event.id,
|
eventId: props.event.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { reposts, isRepostedBy } = useDeprecatedReposts(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: props.event.id,
|
||||||
|
}));
|
||||||
|
|
||||||
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
|
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
|
||||||
|
const isRepostedByMe = createMemo(() => isRepostedBy(pubkey()));
|
||||||
|
|
||||||
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]),
|
||||||
@@ -79,6 +86,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
<ColumnItem>
|
<ColumnItem>
|
||||||
<div class="author-icon h-10 w-10 shrink-0">
|
<div class="author-icon h-10 w-10 shrink-0">
|
||||||
<Show when={author()?.picture}>
|
<Show when={author()?.picture}>
|
||||||
|
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
|
||||||
<img
|
<img
|
||||||
src={author()?.picture}
|
src={author()?.picture}
|
||||||
alt="icon"
|
alt="icon"
|
||||||
@@ -121,9 +129,20 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
<button class="h-4 w-4 text-zinc-400">
|
<button class="h-4 w-4 text-zinc-400">
|
||||||
<ChatBubbleLeft />
|
<ChatBubbleLeft />
|
||||||
</button>
|
</button>
|
||||||
<button class="h-4 w-4 text-zinc-400" onClick={handleRepost}>
|
<div
|
||||||
<ArrowPathRoundedSquare />
|
class="flex items-center gap-1"
|
||||||
</button>
|
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
|
<div
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
classList={{
|
classList={{
|
||||||
@@ -136,7 +155,9 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
<HeartSolid />
|
<HeartSolid />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
<div class="text-sm text-zinc-400">{reactions().length}</div>
|
<Show when={reactions().length > 0}>
|
||||||
|
<div class="text-sm text-zinc-400">{reactions().length}</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<button class="h-4 w-4 text-zinc-400">
|
<button class="h-4 w-4 text-zinc-400">
|
||||||
<EllipsisHorizontal />
|
<EllipsisHorizontal />
|
||||||
|
|||||||
Reference in New Issue
Block a user