mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
update
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
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 ({
|
||||
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;
|
||||
|
||||
return relayUrls.map(async (relayUrl) => {
|
||||
const relay = await pool().ensureRelay(relayUrl);
|
||||
const pub = relay.publish(signedEvent);
|
||||
return waitCommandResult(pub);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// NIP-01
|
||||
publishTextNote({
|
||||
relayUrls,
|
||||
pubkey,
|
||||
content,
|
||||
@@ -22,7 +44,7 @@ const useCommands = (): UseCommands => {
|
||||
relayUrls: string[];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
}) => {
|
||||
}): Promise<Promise<void>[]> {
|
||||
const preSignedEvent: NostrEvent = {
|
||||
kind: 1,
|
||||
pubkey,
|
||||
@@ -30,27 +52,38 @@ const useCommands = (): UseCommands => {
|
||||
tags: [],
|
||||
content,
|
||||
};
|
||||
preSignedEvent.id = getEventHash(preSignedEvent);
|
||||
const signedEvent = await window.nostr.signEvent(preSignedEvent);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
|
||||
return { publishTextNote };
|
||||
};
|
||||
|
||||
export default useCommands;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
0
src/clients/useReactions.ts
Normal file
0
src/clients/useReactions.ts
Normal file
43
src/components/DeprecatedRepost.tsx
Normal file
43
src/components/DeprecatedRepost.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
13
src/hooks/useDatePulser.ts
Normal file
13
src/hooks/useDatePulser.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
67
src/utils/formatDate.ts
Normal 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));
|
||||
Reference in New Issue
Block a user