diff --git a/src/components/Column.tsx b/src/components/Column.tsx index 454f8d0..a88b100 100644 --- a/src/components/Column.tsx +++ b/src/components/Column.tsx @@ -47,7 +47,7 @@ const Column: Component = (props) => { {/* 🏠 */} {props.name} - + ); }; diff --git a/src/components/Config.tsx b/src/components/Config.tsx index 91ff0b5..5c7c27b 100644 --- a/src/components/Config.tsx +++ b/src/components/Config.tsx @@ -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 = () => { {(relayUrl: string) => { return ( -
  • -
    {relayUrl}
    - +
  • +
    {relayUrl}
    +
  • ); }} @@ -154,33 +159,22 @@ const OtherConfig = () => { }; const ConfigUI = (props: ConfigProps) => { - let containerRef: HTMLDivElement | undefined; - - const handleClickContainer: JSX.EventHandler = (ev) => { - if (ev.target === containerRef) { - props.onClose(); - } - }; - return ( -
    +
    -

    設定

    - +
    +

    設定

    + +
    -
    + ); }; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..c5a5585 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,28 @@ +import { type Component, type JSX } from 'solid-js'; + +export type ModalProps = { + onClose?: () => void; + children?: JSX.Element; +}; + +const Modal: Component = (props) => { + let containerRef: HTMLDivElement | undefined; + + const handleClickContainer: JSX.EventHandler = (ev) => { + if (ev.target === containerRef) { + props.onClose?.(); + } + }; + + return ( +
    + {props.children} +
    + ); +}; + +export default Modal; diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 93cefa2..f46935f 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -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 = (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 = (props) => { submit(); } else if (ev.key === 'Escape') { textAreaRef?.blur(); - props.onClose(); + close(); } }; @@ -168,6 +171,7 @@ const NotePostForm: Component = (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 = (props) => { const submitDisabled = () => text().trim().length === 0 || - (contentWarning() && contentWarningReason().length === 0) || publishTextNoteMutation.isLoading || uploadFilesMutation.isLoading; @@ -192,6 +195,7 @@ const NotePostForm: Component = (props) => { onMount(() => { setTimeout(() => { + textAreaRef?.click(); textAreaRef?.focus(); }, 50); }); @@ -239,7 +243,7 @@ const NotePostForm: Component = (props) => {
    -
    diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx new file mode 100644 index 0000000..d698752 --- /dev/null +++ b/src/components/Profile.tsx @@ -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 = (props) => { + const { config } = useConfig(); + const { profile, query } = useProfile(() => ({ + relayUrls: config().relayUrls, + pubkey: props.pubkey, + })); + + const npub = createMemo(() => npubEncode(props.pubkey)); + + return ( + +
    +
    + +
    +
    + loading}> +
    + + {(bannerUrl) => ( + header + )} + +
    +
    +
    + + {(pictureUrl) => user icon} + +
    +
    +
    +
    {profile()?.display_name}
    +
    @{profile()?.name}
    +
    +
    +
    {npub()}
    + +
    +
    +
    +
    + {profile()?.about} +
    + +
    +
    +
    +
    + + ); +}; + +export default ProfileDisplay; diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 09a9ff4..cc1f47b 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -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 = () => {
    @@ -55,14 +62,19 @@ const SideBar: Component = () => {
    - +
    { textAreaRef = el; }} onClose={closeForm} /> - +
    setConfigOpened(false)} /> diff --git a/src/components/utils/Copy.tsx b/src/components/utils/Copy.tsx new file mode 100644 index 0000000..8c6e17d --- /dev/null +++ b/src/components/utils/Copy.tsx @@ -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 = (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 ( +
    + + +
    + Copied! +
    +
    +
    + ); +}; + +export default Copy; diff --git a/src/core/event.ts b/src/core/event.ts index 1ce5aaf..e5ca4b1 100644 --- a/src/core/event.ts +++ b/src/core/event.ts @@ -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, })); }, diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index ab615d8..9a9a5e7 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -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 = { - 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; + +// 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 | undefined>; +}; -const useBatchedEvents = (propsProvider: () => UseBatchedEventsProps) => { +// Textnote +export type UseTextNoteProps = { + eventId: string; +}; + +export type UseTextNote = { + event: Accessor; + query: CreateQueryResult | undefined>; +}; + +// Reactions +export type UseReactionsProps = { + eventId: string; +}; + +export type UseReactions = { + reactions: Accessor; + reactionsGroupedByContent: Accessor>; + isReactedBy: (pubkey: string) => boolean; + invalidateReactions: () => Promise; + query: CreateQueryResult>; +}; + +// DeprecatedReposts +export type UseDeprecatedRepostsProps = { + eventId: string; +}; + +export type UseDeprecatedReposts = { + reposts: Accessor; + isRepostedBy: (pubkey: string) => boolean; + invalidateDeprecatedReposts: () => Promise; + query: CreateQueryResult>; +}; + +const { exec } = useBatch(() => ({ + executor: (tasks) => { + const profileTasks = new Map[]>(); + const textNoteTasks = new Map[]>(); + const reactionsTasks = new Map[]>(); + const repostsTasks = new Map[]>(); + + 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>(); + + const resolveTasks = (registeredTasks: Task[], 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>(() => ({ - interval: props().interval, - executor: (tasks) => { - const { generateKey, mergeFilters, extractKey } = props(); - // TODO relayUrlsを考慮する - const { config } = useConfig(); - - const keyTaskMap = new Map>>( - tasks.map((task) => [generateKey(task.args), task]), - ); - const filters = mergeFilters(tasks.map((task) => task.args)); - const keyEventSignalsMap = new Map>(); - - const getSignalForKey = (key: string | number): Signal => { - const eventsSignal = - keyEventSignalsMap.get(key) ?? - createRoot((dispose) => { - return createSignal({ - 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(); + 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 => 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 => + queryClient.invalidateQueries(queryKey()); + + return { reposts, isRepostedBy, invalidateDeprecatedReposts, query }; +}; diff --git a/src/nostr/useDeprecatedReposts.ts b/src/nostr/useDeprecatedReposts.ts index 2baf10e..4548c54 100644 --- a/src/nostr/useDeprecatedReposts.ts +++ b/src/nostr/useDeprecatedReposts.ts @@ -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; - isRepostedBy: (pubkey: string) => boolean; - invalidateDeprecatedReposts: () => Promise; - query: CreateQueryResult>; -}; - -const { exec } = useBatchedEvents(() => ({ - 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 => - queryClient.invalidateQueries(queryKey()); - - return { reposts, isRepostedBy, invalidateDeprecatedReposts, query }; -}; +import { useDeprecatedReposts } from '@/nostr/useBatchedEvents'; export default useDeprecatedReposts; diff --git a/src/nostr/useEvent.ts b/src/nostr/useEvent.ts index aa7aabb..c1f48a4 100644 --- a/src/nostr/useEvent.ts +++ b/src/nostr/useEvent.ts @@ -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; - query: CreateQueryResult | undefined>; -}; - -const { exec } = useBatchedEvent(() => ({ - 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; diff --git a/src/nostr/useFollowings.ts b/src/nostr/useFollowings.ts index 4a1da6d..1079b8b 100644 --- a/src/nostr/useFollowings.ts +++ b/src/nostr/useFollowings.ts @@ -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[] = []; diff --git a/src/nostr/useProfile.ts b/src/nostr/useProfile.ts index 8c0a55f..aec6359 100644 --- a/src/nostr/useProfile.ts +++ b/src/nostr/useProfile.ts @@ -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; - query: CreateQueryResult | undefined>; -}; - -const { exec } = useBatchedEvent(() => ({ - 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; diff --git a/src/nostr/useReactions.ts b/src/nostr/useReactions.ts index 9e8573e..d6b43ee 100644 --- a/src/nostr/useReactions.ts +++ b/src/nostr/useReactions.ts @@ -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; - reactionsGroupedByContent: Accessor>; - isReactedBy: (pubkey: string) => boolean; - invalidateReactions: () => Promise; - query: CreateQueryResult>; -}; - -const { exec } = useBatchedEvents(() => ({ - 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(); - 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 => queryClient.invalidateQueries(queryKey()); - - return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query }; -}; +import { useReactions } from '@/nostr/useBatchedEvents'; export default useReactions; diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index 8af333a..9014923 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -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([]); - 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 }; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f8606f3..5f7210c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 ( -
    +
    @@ -154,6 +153,11 @@ const Home: Component = () => {
    + {/* + + {(pubkeyNonNull: string) => } + + */}
    ); };