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-icon">🏠</span> */}
<span class="column-name">{props.name}</span> <span class="column-name">{props.name}</span>
</div> </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> </div>
); );
}; };

View File

@@ -1,5 +1,8 @@
import useConfig, { type Config } from '@/nostr/useConfig'; import useConfig, { type Config } from '@/nostr/useConfig';
import { createSignal, For, type JSX } from 'solid-js'; import { createSignal, For, type JSX } from 'solid-js';
import XMark from 'heroicons/24/outline/x-mark.svg';
import Modal from '@/components/Modal';
type ConfigProps = { type ConfigProps = {
onClose: () => void; onClose: () => void;
@@ -24,9 +27,11 @@ const RelayConfig = () => {
<For each={config().relayUrls}> <For each={config().relayUrls}>
{(relayUrl: string) => { {(relayUrl: string) => {
return ( return (
<li class="flex"> <li class="flex items-center">
<div class="flex-1">{relayUrl}</div> <div class="flex-1 truncate">{relayUrl}</div>
<button onClick={() => removeRelay(relayUrl)}>x</button> <button class="h-3 w-3 shrink-0" onClick={() => removeRelay(relayUrl)}>
<XMark />
</button>
</li> </li>
); );
}} }}
@@ -154,33 +159,22 @@ const OtherConfig = () => {
}; };
const ConfigUI = (props: ConfigProps) => { const ConfigUI = (props: ConfigProps) => {
let containerRef: HTMLDivElement | undefined;
const handleClickContainer: JSX.EventHandler<HTMLDivElement, MouseEvent> = (ev) => {
if (ev.target === containerRef) {
props.onClose();
}
};
return ( return (
<div <Modal title="設定" onClose={props.onClose}>
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}
>
<div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow"> <div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow">
<div class="relative"> <div class="relative">
<h2 class="flex-1 text-center font-bold"></h2> <div class="flex flex-col gap-1">
<button class="absolute top-1 right-0" onClick={() => props.onClose()}> <h2 class="flex-1 text-center font-bold"></h2>
X <button class="absolute top-1 right-0 h-4 w-4" onClick={() => props.onClose?.()}>
</button> <XMark />
</button>
</div>
</div> </div>
<RelayConfig /> <RelayConfig />
<DateFormatConfig /> <DateFormatConfig />
<OtherConfig /> <OtherConfig />
</div> </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 PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import Photo from 'heroicons/24/outline/photo.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 XMark from 'heroicons/24/outline/x-mark.svg';
import UserNameDisplay from '@/components/UserDisplayName'; import UserNameDisplay from '@/components/UserDisplayName';
@@ -61,6 +59,11 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
setContentWarning(false); setContentWarning(false);
}; };
const close = () => {
textAreaRef?.blur();
props.onClose();
};
const { config } = useConfig(); const { config } = useConfig();
const getPubkey = usePubkey(); const getPubkey = usePubkey();
const commands = useCommands(); const commands = useCommands();
@@ -160,7 +163,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
submit(); submit();
} else if (ev.key === 'Escape') { } else if (ev.key === 'Escape') {
textAreaRef?.blur(); textAreaRef?.blur();
props.onClose(); close();
} }
}; };
@@ -168,6 +171,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
ev.preventDefault(); ev.preventDefault();
const files = [...(ev.currentTarget.files ?? [])]; const files = [...(ev.currentTarget.files ?? [])];
uploadFilesMutation.mutate(files); uploadFilesMutation.mutate(files);
// eslint-disable-next-line no-param-reassign
ev.currentTarget.value = ''; ev.currentTarget.value = '';
}; };
@@ -184,7 +188,6 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const submitDisabled = () => const submitDisabled = () =>
text().trim().length === 0 || text().trim().length === 0 ||
(contentWarning() && contentWarningReason().length === 0) ||
publishTextNoteMutation.isLoading || publishTextNoteMutation.isLoading ||
uploadFilesMutation.isLoading; uploadFilesMutation.isLoading;
@@ -192,6 +195,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
onMount(() => { onMount(() => {
setTimeout(() => { setTimeout(() => {
textAreaRef?.click();
textAreaRef?.focus(); textAreaRef?.focus();
}, 50); }, 50);
}); });
@@ -239,7 +243,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
<div class="flex items-end justify-end gap-1"> <div class="flex items-end justify-end gap-1">
<Show when={mode() === 'reply'}> <Show when={mode() === 'reply'}>
<div class="flex-1"> <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 /> <XMark />
</button> </button>
</div> </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 [formOpened, setFormOpened] = createSignal(false);
const [configOpened, setConfigOpened] = createSignal(false); const [configOpened, setConfigOpened] = createSignal(false);
const focusTextArea = () => {
textAreaRef?.focus();
textAreaRef?.click();
};
const openForm = () => setFormOpened(true); const openForm = () => setFormOpened(true);
const closeForm = () => setFormOpened(false); const closeForm = () => setFormOpened(false);
const toggleForm = () => setFormOpened((current) => !current);
useHandleCommand(() => ({ useHandleCommand(() => ({
commandType: 'openPostForm', commandType: 'openPostForm',
handler: () => { handler: () => {
openForm(); 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 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"> <div class="flex flex-col items-center gap-3">
<button <button
class={`h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white`} class="h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white"
onClick={() => setFormOpened((current) => !current)} onClick={() => toggleForm()}
> >
<PencilSquare /> <PencilSquare />
</button> </button>
@@ -55,14 +62,19 @@ const SideBar: Component = () => {
</button> </button>
</div> </div>
</div> </div>
<Show when={formOpened() || config().keepOpenPostForm}> <div
classList={{
static: formOpened() || config().keepOpenPostForm,
hidden: !(formOpened() || config().keepOpenPostForm),
}}
>
<NotePostForm <NotePostForm
textAreaRef={(el) => { textAreaRef={(el) => {
textAreaRef = el; textAreaRef = el;
}} }}
onClose={closeForm} onClose={closeForm}
/> />
</Show> </div>
<Show when={configOpened()}> <Show when={configOpened()}>
<Config onClose={() => setConfigOpened(false)} /> <Config onClose={() => setConfigOpened(false)} />
</Show> </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; id: string;
relayUrl?: string; relayUrl?: string;
index: number; index: number;
marker: EventMarker; marker?: EventMarker;
}; };
export type ContentWarning = { export type ContentWarning = {
@@ -50,7 +50,10 @@ const eventWrapper = (event: NostrEvent) => {
.filter(([[tagName]]) => tagName === 'e'); .filter(([[tagName]]) => tagName === 'e');
// NIP-10: Positional "e" tags (DEPRECATED) // 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 // One "e" tag
if (events.length === 1) return 'reply'; if (events.length === 1) return 'reply';
// Two "e" tags or many "e" tags : first tag is root // 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) => ({ return events.map(([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({
id: eventId, id: eventId,
relayUrl, relayUrl,
marker: (marker as EventMarker | undefined) ?? positionToMarker(eTagIndex), marker: positionToMarker(marker, eTagIndex),
index: originalIndex, index: originalIndex,
})); }));
}, },

View File

@@ -1,97 +1,344 @@
import { createSignal, createMemo, createRoot, type Signal, type Accessor } from 'solid-js'; import { createSignal, createMemo, untrack, type Accessor, type Signal } from 'solid-js';
import { type Event as NostrEvent, type Filter } from 'nostr-tools'; 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 useBatch, { type Task } from '@/nostr/useBatch';
import eventWrapper from '@/core/event';
import useSubscription from '@/nostr/useSubscription'; import useSubscription from '@/nostr/useSubscription';
import useConfig from './useConfig';
export type UseBatchedEventsProps<TaskArgs> = { type TaskArg =
interval?: number; | { type: 'Profile'; pubkey: string }
generateKey: (args: TaskArgs) => string | number; | { type: 'TextNote'; eventId: string }
mergeFilters: (args: TaskArgs[]) => Filter[]; | { type: 'Reactions'; mentionedEventId: string }
extractKey: (event: NostrEvent) => string | number | undefined; | { 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 = { export type NonStandardProfile = {
events: NostrEvent[]; display_name?: string;
completed: boolean; website?: string;
}; };
const emptyBatchedEvents = () => ({ completed: true, events: [] }); export type Profile = StandardProfile & NonStandardProfile;
const completeBatchedEvents = (current: BatchedEvents): BatchedEvents => ({ export type UseProfileProps = {
...current, pubkey: string;
completed: true, };
});
const addEvent = type UseProfile = {
(event: NostrEvent) => profile: () => Profile | undefined;
(current: BatchedEvents): BatchedEvents => ({ query: CreateQueryResult<Accessor<NostrEvent> | undefined>;
...current, };
events: [...current.events, event],
});
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); const props = createMemo(propsProvider);
return useBatch<TaskArgs, Accessor<BatchedEvents>>(() => ({ const query = createQuery(
interval: props().interval, () => ['useProfile', props()] as const,
executor: (tasks) => { ({ queryKey, signal }) => {
const { generateKey, mergeFilters, extractKey } = props(); const [, currentProps] = queryKey;
// TODO relayUrlsを考慮する if (currentProps == null) return undefined;
const { config } = useConfig(); const { pubkey } = currentProps;
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
const keyTaskMap = new Map<string | number, Task<TaskArgs, Accessor<BatchedEvents>>>( return createMemo(() => {
tasks.map((task) => [generateKey(task.args), task]), const { events } = batchedEvents();
); if (events == null || events.length === 0)
const filters = mergeFilters(tasks.map((task) => task.args)); throw new Error(`profile not found: ${pubkey}`);
const keyEventSignalsMap = new Map<string | number, Signal<BatchedEvents>>(); const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
return latest;
const getSignalForKey = (key: string | number): Signal<BatchedEvents> => { });
const eventsSignal = });
keyEventSignalsMap.get(key) ?? // TODO timeoutと同時にsignalでキャンセルするようにしたい
createRoot((dispose) => { return timeout(15000, `useProfile: ${pubkey}`)(promise);
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);
}
});
},
}));
}, },
})); {
// 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 { useDeprecatedReposts } from '@/nostr/useBatchedEvents';
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 };
};
export default useDeprecatedReposts; export default useDeprecatedReposts;

View File

@@ -1,49 +1,3 @@
import { createMemo, type Accessor } from 'solid-js'; import { useTextNote } from '@/nostr/useBatchedEvents';
import { type Event as NostrEvent } from 'nostr-tools';
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import timeout from '@/utils/timeout';
import useBatchedEvent from '@/nostr/useBatchedEvent'; export default useTextNote;
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;

View File

@@ -31,7 +31,10 @@ const useFollowings = (propsProvider: () => UseFollowingsProps | null) => {
}); });
const followings = () => { 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 []; if (event == null) return [];
const result: Following[] = []; const result: Following[] = [];

View File

@@ -1,78 +1,3 @@
import { createMemo, type Accessor } from 'solid-js'; import { useProfile } from '@/nostr/useBatchedEvents';
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 };
};
export default useProfile; export default useProfile;

View File

@@ -1,72 +1,3 @@
import { createMemo, type Accessor } from 'solid-js'; import { useReactions } from '@/nostr/useBatchedEvents';
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 };
};
export default useReactions; export default useReactions;

View File

@@ -11,7 +11,7 @@ export type UseSubscriptionProps = {
// default is true // default is true
clientEventFilter?: (event: NostrEvent) => boolean; clientEventFilter?: (event: NostrEvent) => boolean;
continuous?: boolean; continuous?: boolean;
onEvent?: (event: NostrEvent) => void; onEvent?: (event: NostrEvent & { id: string }) => void;
onEOSE?: () => void; onEOSE?: () => void;
signal?: AbortSignal; signal?: AbortSignal;
}; };
@@ -19,24 +19,30 @@ export type UseSubscriptionProps = {
const sortEvents = (events: NostrEvent[]) => const sortEvents = (events: NostrEvent[]) =>
Array.from(events).sort((a, b) => b.created_at - a.created_at); 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 useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const pool = usePool(); const pool = usePool();
const [events, setEvents] = createSignal<NostrEvent[]>([]); const [events, setEvents] = createSignal<NostrEvent[]>([]);
createEffect(() => { const startSubscription = () => {
const props = propsProvider(); const props = propsProvider();
if (props == null) return; if (props == null) return;
const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props; const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props;
const sub = pool().sub(relayUrls, filters, options); const sub = pool().sub(relayUrls, filters, options);
count += 1;
let pushed = false; let pushed = false;
let eose = false; let eose = false;
const storedEvents: NostrEvent[] = []; const storedEvents: NostrEvent[] = [];
sub.on('event', (event: NostrEvent) => { sub.on('event', (event: NostrEvent) => {
if (onEvent != null) { if (onEvent != null) {
onEvent(event); onEvent(event as NostrEvent & { id: string });
} }
if (props.clientEventFilter != null && !props.clientEventFilter(event)) { if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
return; return;
@@ -69,6 +75,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (!continuous) { if (!continuous) {
sub.unsub(); sub.unsub();
count -= 1;
} }
}); });
@@ -86,8 +93,13 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
onCleanup(() => { onCleanup(() => {
sub.unsub(); sub.unsub();
// count -= 1;
clearInterval(intervalId); clearInterval(intervalId);
}); });
};
createEffect(() => {
startSubscription();
}); });
return { events }; 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 { useNavigate } from '@solidjs/router';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
@@ -16,6 +16,7 @@ import usePubkey from '@/nostr/usePubkey';
import { useMountShortcutKeys } from '@/hooks/useShortcutKeys'; import { useMountShortcutKeys } from '@/hooks/useShortcutKeys';
import usePersistStatus from '@/hooks/usePersistStatus'; import usePersistStatus from '@/hooks/usePersistStatus';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import ProfileDisplay from '@/components/Profile';
const Home: Component = () => { const Home: Component = () => {
useMountShortcutKeys(); useMountShortcutKeys();
@@ -51,7 +52,6 @@ const Home: Component = () => {
kinds: [1, 6], kinds: [1, 6],
authors: uniq([...followingPubkeys(), pubkeyNonNull]), authors: uniq([...followingPubkeys(), pubkeyNonNull]),
limit: 25, limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
}, },
], ],
})), })),
@@ -91,7 +91,6 @@ const Home: Component = () => {
kinds: [1, 6, 7], kinds: [1, 6, 7],
'#p': [pubkeyNonNull], '#p': [pubkeyNonNull],
limit: 25, limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
}, },
], ],
})), })),
@@ -135,7 +134,7 @@ const Home: Component = () => {
}); });
return ( 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 /> <SideBar />
<div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll"> <div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll">
<Column name="ホーム" columnIndex={1} width="widest"> <Column name="ホーム" columnIndex={1} width="widest">
@@ -154,6 +153,11 @@ const Home: Component = () => {
<Notification events={myReactions()} /> <Notification events={myReactions()} />
</Column> </Column>
</div> </div>
{/*
<Show when={pubkey()} keyed>
{(pubkeyNonNull: string) => <ProfileDisplay pubkey={pubkeyNonNull} />}
</Show>
*/}
</div> </div>
); );
}; };