This commit is contained in:
Shusui MOYATANI
2023-02-24 10:05:07 +09:00
parent dadf285428
commit 717c264c2f
13 changed files with 261 additions and 58 deletions

View File

@@ -14,7 +14,7 @@
<meta property="og:image" content="" />
<meta property="og:locale" content="ja_JP">
<!-- <link rel="shortcut icon" type="image/png" href="/src/assets/icon.png" /> -->
<title>nostRabbit</title>
<title>🐰</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -44,7 +44,8 @@ const getEvents = async ({
/**
* This aims to fetch stored data, and doesn't support fetching streaming data continuously.
*
* This will be useful when you want to fetch profile or following list, reactions, and something like that.
* This is useful when you want to fetch some data which change occasionally:
* profile or following list, reactions, and something like that.
*/
const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
const pool = usePool();
@@ -52,7 +53,7 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
return createQuery(
() => {
const { relayUrls, filters, options } = propsProvider();
return ['useCachedEvents', relayUrls, filters, options];
return ['useCachedEvents', relayUrls, filters, options] as const;
},
({ queryKey, signal }) => {
const [, relayUrls, filters, options] = queryKey;

View File

@@ -1,56 +1,89 @@
import { getEventHash } from 'nostr-tools/event';
import type { Event as NostrEvent } from 'nostr-tools/event';
import type { Pub } from 'nostr-tools/relay';
import usePool from '@/clients/usePool';
type UseCommands = {
publishTextNote: (props: {
relayUrls: string[];
pubkey: string;
content: string;
}) => Promise<void>;
// NIP-20: Command Result
const waitCommandResult = (pub: Pub): Promise<void> => {
return new Promise((resolve, reject) => {
pub.on('ok', () => {
console.log(`${relayUrl} has accepted our event`);
resolve();
});
pub.on('failed', (reason: string) => {
console.log(`failed to publish to ${relayUrl}: ${reason}`);
reject(reason);
});
});
};
const useCommands = (): UseCommands => {
const useCommands = () => {
const pool = usePool();
const publishTextNote = async ({
relayUrls,
pubkey,
content,
}: {
relayUrls: string[];
pubkey: string;
content: string;
}) => {
const preSignedEvent: NostrEvent = {
kind: 1,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content,
};
const publishEvent = async (relayUrls: string[], event: NostrEvent): Promise<Promise<void>[]> => {
const preSignedEvent: NostrEvent = { ...event };
preSignedEvent.id = getEventHash(preSignedEvent);
const signedEvent = await window.nostr.signEvent(preSignedEvent);
// TODO define window.nostr
const signedEvent = (await window.nostr.signEvent(preSignedEvent)) as NostrEvent;
return relayUrls.map(async (relayUrl) => {
const relay = await pool().ensureRelay(relayUrl);
const pub = relay.publish(signedEvent);
return new Promise((resolve, reject) => {
pub.on('ok', () => {
console.log(`${relayUrl} has accepted our event`);
resolve(null);
});
pub.on('failed', (reason: any) => {
console.log(`failed to publish to ${relayUrl}: ${reason}`);
reject(reason);
});
});
return waitCommandResult(pub);
});
};
return { publishTextNote };
return {
// NIP-01
publishTextNote({
relayUrls,
pubkey,
content,
}: {
relayUrls: string[];
pubkey: string;
content: string;
}): Promise<Promise<void>[]> {
const preSignedEvent: NostrEvent = {
kind: 1,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content,
};
// TODO define window.nostr
return publishEvent(relayUrls, preSignedEvent);
},
// NIP-25
publishReaction({
relayUrls,
pubkey,
content,
eventId,
notifyPubkey,
}: {
relayUrls: string[];
pubkey: string;
content: string;
eventId: string;
notifyPubkey: string;
}): Promise<Promise<void>[]> {
// TODO ensure that content is + or - or emoji.
const preSignedEvent: NostrEvent = {
kind: 7,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', eventId],
['p', notifyPubkey],
],
content,
};
// TODO define window.nostr
return publishEvent(relayUrls, preSignedEvent);
},
};
};
export default useCommands;

View File

@@ -1,11 +1,18 @@
import { type Event as NostrEvent } from 'nostr-tools/event';
import { type Accessor } from 'solid-js';
import useCachedEvents from '@/clients/useCachedEvents';
type UseEventProps = {
export type UseEventProps = {
relayUrls: string[];
eventId: string;
};
const useEvent = (propsProvider: () => UseEventProps) => {
export type UseEvent = {
event: Accessor<NostrEvent>;
};
const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
const query = useCachedEvents(() => {
const { relayUrls, eventId } = propsProvider();
return {
@@ -20,7 +27,7 @@ const useEvent = (propsProvider: () => UseEventProps) => {
};
});
const event = () => query.data?.[0];
const event = () => query.data?.[0] as NostrEvent;
return { event };
};

View File

View File

@@ -0,0 +1,43 @@
// NIP-18 (DEPRECATED)
import { Show, type Component } from 'solid-js';
import { Event as NostrEvent } from 'nostr-tools/event';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import useConfig from '@/clients/useConfig';
import useEvent from '@/clients/useEvent';
import useProfile from '@/clients/useProfile';
import TextNote from '@/components/TextNote';
export type DeprecatedRepostProps = {
event: NostrEvent;
};
const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
const [config] = useConfig();
const pubkey = () => props.event.pubkey;
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
if (eventId() == null) {
return 'event not found';
}
const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() }));
const { event } = 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">
<ArrowPathRoundedSquare />
</div>
<div>{profile()?.display_name} Reposted</div>
</div>
<Show when={event() != null}>
<TextNote event={event()} />
</Show>
</div>
);
};
export default DeprecatedRepost;

View File

@@ -1,17 +1,24 @@
import { createMemo, Show, For } from 'solid-js';
import { Show, For, createSignal, createMemo, onMount, onCleanup } from 'solid-js';
import type { Component } from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event';
import HeartOutlined from 'heroicons/24/outline/heart.svg';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import useProfile from '@/clients/useProfile';
import useConfig from '@/clients/useConfig';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
import useDatePulser from '@/hooks/useDatePulser';
import { formatRelative } from '@/utils/formatDate';
import ColumnItem from '@/components/ColumnItem';
import GeneralUserMentionDisplay from './textNote/GeneralUserMentionDisplay';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
export type TextNoteProps = {
event: NostrEvent;
};
const TextNote: Component<TextNoteProps> = (props) => {
const currentDate = useDatePulser();
const [config] = useConfig();
const { profile: author } = useProfile(() => ({
relayUrls: config().relayUrls,
@@ -21,7 +28,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
);
// TODO 日付をいい感じにフォーマットする関数を作る
const createdAt = () => new Date(props.event.created_at * 1000).toLocaleTimeString();
const createdAt = () => formatRelative(new Date(props.event.created_at * 1000), currentDate());
return (
<div class="textnote">
@@ -43,7 +50,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
<Show when={author()?.display_name}>
<div class="author-name pr-1 font-bold">{author()?.display_name}</div>
</Show>
<div class="author-username truncate">
<div class="author-username truncate text-zinc-600">
<Show when={author()?.name} fallback={props.event.pubkey}>
@{author()?.name}
</Show>
@@ -66,6 +73,14 @@ const TextNote: Component<TextNoteProps> = (props) => {
<div class="content whitespace-pre-wrap break-all">
<TextNoteContentDisplay event={props.event} />
</div>
<div class="flex justify-evenly">
<button class="h-4 w-4 text-zinc-400">
<ArrowPathRoundedSquare />
</button>
<button class="h-4 w-4 text-zinc-400">
<HeartOutlined />
</button>
</div>
</div>
</ColumnItem>
</div>

View File

@@ -2,6 +2,7 @@ import { For, Switch, Match, type Component } from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event';
import TextNote from '@/components/TextNote';
import DeprecatedRepost from '@/components/DeprecatedRepost';
export type TimelineProps = {
events: NostrEvent[];
@@ -16,7 +17,7 @@ export const Timeline: Component<TimelineProps> = (props) => {
<TextNote event={event} />
</Match>
<Match when={event.kind === 6}>
<div>Deprecated Repost</div>
<DeprecatedRepost event={event} />
</Match>
</Switch>
)}

View File

@@ -9,7 +9,7 @@ export type TextNoteContentDisplayProps = {
event: NostrEvent;
};
export const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
return (
<For each={parseTextNote(props.event)}>
{(item: ParsedTextNoteNode) => {

View File

@@ -0,0 +1,13 @@
import { createSignal, type Accessor } from 'solid-js';
const [currentDate, setCurrentDate] = createSignal(new Date());
setInterval(() => {
setCurrentDate(new Date());
}, 10000);
const useDatePulser = (): Accessor<Date> => {
return currentDate;
};
export default useDatePulser;

View File

@@ -8,8 +8,16 @@ export type UseMessageChannelProps = {
id: typeof CommandChannel;
};
const useMessageChannel = (propsProvider: () => UseMessageChannelProps) => {
const channel = () => channels()[id];
export type MessageChannelRequest<T> = {
requestId: string;
message: T;
};
type Primitives = number | string | null;
type Serializable = Record<string, Primitives | Array<Primitives>>;
const useMessageChannel = <T extends Serializable>(propsProvider: () => UseMessageChannelProps) => {
const channel = () => channels()[propsProvider().id];
onMount(() => {
const { id } = propsProvider();
@@ -21,17 +29,18 @@ const useMessageChannel = (propsProvider: () => UseMessageChannelProps) => {
}
});
const listen = async (requestId: string, timeout = 1000) => {
const listen = async (requestId: string, timeout = 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 data = JSON.parse(event.data);
const data = JSON.parse(event.data) as MessageChannelRequest<T>;
if (data.requestId !== requestId) return;
channel().port2.removeEventListener('message', listener);
resolve(data);
resolve(data.message);
};
setTimeout(() => {
@@ -44,14 +53,15 @@ const useMessageChannel = (propsProvider: () => UseMessageChannelProps) => {
};
return {
async requst(message) {
const requestId = Math.random();
const messageStr = JSON.stringify({ ...message, requestId });
async requst(message: T) {
const requestId = Math.random().toString();
const messageStr = JSON.stringify({ message, requestId });
const response = listen(requestId, timeout);
channel().postMessage(messageStr);
return response;
},
handle(handler) {},
};
};
export default useMessageChannel;

View File

@@ -5,6 +5,7 @@ import Column from '@/components/Column';
import NotePostForm from '@/components/NotePostForm';
import SideBar from '@/components/SideBar';
import Timeline from '@/components/Timeline';
import TextNote from '@/components/TextNote';
import useCommands from '@/clients/useCommands';
import useConfig from '@/clients/useConfig';
import useSubscription from '@/clients/useSubscription';
@@ -93,6 +94,18 @@ const Home: Component = () => {
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
<div class="flex flex-row overflow-y-hidden overflow-x-scroll">
<Column name="ホーム" width="widest">
<TextNote
event={
{
id: 12345,
kind: 1,
pubkey: pubkeyHex,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
} as NostrEvent
}
/>
<Timeline events={followingsPosts()} />
</Column>
<Column name="自分の投稿" width="medium">

67
src/utils/formatDate.ts Normal file
View File

@@ -0,0 +1,67 @@
type ParsedDate =
| { kind: 'abs'; value: Date }
| { kind: 'days'; value: number }
| { kind: 'hours'; value: number }
| { kind: 'minutes'; value: number }
| { kind: 'seconds'; value: number }
| { kind: 'now' };
export type DateFormatter = (parsedDate: ParsedDate) => string;
const defaultDateFormatter = (parsedDate: ParsedDate): string => {
switch (parsedDate.kind) {
case 'abs':
return parsedDate.value.toLocaleDateString();
case 'days':
return `${parsedDate.value}d`;
case 'hours':
return `${parsedDate.value}h`;
case 'minutes':
return `${parsedDate.value}m`;
case 'seconds':
return `${parsedDate.value}s`;
case 'now':
return 'now';
default:
return '';
}
};
const calcDiffSec = (date: Date, currentDate: Date): number =>
(Number(currentDate) - Number(date)) / 1000;
const parseDateDiff = (date: Date, currentDate: Date): ParsedDate => {
const diffSec = calcDiffSec(date, currentDate);
if (diffSec < 10) {
return { kind: 'now' };
}
if (diffSec < 60) {
return { kind: 'seconds', value: Math.round(diffSec) };
}
if (diffSec < 3600) {
return { kind: 'minutes', value: Math.round(diffSec / 60) };
}
if (diffSec < 86400) {
// 1 days
return { kind: 'hours', value: Math.round(diffSec / 3600) };
}
if (diffSec < 604800) {
// 1 week
return { kind: 'days', value: Math.round(diffSec / 86400) };
}
return { kind: 'abs', value: date };
};
export const formatAbsolute = (date: Date, currentDate: Date = new Date()): string => {
if (date.getDate() === currentDate.getDate()) {
return date.toLocaleTimeString();
}
return date.toLocaleString();
};
export const formatRelative = (
date: Date,
currentDate: Date = new Date(),
formatter: DateFormatter = defaultDateFormatter,
): string => formatter(parseDateDiff(date, currentDate));