mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 22:44:26 +01:00
update
This commit is contained in:
@@ -1,117 +1,39 @@
|
||||
import { createSignal, createMemo, observable, type Accessor, type Signal } from 'solid-js';
|
||||
import { createSignal, type Accessor, type Signal } from 'solid-js';
|
||||
|
||||
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
|
||||
import { type Event as NostrEvent, type Filter, Kind } from 'nostr-tools';
|
||||
|
||||
import useConfig from '@/core/useConfig';
|
||||
import eventWrapper from '@/nostr/event';
|
||||
import { genericEvent } from '@/nostr/event';
|
||||
import useBatch, { type Task } from '@/nostr/useBatch';
|
||||
import usePool from '@/nostr/usePool';
|
||||
import useStats from '@/nostr/useStats';
|
||||
import timeout from '@/utils/timeout';
|
||||
|
||||
type ProfileTask = { type: 'Profile'; pubkey: string };
|
||||
type EventTask = { type: 'Event'; eventId: string };
|
||||
type ReactionsTask = { type: 'Reactions'; mentionedEventId: string };
|
||||
type ZapReceiptsTask = { type: 'ZapReceipts'; mentionedEventId: string };
|
||||
type RepostsTask = { type: 'Reposts'; mentionedEventId: string };
|
||||
type FollowingsTask = { type: 'Followings'; pubkey: string };
|
||||
type ParameterizedReplaceableEventTask = {
|
||||
type: 'ParameterizedReplaceableEvent';
|
||||
kind: number;
|
||||
author: string;
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
type TaskArg =
|
||||
| { type: 'Profile'; pubkey: string }
|
||||
| { type: 'Event'; eventId: string }
|
||||
| { type: 'Reactions'; mentionedEventId: string }
|
||||
| { type: 'ZapReceipts'; mentionedEventId: string }
|
||||
| { type: 'Reposts'; mentionedEventId: string }
|
||||
| { type: 'Followings'; pubkey: string };
|
||||
| ProfileTask
|
||||
| EventTask
|
||||
| ReactionsTask
|
||||
| ZapReceiptsTask
|
||||
| RepostsTask
|
||||
| FollowingsTask
|
||||
| ParameterizedReplaceableEventTask;
|
||||
|
||||
type BatchedEvents = { completed: boolean; events: NostrEvent[] };
|
||||
|
||||
type TaskRes = Accessor<BatchedEvents>;
|
||||
|
||||
// Profile
|
||||
// TODO zodにする
|
||||
// deleted等の特殊なもの
|
||||
export type StandardProfile = {
|
||||
name?: string;
|
||||
about?: string;
|
||||
// user's icon
|
||||
picture?: string;
|
||||
// user's banner image
|
||||
banner?: string;
|
||||
nip05?: string; // NIP-05
|
||||
lud06?: string; // NIP-57
|
||||
lud16?: string; // NIP-57
|
||||
};
|
||||
|
||||
export type NonStandardProfile = {
|
||||
display_name?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
export type Profile = StandardProfile & NonStandardProfile;
|
||||
|
||||
export type ProfileWithOtherProperties = Profile & Record<string, any>;
|
||||
|
||||
export type UseProfileProps = {
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
type UseProfile = {
|
||||
profile: () => ProfileWithOtherProperties | null;
|
||||
invalidateProfile: () => Promise<void>;
|
||||
query: CreateQueryResult<NostrEvent | null>;
|
||||
};
|
||||
|
||||
// Event
|
||||
export type UseEventProps = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
export type UseEvent = {
|
||||
event: () => NostrEvent | null;
|
||||
query: CreateQueryResult<NostrEvent | null>;
|
||||
};
|
||||
|
||||
// Reactions
|
||||
export type UseReactionsProps = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
export type UseReactions = {
|
||||
reactions: () => NostrEvent[];
|
||||
reactionsGroupedByContent: () => Map<string, NostrEvent[]>;
|
||||
isReactedBy: (pubkey: string) => boolean;
|
||||
isReactedByWithEmoji: (pubkey: string) => boolean;
|
||||
invalidateReactions: () => Promise<void>;
|
||||
query: CreateQueryResult<NostrEvent[]>;
|
||||
};
|
||||
|
||||
// Reposts
|
||||
export type UseRepostsProps = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
export type UseReposts = {
|
||||
reposts: () => NostrEvent[];
|
||||
isRepostedBy: (pubkey: string) => boolean;
|
||||
invalidateReposts: () => Promise<void>;
|
||||
query: CreateQueryResult<NostrEvent[]>;
|
||||
};
|
||||
|
||||
// Followings
|
||||
type UseFollowingsProps = {
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
type Following = {
|
||||
pubkey: string;
|
||||
mainRelayUrl?: string;
|
||||
petname?: string;
|
||||
};
|
||||
|
||||
export type UseFollowings = {
|
||||
followings: () => Following[];
|
||||
followingPubkeys: () => string[];
|
||||
invalidateFollowings: () => Promise<void>;
|
||||
query: CreateQueryResult<NostrEvent | null>;
|
||||
};
|
||||
|
||||
const EmojiRegex = /\p{Emoji_Presentation}/u;
|
||||
|
||||
let count = 0;
|
||||
|
||||
const { setActiveBatchSubscriptions } = useStats();
|
||||
@@ -123,7 +45,16 @@ setInterval(() => {
|
||||
const EmptyBatchedEvents = { events: [], completed: true };
|
||||
const emptyBatchedEvents = () => EmptyBatchedEvents;
|
||||
|
||||
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
const isParameterizedReplaceableEvent = (event: NostrEvent) =>
|
||||
event.kind >= 30000 && event.kind < 40000;
|
||||
|
||||
const keyForParameterizedReplaceableEvent = ({
|
||||
kind,
|
||||
author,
|
||||
identifier,
|
||||
}: ParameterizedReplaceableEventTask) => `${kind}:${author}:${identifier}`;
|
||||
|
||||
export const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
interval: 2000,
|
||||
batchSize: 150,
|
||||
executor: (tasks) => {
|
||||
@@ -132,6 +63,7 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
const reactionsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||
const repostsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||
const zapReceiptsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||
const parameterizedReplaceableEventsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||
const followingsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||
|
||||
tasks.forEach((task) => {
|
||||
@@ -150,6 +82,10 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
} else if (task.args.type === 'ZapReceipts') {
|
||||
const current = zapReceiptsTasks.get(task.args.mentionedEventId) ?? [];
|
||||
repostsTasks.set(task.args.mentionedEventId, [...current, task]);
|
||||
} else if (task.args.type === 'ParameterizedReplaceableEvent') {
|
||||
const key = keyForParameterizedReplaceableEvent(task.args);
|
||||
const current = parameterizedReplaceableEventsTasks.get(key) ?? [];
|
||||
parameterizedReplaceableEventsTasks.set(key, [...current, task]);
|
||||
} else if (task.args.type === 'Followings') {
|
||||
const current = followingsTasks.get(task.args.pubkey) ?? [];
|
||||
followingsTasks.set(task.args.pubkey, [...current, task]);
|
||||
@@ -183,6 +119,15 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
if (followingsIds.length > 0) {
|
||||
filters.push({ kinds: [Kind.Contacts], authors: followingsIds });
|
||||
}
|
||||
if (parameterizedReplaceableEventsTasks.size > 0) {
|
||||
Array.from(parameterizedReplaceableEventsTasks.values()).forEach(([firstTask]) => {
|
||||
if (firstTask.args.type !== 'ParameterizedReplaceableEvent') return;
|
||||
const {
|
||||
args: { kind, author, identifier },
|
||||
} = firstTask;
|
||||
filters.push({ kinds: [Kind.Contacts], authors: [author], '#d': [identifier] });
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.length === 0) return;
|
||||
|
||||
@@ -229,20 +174,20 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
|
||||
if (event.kind === Kind.Reaction) {
|
||||
// Use the last event id
|
||||
const id = eventWrapper(event).lastTaggedEventId();
|
||||
const id = genericEvent(event).lastTaggedEventId();
|
||||
if (id != null) {
|
||||
const registeredTasks = reactionsTasks.get(id) ?? [];
|
||||
resolveTasks(registeredTasks, event);
|
||||
}
|
||||
} else if ((event.kind as number) === 6) {
|
||||
// Use the last event id
|
||||
const id = eventWrapper(event).lastTaggedEventId();
|
||||
const id = genericEvent(event).lastTaggedEventId();
|
||||
if (id != null) {
|
||||
const registeredTasks = repostsTasks.get(id) ?? [];
|
||||
resolveTasks(registeredTasks, event);
|
||||
}
|
||||
} else if (event.kind === Kind.Zap) {
|
||||
const eTags = eventWrapper(event).eTags();
|
||||
const eTags = genericEvent(event).eTags();
|
||||
eTags.forEach(([, id]) => {
|
||||
const registeredTasks = repostsTasks.get(id) ?? [];
|
||||
resolveTasks(registeredTasks, event);
|
||||
@@ -250,6 +195,15 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
} else if (event.kind === Kind.Contacts) {
|
||||
const registeredTasks = followingsTasks.get(event.pubkey) ?? [];
|
||||
resolveTasks(registeredTasks, event);
|
||||
} else if (isParameterizedReplaceableEvent(event)) {
|
||||
const identifier = genericEvent(event).findFirstTagByName('d')?.[1];
|
||||
if (identifier != null) {
|
||||
const key = `${event.kind}:${event.pubkey}:${identifier}`;
|
||||
const registeredTasks = parameterizedReplaceableEventsTasks.get(key) ?? [];
|
||||
resolveTasks(registeredTasks, event);
|
||||
} else {
|
||||
console.warn('identifier is undefined');
|
||||
}
|
||||
} else {
|
||||
const registeredTasks = eventTasks.get(event.id) ?? [];
|
||||
if (registeredTasks.length > 0) {
|
||||
@@ -268,259 +222,7 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const pickLatestEvent = (events: NostrEvent[]): NostrEvent | null => {
|
||||
export const pickLatestEvent = (events: NostrEvent[]): NostrEvent | null => {
|
||||
if (events.length === 0) return null;
|
||||
return events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
|
||||
};
|
||||
|
||||
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
|
||||
const queryClient = useQueryClient();
|
||||
const props = createMemo(propsProvider);
|
||||
const genQueryKey = createMemo(() => ['useProfile', props()] as const);
|
||||
|
||||
const query = createQuery(
|
||||
genQueryKey,
|
||||
({ queryKey, signal }) => {
|
||||
const [, currentProps] = queryKey;
|
||||
if (currentProps == null) return Promise.resolve(null);
|
||||
const { pubkey } = currentProps;
|
||||
if (pubkey.startsWith('npub1')) return Promise.resolve(null);
|
||||
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
|
||||
const latestEvent = () => {
|
||||
const latest = pickLatestEvent(batchedEvents().events);
|
||||
if (latest == null) throw new Error(`profile not found: ${pubkey}`);
|
||||
return latest;
|
||||
};
|
||||
observable(batchedEvents).subscribe(() => {
|
||||
try {
|
||||
queryClient.setQueryData(queryKey, latestEvent());
|
||||
} catch (err) {
|
||||
console.error('error occurred while updating profile cache: ', err);
|
||||
}
|
||||
});
|
||||
return latestEvent();
|
||||
});
|
||||
// TODO timeoutと同時にsignalでキャンセルするようにしたい
|
||||
return timeout(15000, `useProfile: ${pubkey}`)(promise);
|
||||
},
|
||||
{
|
||||
// Profiles are updated occasionally, so a short staleTime is used here.
|
||||
// cacheTime is long so that the user see profiles instantly.
|
||||
staleTime: 5 * 60 * 1000, // 5 min
|
||||
cacheTime: 24 * 60 * 60 * 1000, // 1 day
|
||||
refetchInterval: 5 * 60 * 1000, // 5 min
|
||||
},
|
||||
);
|
||||
|
||||
const profile = createMemo((): Profile | null => {
|
||||
if (query.data == null) return null;
|
||||
const { content } = query.data;
|
||||
if (content == null || content.length === 0) return null;
|
||||
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
|
||||
try {
|
||||
return JSON.parse(content) as Profile;
|
||||
} catch (err) {
|
||||
console.warn('failed to parse profile (kind 0): ', err, content);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const invalidateProfile = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
|
||||
|
||||
return { profile, invalidateProfile, query };
|
||||
};
|
||||
|
||||
export const useEvent = (propsProvider: () => UseEventProps | null): UseEvent => {
|
||||
const props = createMemo(propsProvider);
|
||||
|
||||
const query = createQuery(
|
||||
() => ['useEvent', props()] as const,
|
||||
({ queryKey, signal }) => {
|
||||
const [, currentProps] = queryKey;
|
||||
if (currentProps == null) return null;
|
||||
const { eventId } = currentProps;
|
||||
const promise = exec({ type: 'Event', eventId }, signal).then((batchedEvents) => {
|
||||
const event = batchedEvents().events[0];
|
||||
if (event == null) throw new Error(`event not found: ${eventId}`);
|
||||
return event;
|
||||
});
|
||||
return timeout(15000, `useEvent: ${eventId}`)(promise);
|
||||
},
|
||||
{
|
||||
// Text notes never change, so they can be stored for a long time.
|
||||
// However, events tend to be unreferenced as time passes.
|
||||
staleTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
);
|
||||
|
||||
const event = () => query.data ?? null;
|
||||
|
||||
return { event, query };
|
||||
};
|
||||
|
||||
export const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactions => {
|
||||
const queryClient = useQueryClient();
|
||||
const props = createMemo(propsProvider);
|
||||
const genQueryKey = createMemo(() => ['useReactions', props()] as const);
|
||||
|
||||
const query = createQuery(
|
||||
genQueryKey,
|
||||
({ queryKey, signal }) => {
|
||||
const [, currentProps] = queryKey;
|
||||
if (currentProps == null) return [];
|
||||
|
||||
const { eventId: mentionedEventId } = currentProps;
|
||||
const promise = exec({ type: 'Reactions', mentionedEventId }, signal).then(
|
||||
(batchedEvents) => {
|
||||
const events = () => batchedEvents().events;
|
||||
observable(batchedEvents).subscribe(() => {
|
||||
queryClient.setQueryData(queryKey, events());
|
||||
});
|
||||
return events();
|
||||
},
|
||||
);
|
||||
return timeout(15000, `useReactions: ${mentionedEventId}`)(promise);
|
||||
},
|
||||
{
|
||||
staleTime: 1 * 60 * 1000, // 1 min
|
||||
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||
refetchInterval: 1 * 60 * 1000, // 1 min
|
||||
},
|
||||
);
|
||||
|
||||
const reactions = () => query.data ?? [];
|
||||
|
||||
const reactionsGroupedByContent = () => {
|
||||
const result = new Map<string, NostrEvent[]>();
|
||||
reactions().forEach((event) => {
|
||||
const events = result.get(event.content) ?? [];
|
||||
events.push(event);
|
||||
result.set(event.content, events);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const isReactedBy = (pubkey: string): boolean =>
|
||||
reactions().findIndex((event) => event.pubkey === pubkey) !== -1;
|
||||
|
||||
const isReactedByWithEmoji = (pubkey: string): boolean =>
|
||||
reactions().findIndex((event) => event.pubkey === pubkey && EmojiRegex.test(event.content)) !==
|
||||
-1;
|
||||
|
||||
const invalidateReactions = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
|
||||
|
||||
return {
|
||||
reactions,
|
||||
reactionsGroupedByContent,
|
||||
isReactedBy,
|
||||
isReactedByWithEmoji,
|
||||
invalidateReactions,
|
||||
query,
|
||||
};
|
||||
};
|
||||
|
||||
export const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
|
||||
const queryClient = useQueryClient();
|
||||
const props = createMemo(propsProvider);
|
||||
const genQueryKey = createMemo(() => ['useReposts', props()] as const);
|
||||
|
||||
const query = createQuery(
|
||||
genQueryKey,
|
||||
({ queryKey, signal }) => {
|
||||
const [, currentProps] = queryKey;
|
||||
if (currentProps == null) return [];
|
||||
const { eventId: mentionedEventId } = currentProps;
|
||||
const promise = exec({ type: 'Reposts', mentionedEventId }, signal).then((batchedEvents) => {
|
||||
const events = () => batchedEvents().events;
|
||||
observable(batchedEvents).subscribe(() => {
|
||||
queryClient.setQueryData(queryKey, events());
|
||||
});
|
||||
return events();
|
||||
});
|
||||
return timeout(15000, `useReposts: ${mentionedEventId}`)(promise);
|
||||
},
|
||||
{
|
||||
staleTime: 1 * 60 * 1000, // 1 min
|
||||
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||
refetchInterval: 1 * 60 * 1000, // 1 min
|
||||
},
|
||||
);
|
||||
|
||||
const reposts = () => query.data ?? [];
|
||||
|
||||
const isRepostedBy = (pubkey: string): boolean =>
|
||||
reposts().findIndex((event) => event.pubkey === pubkey) !== -1;
|
||||
|
||||
const invalidateReposts = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
|
||||
|
||||
return { reposts, isRepostedBy, invalidateReposts, query };
|
||||
};
|
||||
|
||||
export const useFollowings = (propsProvider: () => UseFollowingsProps | null): UseFollowings => {
|
||||
const queryClient = useQueryClient();
|
||||
const props = createMemo(propsProvider);
|
||||
const genQueryKey = () => ['useFollowings', props()] as const;
|
||||
|
||||
const query = createQuery(
|
||||
genQueryKey,
|
||||
({ queryKey, signal }) => {
|
||||
console.debug('useFollowings');
|
||||
const [, currentProps] = queryKey;
|
||||
if (currentProps == null) return Promise.resolve(null);
|
||||
const { pubkey } = currentProps;
|
||||
const promise = exec({ type: 'Followings', pubkey }, signal).then((batchedEvents) => {
|
||||
const latestEvent = () => {
|
||||
const latest = pickLatestEvent(batchedEvents().events);
|
||||
if (latest == null) throw new Error(`followings not found: ${pubkey}`);
|
||||
return latest;
|
||||
};
|
||||
observable(batchedEvents).subscribe(() => {
|
||||
try {
|
||||
queryClient.setQueryData(queryKey, latestEvent());
|
||||
} catch (err) {
|
||||
console.error('error occurred while updating followings cache: ', err);
|
||||
}
|
||||
});
|
||||
return latestEvent();
|
||||
});
|
||||
return timeout(15000, `useFollowings: ${pubkey}`)(promise);
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 min
|
||||
cacheTime: 24 * 60 * 60 * 1000, // 24 hour
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const followings = () => {
|
||||
if (query.data == null) return [];
|
||||
|
||||
const result: Following[] = [];
|
||||
|
||||
// TODO zodにする
|
||||
const event = eventWrapper(query.data);
|
||||
event.pTags().forEach((tag) => {
|
||||
const [, followingPubkey, mainRelayUrl, petname] = tag;
|
||||
|
||||
const following: Following = { pubkey: followingPubkey, petname };
|
||||
if (mainRelayUrl != null && mainRelayUrl.length > 0) {
|
||||
following.mainRelayUrl = mainRelayUrl;
|
||||
}
|
||||
|
||||
result.push(following);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const followingPubkeys = (): string[] => followings().map((follow) => follow.pubkey);
|
||||
|
||||
const invalidateFollowings = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
|
||||
|
||||
return { followings, followingPubkeys, invalidateFollowings, query };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user