This commit is contained in:
Shusui MOYATANI
2023-03-01 00:31:04 +09:00
parent 57bc321436
commit 471b03eb1d
20 changed files with 279 additions and 118 deletions

View File

@@ -25,7 +25,14 @@ module.exports = {
}, },
plugins: ['import', 'solid', 'jsx-a11y', 'prettier', '@typescript-eslint', 'tailwindcss'], plugins: ['import', 'solid', 'jsx-a11y', 'prettier', '@typescript-eslint', 'tailwindcss'],
rules: { rules: {
'import/extensions': ['error', 'ignorePackages', { ts: 'never', tsx: 'never' }], 'import/extensions': [
'error',
'ignorePackages',
{
ts: 'never',
tsx: 'never',
},
],
'prettier/prettier': 'error', 'prettier/prettier': 'error',
}, },
settings: { settings: {

View File

@@ -1,5 +1,5 @@
{ {
"name": "nostiger", "name": "rabbit",
"version": "0.0.0", "version": "0.0.0",
"description": "", "description": "",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",

View File

@@ -60,8 +60,9 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
return getEvents({ pool: pool(), relayUrls, filters, options, signal }); return getEvents({ pool: pool(), relayUrls, filters, options, signal });
}, },
{ {
staleTime: 5 * 60 * 1000, // 5 minutes // 5 minutes
cacheTime: 24 * 60 * 60 * 1000, // 24 hours staleTime: 5 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
}, },
); );
}; };

View File

@@ -2,6 +2,7 @@ import { getEventHash } from 'nostr-tools/event';
import type { Event as NostrEvent } from 'nostr-tools/event'; import type { Event as NostrEvent } from 'nostr-tools/event';
import type { Pub } from 'nostr-tools/relay'; import type { Pub } from 'nostr-tools/relay';
import '@/types/nostr.d';
import usePool from '@/clients/usePool'; import usePool from '@/clients/usePool';
const currentDate = (): number => Math.floor(Date.now() / 1000); const currentDate = (): number => Math.floor(Date.now() / 1000);
@@ -26,8 +27,11 @@ const useCommands = () => {
const publishEvent = async (relayUrls: string[], event: NostrEvent): Promise<Promise<void>[]> => { const publishEvent = async (relayUrls: string[], event: NostrEvent): Promise<Promise<void>[]> => {
const preSignedEvent: NostrEvent = { ...event }; const preSignedEvent: NostrEvent = { ...event };
preSignedEvent.id = getEventHash(preSignedEvent); preSignedEvent.id = getEventHash(preSignedEvent);
// TODO define window.nostr
const signedEvent = (await window.nostr.signEvent(preSignedEvent)) as NostrEvent; if (window.nostr == null) {
throw new Error('NIP-07 implementation not found');
}
const signedEvent = await window.nostr.signEvent(preSignedEvent);
return relayUrls.map(async (relayUrl) => { return relayUrls.map(async (relayUrl) => {
const relay = await pool().ensureRelay(relayUrl); const relay = await pool().ensureRelay(relayUrl);
@@ -60,14 +64,14 @@ const useCommands = () => {
publishReaction({ publishReaction({
relayUrls, relayUrls,
pubkey, pubkey,
content,
eventId, eventId,
content,
notifyPubkey, notifyPubkey,
}: { }: {
relayUrls: string[]; relayUrls: string[];
pubkey: string; pubkey: string;
content: string;
eventId: string; eventId: string;
content: string;
notifyPubkey: string; notifyPubkey: string;
}): Promise<Promise<void>[]> { }): Promise<Promise<void>[]> {
// TODO ensure that content is + or - or emoji. // TODO ensure that content is + or - or emoji.
@@ -76,11 +80,12 @@ const useCommands = () => {
pubkey, pubkey,
created_at: currentDate(), created_at: currentDate(),
tags: [ tags: [
['e', eventId], ['e', eventId, ''],
['p', notifyPubkey], ['p', notifyPubkey],
], ],
content, content,
}; };
console.log(preSignedEvent);
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);
}, },
// NIP-18 // NIP-18
@@ -100,12 +105,9 @@ const useCommands = () => {
pubkey, pubkey,
created_at: currentDate(), created_at: currentDate(),
tags: [ tags: [
['e', eventId], ['e', eventId, ''],
['p', notifyPubkey], ['p', notifyPubkey],
], ],
// Some clients includes some contents here.
// Damus includes an original event. Iris includes #[0] as a mention.
// We just follow the specification.
content: '', content: '',
}; };
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);

View File

@@ -14,7 +14,6 @@ const InitialConfig: Config = {
'wss://nostr.h3z.jp/', 'wss://nostr.h3z.jp/',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nos.lol', 'wss://nos.lol',
'wss://brb.io',
'wss://relay.snort.social', 'wss://relay.snort.social',
'wss://relay.current.fyi', 'wss://relay.current.fyi',
'wss://relay.nostr.wirednet.jp', 'wss://relay.nostr.wirednet.jp',

View File

@@ -1,5 +1,6 @@
import { type Event as NostrEvent } from 'nostr-tools/event';
import { type Accessor } from 'solid-js'; import { type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import { type CreateQueryResult } from '@tanstack/solid-query';
import useCachedEvents from '@/clients/useCachedEvents'; import useCachedEvents from '@/clients/useCachedEvents';
@@ -9,7 +10,8 @@ export type UseEventProps = {
}; };
export type UseEvent = { export type UseEvent = {
event: Accessor<NostrEvent>; event: Accessor<NostrEvent | undefined>;
query: CreateQueryResult<NostrEvent[]>;
}; };
const useEvent = (propsProvider: () => UseEventProps): UseEvent => { const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
@@ -27,9 +29,9 @@ const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
}; };
}); });
const event = () => query.data?.[0] as NostrEvent; const event = () => query.data?.[0];
return { event }; return { event, query };
}; };
export default useEvent; export default useEvent;

View File

@@ -1,3 +1,7 @@
import { type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import { type CreateQueryResult } from '@tanstack/solid-query';
import useCachedEvents from '@/clients/useCachedEvents'; import useCachedEvents from '@/clients/useCachedEvents';
type UseProfileProps = { type UseProfileProps = {
@@ -23,7 +27,12 @@ type NonStandardProfile = {
type Profile = StandardProfile & NonStandardProfile; type Profile = StandardProfile & NonStandardProfile;
const useProfile = (propsProvider: () => UseProfileProps) => { type UseProfile = {
profile: Accessor<Profile | undefined>;
query: CreateQueryResult<NostrEvent[]>;
};
const useProfile = (propsProvider: () => UseProfileProps): UseProfile => {
const query = useCachedEvents(() => { const query = useCachedEvents(() => {
const { relayUrls, pubkey } = propsProvider(); const { relayUrls, pubkey } = propsProvider();
return { return {
@@ -40,13 +49,13 @@ const useProfile = (propsProvider: () => UseProfileProps) => {
const profile = () => { const profile = () => {
const maybeProfile = query.data?.[0]; const maybeProfile = query.data?.[0];
if (maybeProfile == null) return null; if (maybeProfile == null) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック // TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
return JSON.parse(maybeProfile.content) as Profile; return JSON.parse(maybeProfile.content) as Profile;
}; };
return { profile }; return { profile, query };
}; };
export default useProfile; export default useProfile;

View File

@@ -1,11 +1,17 @@
import '@/types/nostr.d';
import { createSignal, onMount, type Accessor } from 'solid-js'; import { createSignal, onMount, type Accessor } from 'solid-js';
const usePubkey = (): Accessor<string | undefined> => { let asking = false;
const [pubkey, setPubkey] = createSignal<string | undefined>(undefined); const [pubkey, setPubkey] = createSignal<string | undefined>(undefined);
const usePubkey = (): Accessor<string | undefined> => {
onMount(() => { onMount(() => {
if (window.nostr != null) { if (window.nostr != null && pubkey() == null && !asking) {
window.nostr.getPublicKey().then((pubkey) => setPubkey(pubkey)); asking = true;
window.nostr
.getPublicKey()
.then((key) => setPubkey(key))
.catch((err) => console.error(`failed to obtain public key: ${err}`));
} }
}); });

View File

@@ -0,0 +1,51 @@
import { type Accessor } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import { type CreateQueryResult } from '@tanstack/solid-query';
import useCachedEvents from '@/clients/useCachedEvents';
export type UseEventProps = {
relayUrls: string[];
eventId: string;
};
export type UseEvent = {
reactions: Accessor<NostrEvent[]>;
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
isReactedBy(pubkey: string): boolean;
query: CreateQueryResult<NostrEvent[]>;
};
const useReactions = (propsProvider: () => UseEventProps): UseEvent => {
const query = useCachedEvents(() => {
const { relayUrls, eventId } = propsProvider();
return {
relayUrls,
filters: [
{
'#e': [eventId],
kinds: [7],
},
],
};
});
const reactions = () => query.data ?? [];
const reactionsGroupedByContent = () => {
const result = new Map<string, NostrEvent[]>();
reactions().forEach((event) => {
const events = result.get(event.content) ?? [];
events.push(event);
result.set(event.content, events);
});
return result;
};
const isReactedBy = (pubkey: string): boolean =>
reactions().findIndex((event) => event.pubkey === pubkey) !== -1;
return { reactions, reactionsGroupedByContent, isReactedBy, query };
};
export default useReactions;

View File

@@ -33,7 +33,8 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
pushed = true; pushed = true;
storedEvents.push(event); storedEvents.push(event);
} else { } else {
setEvents((prevEvents) => sortEvents([event, ...prevEvents])); // いったん1000件だけ保持
setEvents((prevEvents) => sortEvents([event, ...prevEvents].slice(0, 1000)));
} }
}); });
@@ -60,10 +61,6 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
}); });
}); });
createEffect(() => {
console.log(events());
});
return { events }; return { events };
}; };

View File

@@ -19,17 +19,25 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1]; const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() })); const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() }));
const { event } = useEvent(() => ({ relayUrls: config().relayUrls, eventId: eventId() })); const { event, query: eventQuery } = useEvent(() => ({
relayUrls: config().relayUrls,
eventId: eventId(),
}));
return ( return (
<div> <div>
<div class="flex content-center px-1 text-xs"> <div class="flex content-center px-1 text-xs">
<div class="h-5 w-5 pr-1 text-green-500" aria-hidden="true"> <div class="h-5 w-5 shrink-0 pr-1 text-green-500" aria-hidden="true">
<ArrowPathRoundedSquare /> <ArrowPathRoundedSquare />
</div> </div>
<div>{profile()?.display_name} Reposted</div> <div class="truncate">
<Show when={(profile()?.display_name?.length ?? 0) > 0} fallback={props.event.pubkey}>
{profile()?.display_name}
</Show>
{' Reposted'}
</div>
</div> </div>
<Show when={event() != null} fallback={'loading'}> <Show when={event() != null} fallback={<Show when={eventQuery.isLoading}>loading</Show>}>
<TextNote event={event()} /> <TextNote event={event()} />
</Show> </Show>
</div> </div>

View File

@@ -8,14 +8,17 @@ type NotePostFormProps = {
const NotePostForm: Component<NotePostFormProps> = (props) => { const NotePostForm: Component<NotePostFormProps> = (props) => {
const [text, setText] = createSignal<string>(''); const [text, setText] = createSignal<string>('');
const clearText = () => setText('');
const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => { const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => {
setText(ev.currentTarget.value); setText(ev.currentTarget.value);
}; };
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => { const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault(); ev.preventDefault();
// TODO 投稿完了したかどうかの検知をしたい
props.onPost({ content: text() }); props.onPost({ content: text() });
// TODO 投稿完了したらなんかする clearText();
}; };
const submitDisabled = createMemo(() => text().trim().length === 0); const submitDisabled = createMemo(() => text().trim().length === 0);

View File

@@ -1,23 +1,17 @@
import { import { Show, For, createMemo, type JSX, type Component } from 'solid-js';
Show,
For,
createSignal,
createMemo,
onMount,
onCleanup,
type JSX,
Component,
} from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event'; import type { Event as NostrEvent } from 'nostr-tools/event';
import HeartOutlined from 'heroicons/24/outline/heart.svg'; import HeartOutlined from 'heroicons/24/outline/heart.svg';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg'; import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg'; import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg'; import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import useProfile from '@/clients/useProfile'; import useProfile from '@/clients/useProfile';
import useConfig from '@/clients/useConfig'; import useConfig from '@/clients/useConfig';
import usePubkey from '@/clients/usePubkey';
import useCommands from '@/clients/useCommands'; import useCommands from '@/clients/useCommands';
import useReactions from '@/clients/useReactions';
import useDatePulser from '@/hooks/useDatePulser'; import useDatePulser from '@/hooks/useDatePulser';
import { formatRelative } from '@/utils/formatDate'; import { formatRelative } from '@/utils/formatDate';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
@@ -32,11 +26,24 @@ const TextNote: Component<TextNoteProps> = (props) => {
const currentDate = useDatePulser(); const currentDate = useDatePulser();
const [config] = useConfig(); const [config] = useConfig();
const commands = useCommands(); const commands = useCommands();
const pubkey = usePubkey();
const { profile: author } = useProfile(() => ({ const { profile: author } = useProfile(() => ({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
pubkey: props.event.pubkey, pubkey: props.event.pubkey,
})); }));
const {
reactions,
isReactedBy,
query: reactionsQuery,
} = useReactions(() => ({
relayUrls: config().relayUrls,
eventId: props.event.id,
}));
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
const replyingToPubKeys = createMemo(() => const replyingToPubKeys = createMemo(() =>
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]), props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
); );
@@ -45,16 +52,31 @@ const TextNote: Component<TextNoteProps> = (props) => {
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.preventDefault(); ev.preventDefault();
commands.publishDeprecatedRepost({}); commands.publishDeprecatedRepost({
relayUrls: config().relayUrls,
pubkey: pubkey(),
eventId: props.event.id,
notifyPubkey: props.event.pubkey,
});
}; };
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
if (isReactedByMe()) {
// TODO remove reaction
return;
}
ev.preventDefault(); ev.preventDefault();
commands.publishReaction({ commands
relayUrls: config().relayUrls, .publishReaction({
pubkey: pubkeyHex, relayUrls: config().relayUrls,
eventId: props.event.id, pubkey: pubkey(),
}); content: '+',
eventId: props.event.id,
notifyPubkey: props.event.pubkey,
})
.then(() => {
reactionsQuery.refetch();
});
}; };
return ( return (
@@ -74,8 +96,8 @@ const TextNote: Component<TextNoteProps> = (props) => {
<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"> <div class="author flex min-w-0 truncate">
{/* TODO link to author */} {/* TODO link to author */}
<Show when={author()?.display_name != null && author()?.display_name.length > 0}> <Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name 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} fallback={props.event.pubkey}> <Show when={author()?.name} fallback={props.event.pubkey}>
@@ -89,9 +111,9 @@ const TextNote: Component<TextNoteProps> = (props) => {
<div class="text-xs"> <div class="text-xs">
{'Replying to '} {'Replying to '}
<For each={replyingToPubKeys()}> <For each={replyingToPubKeys()}>
{(pubkey: string) => ( {(replyToPubkey: string) => (
<span class="pr-1 text-blue-500 underline"> <span class="pr-1 text-blue-500 underline">
<GeneralUserMentionDisplay pubkey={pubkey} /> <GeneralUserMentionDisplay pubkey={replyToPubkey} />
</span> </span>
)} )}
</For> </For>
@@ -100,16 +122,27 @@ const TextNote: Component<TextNoteProps> = (props) => {
<div class="content whitespace-pre-wrap break-all"> <div class="content whitespace-pre-wrap break-all">
<TextNoteContentDisplay event={props.event} embedding={true} /> <TextNoteContentDisplay event={props.event} embedding={true} />
</div> </div>
<div class="flex justify-end gap-16"> <div class="flex w-48 items-center justify-between gap-8 pt-1">
<button class="h-4 w-4 text-zinc-400"> <button class="h-4 w-4 text-zinc-400">
<ChatBubbleLeft /> <ChatBubbleLeft />
</button> </button>
<button class="h-4 w-4 text-zinc-400" onClick={handleRepost}> <button class="h-4 w-4 text-zinc-400" onClick={handleRepost}>
<ArrowPathRoundedSquare /> <ArrowPathRoundedSquare />
</button> </button>
<button class="h-4 w-4 text-zinc-400" onClick={handleReaction}> <div
<HeartOutlined /> class="flex items-center gap-1"
</button> classList={{
'text-zinc-400': !isReactedByMe(),
'text-rose-400': isReactedByMe(),
}}
>
<button class="h-4 w-4" onClick={handleReaction}>
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
<div class="text-sm text-zinc-400">{reactions().length}</div>
</div>
<button class="h-4 w-4 text-zinc-400"> <button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal /> <EllipsisHorizontal />
</button> </button>

View File

@@ -19,31 +19,54 @@ const Reaction: Component<ReactionProps> = (props) => {
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
pubkey: props.event.pubkey, pubkey: props.event.pubkey,
})); }));
const { event } = useEvent(() => ({ relayUrls: config().relayUrls, eventId: eventId() })); const { event: reactedEvent, query: reactedEventQuery } = useEvent(() => ({
relayUrls: config().relayUrls,
eventId: eventId(),
}));
const isRemoved = () => reactedEventQuery.isSuccess && reactedEvent() == null;
return ( return (
<div> // if the reacted event is not found, it should be a removed event
<div class="flex gap-1 text-sm"> <Show when={!isRemoved()}>
<div>
<Switch fallback={props.event.content}>
<Match when={props.event.content === '+'}>
<span class="inline-block h-4 w-4 text-rose-400">
<HeartSolid />
</span>
</Match>
</Switch>
</div>
<div>
<span class="font-bold">{profile()?.display_name}</span>
{' reacted'}
</div>
</div>
<div> <div>
<Show when={event() != null} fallback={'loading'}> <div class="notification-icon flex gap-1 px-1 text-sm">
<TextNote event={event()} /> <div class="flex place-items-center">
</Show> <Switch fallback={props.event.content}>
<Match when={props.event.content === '+'}>
<span class="h-4 w-4 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Match>
</Switch>
</div>
<div class="notification-user flex gap-1 pt-1">
<div class="author-icon h-5 w-5 shrink-0">
<Show when={profile()?.picture != null}>
<img
src={profile()?.picture}
alt="icon"
// TODO autofit
class="rounded"
/>
</Show>
</div>
<div>
<span class="truncate whitespace-pre-wrap break-all font-bold">
<Show when={profile() != null} fallback={props.event.pubkey}>
{profile()?.display_name}
</Show>
</span>
{' reacted'}
</div>
</div>
</div>
<div class="notification-event">
<Show when={reactedEvent() != null} fallback="loading">
<TextNote event={reactedEvent()} />
</Show>
</div>
</div> </div>
</div> </Show>
); );
}; };

View File

@@ -1,7 +1,7 @@
import type { Event as NostrEvent } from 'nostr-tools/event'; import type { Event as NostrEvent } from 'nostr-tools/event';
type EventMarker = 'reply' | 'root' | 'mention'; export type EventMarker = 'reply' | 'root' | 'mention';
type TaggedEvent = { export type TaggedEvent = {
id: string; id: string;
relayUrl?: string; relayUrl?: string;
marker: EventMarker; marker: EventMarker;
@@ -9,9 +9,9 @@ type TaggedEvent = {
const eventWrapper = (event: NostrEvent) => { const eventWrapper = (event: NostrEvent) => {
return { return {
/** event(): NostrEvent {
* "replyingTo" return event;
*/ },
taggedUsers(): string[] { taggedUsers(): string[] {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
event.tags.forEach(([tagName, pubkey]) => { event.tags.forEach(([tagName, pubkey]) => {
@@ -24,6 +24,7 @@ const eventWrapper = (event: NostrEvent) => {
taggedEvents(): TaggedEvent[] { taggedEvents(): TaggedEvent[] {
const events = event.tags.filter(([tagName]) => tagName === 'e'); const events = event.tags.filter(([tagName]) => tagName === 'e');
// NIP-10: Positional "e" tags (DEPRECATED)
const positionToMarker = (index: number): EventMarker => { const positionToMarker = (index: number): EventMarker => {
// One "e" tag // One "e" tag
if (events.length === 1) return 'reply'; if (events.length === 1) return 'reply';
@@ -32,9 +33,9 @@ const eventWrapper = (event: NostrEvent) => {
// Two "e" tags // Two "e" tags
if (events.length === 2) return 'reply'; if (events.length === 2) return 'reply';
// Many "e" tags // Many "e" tags
// Last one is reply. // The last one is reply.
if (index === events.length - 1) return 'reply'; if (index === events.length - 1) return 'reply';
// other ones are mentions. // The rest are mentions.
return 'mention'; return 'mention';
}; };
@@ -55,3 +56,5 @@ const eventWrapper = (event: NostrEvent) => {
}, },
}; };
}; };
export default eventWrapper;

View File

@@ -10,7 +10,12 @@ export type UseMessageChannelProps = {
export type MessageChannelRequest<T> = { export type MessageChannelRequest<T> = {
requestId: string; requestId: string;
message: T; request: T;
};
export type MessageChannelResponse<T> = {
requestId: string;
response: T;
}; };
// https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm // https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
@@ -24,9 +29,9 @@ type Clonable =
| Array<Clonable> | Array<Clonable>
| Record<string, Clonable>; | Record<string, Clonable>;
const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageChannelProps) => { const useMessageBus = <Req extends Clonable, Res extends Clonable>(
const channel = () => channels()[propsProvider().id]; propsProvider: () => UseMessageChannelProps,
) => {
onMount(() => { onMount(() => {
const { id } = propsProvider(); const { id } = propsProvider();
if (channel() == null) { if (channel() == null) {
@@ -37,40 +42,50 @@ const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageCh
} }
}); });
const listen = async (requestId: string, timeoutMs = 1000): Promise<T> => { const channel = () => channels()[propsProvider().id];
return new Promise((resolve, reject) => {
const listener = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (typeof event.data !== 'string') return;
const data = JSON.parse(event.data) as MessageChannelRequest<T>; const sendRequest = (requestId: string, message: Req) => {
const request: MessageChannelRequest<Req> = { requestId, request: message };
const messageStr = JSON.stringify(request);
channel().port1.postMessage(messageStr);
};
const waitResponse = (requestId: string, timeoutMs = 1000): Promise<Res> =>
new Promise((resolve, reject) => {
const listener = (event: MessageEvent) => {
const data = event.data as MessageChannelResponse<Res>;
if (data.requestId !== requestId) return; if (data.requestId !== requestId) return;
channel().port2.removeEventListener('message', listener); channel().port1.removeEventListener('message', listener);
resolve(data.message); resolve(data.response);
}; };
setTimeout(() => { setTimeout(() => {
channel().port2.removeEventListener('message', listener); channel().port1.removeEventListener('message', listener);
reject(new Error('TimeoutError')); reject(new Error('TimeoutError'));
}, timeoutMs); }, timeoutMs);
channel().port2.addEventListener('message', listener, false); channel().port1.addEventListener('message', listener, false);
channel().port2.start(); channel().port1.start();
}); });
};
const sendResponse = (res: Res) => {};
return { return {
async requst(message: T) { async requst(message: Req): Promise<Res> {
const requestId = Math.random().toString(); const requestId = Math.random().toString();
const messageStr = JSON.stringify({ message, requestId }); const response = waitResponse(requestId);
const response = listen(requestId, timeoutMs); sendRequest(requestId, message);
channel().postMessage(messageStr);
return response; return response;
}, },
handle(handler) {}, handle(handler: (message: Req) => Res | Promise<Res>) {
channel().port2.addEventListener('message', (ev) => {
const request = event.data as MessageChannelRequest<Req>;
const res = handler(request.request).then((res) => {});
});
},
}; };
}; };
export default useMessageChannel; export default useMessageBus;

View File

@@ -1,7 +1,7 @@
// const commands = ['openPostForm'] as const; // const commands = ['openPostForm'] as const;
// type Commands = (typeof commands)[number]; // type Commands = (typeof commands)[number];
import { onMount, type JSX } from 'solid-js'; import { onMount, onCleanup, type JSX } from 'solid-js';
type Shortcut = { key: string; command: string }; type Shortcut = { key: string; command: string };
@@ -54,7 +54,9 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown); onCleanup(() => {
window.removeEventListener('keydown', handleKeydown);
});
}); });
}; };

View File

@@ -47,7 +47,6 @@ const Home: Component = () => {
kinds: [1, 6], kinds: [1, 6],
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex], authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
limit: 25, limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
}, },
], ],
})); }));
@@ -135,7 +134,7 @@ const Home: Component = () => {
<Column name="通知" width="medium"> <Column name="通知" width="medium">
<Notification events={notifications()} /> <Notification events={notifications()} />
</Column> </Column>
<Column name="ローカル" width="medium"> <Column name="日本サーバ" width="medium">
<Timeline events={localTimeline()} /> <Timeline events={localTimeline()} />
</Column> </Column>
<Column name="自分の投稿" width="medium"> <Column name="自分の投稿" width="medium">

View File

@@ -6,7 +6,7 @@ type NostrAPI = {
/** returns a public key as hex */ /** returns a public key as hex */
getPublicKey(): Promise<string>; getPublicKey(): Promise<string>;
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */ /** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
signEvent(event: Event): Promise<NostrEvent>; signEvent(event: NostrEvent): Promise<NostrEvent>;
// Optional // Optional
@@ -22,6 +22,8 @@ type NostrAPI = {
}; };
}; };
interface Window { declare global {
nostr?: NostrAPI; interface Window {
nostr?: NostrAPI;
}
} }

View File

@@ -20,7 +20,6 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"],
"*": ["types/*"]
}, },
"incremental": true "incremental": true
}, },