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'],
rules: {
'import/extensions': ['error', 'ignorePackages', { ts: 'never', tsx: 'never' }],
'import/extensions': [
'error',
'ignorePackages',
{
ts: 'never',
tsx: 'never',
},
],
'prettier/prettier': 'error',
},
settings: {

View File

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

View File

@@ -60,8 +60,9 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
return getEvents({ pool: pool(), relayUrls, filters, options, signal });
},
{
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 24 * 60 * 60 * 1000, // 24 hours
// 5 minutes
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 { Pub } from 'nostr-tools/relay';
import '@/types/nostr.d';
import usePool from '@/clients/usePool';
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 preSignedEvent: NostrEvent = { ...event };
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) => {
const relay = await pool().ensureRelay(relayUrl);
@@ -60,14 +64,14 @@ const useCommands = () => {
publishReaction({
relayUrls,
pubkey,
content,
eventId,
content,
notifyPubkey,
}: {
relayUrls: string[];
pubkey: string;
content: string;
eventId: string;
content: string;
notifyPubkey: string;
}): Promise<Promise<void>[]> {
// TODO ensure that content is + or - or emoji.
@@ -76,11 +80,12 @@ const useCommands = () => {
pubkey,
created_at: currentDate(),
tags: [
['e', eventId],
['e', eventId, ''],
['p', notifyPubkey],
],
content,
};
console.log(preSignedEvent);
return publishEvent(relayUrls, preSignedEvent);
},
// NIP-18
@@ -100,12 +105,9 @@ const useCommands = () => {
pubkey,
created_at: currentDate(),
tags: [
['e', eventId],
['e', eventId, ''],
['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: '',
};
return publishEvent(relayUrls, preSignedEvent);

View File

@@ -14,7 +14,6 @@ const InitialConfig: Config = {
'wss://nostr.h3z.jp/',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://brb.io',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'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 Event as NostrEvent } from 'nostr-tools/event';
import { type CreateQueryResult } from '@tanstack/solid-query';
import useCachedEvents from '@/clients/useCachedEvents';
@@ -9,7 +10,8 @@ export type UseEventProps = {
};
export type UseEvent = {
event: Accessor<NostrEvent>;
event: Accessor<NostrEvent | undefined>;
query: CreateQueryResult<NostrEvent[]>;
};
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;

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';
type UseProfileProps = {
@@ -23,7 +27,12 @@ type 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 { relayUrls, pubkey } = propsProvider();
return {
@@ -40,13 +49,13 @@ const useProfile = (propsProvider: () => UseProfileProps) => {
const profile = () => {
const maybeProfile = query.data?.[0];
if (maybeProfile == null) return null;
if (maybeProfile == null) return undefined;
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
return JSON.parse(maybeProfile.content) as Profile;
};
return { profile };
return { profile, query };
};
export default useProfile;

View File

@@ -1,11 +1,17 @@
import '@/types/nostr.d';
import { createSignal, onMount, type Accessor } from 'solid-js';
const usePubkey = (): Accessor<string | undefined> => {
const [pubkey, setPubkey] = createSignal<string | undefined>(undefined);
let asking = false;
const [pubkey, setPubkey] = createSignal<string | undefined>(undefined);
const usePubkey = (): Accessor<string | undefined> => {
onMount(() => {
if (window.nostr != null) {
window.nostr.getPublicKey().then((pubkey) => setPubkey(pubkey));
if (window.nostr != null && pubkey() == null && !asking) {
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;
storedEvents.push(event);
} 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 };
};

View File

@@ -19,17 +19,25 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
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 (
<div>
<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 />
</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>
<Show when={event() != null} fallback={'loading'}>
</div>
<Show when={event() != null} fallback={<Show when={eventQuery.isLoading}>loading</Show>}>
<TextNote event={event()} />
</Show>
</div>

View File

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

View File

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

View File

@@ -19,31 +19,54 @@ const Reaction: Component<ReactionProps> = (props) => {
relayUrls: config().relayUrls,
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 (
// if the reacted event is not found, it should be a removed event
<Show when={!isRemoved()}>
<div>
<div class="flex gap-1 text-sm">
<div>
<div class="notification-icon flex gap-1 px-1 text-sm">
<div class="flex place-items-center">
<Switch fallback={props.event.content}>
<Match when={props.event.content === '+'}>
<span class="inline-block h-4 w-4 text-rose-400">
<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="font-bold">{profile()?.display_name}</span>
<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>
<Show when={event() != null} fallback={'loading'}>
<TextNote event={event()} />
</div>
<div class="notification-event">
<Show when={reactedEvent() != null} fallback="loading">
<TextNote event={reactedEvent()} />
</Show>
</div>
</div>
</Show>
);
};

View File

@@ -1,7 +1,7 @@
import type { Event as NostrEvent } from 'nostr-tools/event';
type EventMarker = 'reply' | 'root' | 'mention';
type TaggedEvent = {
export type EventMarker = 'reply' | 'root' | 'mention';
export type TaggedEvent = {
id: string;
relayUrl?: string;
marker: EventMarker;
@@ -9,9 +9,9 @@ type TaggedEvent = {
const eventWrapper = (event: NostrEvent) => {
return {
/**
* "replyingTo"
*/
event(): NostrEvent {
return event;
},
taggedUsers(): string[] {
const pubkeys = new Set<string>();
event.tags.forEach(([tagName, pubkey]) => {
@@ -24,6 +24,7 @@ const eventWrapper = (event: NostrEvent) => {
taggedEvents(): TaggedEvent[] {
const events = event.tags.filter(([tagName]) => tagName === 'e');
// NIP-10: Positional "e" tags (DEPRECATED)
const positionToMarker = (index: number): EventMarker => {
// One "e" tag
if (events.length === 1) return 'reply';
@@ -32,9 +33,9 @@ const eventWrapper = (event: NostrEvent) => {
// Two "e" tags
if (events.length === 2) return 'reply';
// Many "e" tags
// Last one is reply.
// The last one is reply.
if (index === events.length - 1) return 'reply';
// other ones are mentions.
// The rest are mentions.
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> = {
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
@@ -24,9 +29,9 @@ type Clonable =
| Array<Clonable>
| Record<string, Clonable>;
const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageChannelProps) => {
const channel = () => channels()[propsProvider().id];
const useMessageBus = <Req extends Clonable, Res extends Clonable>(
propsProvider: () => UseMessageChannelProps,
) => {
onMount(() => {
const { id } = propsProvider();
if (channel() == null) {
@@ -37,40 +42,50 @@ const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageCh
}
});
const listen = async (requestId: string, timeoutMs = 1000): Promise<T> => {
return new Promise((resolve, reject) => {
const listener = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (typeof event.data !== 'string') return;
const channel = () => channels()[propsProvider().id];
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;
channel().port2.removeEventListener('message', listener);
resolve(data.message);
channel().port1.removeEventListener('message', listener);
resolve(data.response);
};
setTimeout(() => {
channel().port2.removeEventListener('message', listener);
channel().port1.removeEventListener('message', listener);
reject(new Error('TimeoutError'));
}, timeoutMs);
channel().port2.addEventListener('message', listener, false);
channel().port2.start();
channel().port1.addEventListener('message', listener, false);
channel().port1.start();
});
};
const sendResponse = (res: Res) => {};
return {
async requst(message: T) {
async requst(message: Req): Promise<Res> {
const requestId = Math.random().toString();
const messageStr = JSON.stringify({ message, requestId });
const response = listen(requestId, timeoutMs);
channel().postMessage(messageStr);
const response = waitResponse(requestId);
sendRequest(requestId, message);
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;
// 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 };
@@ -54,7 +54,9 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
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],
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
],
}));
@@ -135,7 +134,7 @@ const Home: Component = () => {
<Column name="通知" width="medium">
<Notification events={notifications()} />
</Column>
<Column name="ローカル" width="medium">
<Column name="日本サーバ" width="medium">
<Timeline events={localTimeline()} />
</Column>
<Column name="自分の投稿" width="medium">

View File

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

View File

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