mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
enhance batching
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<h2 class="flex-1 text-center font-bold">設定</h2>
|
<h2 class="flex-1 text-center font-bold">設定</h2>
|
||||||
<button class="absolute top-1 right-0" onClick={() => props.onClose()}>
|
<button class="absolute top-1 right-0 h-4 w-4" onClick={() => props.onClose?.()}>
|
||||||
X
|
<XMark />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<RelayConfig />
|
<RelayConfig />
|
||||||
<DateFormatConfig />
|
<DateFormatConfig />
|
||||||
<OtherConfig />
|
<OtherConfig />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
28
src/components/Modal.tsx
Normal file
28
src/components/Modal.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
83
src/components/Profile.tsx
Normal file
83
src/components/Profile.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
42
src/components/utils/Copy.tsx
Normal file
42
src/components/utils/Copy.tsx
Normal 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;
|
||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
...current,
|
||||||
events: [...current.events, event],
|
events: [...current.events, event],
|
||||||
|
}));
|
||||||
|
task.resolve(batchedEvents);
|
||||||
});
|
});
|
||||||
|
|
||||||
const useBatchedEvents = <TaskArgs>(propsProvider: () => UseBatchedEventsProps<TaskArgs>) => {
|
|
||||||
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);
|
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(() => ({
|
useSubscription(() => ({
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
filters,
|
filters,
|
||||||
continuous: false,
|
continuous: false,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent & { id: string }) => {
|
||||||
const key = extractKey(event);
|
if (event.kind === Kind.Metadata) {
|
||||||
if (key == null) return;
|
const registeredTasks = profileTasks.get(event.pubkey) ?? [];
|
||||||
const task = keyTaskMap.get(key);
|
resolveTasks(registeredTasks, event);
|
||||||
if (task == null) return;
|
} else if (event.kind === Kind.Text) {
|
||||||
|
const registeredTasks = textNoteTasks.get(event.id) ?? [];
|
||||||
const [events, setEvents] = getSignalForKey(key);
|
resolveTasks(registeredTasks, event);
|
||||||
|
} else if (event.kind === Kind.Reaction) {
|
||||||
setEvents(addEvent(event));
|
const eventTags = eventWrapper(event).taggedEvents();
|
||||||
|
eventTags.forEach((eventTag) => {
|
||||||
task.resolve(events);
|
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: () => {
|
onEOSE: () => {
|
||||||
tasks.forEach((task) => {
|
finalizeTasks();
|
||||||
const key = generateKey(task.args);
|
},
|
||||||
if (didReceivedEventsForKey(key)) {
|
}));
|
||||||
const [, setEvents] = getSignalForKey(key);
|
},
|
||||||
setEvents(completeBatchedEvents);
|
}));
|
||||||
} else {
|
|
||||||
task.resolve(emptyBatchedEvents);
|
export 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 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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user