diff --git a/index.html b/index.html index 39ffe1b..642850a 100644 --- a/index.html +++ b/index.html @@ -14,7 +14,7 @@ - nostRabbit + ๐Ÿฐ diff --git a/src/clients/useCachedEvents.ts b/src/clients/useCachedEvents.ts index a07d3d1..b0c9471 100644 --- a/src/clients/useCachedEvents.ts +++ b/src/clients/useCachedEvents.ts @@ -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; diff --git a/src/clients/useCommands.ts b/src/clients/useCommands.ts index 382d363..82a88fd 100644 --- a/src/clients/useCommands.ts +++ b/src/clients/useCommands.ts @@ -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; +// NIP-20: Command Result +const waitCommandResult = (pub: Pub): Promise => { + 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[]> => { + 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[]> { + 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[]> { + // 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; diff --git a/src/clients/useEvent.ts b/src/clients/useEvent.ts index 36722b7..eeb92cd 100644 --- a/src/clients/useEvent.ts +++ b/src/clients/useEvent.ts @@ -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; +}; + +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 }; }; diff --git a/src/clients/useReactions.ts b/src/clients/useReactions.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/DeprecatedRepost.tsx b/src/components/DeprecatedRepost.tsx new file mode 100644 index 0000000..c7b52d9 --- /dev/null +++ b/src/components/DeprecatedRepost.tsx @@ -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 = (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 ( +
+
+ +
{profile()?.display_name} Reposted
+
+ + + +
+ ); +}; + +export default DeprecatedRepost; diff --git a/src/components/TextNote.tsx b/src/components/TextNote.tsx index a49d270..bec4014 100644 --- a/src/components/TextNote.tsx +++ b/src/components/TextNote.tsx @@ -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 = (props) => { + const currentDate = useDatePulser(); const [config] = useConfig(); const { profile: author } = useProfile(() => ({ relayUrls: config().relayUrls, @@ -21,7 +28,7 @@ const TextNote: Component = (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 (
@@ -43,7 +50,7 @@ const TextNote: Component = (props) => {
{author()?.display_name}
-
+
@{author()?.name} @@ -66,6 +73,14 @@ const TextNote: Component = (props) => {
+
+ + +
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 022ddfb..b17585b 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -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 = (props) => { -
Deprecated Repost
+
)} diff --git a/src/components/textNote/TextNoteContentDisplay.tsx b/src/components/textNote/TextNoteContentDisplay.tsx index 8b13fcc..e4916c9 100644 --- a/src/components/textNote/TextNoteContentDisplay.tsx +++ b/src/components/textNote/TextNoteContentDisplay.tsx @@ -9,7 +9,7 @@ export type TextNoteContentDisplayProps = { event: NostrEvent; }; -export const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { +const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { return ( {(item: ParsedTextNoteNode) => { diff --git a/src/hooks/useDatePulser.ts b/src/hooks/useDatePulser.ts new file mode 100644 index 0000000..ced3d50 --- /dev/null +++ b/src/hooks/useDatePulser.ts @@ -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 => { + return currentDate; +}; + +export default useDatePulser; diff --git a/src/hooks/useMessageBus.ts b/src/hooks/useMessageBus.ts index 2fa1341..32da12a 100644 --- a/src/hooks/useMessageBus.ts +++ b/src/hooks/useMessageBus.ts @@ -8,8 +8,16 @@ export type UseMessageChannelProps = { id: typeof CommandChannel; }; -const useMessageChannel = (propsProvider: () => UseMessageChannelProps) => { - const channel = () => channels()[id]; +export type MessageChannelRequest = { + requestId: string; + message: T; +}; + +type Primitives = number | string | null; +type Serializable = Record>; + +const useMessageChannel = (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 => { 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; 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; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index de22e50..7afb4aa 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 = () => { } />
+ diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts new file mode 100644 index 0000000..1a7d5cb --- /dev/null +++ b/src/utils/formatDate.ts @@ -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));