From 4e165bc879e3e1ab6589c099a2cf4b028a3d3b78 Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Wed, 22 Mar 2023 02:36:04 +0900 Subject: [PATCH] update --- src/batchClient.ts | 211 ++++++++++++++++++ src/components/Column.tsx | 2 +- src/components/Config.tsx | 11 + src/components/NotePostForm.tsx | 8 +- .../{Profile.tsx => ProfileDisplay.tsx} | 40 ++-- src/components/UserDisplayName.tsx | 4 +- src/components/notification/Reaction.tsx | 1 - .../textNote/GeneralUserMentionDisplay.tsx | 7 +- .../textNote/MentionedUserDisplay.tsx | 10 +- .../textNote/TextNoteContentDisplay.tsx | 6 +- src/components/textNote/TextNoteDisplay.tsx | 30 ++- src/components/utils/Copy.tsx | 2 +- src/core/column.ts | 5 +- src/hooks/createSignalWithStorage.ts | 35 ++- src/hooks/useModalState.ts | 20 ++ src/nostr/useBatchedEvents.ts | 110 +++++---- src/nostr/useConfig.ts | 18 +- src/nostr/useSubscription.ts | 13 +- src/pages/Home.tsx | 29 ++- src/utils/npubEncodeFallback.ts | 12 + vite.config.ts | 1 + 21 files changed, 463 insertions(+), 112 deletions(-) create mode 100644 src/batchClient.ts rename src/components/{Profile.tsx => ProfileDisplay.tsx} (68%) create mode 100644 src/hooks/useModalState.ts create mode 100644 src/utils/npubEncodeFallback.ts diff --git a/src/batchClient.ts b/src/batchClient.ts new file mode 100644 index 0000000..55d9a44 --- /dev/null +++ b/src/batchClient.ts @@ -0,0 +1,211 @@ +/** + * This file is licensed under MIT license, not AGPL. + * + * Copyright (c) 2023 Syusui Moyatani + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { matchFilter, type Filter, type Event as NostrEvent, type SimplePool } from 'nostr-tools'; + +export type BatchExecutorConstructor = { + executor: (reqs: Task[]) => void; + interval: number; + size: number; +}; + +let incrementalId = 0; + +const nextId = (): number => { + const currentId = incrementalId; + incrementalId += 1; + return currentId; +}; + +export class ObservableTask { + id: number; + + req: BatchRequest; + + res: BatchResponse | undefined; + + isCompleted = false; + + #updateListeners: ((res: BatchResponse) => void)[] = []; + + #completeListeners: (() => void)[] = []; + + #promise: Promise | undefined; + + constructor(req: BatchRequest) { + this.id = nextId(); + this.req = req; + } + + #executeUpdateListeners() { + const { res } = this; + if (res != null) { + this.#updateListeners.forEach((listener) => { + listener(res); + }); + } + } + + update(res: BatchResponse) { + this.res = res; + this.#executeUpdateListeners(); + } + + updateWith(f: (current: BatchResponse | undefined) => BatchResponse) { + this.res = f(this.res); + this.#executeUpdateListeners(); + } + + complete() { + this.isCompleted = true; + this.#completeListeners.forEach((listener) => { + listener(); + }); + } + + onUpdate(f: (res: BatchResponse) => void) { + this.#updateListeners.push(f); + } + + onComplete(f: () => void) { + this.#completeListeners.push(f); + } + + toPromise(): Promise { + if (this.#promise == null) { + this.#promise = new Promise((resolve, reject) => { + this.onComplete(() => { + if (this.res != null) { + resolve(this.res); + } else { + reject(); + } + }); + }); + } + return this.#promise; + } +} + +export class BatchExecutor { + #executor: (reqs: Task[]) => void; + + #interval: number; + + #size: number; + + #tasks: Task[] = []; + + #timerId: ReturnType | null = null; + + constructor({ executor, interval, size }: BatchExecutorConstructor) { + this.#executor = executor; + this.#interval = interval; + this.#size = size; + } + + #executeTasks() { + this.#executor(this.#tasks); + this.#tasks = []; + } + + #startTimerIfNotStarted() { + if (this.#timerId == null) { + this.#timerId = setTimeout(() => { + this.#executeTasks(); + this.stop(); + }, this.#interval); + } + } + + pushTask(task: Task) { + this.#tasks.push(task); + if (this.#tasks.length < this.#size) { + this.#startTimerIfNotStarted(); + } else { + this.#executeTasks(); + } + } + + stop() { + if (this.#timerId != null) { + clearTimeout(this.#timerId); + this.#timerId = null; + } + } +} + +export type BatchSubscriptionTask = ObservableTask; + +export class BatchSubscription { + #batchExecutor: BatchExecutor; + + constructor(pool: SimplePool, relays: string[]) { + this.#batchExecutor = new BatchExecutor({ + interval: 2000, + size: 50, + executor: (tasks) => { + const filterTaskMap = new Map(); + tasks.forEach((task) => { + const filters = task.req; + filters.forEach((filter) => { + filterTaskMap.set(filter, task); + }); + }); + + const mergedFilter = [...filterTaskMap.keys()]; + const sub = pool.sub(relays, mergedFilter); + const filterEvents = new Map(); + + sub.on('event', (event: NostrEvent & { id: string }) => { + mergedFilter.forEach((filter) => { + if (matchFilter(filter, event)) { + const task = filterTaskMap.get(filter); + if (task == null) { + console.error('task for filter not found', filter); + return; + } + task.updateWith((current) => { + if (current == null) return [event]; + return [...current, event]; + }); + } + }); + }); + + sub.on('eose', () => { + tasks.forEach((task) => { + task.complete(); + }); + sub.unsub(); + }); + }, + }); + } + + sub(filters: Filter[]): BatchSubscriptionTask { + const task = new ObservableTask(filters); + this.#batchExecutor.pushTask(task); + return task; + } +} diff --git a/src/components/Column.tsx b/src/components/Column.tsx index 940723b..4f62d58 100644 --- a/src/components/Column.tsx +++ b/src/components/Column.tsx @@ -1,7 +1,7 @@ import type { Component, JSX } from 'solid-js'; import { useHandleCommand } from '@/hooks/useCommandBus'; -type ColumnProps = { +export type ColumnProps = { name: string; columnIndex: number; lastColumn?: true; diff --git a/src/components/Config.tsx b/src/components/Config.tsx index 3142e7f..52f54fa 100644 --- a/src/components/Config.tsx +++ b/src/components/Config.tsx @@ -142,6 +142,13 @@ const OtherConfig = () => { })); }; + const toggleShowImage = () => { + setConfig((current) => ({ + ...current, + showImage: !(current.showImage ?? true), + })); + }; + return (

その他

@@ -153,6 +160,10 @@ const OtherConfig = () => { onClick={() => toggleKeepOpenPostForm()} />
+
+
画像をデフォルトで表示する
+ toggleShowImage()} /> +
{/*
リアクションのデフォルト
diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 6f37f12..0790470 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -232,7 +232,7 @@ const NotePostForm: Component = (props) => { props.textAreaRef?.(el); }} name="text" - class="rounded border-none" + class="min-h-[40px] rounded border-none" rows={4} placeholder={placeholder(mode())} onInput={handleInput} @@ -260,7 +260,7 @@ const NotePostForm: Component = (props) => { 'w-7': mode() === 'reply', }} type="button" - area-label="コンテンツ警告を設定" + aria-label="コンテンツ警告を設定" title="コンテンツ警告を設定" onClick={() => setContentWarning((e) => !e)} > @@ -278,7 +278,7 @@ const NotePostForm: Component = (props) => { }} type="button" title="画像を投稿" - area-label="画像を投稿" + aria-label="画像を投稿" disabled={fileUploadDisabled()} onClick={() => fileInputRef?.click()} > @@ -295,7 +295,7 @@ const NotePostForm: Component = (props) => { 'w-7': mode() === 'reply', }} type="submit" - area-label="投稿" + aria-label="投稿" title="投稿" disabled={submitDisabled()} > diff --git a/src/components/Profile.tsx b/src/components/ProfileDisplay.tsx similarity index 68% rename from src/components/Profile.tsx rename to src/components/ProfileDisplay.tsx index c3ec7f4..c3b1187 100644 --- a/src/components/Profile.tsx +++ b/src/components/ProfileDisplay.tsx @@ -1,5 +1,4 @@ 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'; @@ -8,10 +7,11 @@ import Modal from '@/components/Modal'; import Copy from '@/components/utils/Copy'; import useProfile from '@/nostr/useProfile'; -import useConfig from '@/nostr/useConfig'; +import npubEncodeFallback from '@/utils/npubEncodeFallback'; export type ProfileDisplayProps = { pubkey: string; + onClose?: () => void; }; const ProfileDisplay: Component = (props) => { @@ -19,13 +19,17 @@ const ProfileDisplay: Component = (props) => { pubkey: props.pubkey, })); - const npub = createMemo(() => npubEncode(props.pubkey)); + const npub = createMemo(() => npubEncodeFallback(props.pubkey)); return ( - + props.onClose?.()}>
-
@@ -38,16 +42,22 @@ const ProfileDisplay: Component = (props) => { )}
-
-
+
+
- {(pictureUrl) => user icon} + {(pictureUrl) => ( + user icon + )}
{profile()?.display_name}
-
@{profile()?.name}
+
@{profile()?.name}
{npub()}
@@ -55,23 +65,27 @@ const ProfileDisplay: Component = (props) => {
-
+
{profile()?.about}
-
diff --git a/src/components/UserDisplayName.tsx b/src/components/UserDisplayName.tsx index 175a21a..544dea7 100644 --- a/src/components/UserDisplayName.tsx +++ b/src/components/UserDisplayName.tsx @@ -1,7 +1,7 @@ import { Component, Switch, Match } from 'solid-js'; -import { npubEncode } from 'nostr-tools/nip19'; import useProfile from '@/nostr/useProfile'; +import npubEncodeFallback from '@/utils/npubEncodeFallback'; type UserNameDisplayProps = { pubkey: string; @@ -13,7 +13,7 @@ const UserNameDisplay: Component = (props) => { })); return ( - + 0}>{profile()?.display_name} 0}>@{profile()?.name} diff --git a/src/components/notification/Reaction.tsx b/src/components/notification/Reaction.tsx index 38648c0..714c9ce 100644 --- a/src/components/notification/Reaction.tsx +++ b/src/components/notification/Reaction.tsx @@ -8,7 +8,6 @@ import UserDisplayName from '@/components/UserDisplayName'; import useProfile from '@/nostr/useProfile'; import useEvent from '@/nostr/useEvent'; -import { npubEncode } from 'nostr-tools/nip19'; type ReactionProps = { event: NostrEvent; diff --git a/src/components/textNote/GeneralUserMentionDisplay.tsx b/src/components/textNote/GeneralUserMentionDisplay.tsx index 288ead5..a25ec42 100644 --- a/src/components/textNote/GeneralUserMentionDisplay.tsx +++ b/src/components/textNote/GeneralUserMentionDisplay.tsx @@ -1,7 +1,7 @@ import { Show } from 'solid-js'; -import { npubEncode } from 'nostr-tools/nip19'; import useProfile from '@/nostr/useProfile'; +import npubEncodeFallback from '@/utils/npubEncodeFallback'; export type GeneralUserMentionDisplayProps = { pubkey: string; @@ -13,7 +13,10 @@ const GeneralUserMentionDisplay = (props: GeneralUserMentionDisplayProps) => { })); return ( - 0} fallback={`@${npubEncode(props.pubkey)}`}> + 0} + fallback={`@${npubEncodeFallback(props.pubkey)}`} + > @{profile()?.name ?? props.pubkey} ); diff --git a/src/components/textNote/MentionedUserDisplay.tsx b/src/components/textNote/MentionedUserDisplay.tsx index 3d82f66..947fb5d 100644 --- a/src/components/textNote/MentionedUserDisplay.tsx +++ b/src/components/textNote/MentionedUserDisplay.tsx @@ -1,15 +1,21 @@ import type { MentionedUser } from '@/core/parseTextNote'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; +import useModalState from '@/hooks/useModalState'; export type MentionedUserDisplayProps = { mentionedUser: MentionedUser; }; const MentionedUserDisplay = (props: MentionedUserDisplayProps) => { + const { showProfile } = useModalState(); + + const handleClick = () => { + showProfile(props.mentionedUser.pubkey); + }; return ( - + ); }; diff --git a/src/components/textNote/TextNoteContentDisplay.tsx b/src/components/textNote/TextNoteContentDisplay.tsx index cdaadf5..1fca26e 100644 --- a/src/components/textNote/TextNoteContentDisplay.tsx +++ b/src/components/textNote/TextNoteContentDisplay.tsx @@ -7,6 +7,7 @@ import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay'; import ImageDisplay from '@/components/textNote/ImageDisplay'; import eventWrapper from '@/core/event'; import { isImageUrl } from '@/utils/imageUrl'; +import useConfig from '@/nostr/useConfig'; import EventLink from '../EventLink'; import TextNoteDisplayById from './TextNoteDisplayById'; @@ -16,6 +17,7 @@ export type TextNoteContentDisplayProps = { }; const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { + const { config } = useConfig(); const event = () => eventWrapper(props.event); return ( @@ -50,7 +52,9 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { return ( ); } diff --git a/src/components/textNote/TextNoteDisplay.tsx b/src/components/textNote/TextNoteDisplay.tsx index 4ef6c39..f0c8f0d 100644 --- a/src/components/textNote/TextNoteDisplay.tsx +++ b/src/components/textNote/TextNoteDisplay.tsx @@ -25,7 +25,8 @@ import useDeprecatedReposts from '@/nostr/useDeprecatedReposts'; import useFormatDate from '@/hooks/useFormatDate'; import ensureNonNull from '@/utils/ensureNonNull'; -import { npubEncode } from 'nostr-tools/nip19'; +import npubEncodeFallback from '@/utils/npubEncodeFallback'; +import useModalState from '@/hooks/useModalState'; import UserNameDisplay from '../UserDisplayName'; import TextNoteDisplayById from './TextNoteDisplayById'; @@ -39,6 +40,7 @@ const TextNoteDisplay: Component = (props) => { const { config } = useConfig(); const formatDate = useFormatDate(); const pubkey = usePubkey(); + const { showProfile } = useModalState(); const [showReplyForm, setShowReplyForm] = createSignal(false); const closeReplyForm = () => setShowReplyForm(false); @@ -142,7 +144,10 @@ const TextNoteDisplay: Component = (props) => { return (
-
+
+
-
+
+
{createdAt()}
@@ -180,9 +191,12 @@ const TextNoteDisplay: Component = (props) => {
{(replyToPubkey: string) => ( - + )} {'への返信'} diff --git a/src/components/utils/Copy.tsx b/src/components/utils/Copy.tsx index 8c6e17d..8ea6190 100644 --- a/src/components/utils/Copy.tsx +++ b/src/components/utils/Copy.tsx @@ -29,7 +29,7 @@ const Copy: Component = (props) => {
Copied! diff --git a/src/core/column.ts b/src/core/column.ts index 329d37b..619e775 100644 --- a/src/core/column.ts +++ b/src/core/column.ts @@ -1,6 +1,6 @@ // import { z } from 'zod'; import { type Event as NostrEvent, type Filter } from 'nostr-tools'; -import ColumnComponent from '@/components/Column'; +import { type ColumnProps } from '@/components/Column'; export type NotificationType = // The event which includes ["p", ...] tags. @@ -42,7 +42,8 @@ type BulidOptions = { // export const buildFilter = (options: BuildOptions) => {}; export type BaseColumn = { - columnWidth: (typeof ColumnComponent)['width']; + title: string; + columnWidth: ColumnProps['width']; }; /** A column which shows posts by following users */ diff --git a/src/hooks/createSignalWithStorage.ts b/src/hooks/createSignalWithStorage.ts index accc45b..8cfcc5b 100644 --- a/src/hooks/createSignalWithStorage.ts +++ b/src/hooks/createSignalWithStorage.ts @@ -1,4 +1,5 @@ -import { createSignal, createEffect, onMount, Accessor, Setter } from 'solid-js'; +import { createSignal, createEffect, onMount, type Signal } from 'solid-js'; +import { createStore, SetStoreFunction, type Store, type StoreNode } from 'solid-js/store'; type GenericStorage = { getItem(key: string): T | null; @@ -28,20 +29,42 @@ export const createSignalWithStorage = ( key: string, initialValue: T, storage: GenericStorage, -): [Accessor, Setter] => { +): Signal => { const [loaded, setLoaded] = createSignal(false); - const [value, setValue] = createSignal(initialValue); + const [state, setState] = createSignal(initialValue); onMount(() => { const data = storage.getItem(key); // If there is no data, default value is used. - if (data != null) setValue(() => data); + if (data != null) setState(() => data); setLoaded(true); }); createEffect(() => { - if (loaded()) storage.setItem(key, value()); + if (loaded()) storage.setItem(key, state()); }); - return [value, setValue]; + return [state, setState]; +}; + +export const createStoreWithStorage = ( + key: string, + initialValue: T, + storage: GenericStorage, +): [Store, SetStoreFunction] => { + const [loaded, setLoaded] = createSignal(false); + const [state, setState] = createStore(initialValue); + + onMount(() => { + const data = storage.getItem(key); + // If there is no data, default value is used. + if (data != null) setState(data); + setLoaded(true); + }); + + createEffect(() => { + if (loaded()) storage.setItem(key, state); + }); + + return [state, setState]; }; diff --git a/src/hooks/useModalState.ts b/src/hooks/useModalState.ts new file mode 100644 index 0000000..7112114 --- /dev/null +++ b/src/hooks/useModalState.ts @@ -0,0 +1,20 @@ +import { createSignal, type Signal } from 'solid-js'; + +type ModalState = + | { type: 'Profile'; pubkey: string } + | { type: 'UserTimeline'; pubkey: string } + | { type: 'Closed' }; + +const [modalState, setModalState] = createSignal({ type: 'Closed' }); + +const useModalState = () => { + const showProfile = (pubkey: string) => { + setModalState({ type: 'Profile', pubkey }); + }; + const closeModal = () => { + setModalState({ type: 'Closed' }); + }; + return { modalState, setModalState, showProfile, closeModal }; +}; + +export default useModalState; diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index e5cc305..2c0a1d4 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -1,6 +1,5 @@ import { createSignal, - createEffect, createMemo, createRoot, observable, @@ -8,13 +7,16 @@ import { type Signal, } from 'solid-js'; import { type Event as NostrEvent, type Filter, Kind } from 'nostr-tools'; +import { npubEncode } from 'nostr-tools/nip19'; import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query'; import timeout from '@/utils/timeout'; import useBatch, { type Task } from '@/nostr/useBatch'; import eventWrapper from '@/core/event'; import useSubscription from '@/nostr/useSubscription'; +import npubEncodeFallback from '@/utils/npubEncodeFallback'; import useConfig from './useConfig'; +import usePool from './usePool'; type TaskArg = | { type: 'Profile'; pubkey: string } @@ -54,8 +56,8 @@ export type UseProfileProps = { }; type UseProfile = { - profile: () => Profile | undefined; - query: CreateQueryResult; + profile: () => Profile | null; + query: CreateQueryResult; }; // Textnote @@ -64,8 +66,8 @@ export type UseTextNoteProps = { }; export type UseTextNote = { - event: Accessor; - query: CreateQueryResult; + event: Accessor; + query: CreateQueryResult; }; // Reactions @@ -107,10 +109,16 @@ type Following = { export type UseFollowings = { followings: Accessor; followingPubkeys: Accessor; - query: CreateQueryResult; + query: CreateQueryResult; }; +let count = 0; + +setInterval(() => console.log('batchSub', count), 1000); + const { exec } = useBatch(() => ({ + interval: 2000, + batchSize: 100, executor: (tasks) => { const profileTasks = new Map[]>(); const textNoteTasks = new Map[]>(); @@ -194,46 +202,49 @@ const { exec } = useBatch(() => ({ }; const { config } = useConfig(); + const pool = usePool(); - useSubscription(() => ({ - relayUrls: config().relayUrls, - filters, - continuous: false, - onEvent: (event: NostrEvent & { id: string }) => { - if (event.kind === Kind.Metadata) { - const registeredTasks = profileTasks.get(event.pubkey) ?? []; + const sub = pool().sub(config().relayUrls, filters); + + count += 1; + + sub.on('event', (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 === Kind.Text) { - const registeredTasks = textNoteTasks.get(event.id) ?? []; + }); + } 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); - } 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); - }); - } else if (event.kind === Kind.Contacts) { - const registeredTasks = followingsTasks.get(event.pubkey) ?? []; - resolveTasks(registeredTasks, event); - } - }, - onEOSE: () => { - finalizeTasks(); - }, - })); + }); + } else if (event.kind === Kind.Contacts) { + const registeredTasks = followingsTasks.get(event.pubkey) ?? []; + resolveTasks(registeredTasks, event); + } + }); + + sub.on('eose', () => { + finalizeTasks(); + sub.unsub(); + count -= 1; + }); }, })); -const pickLatestEvent = (events: NostrEvent[]): NostrEvent | undefined => { - if (events.length === 0) return undefined; +const pickLatestEvent = (events: NostrEvent[]): NostrEvent | null => { + if (events.length === 0) return null; return events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); }; @@ -245,8 +256,9 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf () => ['useProfile', props()] as const, ({ queryKey, signal }) => { const [, currentProps] = queryKey; - if (currentProps == null) return undefined; + if (currentProps == null) return Promise.resolve(null); const { pubkey } = currentProps; + if (pubkey.startsWith('npub1')) return Promise.resolve(null); const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => { const latestEvent = () => { const latest = pickLatestEvent(batchedEvents().events); @@ -257,7 +269,7 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf try { queryClient.setQueryData(queryKey, latestEvent()); } catch (err) { - console.error(err); + console.error('updating profile error', err); } }); return latestEvent(); @@ -273,16 +285,16 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf }, ); - const profile = createMemo((): Profile | undefined => { - if (query.data == null) return undefined; + const profile = createMemo((): Profile | null => { + if (query.data == null) return null; const { content } = query.data; - if (content == null || content.length === 0) return undefined; + if (content == null || content.length === 0) return null; // TODO 大きすぎたりしないかどうか、JSONかどうかのチェック try { return JSON.parse(content) as Profile; } catch (err) { console.error('failed to parse profile (kind 0): ', err, content); - return undefined; + return null; } }); @@ -297,7 +309,7 @@ export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTe () => ['useTextNote', props()] as const, ({ queryKey, signal }) => { const [, currentProps] = queryKey; - if (currentProps == null) return undefined; + if (currentProps == null) return null; const { eventId } = currentProps; const promise = exec({ type: 'TextNote', eventId }, signal).then((batchedEvents) => { const event = batchedEvents().events[0]; @@ -417,7 +429,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U genQueryKey, ({ queryKey, signal }) => { const [, currentProps] = queryKey; - if (currentProps == null) return undefined; + if (currentProps == null) return Promise.resolve(null); const { pubkey } = currentProps; const promise = exec({ type: 'Followings', pubkey }, signal).then((batchedEvents) => { const latestEvent = () => { @@ -429,7 +441,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U try { queryClient.setQueryData(queryKey, latestEvent()); } catch (err) { - console.error(err); + console.error('updating followings error', err); } }); return latestEvent(); diff --git a/src/nostr/useConfig.ts b/src/nostr/useConfig.ts index 434ace8..34c82b7 100644 --- a/src/nostr/useConfig.ts +++ b/src/nostr/useConfig.ts @@ -1,13 +1,14 @@ import { type Accessor, type Setter } from 'solid-js'; import { createStorageWithSerializer, - createSignalWithStorage, + createStoreWithStorage, } from '@/hooks/createSignalWithStorage'; export type Config = { relayUrls: string[]; dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; keepOpenPostForm: boolean; + showImage: boolean; }; type UseConfig = { @@ -38,6 +39,7 @@ const InitialConfig = (): Config => { relayUrls, dateFormat: 'relative', keepOpenPostForm: false, + showImage: true, }; }; @@ -50,25 +52,19 @@ const deserializer = (json: string): Config => } as Config); const storage = createStorageWithSerializer(() => window.localStorage, serializer, deserializer); -const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig(), storage); +const [config, setConfig] = createStoreWithStorage('RabbitConfig', InitialConfig(), storage); const useConfig = (): UseConfig => { const addRelay = (relayUrl: string) => { - setConfig((current) => ({ - ...current, - relayUrls: [...current.relayUrls, relayUrl], - })); + setConfig('relayUrls', (current) => [...current, relayUrl]); }; const removeRelay = (relayUrl: string) => { - setConfig((current) => ({ - ...current, - relayUrls: current.relayUrls.filter((e) => e !== relayUrl), - })); + setConfig('relayUrls', (current) => current.filter((e) => e !== relayUrl)); }; return { - config, + config: () => config, setConfig, addRelay, removeRelay, diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index 9014923..084c229 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -21,7 +21,7 @@ const sortEvents = (events: NostrEvent[]) => let count = 0; -setInterval(() => console.log(count), 1000); +setInterval(() => console.log('sub', count), 1000); const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { const pool = usePool(); @@ -34,6 +34,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props; const sub = pool().sub(relayUrls, filters, options); + let subscribing = true; count += 1; let pushed = false; @@ -75,7 +76,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { if (!continuous) { sub.unsub(); - count -= 1; + if (subscribing) { + subscribing = false; + count -= 1; + } } }); @@ -93,7 +97,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { onCleanup(() => { sub.unsub(); - // count -= 1; + if (subscribing) { + subscribing = false; + count -= 1; + } clearInterval(intervalId); }); }; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a4a853e..8c008a4 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,4 +1,13 @@ -import { createSignal, createEffect, onMount, onCleanup, Show, type Component } from 'solid-js'; +import { + createSignal, + createEffect, + onMount, + onCleanup, + Show, + Switch, + Match, + type Component, +} from 'solid-js'; import { useNavigate } from '@solidjs/router'; import uniq from 'lodash/uniq'; @@ -16,12 +25,14 @@ 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'; +import ProfileDisplay from '@/components/ProfileDisplay'; +import useModalState from '@/hooks/useModalState'; const Home: Component = () => { useMountShortcutKeys(); const navigate = useNavigate(); const { persistStatus } = usePersistStatus(); + const { modalState, closeModal } = useModalState(); const pool = usePool(); const { config } = useConfig(); @@ -156,11 +167,17 @@ const Home: Component = () => {
- {/* - - {(pubkeyNonNull: string) => } + + {(state) => ( + + + {(pubkeyNonNull: string) => ( + + )} + + + )} - */}
); }; diff --git a/src/utils/npubEncodeFallback.ts b/src/utils/npubEncodeFallback.ts new file mode 100644 index 0000000..b0fef59 --- /dev/null +++ b/src/utils/npubEncodeFallback.ts @@ -0,0 +1,12 @@ +import { npubEncode } from 'nostr-tools/nip19'; + +const npubEncodeFallback = (pubkey: string): string => { + try { + return npubEncode(pubkey); + } catch (err) { + console.error('failed to encode pubkey into npub', pubkey); + return pubkey; + } +}; + +export default npubEncodeFallback; diff --git a/vite.config.ts b/vite.config.ts index e805f40..d862f1f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ }, build: { target: 'esnext', + sourcemap: 'inline', }, resolve: { alias: {