This commit is contained in:
Shusui MOYATANI
2023-03-22 02:36:04 +09:00
parent db289c5276
commit 4e165bc879
21 changed files with 463 additions and 112 deletions

211
src/batchClient.ts Normal file
View File

@@ -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<Task> = {
executor: (reqs: Task[]) => void;
interval: number;
size: number;
};
let incrementalId = 0;
const nextId = (): number => {
const currentId = incrementalId;
incrementalId += 1;
return currentId;
};
export class ObservableTask<BatchRequest, BatchResponse> {
id: number;
req: BatchRequest;
res: BatchResponse | undefined;
isCompleted = false;
#updateListeners: ((res: BatchResponse) => void)[] = [];
#completeListeners: (() => void)[] = [];
#promise: Promise<BatchResponse> | 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<BatchResponse> {
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<Task> {
#executor: (reqs: Task[]) => void;
#interval: number;
#size: number;
#tasks: Task[] = [];
#timerId: ReturnType<typeof setTimeout> | null = null;
constructor({ executor, interval, size }: BatchExecutorConstructor<Task>) {
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<Filter[], NostrEvent[]>;
export class BatchSubscription {
#batchExecutor: BatchExecutor<BatchSubscriptionTask>;
constructor(pool: SimplePool, relays: string[]) {
this.#batchExecutor = new BatchExecutor<BatchSubscriptionTask>({
interval: 2000,
size: 50,
executor: (tasks) => {
const filterTaskMap = new Map<Filter, BatchSubscriptionTask>();
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<Filter, NostrEvent[]>();
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<Filter[], NostrEvent[]>(filters);
this.#batchExecutor.pushTask(task);
return task;
}
}

View File

@@ -1,7 +1,7 @@
import type { Component, JSX } from 'solid-js'; import type { Component, JSX } from 'solid-js';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';
type ColumnProps = { export type ColumnProps = {
name: string; name: string;
columnIndex: number; columnIndex: number;
lastColumn?: true; lastColumn?: true;

View File

@@ -142,6 +142,13 @@ const OtherConfig = () => {
})); }));
}; };
const toggleShowImage = () => {
setConfig((current) => ({
...current,
showImage: !(current.showImage ?? true),
}));
};
return ( return (
<div> <div>
<h3 class="font-bold"></h3> <h3 class="font-bold"></h3>
@@ -153,6 +160,10 @@ const OtherConfig = () => {
onClick={() => toggleKeepOpenPostForm()} onClick={() => toggleKeepOpenPostForm()}
/> />
</div> </div>
<div class="flex w-full">
<div class="flex-1"></div>
<ToggleButton value={config().showImage} onClick={() => toggleShowImage()} />
</div>
{/* {/*
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1">リアクションのデフォルト</div> <div class="flex-1">リアクションのデフォルト</div>

View File

@@ -232,7 +232,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
props.textAreaRef?.(el); props.textAreaRef?.(el);
}} }}
name="text" name="text"
class="rounded border-none" class="min-h-[40px] rounded border-none"
rows={4} rows={4}
placeholder={placeholder(mode())} placeholder={placeholder(mode())}
onInput={handleInput} onInput={handleInput}
@@ -260,7 +260,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
'w-7': mode() === 'reply', 'w-7': mode() === 'reply',
}} }}
type="button" type="button"
area-label="コンテンツ警告を設定" aria-label="コンテンツ警告を設定"
title="コンテンツ警告を設定" title="コンテンツ警告を設定"
onClick={() => setContentWarning((e) => !e)} onClick={() => setContentWarning((e) => !e)}
> >
@@ -278,7 +278,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
}} }}
type="button" type="button"
title="画像を投稿" title="画像を投稿"
area-label="画像を投稿" aria-label="画像を投稿"
disabled={fileUploadDisabled()} disabled={fileUploadDisabled()}
onClick={() => fileInputRef?.click()} onClick={() => fileInputRef?.click()}
> >
@@ -295,7 +295,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
'w-7': mode() === 'reply', 'w-7': mode() === 'reply',
}} }}
type="submit" type="submit"
area-label="投稿" aria-label="投稿"
title="投稿" title="投稿"
disabled={submitDisabled()} disabled={submitDisabled()}
> >

View File

@@ -1,5 +1,4 @@
import { Component, createMemo, Show } from 'solid-js'; import { Component, createMemo, Show } from 'solid-js';
import { npubEncode } from 'nostr-tools/nip19';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg'; import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import XMark from 'heroicons/24/outline/x-mark.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 Copy from '@/components/utils/Copy';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import useConfig from '@/nostr/useConfig'; import npubEncodeFallback from '@/utils/npubEncodeFallback';
export type ProfileDisplayProps = { export type ProfileDisplayProps = {
pubkey: string; pubkey: string;
onClose?: () => void;
}; };
const ProfileDisplay: Component<ProfileDisplayProps> = (props) => { const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
@@ -19,13 +19,17 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
pubkey: props.pubkey, pubkey: props.pubkey,
})); }));
const npub = createMemo(() => npubEncode(props.pubkey)); const npub = createMemo(() => npubEncodeFallback(props.pubkey));
return ( return (
<Modal> <Modal onClose={() => props.onClose?.()}>
<div class="max-h-full w-[640px] max-w-full overflow-scroll"> <div class="max-h-full w-[640px] max-w-full overflow-scroll">
<div class="flex justify-end"> <div class="flex justify-end">
<button class="h-8 w-8 text-stone-700"> <button
class="h-8 w-8 text-stone-700"
aria-label="Close"
onClick={() => props.onClose?.()}
>
<XMark /> <XMark />
</button> </button>
</div> </div>
@@ -38,16 +42,22 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
)} )}
</Show> </Show>
</div> </div>
<div class="flex h-[64px] items-center gap-2 px-2"> <div class="flex h-[64px] items-center gap-4 px-4">
<div class="mt-[-64px] h-28 w-28 shrink-0 rounded-lg border-2 object-cover"> <div class="mt-[-64px] h-28 w-28 shrink-0 rounded-lg bg-stone-400 shadow-md">
<Show when={profile()?.picture} keyed> <Show when={profile()?.picture} keyed>
{(pictureUrl) => <img src={pictureUrl} alt="user icon" class="h-full w-full" />} {(pictureUrl) => (
<img
src={pictureUrl}
alt="user icon"
class="h-full w-full rounded-lg object-cover"
/>
)}
</Show> </Show>
</div> </div>
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="truncate text-xl font-bold">{profile()?.display_name}</div> <div class="truncate text-xl font-bold">{profile()?.display_name}</div>
<div class="shrink-0 text-sm">@{profile()?.name}</div> <div class="shrink-0 text-sm font-bold">@{profile()?.name}</div>
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<div class="truncate text-xs">{npub()}</div> <div class="truncate text-xs">{npub()}</div>
@@ -55,23 +65,27 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<div class="max-h-32 overflow-scroll whitespace-pre-wrap px-4 pt-1 text-sm"> <div class="max-h-32 overflow-scroll whitespace-pre-wrap px-5 py-2 text-sm">
{profile()?.about} {profile()?.about}
</div> </div>
<ul class="px-4 py-2 text-xs"> <ul class="border-t px-5 py-2 text-xs">
<Show when={profile()?.website}> <Show when={profile()?.website}>
<li class="flex items-center gap-1"> <li class="flex items-center gap-1">
<span class="inline-block h-4 w-4" area-label="website" title="website"> <span class="inline-block h-4 w-4" area-label="website" title="website">
<GlobeAlt /> <GlobeAlt />
</span> </span>
<a href={profile()?.website} target="_blank" rel="noreferrer noopener"> <a
class="text-blue-500 underline"
href={profile()?.website}
target="_blank"
rel="noreferrer noopener"
>
{profile()?.website} {profile()?.website}
</a> </a>
</li> </li>
</Show> </Show>
</ul> </ul>
</Show> </Show>
<div class="h-16 border" />
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -1,7 +1,7 @@
import { Component, Switch, Match } from 'solid-js'; import { Component, Switch, Match } from 'solid-js';
import { npubEncode } from 'nostr-tools/nip19';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
type UserNameDisplayProps = { type UserNameDisplayProps = {
pubkey: string; pubkey: string;
@@ -13,7 +13,7 @@ const UserNameDisplay: Component<UserNameDisplayProps> = (props) => {
})); }));
return ( return (
<Switch fallback={npubEncode(props.pubkey)}> <Switch fallback={npubEncodeFallback(props.pubkey)}>
<Match when={(profile()?.display_name?.length ?? 0) > 0}>{profile()?.display_name}</Match> <Match when={(profile()?.display_name?.length ?? 0) > 0}>{profile()?.display_name}</Match>
<Match when={(profile()?.name?.length ?? 0) > 0}>@{profile()?.name}</Match> <Match when={(profile()?.name?.length ?? 0) > 0}>@{profile()?.name}</Match>
</Switch> </Switch>

View File

@@ -8,7 +8,6 @@ import UserDisplayName from '@/components/UserDisplayName';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import useEvent from '@/nostr/useEvent'; import useEvent from '@/nostr/useEvent';
import { npubEncode } from 'nostr-tools/nip19';
type ReactionProps = { type ReactionProps = {
event: NostrEvent; event: NostrEvent;

View File

@@ -1,7 +1,7 @@
import { Show } from 'solid-js'; import { Show } from 'solid-js';
import { npubEncode } from 'nostr-tools/nip19';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
export type GeneralUserMentionDisplayProps = { export type GeneralUserMentionDisplayProps = {
pubkey: string; pubkey: string;
@@ -13,7 +13,10 @@ const GeneralUserMentionDisplay = (props: GeneralUserMentionDisplayProps) => {
})); }));
return ( return (
<Show when={(profile()?.name?.length ?? 0) > 0} fallback={`@${npubEncode(props.pubkey)}`}> <Show
when={(profile()?.name?.length ?? 0) > 0}
fallback={`@${npubEncodeFallback(props.pubkey)}`}
>
@{profile()?.name ?? props.pubkey} @{profile()?.name ?? props.pubkey}
</Show> </Show>
); );

View File

@@ -1,15 +1,21 @@
import type { MentionedUser } from '@/core/parseTextNote'; import type { MentionedUser } from '@/core/parseTextNote';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import useModalState from '@/hooks/useModalState';
export type MentionedUserDisplayProps = { export type MentionedUserDisplayProps = {
mentionedUser: MentionedUser; mentionedUser: MentionedUser;
}; };
const MentionedUserDisplay = (props: MentionedUserDisplayProps) => { const MentionedUserDisplay = (props: MentionedUserDisplayProps) => {
const { showProfile } = useModalState();
const handleClick = () => {
showProfile(props.mentionedUser.pubkey);
};
return ( return (
<span class="text-blue-500 underline"> <button class="inline text-blue-500 underline" onClick={handleClick}>
<GeneralUserMentionDisplay pubkey={props.mentionedUser.pubkey} /> <GeneralUserMentionDisplay pubkey={props.mentionedUser.pubkey} />
</span> </button>
); );
}; };

View File

@@ -7,6 +7,7 @@ import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay';
import ImageDisplay from '@/components/textNote/ImageDisplay'; import ImageDisplay from '@/components/textNote/ImageDisplay';
import eventWrapper from '@/core/event'; import eventWrapper from '@/core/event';
import { isImageUrl } from '@/utils/imageUrl'; import { isImageUrl } from '@/utils/imageUrl';
import useConfig from '@/nostr/useConfig';
import EventLink from '../EventLink'; import EventLink from '../EventLink';
import TextNoteDisplayById from './TextNoteDisplayById'; import TextNoteDisplayById from './TextNoteDisplayById';
@@ -16,6 +17,7 @@ export type TextNoteContentDisplayProps = {
}; };
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
const { config } = useConfig();
const event = () => eventWrapper(props.event); const event = () => eventWrapper(props.event);
return ( return (
<For each={parseTextNote(props.event)}> <For each={parseTextNote(props.event)}>
@@ -50,7 +52,9 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
return ( return (
<ImageDisplay <ImageDisplay
url={item.content} url={item.content}
initialHidden={event().contentWarning().contentWarning || !props.embedding} initialHidden={
!config().showImage || event().contentWarning().contentWarning || !props.embedding
}
/> />
); );
} }

View File

@@ -25,7 +25,8 @@ import useDeprecatedReposts from '@/nostr/useDeprecatedReposts';
import useFormatDate from '@/hooks/useFormatDate'; import useFormatDate from '@/hooks/useFormatDate';
import ensureNonNull from '@/utils/ensureNonNull'; 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 UserNameDisplay from '../UserDisplayName';
import TextNoteDisplayById from './TextNoteDisplayById'; import TextNoteDisplayById from './TextNoteDisplayById';
@@ -39,6 +40,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const { config } = useConfig(); const { config } = useConfig();
const formatDate = useFormatDate(); const formatDate = useFormatDate();
const pubkey = usePubkey(); const pubkey = usePubkey();
const { showProfile } = useModalState();
const [showReplyForm, setShowReplyForm] = createSignal(false); const [showReplyForm, setShowReplyForm] = createSignal(false);
const closeReplyForm = () => setShowReplyForm(false); const closeReplyForm = () => setShowReplyForm(false);
@@ -142,7 +144,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
return ( return (
<div class="nostr-textnote flex flex-col"> <div class="nostr-textnote flex flex-col">
<div class="flex w-full gap-1"> <div class="flex w-full gap-1">
<div class="author-icon h-10 w-10 shrink-0 overflow-hidden object-cover"> <button
class="author-icon h-10 w-10 shrink-0 overflow-hidden object-cover"
onClick={() => showProfile(event().pubkey)}
>
<Show when={author()?.picture}> <Show when={author()?.picture}>
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */} {/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
<img <img
@@ -152,21 +157,27 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
class="h-10 w-10 rounded" class="h-10 w-10 rounded"
/> />
</Show> </Show>
</div> </button>
<div class="min-w-0 flex-auto"> <div class="min-w-0 flex-auto">
<div class="flex justify-between gap-1 text-xs"> <div class="flex justify-between gap-1 text-xs">
<div class="author flex min-w-0 truncate"> <button
class="author flex min-w-0 truncate"
onClick={() => showProfile(event().pubkey)}
>
{/* TODO link to author */} {/* TODO link to author */}
<Show when={(author()?.display_name?.length ?? 0) > 0}> <Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div> <div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
</Show> </Show>
<div class="author-username truncate text-zinc-600"> <div class="author-username truncate text-zinc-600">
<Show when={author()?.name != null} fallback={`@${npubEncode(props.event.pubkey)}`}> <Show
when={author()?.name != null}
fallback={`@${npubEncodeFallback(event().pubkey)}`}
>
@{author()?.name} @{author()?.name}
</Show> </Show>
{/* TODO <Match when={author()?.nip05 != null}>@{author()?.nip05}</Match> */} {/* TODO <Match when={author()?.nip05 != null}>@{author()?.nip05}</Match> */}
</div> </div>
</div> </button>
<div class="created-at shrink-0">{createdAt()}</div> <div class="created-at shrink-0">{createdAt()}</div>
</div> </div>
<Show when={showReplyEvent()} keyed> <Show when={showReplyEvent()} keyed>
@@ -180,9 +191,12 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<div class="text-xs"> <div class="text-xs">
<For each={event().mentionedPubkeys()}> <For each={event().mentionedPubkeys()}>
{(replyToPubkey: string) => ( {(replyToPubkey: string) => (
<span class="pr-1 text-blue-500 underline"> <button
class="pr-1 text-blue-500 underline"
onClick={() => showProfile(replyToPubkey)}
>
<GeneralUserMentionDisplay pubkey={replyToPubkey} /> <GeneralUserMentionDisplay pubkey={replyToPubkey} />
</span> </button>
)} )}
</For> </For>
{'への返信'} {'への返信'}

View File

@@ -29,7 +29,7 @@ const Copy: Component<CopyProps> = (props) => {
</button> </button>
<Show when={showPopup()}> <Show when={showPopup()}>
<div <div
class="absolute left-[-1rem] top-[-1.5rem] rounded-lg class="absolute left-[-1rem] top-[-1.5rem] rounded
bg-rose-300 p-1 text-xs font-bold text-white shadow" bg-rose-300 p-1 text-xs font-bold text-white shadow"
> >
Copied! Copied!

View File

@@ -1,6 +1,6 @@
// import { z } from 'zod'; // import { z } from 'zod';
import { type Event as NostrEvent, type Filter } from 'nostr-tools'; import { type Event as NostrEvent, type Filter } from 'nostr-tools';
import ColumnComponent from '@/components/Column'; import { type ColumnProps } from '@/components/Column';
export type NotificationType = export type NotificationType =
// The event which includes ["p", ...] tags. // The event which includes ["p", ...] tags.
@@ -42,7 +42,8 @@ type BulidOptions = {
// export const buildFilter = (options: BuildOptions) => {}; // export const buildFilter = (options: BuildOptions) => {};
export type BaseColumn = { export type BaseColumn = {
columnWidth: (typeof ColumnComponent)['width']; title: string;
columnWidth: ColumnProps['width'];
}; };
/** A column which shows posts by following users */ /** A column which shows posts by following users */

View File

@@ -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<T> = { type GenericStorage<T> = {
getItem(key: string): T | null; getItem(key: string): T | null;
@@ -28,20 +29,42 @@ export const createSignalWithStorage = <T>(
key: string, key: string,
initialValue: T, initialValue: T,
storage: GenericStorage<T>, storage: GenericStorage<T>,
): [Accessor<T>, Setter<T>] => { ): Signal<T> => {
const [loaded, setLoaded] = createSignal<boolean>(false); const [loaded, setLoaded] = createSignal<boolean>(false);
const [value, setValue] = createSignal<T>(initialValue); const [state, setState] = createSignal(initialValue);
onMount(() => { onMount(() => {
const data = storage.getItem(key); const data = storage.getItem(key);
// If there is no data, default value is used. // If there is no data, default value is used.
if (data != null) setValue(() => data); if (data != null) setState(() => data);
setLoaded(true); setLoaded(true);
}); });
createEffect(() => { createEffect(() => {
if (loaded()) storage.setItem(key, value()); if (loaded()) storage.setItem(key, state());
}); });
return [value, setValue]; return [state, setState];
};
export const createStoreWithStorage = <T extends StoreNode>(
key: string,
initialValue: T,
storage: GenericStorage<T>,
): [Store<T>, SetStoreFunction<T>] => {
const [loaded, setLoaded] = createSignal<boolean>(false);
const [state, setState] = createStore<T>(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];
}; };

View File

@@ -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<ModalState>({ 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;

View File

@@ -1,6 +1,5 @@
import { import {
createSignal, createSignal,
createEffect,
createMemo, createMemo,
createRoot, createRoot,
observable, observable,
@@ -8,13 +7,16 @@ import {
type Signal, type Signal,
} from 'solid-js'; } from 'solid-js';
import { type Event as NostrEvent, type Filter, Kind } from 'nostr-tools'; 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 { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import timeout from '@/utils/timeout'; import timeout from '@/utils/timeout';
import useBatch, { type Task } from '@/nostr/useBatch'; import useBatch, { type Task } from '@/nostr/useBatch';
import eventWrapper from '@/core/event'; import eventWrapper from '@/core/event';
import useSubscription from '@/nostr/useSubscription'; import useSubscription from '@/nostr/useSubscription';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
import useConfig from './useConfig'; import useConfig from './useConfig';
import usePool from './usePool';
type TaskArg = type TaskArg =
| { type: 'Profile'; pubkey: string } | { type: 'Profile'; pubkey: string }
@@ -54,8 +56,8 @@ export type UseProfileProps = {
}; };
type UseProfile = { type UseProfile = {
profile: () => Profile | undefined; profile: () => Profile | null;
query: CreateQueryResult<NostrEvent | undefined>; query: CreateQueryResult<NostrEvent | null>;
}; };
// Textnote // Textnote
@@ -64,8 +66,8 @@ export type UseTextNoteProps = {
}; };
export type UseTextNote = { export type UseTextNote = {
event: Accessor<NostrEvent | undefined>; event: Accessor<NostrEvent | null>;
query: CreateQueryResult<NostrEvent | undefined>; query: CreateQueryResult<NostrEvent | null>;
}; };
// Reactions // Reactions
@@ -107,10 +109,16 @@ type Following = {
export type UseFollowings = { export type UseFollowings = {
followings: Accessor<Following[]>; followings: Accessor<Following[]>;
followingPubkeys: Accessor<string[]>; followingPubkeys: Accessor<string[]>;
query: CreateQueryResult<NostrEvent | undefined>; query: CreateQueryResult<NostrEvent | null>;
}; };
let count = 0;
setInterval(() => console.log('batchSub', count), 1000);
const { exec } = useBatch<TaskArg, TaskRes>(() => ({ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
interval: 2000,
batchSize: 100,
executor: (tasks) => { executor: (tasks) => {
const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>(); const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>(); const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
@@ -194,12 +202,13 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
}; };
const { config } = useConfig(); const { config } = useConfig();
const pool = usePool();
useSubscription(() => ({ const sub = pool().sub(config().relayUrls, filters);
relayUrls: config().relayUrls,
filters, count += 1;
continuous: false,
onEvent: (event: NostrEvent & { id: string }) => { sub.on('event', (event: NostrEvent & { id: string }) => {
if (event.kind === Kind.Metadata) { if (event.kind === Kind.Metadata) {
const registeredTasks = profileTasks.get(event.pubkey) ?? []; const registeredTasks = profileTasks.get(event.pubkey) ?? [];
resolveTasks(registeredTasks, event); resolveTasks(registeredTasks, event);
@@ -224,16 +233,18 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
const registeredTasks = followingsTasks.get(event.pubkey) ?? []; const registeredTasks = followingsTasks.get(event.pubkey) ?? [];
resolveTasks(registeredTasks, event); resolveTasks(registeredTasks, event);
} }
}, });
onEOSE: () => {
sub.on('eose', () => {
finalizeTasks(); finalizeTasks();
}, sub.unsub();
})); count -= 1;
});
}, },
})); }));
const pickLatestEvent = (events: NostrEvent[]): NostrEvent | undefined => { const pickLatestEvent = (events: NostrEvent[]): NostrEvent | null => {
if (events.length === 0) return undefined; if (events.length === 0) return null;
return events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); 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, () => ['useProfile', props()] as const,
({ queryKey, signal }) => { ({ queryKey, signal }) => {
const [, currentProps] = queryKey; const [, currentProps] = queryKey;
if (currentProps == null) return undefined; if (currentProps == null) return Promise.resolve(null);
const { pubkey } = currentProps; const { pubkey } = currentProps;
if (pubkey.startsWith('npub1')) return Promise.resolve(null);
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => { const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
const latestEvent = () => { const latestEvent = () => {
const latest = pickLatestEvent(batchedEvents().events); const latest = pickLatestEvent(batchedEvents().events);
@@ -257,7 +269,7 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
try { try {
queryClient.setQueryData(queryKey, latestEvent()); queryClient.setQueryData(queryKey, latestEvent());
} catch (err) { } catch (err) {
console.error(err); console.error('updating profile error', err);
} }
}); });
return latestEvent(); return latestEvent();
@@ -273,16 +285,16 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
}, },
); );
const profile = createMemo((): Profile | undefined => { const profile = createMemo((): Profile | null => {
if (query.data == null) return undefined; if (query.data == null) return null;
const { content } = query.data; const { content } = query.data;
if (content == null || content.length === 0) return undefined; if (content == null || content.length === 0) return null;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック // TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
try { try {
return JSON.parse(content) as Profile; return JSON.parse(content) as Profile;
} catch (err) { } catch (err) {
console.error('failed to parse profile (kind 0): ', err, content); 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, () => ['useTextNote', props()] as const,
({ queryKey, signal }) => { ({ queryKey, signal }) => {
const [, currentProps] = queryKey; const [, currentProps] = queryKey;
if (currentProps == null) return undefined; if (currentProps == null) return null;
const { eventId } = currentProps; const { eventId } = currentProps;
const promise = exec({ type: 'TextNote', eventId }, signal).then((batchedEvents) => { const promise = exec({ type: 'TextNote', eventId }, signal).then((batchedEvents) => {
const event = batchedEvents().events[0]; const event = batchedEvents().events[0];
@@ -417,7 +429,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
genQueryKey, genQueryKey,
({ queryKey, signal }) => { ({ queryKey, signal }) => {
const [, currentProps] = queryKey; const [, currentProps] = queryKey;
if (currentProps == null) return undefined; if (currentProps == null) return Promise.resolve(null);
const { pubkey } = currentProps; const { pubkey } = currentProps;
const promise = exec({ type: 'Followings', pubkey }, signal).then((batchedEvents) => { const promise = exec({ type: 'Followings', pubkey }, signal).then((batchedEvents) => {
const latestEvent = () => { const latestEvent = () => {
@@ -429,7 +441,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
try { try {
queryClient.setQueryData(queryKey, latestEvent()); queryClient.setQueryData(queryKey, latestEvent());
} catch (err) { } catch (err) {
console.error(err); console.error('updating followings error', err);
} }
}); });
return latestEvent(); return latestEvent();

View File

@@ -1,13 +1,14 @@
import { type Accessor, type Setter } from 'solid-js'; import { type Accessor, type Setter } from 'solid-js';
import { import {
createStorageWithSerializer, createStorageWithSerializer,
createSignalWithStorage, createStoreWithStorage,
} from '@/hooks/createSignalWithStorage'; } from '@/hooks/createSignalWithStorage';
export type Config = { export type Config = {
relayUrls: string[]; relayUrls: string[];
dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean; keepOpenPostForm: boolean;
showImage: boolean;
}; };
type UseConfig = { type UseConfig = {
@@ -38,6 +39,7 @@ const InitialConfig = (): Config => {
relayUrls, relayUrls,
dateFormat: 'relative', dateFormat: 'relative',
keepOpenPostForm: false, keepOpenPostForm: false,
showImage: true,
}; };
}; };
@@ -50,25 +52,19 @@ const deserializer = (json: string): Config =>
} as Config); } as Config);
const storage = createStorageWithSerializer(() => window.localStorage, serializer, deserializer); 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 useConfig = (): UseConfig => {
const addRelay = (relayUrl: string) => { const addRelay = (relayUrl: string) => {
setConfig((current) => ({ setConfig('relayUrls', (current) => [...current, relayUrl]);
...current,
relayUrls: [...current.relayUrls, relayUrl],
}));
}; };
const removeRelay = (relayUrl: string) => { const removeRelay = (relayUrl: string) => {
setConfig((current) => ({ setConfig('relayUrls', (current) => current.filter((e) => e !== relayUrl));
...current,
relayUrls: current.relayUrls.filter((e) => e !== relayUrl),
}));
}; };
return { return {
config, config: () => config,
setConfig, setConfig,
addRelay, addRelay,
removeRelay, removeRelay,

View File

@@ -21,7 +21,7 @@ const sortEvents = (events: NostrEvent[]) =>
let count = 0; let count = 0;
setInterval(() => console.log(count), 1000); setInterval(() => console.log('sub', count), 1000);
const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const pool = usePool(); const pool = usePool();
@@ -34,6 +34,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
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);
let subscribing = true;
count += 1; count += 1;
let pushed = false; let pushed = false;
@@ -75,8 +76,11 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (!continuous) { if (!continuous) {
sub.unsub(); sub.unsub();
if (subscribing) {
subscribing = false;
count -= 1; count -= 1;
} }
}
}); });
// avoid updating an array too rapidly while this is fetching stored events // avoid updating an array too rapidly while this is fetching stored events
@@ -93,7 +97,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
onCleanup(() => { onCleanup(() => {
sub.unsub(); sub.unsub();
// count -= 1; if (subscribing) {
subscribing = false;
count -= 1;
}
clearInterval(intervalId); clearInterval(intervalId);
}); });
}; };

View File

@@ -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 { useNavigate } from '@solidjs/router';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
@@ -16,12 +25,14 @@ 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'; import ProfileDisplay from '@/components/ProfileDisplay';
import useModalState from '@/hooks/useModalState';
const Home: Component = () => { const Home: Component = () => {
useMountShortcutKeys(); useMountShortcutKeys();
const navigate = useNavigate(); const navigate = useNavigate();
const { persistStatus } = usePersistStatus(); const { persistStatus } = usePersistStatus();
const { modalState, closeModal } = useModalState();
const pool = usePool(); const pool = usePool();
const { config } = useConfig(); const { config } = useConfig();
@@ -156,11 +167,17 @@ const Home: Component = () => {
<Notification events={myReactions()} /> <Notification events={myReactions()} />
</Column> </Column>
</div> </div>
{/* <Show when={modalState()} keyed>
<Show when={pubkey()} keyed> {(state) => (
{(pubkeyNonNull: string) => <ProfileDisplay pubkey={pubkeyNonNull} />} <Switch>
<Match when={state.type === 'Profile' && state.pubkey} keyed>
{(pubkeyNonNull: string) => (
<ProfileDisplay pubkey={pubkeyNonNull} onClose={closeModal} />
)}
</Match>
</Switch>
)}
</Show> </Show>
*/}
</div> </div>
); );
}; };

View File

@@ -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;

View File

@@ -13,6 +13,7 @@ export default defineConfig({
}, },
build: { build: {
target: 'esnext', target: 'esnext',
sourcemap: 'inline',
}, },
resolve: { resolve: {
alias: { alias: {