enhance batching

This commit is contained in:
Shusui MOYATANI
2023-03-20 12:45:07 +09:00
parent cd38f7e976
commit 9a764ba086
16 changed files with 562 additions and 384 deletions

View File

@@ -47,7 +47,7 @@ const Column: Component<ColumnProps> = (props) => {
{/* <span class="column-icon">🏠</span> */}
<span class="column-name">{props.name}</span>
</div>
<ul class="block flex flex-col overflow-y-scroll scroll-smooth">{props.children}</ul>
<ul class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</ul>
</div>
);
};

View File

@@ -1,5 +1,8 @@
import useConfig, { type Config } from '@/nostr/useConfig';
import { createSignal, For, type JSX } from 'solid-js';
import XMark from 'heroicons/24/outline/x-mark.svg';
import Modal from '@/components/Modal';
type ConfigProps = {
onClose: () => void;
@@ -24,9 +27,11 @@ const RelayConfig = () => {
<For each={config().relayUrls}>
{(relayUrl: string) => {
return (
<li class="flex">
<div class="flex-1">{relayUrl}</div>
<button onClick={() => removeRelay(relayUrl)}>x</button>
<li class="flex items-center">
<div class="flex-1 truncate">{relayUrl}</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeRelay(relayUrl)}>
<XMark />
</button>
</li>
);
}}
@@ -154,33 +159,22 @@ const OtherConfig = () => {
};
const ConfigUI = (props: ConfigProps) => {
let containerRef: HTMLDivElement | undefined;
const handleClickContainer: JSX.EventHandler<HTMLDivElement, MouseEvent> = (ev) => {
if (ev.target === containerRef) {
props.onClose();
}
};
return (
<div
ref={containerRef}
class="absolute top-0 left-0 flex h-screen w-screen cursor-default place-content-center place-items-center bg-black/25"
role="button"
onClick={handleClickContainer}
>
<Modal title="設定" onClose={props.onClose}>
<div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow">
<div class="relative">
<h2 class="flex-1 text-center font-bold"></h2>
<button class="absolute top-1 right-0" onClick={() => props.onClose()}>
X
</button>
<div class="flex flex-col gap-1">
<h2 class="flex-1 text-center font-bold"></h2>
<button class="absolute top-1 right-0 h-4 w-4" onClick={() => props.onClose?.()}>
<XMark />
</button>
</div>
</div>
<RelayConfig />
<DateFormatConfig />
<OtherConfig />
</div>
</div>
</Modal>
);
};

28
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { type Component, type JSX } from 'solid-js';
export type ModalProps = {
onClose?: () => void;
children?: JSX.Element;
};
const Modal: Component<ModalProps> = (props) => {
let containerRef: HTMLDivElement | undefined;
const handleClickContainer: JSX.EventHandler<HTMLDivElement, MouseEvent> = (ev) => {
if (ev.target === containerRef) {
props.onClose?.();
}
};
return (
<div
ref={containerRef}
class="absolute top-0 left-0 flex h-screen w-screen cursor-default place-content-center place-items-center bg-black/25"
onClick={handleClickContainer}
>
{props.children}
</div>
);
};
export default Modal;

View File

@@ -14,8 +14,6 @@ import uniq from 'lodash/uniq';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import Photo from 'heroicons/24/outline/photo.svg';
import Eye from 'heroicons/24/solid/eye.svg';
import EyeSlash from 'heroicons/24/outline/eye-slash.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import UserNameDisplay from '@/components/UserDisplayName';
@@ -61,6 +59,11 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
setContentWarning(false);
};
const close = () => {
textAreaRef?.blur();
props.onClose();
};
const { config } = useConfig();
const getPubkey = usePubkey();
const commands = useCommands();
@@ -160,7 +163,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
submit();
} else if (ev.key === 'Escape') {
textAreaRef?.blur();
props.onClose();
close();
}
};
@@ -168,6 +171,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
ev.preventDefault();
const files = [...(ev.currentTarget.files ?? [])];
uploadFilesMutation.mutate(files);
// eslint-disable-next-line no-param-reassign
ev.currentTarget.value = '';
};
@@ -184,7 +188,6 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const submitDisabled = () =>
text().trim().length === 0 ||
(contentWarning() && contentWarningReason().length === 0) ||
publishTextNoteMutation.isLoading ||
uploadFilesMutation.isLoading;
@@ -192,6 +195,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
onMount(() => {
setTimeout(() => {
textAreaRef?.click();
textAreaRef?.focus();
}, 50);
});
@@ -239,7 +243,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
<div class="flex items-end justify-end gap-1">
<Show when={mode() === 'reply'}>
<div class="flex-1">
<button class="h-5 w-5 text-stone-500" onClick={() => props.onClose()}>
<button class="h-5 w-5 text-stone-500" onClick={() => close()}>
<XMark />
</button>
</div>

View File

@@ -0,0 +1,83 @@
import { Component, createMemo, Show } from 'solid-js';
import { npubEncode } from 'nostr-tools/nip19';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import Modal from '@/components/Modal';
import Copy from '@/components/utils/Copy';
import useProfile from '@/nostr/useProfile';
import useConfig from '@/nostr/useConfig';
export type ProfileDisplayProps = {
pubkey: string;
};
const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
const { config } = useConfig();
const { profile, query } = useProfile(() => ({
relayUrls: config().relayUrls,
pubkey: props.pubkey,
}));
const npub = createMemo(() => npubEncode(props.pubkey));
return (
<Modal>
<div class="max-h-full w-[640px] max-w-full overflow-scroll">
<div class="flex justify-end">
<button class="h-8 w-8 text-stone-700">
<XMark />
</button>
</div>
<div class="flex w-full flex-col overflow-hidden rounded-2xl border bg-white text-stone-700 shadow-lg">
<Show when={query.isFetched} fallback={<>loading</>}>
<div class="h-40 w-full sm:h-52">
<Show when={profile()?.banner} keyed>
{(bannerUrl) => (
<img src={bannerUrl} alt="header" class="h-full w-full object-cover" />
)}
</Show>
</div>
<div class="flex h-[64px] items-center gap-2 px-2">
<div class="mt-[-64px] h-28 w-28 shrink-0 rounded-lg border-2 object-cover">
<Show when={profile()?.picture} keyed>
{(pictureUrl) => <img src={pictureUrl} alt="user icon" class="h-full w-full" />}
</Show>
</div>
<div>
<div class="flex items-center gap-2">
<div class="truncate text-xl font-bold">{profile()?.display_name}</div>
<div class="shrink-0 text-sm">@{profile()?.name}</div>
</div>
<div class="flex gap-1">
<div class="truncate text-xs">{npub()}</div>
<Copy class="h-4 w-4 text-stone-500 hover:text-stone-700" text={npub()} />
</div>
</div>
</div>
<div class="max-h-32 overflow-scroll whitespace-pre-wrap px-4 pt-1 text-sm">
{profile()?.about}
</div>
<ul class="px-4 py-2 text-xs">
<Show when={profile()?.website}>
<li class="flex items-center gap-1">
<span class="inline-block h-4 w-4" area-label="website" title="website">
<GlobeAlt />
</span>
<a href={profile()?.website} target="_blank" rel="noreferrer noopener">
{profile()?.website}
</a>
</li>
</Show>
</ul>
</Show>
<div class="h-16 border" />
</div>
</div>
</Modal>
);
};
export default ProfileDisplay;

View File

@@ -16,14 +16,21 @@ const SideBar: Component = () => {
const [formOpened, setFormOpened] = createSignal(false);
const [configOpened, setConfigOpened] = createSignal(false);
const focusTextArea = () => {
textAreaRef?.focus();
textAreaRef?.click();
};
const openForm = () => setFormOpened(true);
const closeForm = () => setFormOpened(false);
const toggleForm = () => setFormOpened((current) => !current);
useHandleCommand(() => ({
commandType: 'openPostForm',
handler: () => {
openForm();
setTimeout(() => textAreaRef?.focus?.(), 100);
if (textAreaRef != null) {
setTimeout(() => focusTextArea(), 100);
}
},
}));
@@ -32,8 +39,8 @@ const SideBar: Component = () => {
<div class="flex w-14 flex-auto flex-col items-center gap-3 border-r border-rose-200 pt-5">
<div class="flex flex-col items-center gap-3">
<button
class={`h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white`}
onClick={() => setFormOpened((current) => !current)}
class="h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white"
onClick={() => toggleForm()}
>
<PencilSquare />
</button>
@@ -55,14 +62,19 @@ const SideBar: Component = () => {
</button>
</div>
</div>
<Show when={formOpened() || config().keepOpenPostForm}>
<div
classList={{
static: formOpened() || config().keepOpenPostForm,
hidden: !(formOpened() || config().keepOpenPostForm),
}}
>
<NotePostForm
textAreaRef={(el) => {
textAreaRef = el;
}}
onClose={closeForm}
/>
</Show>
</div>
<Show when={configOpened()}>
<Config onClose={() => setConfigOpened(false)} />
</Show>

View File

@@ -0,0 +1,42 @@
import { createSignal, Show, type Component } from 'solid-js';
import ClipboardDocument from 'heroicons/24/outline/clipboard-document.svg';
type CopyProps = {
class: string;
text: string;
};
const Copy: Component<CopyProps> = (props) => {
const [showPopup, setShowPopup] = createSignal(false);
const handleClick = () => {
navigator.clipboard
.writeText(props.text)
.then((e) => {
setShowPopup(true);
setTimeout(() => setShowPopup(false), 1000);
})
.catch((err) => {
console.error('failed to copy', err);
});
};
return (
<div class="relative inline-block">
<button type="button" class={props.class} onClick={handleClick}>
<ClipboardDocument />
</button>
<Show when={showPopup()}>
<div
class="absolute left-[-1rem] top-[-1.5rem] rounded-lg
bg-rose-300 p-1 text-xs font-bold text-white shadow"
>
Copied!
</div>
</Show>
</div>
);
};
export default Copy;

View File

@@ -7,7 +7,7 @@ export type TaggedEvent = {
id: string;
relayUrl?: string;
index: number;
marker: EventMarker;
marker?: EventMarker;
};
export type ContentWarning = {
@@ -50,7 +50,10 @@ const eventWrapper = (event: NostrEvent) => {
.filter(([[tagName]]) => tagName === 'e');
// NIP-10: Positional "e" tags (DEPRECATED)
const positionToMarker = (index: number): EventMarker => {
const positionToMarker = (marker: string, index: number): EventMarker | undefined => {
// NIP-10 is applied to only kind:1 text note.
if (event.kind !== 1) return undefined;
if (marker === 'root' || marker === 'reply' || marker === 'mention') return marker;
// One "e" tag
if (events.length === 1) return 'reply';
// Two "e" tags or many "e" tags : first tag is root
@@ -67,7 +70,7 @@ const eventWrapper = (event: NostrEvent) => {
return events.map(([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({
id: eventId,
relayUrl,
marker: (marker as EventMarker | undefined) ?? positionToMarker(eTagIndex),
marker: positionToMarker(marker, eTagIndex),
index: originalIndex,
}));
},

View File

@@ -1,97 +1,344 @@
import { createSignal, createMemo, createRoot, type Signal, type Accessor } from 'solid-js';
import { type Event as NostrEvent, type Filter } from 'nostr-tools';
import { createSignal, createMemo, untrack, type Accessor, type Signal } from 'solid-js';
import { type Event as NostrEvent, type Filter, Kind } from 'nostr-tools';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import useConfig from '@/nostr/useConfig';
import timeout from '@/utils/timeout';
import usePool from '@/nostr/usePool';
import useBatch, { type Task } from '@/nostr/useBatch';
import eventWrapper from '@/core/event';
import useSubscription from '@/nostr/useSubscription';
import useConfig from './useConfig';
export type UseBatchedEventsProps<TaskArgs> = {
interval?: number;
generateKey: (args: TaskArgs) => string | number;
mergeFilters: (args: TaskArgs[]) => Filter[];
extractKey: (event: NostrEvent) => string | number | undefined;
type TaskArg =
| { type: 'Profile'; pubkey: string }
| { type: 'TextNote'; eventId: string }
| { type: 'Reactions'; mentionedEventId: string }
| { type: 'DeprecatedReposts'; mentionedEventId: string };
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 BatchedEvents = {
events: NostrEvent[];
completed: boolean;
export type NonStandardProfile = {
display_name?: string;
website?: string;
};
const emptyBatchedEvents = () => ({ completed: true, events: [] });
export type Profile = StandardProfile & NonStandardProfile;
const completeBatchedEvents = (current: BatchedEvents): BatchedEvents => ({
...current,
completed: true,
});
export type UseProfileProps = {
pubkey: string;
};
const addEvent =
(event: NostrEvent) =>
(current: BatchedEvents): BatchedEvents => ({
...current,
events: [...current.events, event],
});
type UseProfile = {
profile: () => Profile | undefined;
query: CreateQueryResult<Accessor<NostrEvent> | undefined>;
};
const useBatchedEvents = <TaskArgs>(propsProvider: () => UseBatchedEventsProps<TaskArgs>) => {
// Textnote
export type UseTextNoteProps = {
eventId: string;
};
export type UseTextNote = {
event: Accessor<NostrEvent | undefined>;
query: CreateQueryResult<Accessor<NostrEvent> | undefined>;
};
// Reactions
export type UseReactionsProps = {
eventId: string;
};
export type UseReactions = {
reactions: Accessor<NostrEvent[]>;
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
isReactedBy: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>;
query: CreateQueryResult<Accessor<BatchedEvents>>;
};
// DeprecatedReposts
export type UseDeprecatedRepostsProps = {
eventId: string;
};
export type UseDeprecatedReposts = {
reposts: Accessor<NostrEvent[]>;
isRepostedBy: (pubkey: string) => boolean;
invalidateDeprecatedReposts: () => Promise<void>;
query: CreateQueryResult<Accessor<BatchedEvents>>;
};
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
executor: (tasks) => {
const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const reactionsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const repostsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
tasks.forEach((task) => {
if (task.args.type === 'Profile') {
const current = profileTasks.get(task.args.pubkey) ?? [];
profileTasks.set(task.args.pubkey, [...current, task]);
} else if (task.args.type === 'TextNote') {
const current = textNoteTasks.get(task.args.eventId) ?? [];
textNoteTasks.set(task.args.eventId, [...current, task]);
} else if (task.args.type === 'Reactions') {
const current = reactionsTasks.get(task.args.mentionedEventId) ?? [];
reactionsTasks.set(task.args.mentionedEventId, [...current, task]);
} else if (task.args.type === 'DeprecatedReposts') {
const current = repostsTasks.get(task.args.mentionedEventId) ?? [];
repostsTasks.set(task.args.mentionedEventId, [...current, task]);
}
});
const profilePubkeys = [...profileTasks.keys()];
const textNoteIds = [...textNoteTasks.keys()];
const reactionsIds = [...reactionsTasks.keys()];
const repostsIds = [...repostsTasks.keys()];
const filters: Filter[] = [];
if (profilePubkeys.length > 0) {
filters.push({ kinds: [Kind.Metadata], authors: profilePubkeys });
}
if (textNoteIds.length > 0) {
filters.push({ kinds: [Kind.Text], ids: textNoteIds });
}
if (reactionsIds.length > 0) {
filters.push({ kinds: [Kind.Reaction], '#e': reactionsIds });
}
if (repostsIds.length > 0) {
filters.push({ kinds: [6], '#e': repostsIds });
}
if (filters.length === 0) return;
const signals = new Map<number, Signal<BatchedEvents>>();
const resolveTasks = (registeredTasks: Task<TaskArg, TaskRes>[], event: NostrEvent) => {
registeredTasks.forEach((task) => {
const signal = signals.get(task.id) ?? createSignal({ events: [], completed: false });
const [batchedEvents, setBatchedEvents] = signal;
setBatchedEvents((current) => ({
...current,
events: [...current.events, event],
}));
task.resolve(batchedEvents);
});
};
const emptyBatchedEvents = () => ({ events: [], completed: true });
const finalizeTasks = () => {
tasks.forEach((task) => {
const signal = signals.get(task.id);
if (signal != null) {
const setEvents = signal[1];
setEvents((current) => ({ ...current, completed: true }));
} else {
task.resolve(emptyBatchedEvents);
}
});
};
const { config } = useConfig();
useSubscription(() => ({
relayUrls: config().relayUrls,
filters,
continuous: false,
onEvent: (event: NostrEvent & { id: string }) => {
if (event.kind === Kind.Metadata) {
const registeredTasks = profileTasks.get(event.pubkey) ?? [];
resolveTasks(registeredTasks, event);
} else if (event.kind === Kind.Text) {
const registeredTasks = textNoteTasks.get(event.id) ?? [];
resolveTasks(registeredTasks, event);
} else if (event.kind === Kind.Reaction) {
const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id;
const registeredTasks = reactionsTasks.get(taggedEventId) ?? [];
resolveTasks(registeredTasks, event);
});
} else if ((event.kind as number) === 6) {
const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id;
const registeredTasks = repostsTasks.get(taggedEventId) ?? [];
resolveTasks(registeredTasks, event);
});
}
},
onEOSE: () => {
finalizeTasks();
},
}));
},
}));
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const props = createMemo(propsProvider);
return useBatch<TaskArgs, Accessor<BatchedEvents>>(() => ({
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) ??
createRoot((dispose) => {
return 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(addEvent(event));
task.resolve(events);
},
onEOSE: () => {
tasks.forEach((task) => {
const key = generateKey(task.args);
if (didReceivedEventsForKey(key)) {
const [, setEvents] = getSignalForKey(key);
setEvents(completeBatchedEvents);
} else {
task.resolve(emptyBatchedEvents);
}
});
},
}));
const query = createQuery(
() => ['useProfile', props()] as const,
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
if (currentProps == null) return undefined;
const { pubkey } = currentProps;
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
return createMemo(() => {
const { events } = batchedEvents();
if (events == null || events.length === 0)
throw new Error(`profile not found: ${pubkey}`);
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
return latest;
});
});
// TODO timeoutと同時にsignalでキャンセルするようにしたい
return timeout(15000, `useProfile: ${pubkey}`)(promise);
},
}));
{
// 5 minutes
staleTime: 5 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
},
);
const profile = createMemo((): Profile | undefined => {
const event = query.data;
if (event == null) return undefined;
const { content } = event();
if (content == null || content.length === 0) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
try {
return JSON.parse(content) as Profile;
} catch (err) {
console.error('failed to parse profile (kind 0): ', err, content);
return undefined;
}
});
return { profile, query };
};
export default useBatchedEvents;
export const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactions => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useReactions', props()] as const);
const query = createQuery(
() => queryKey(),
({ queryKey: currentQueryKey, signal }) => {
const [, currentProps] = currentQueryKey;
if (currentProps == null) return () => ({ events: [], completed: false });
const { eventId: mentionedEventId } = currentProps;
const promise = exec({ type: 'Reactions', mentionedEventId }, signal);
return timeout(15000, `useReactions: ${mentionedEventId}`)(promise);
},
{
// 3 minutes
staleTime: 1 * 60 * 1000,
cacheTime: 3 * 60 * 1000,
},
);
const reactions = () => query.data?.()?.events ?? [];
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 invalidateReactions = (): Promise<void> => queryClient.invalidateQueries(queryKey());
return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query };
};
export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTextNote => {
const props = createMemo(propsProvider);
const query = createQuery(
() => ['useEvent', props()] as const,
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
if (currentProps == null) return undefined;
const { eventId } = currentProps;
const promise = exec({ type: 'TextNote', eventId }, signal).then((events) => {
return createMemo(() => {
const event = events().events[0];
if (event == null) throw new Error(`event not found: ${eventId}`);
return event;
});
});
return timeout(15000, `useEvent: ${eventId}`)(promise);
},
{
// a hour
staleTime: 60 * 60 * 1000,
cacheTime: 60 * 60 * 1000,
},
);
const event = () => query.data?.();
return { event, query };
};
export const useDeprecatedReposts = (
propsProvider: () => UseDeprecatedRepostsProps,
): UseDeprecatedReposts => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useDeprecatedReposts', props()] as const);
const query = createQuery(
() => queryKey(),
({ queryKey: currentQueryKey, signal }) => {
const [, currentProps] = currentQueryKey;
if (currentProps == null) return () => ({ events: [], completed: false });
const { eventId: mentionedEventId } = currentProps;
const promise = exec({ type: 'DeprecatedReposts', mentionedEventId }, signal);
return timeout(15000, `useDeprecatedReposts: ${mentionedEventId}`)(promise);
},
{
// 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> =>
queryClient.invalidateQueries(queryKey());
return { reposts, isRepostedBy, invalidateDeprecatedReposts, query };
};

View File

@@ -1,67 +1,3 @@
import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import useBatchedEvents, { type BatchedEvents } from '@/nostr/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>(() => ({
interval: 3400,
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 queryClient = useQueryClient();
const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useDeprecatedReposts', props()] as const);
const query = createQuery(
() => queryKey(),
({ queryKey: currentQueryKey, signal }) => {
const [, currentProps] = currentQueryKey;
if (currentProps == null) return () => ({ events: [], completed: false });
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> =>
queryClient.invalidateQueries(queryKey());
return { reposts, isRepostedBy, invalidateDeprecatedReposts, query };
};
import { useDeprecatedReposts } from '@/nostr/useBatchedEvents';
export default useDeprecatedReposts;

View File

@@ -1,49 +1,3 @@
import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools';
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import timeout from '@/utils/timeout';
import { useTextNote } from '@/nostr/useBatchedEvents';
import useBatchedEvent from '@/nostr/useBatchedEvent';
export type UseEventProps = {
// TODO リレーURLを考慮したい
relayUrls: string[];
eventId: string;
};
export type UseEvent = {
event: Accessor<NostrEvent | undefined>;
query: CreateQueryResult<Accessor<NostrEvent> | undefined>;
};
const { exec } = useBatchedEvent<UseEventProps>(() => ({
generateKey: ({ eventId }: UseEventProps) => eventId,
mergeFilters: (args: UseEventProps[]) => {
const eventIds = args.map((arg) => arg.eventId);
return [{ kinds: [1], ids: eventIds }];
},
extractKey: (event: NostrEvent) => event.id,
}));
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 undefined;
return timeout(15000, `useEvent: ${currentProps.eventId}`)(exec(currentProps, signal));
},
{
// a hour
staleTime: 60 * 60 * 1000,
cacheTime: 60 * 60 * 1000,
},
);
const event = () => query.data?.();
return { event, query };
};
export default useEvent;
export default useTextNote;

View File

@@ -31,7 +31,10 @@ const useFollowings = (propsProvider: () => UseFollowingsProps | null) => {
});
const followings = () => {
const event = query?.data?.[0];
if (query.data != null && query.data.length === 0) return [];
const event = query.data?.reduce((a, b) => (a.created_at > b.created_at ? a : b));
if (event == null) return [];
const result: Following[] = [];

View File

@@ -1,78 +1,3 @@
import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent, type Filter } from 'nostr-tools';
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import useBatchedEvent from '@/nostr/useBatchedEvent';
import timeout from '@/utils/timeout';
// TODO zodにする
// deleted等の特殊なもの
export type StandardProfile = {
name?: string;
about?: string;
picture?: 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 UseProfileProps = {
relayUrls: string[];
pubkey: string;
};
type UseProfile = {
profile: Accessor<Profile | undefined>;
query: CreateQueryResult<Accessor<NostrEvent> | undefined>;
};
const { exec } = useBatchedEvent<UseProfileProps>(() => ({
generateKey: ({ pubkey }: UseProfileProps): string => pubkey,
mergeFilters: (args: UseProfileProps[]): Filter[] => {
const pubkeys = args.map((arg) => arg.pubkey);
return [{ kinds: [0], authors: pubkeys }];
},
extractKey: (event: NostrEvent): string => event.pubkey,
}));
const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const props = createMemo(propsProvider);
const query = createQuery(
() => ['useProfile', props()] as const,
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
if (currentProps == null) return null;
// TODO timeoutと同時にsignalでキャンセルするようにしたい
return timeout(15000, `useProfile: ${currentProps.pubkey}`)(exec(currentProps, signal));
},
{
// 5 minutes
staleTime: 5 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
},
);
const profile = () => {
const content = query.data?.()?.content;
if (content == null) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
try {
return JSON.parse(content) as Profile;
} catch (e) {
console.error(e, content);
return undefined;
}
};
return { profile, query };
};
import { useProfile } from '@/nostr/useBatchedEvents';
export default useProfile;

View File

@@ -1,72 +1,3 @@
import { createMemo, type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import useBatchedEvents, { type BatchedEvents } from '@/nostr/useBatchedEvents';
import timeout from '@/utils/timeout';
export type UseReactionsProps = {
relayUrls: string[];
eventId: string;
};
export type UseReactions = {
reactions: Accessor<NostrEvent[]>;
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
isReactedBy: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>;
query: CreateQueryResult<Accessor<BatchedEvents>>;
};
const { exec } = useBatchedEvents<UseReactionsProps>(() => ({
interval: 3400,
generateKey: ({ eventId }) => eventId,
mergeFilters: (args) => {
const eventIds = args.map((arg) => arg.eventId);
return [{ kinds: [7], '#e': eventIds }];
},
extractKey: (event: NostrEvent) => {
return event.tags.find((e) => e[0] === 'e')?.[1];
},
}));
const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactions => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useReactions', props()] as const);
const query = createQuery(
() => queryKey(),
({ queryKey: currentQueryKey, signal }) => {
const [, currentProps] = currentQueryKey;
if (currentProps == null) return () => ({ events: [], completed: false });
return timeout(15000, `useReactions: ${currentProps.eventId}`)(exec(currentProps, signal));
},
{
// 3 minutes
staleTime: 1 * 60 * 1000,
cacheTime: 3 * 60 * 1000,
},
);
const reactions = () => query.data?.()?.events ?? [];
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 invalidateReactions = (): Promise<void> => queryClient.invalidateQueries(queryKey());
return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query };
};
import { useReactions } from '@/nostr/useBatchedEvents';
export default useReactions;

View File

@@ -11,7 +11,7 @@ export type UseSubscriptionProps = {
// default is true
clientEventFilter?: (event: NostrEvent) => boolean;
continuous?: boolean;
onEvent?: (event: NostrEvent) => void;
onEvent?: (event: NostrEvent & { id: string }) => void;
onEOSE?: () => void;
signal?: AbortSignal;
};
@@ -19,24 +19,30 @@ export type UseSubscriptionProps = {
const sortEvents = (events: NostrEvent[]) =>
Array.from(events).sort((a, b) => b.created_at - a.created_at);
let count = 0;
setInterval(() => console.log(count), 1000);
const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const pool = usePool();
const [events, setEvents] = createSignal<NostrEvent[]>([]);
createEffect(() => {
const startSubscription = () => {
const props = propsProvider();
if (props == null) return;
const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props;
const sub = pool().sub(relayUrls, filters, options);
count += 1;
let pushed = false;
let eose = false;
const storedEvents: NostrEvent[] = [];
sub.on('event', (event: NostrEvent) => {
if (onEvent != null) {
onEvent(event);
onEvent(event as NostrEvent & { id: string });
}
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
return;
@@ -69,6 +75,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (!continuous) {
sub.unsub();
count -= 1;
}
});
@@ -86,8 +93,13 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
onCleanup(() => {
sub.unsub();
// count -= 1;
clearInterval(intervalId);
});
};
createEffect(() => {
startSubscription();
});
return { events };

View File

@@ -1,4 +1,4 @@
import { createSignal, createEffect, onMount, type Component, onCleanup } from 'solid-js';
import { createSignal, createEffect, onMount, onCleanup, Show, type Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import uniq from 'lodash/uniq';
@@ -16,6 +16,7 @@ import usePubkey from '@/nostr/usePubkey';
import { useMountShortcutKeys } from '@/hooks/useShortcutKeys';
import usePersistStatus from '@/hooks/usePersistStatus';
import ensureNonNull from '@/utils/ensureNonNull';
import ProfileDisplay from '@/components/Profile';
const Home: Component = () => {
useMountShortcutKeys();
@@ -51,7 +52,6 @@ const Home: Component = () => {
kinds: [1, 6],
authors: uniq([...followingPubkeys(), pubkeyNonNull]),
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
],
})),
@@ -91,7 +91,6 @@ const Home: Component = () => {
kinds: [1, 6, 7],
'#p': [pubkeyNonNull],
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
],
})),
@@ -135,7 +134,7 @@ const Home: Component = () => {
});
return (
<div class="absolute inset-0 flex w-screen flex-row overflow-hidden">
<div class="absolute inset-0 flex w-screen touch-manipulation flex-row overflow-hidden">
<SideBar />
<div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll">
<Column name="ホーム" columnIndex={1} width="widest">
@@ -154,6 +153,11 @@ const Home: Component = () => {
<Notification events={myReactions()} />
</Column>
</div>
{/*
<Show when={pubkey()} keyed>
{(pubkeyNonNull: string) => <ProfileDisplay pubkey={pubkeyNonNull} />}
</Show>
*/}
</div>
);
};