diff --git a/.eslintrc.js b/.eslintrc.js index 27daaf2..0100d24 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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: { diff --git a/package.json b/package.json index 3ddc811..7ca1762 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "nostiger", + "name": "rabbit", "version": "0.0.0", "description": "", "license": "AGPL-3.0-or-later", diff --git a/src/clients/useCachedEvents.ts b/src/clients/useCachedEvents.ts index 617ce39..8257471 100644 --- a/src/clients/useCachedEvents.ts +++ b/src/clients/useCachedEvents.ts @@ -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, }, ); }; diff --git a/src/clients/useCommands.ts b/src/clients/useCommands.ts index 724c085..fddc9a2 100644 --- a/src/clients/useCommands.ts +++ b/src/clients/useCommands.ts @@ -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[]> => { 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[]> { // 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); diff --git a/src/clients/useConfig.ts b/src/clients/useConfig.ts index 7603a81..67cd591 100644 --- a/src/clients/useConfig.ts +++ b/src/clients/useConfig.ts @@ -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', diff --git a/src/clients/useEvent.ts b/src/clients/useEvent.ts index eeb92cd..1357930 100644 --- a/src/clients/useEvent.ts +++ b/src/clients/useEvent.ts @@ -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; + event: Accessor; + query: CreateQueryResult; }; 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; diff --git a/src/clients/useProfile.ts b/src/clients/useProfile.ts index de7a8b6..57af445 100644 --- a/src/clients/useProfile.ts +++ b/src/clients/useProfile.ts @@ -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; + query: CreateQueryResult; +}; + +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; diff --git a/src/clients/usePubkey.ts b/src/clients/usePubkey.ts index 0159591..b12aeae 100644 --- a/src/clients/usePubkey.ts +++ b/src/clients/usePubkey.ts @@ -1,11 +1,17 @@ +import '@/types/nostr.d'; import { createSignal, onMount, type Accessor } from 'solid-js'; -const usePubkey = (): Accessor => { - const [pubkey, setPubkey] = createSignal(undefined); +let asking = false; +const [pubkey, setPubkey] = createSignal(undefined); +const usePubkey = (): Accessor => { 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}`)); } }); diff --git a/src/clients/useReactions.ts b/src/clients/useReactions.ts new file mode 100644 index 0000000..e65b677 --- /dev/null +++ b/src/clients/useReactions.ts @@ -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; + reactionsGroupedByContent: Accessor>; + isReactedBy(pubkey: string): boolean; + query: CreateQueryResult; +}; + +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(); + 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; diff --git a/src/clients/useSubscription.ts b/src/clients/useSubscription.ts index 5ea63f2..6bea6a8 100644 --- a/src/clients/useSubscription.ts +++ b/src/clients/useSubscription.ts @@ -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 }; }; diff --git a/src/components/DeprecatedRepost.tsx b/src/components/DeprecatedRepost.tsx index cdcd36a..e2dcc99 100644 --- a/src/components/DeprecatedRepost.tsx +++ b/src/components/DeprecatedRepost.tsx @@ -19,17 +19,25 @@ const DeprecatedRepost: Component = (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 (
-
- diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index ed26c28..64d7a5d 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -8,14 +8,17 @@ type NotePostFormProps = { const NotePostForm: Component = (props) => { const [text, setText] = createSignal(''); + const clearText = () => setText(''); + const handleChangeText: JSX.EventHandler = (ev) => { setText(ev.currentTarget.value); }; const handleSubmit: JSX.EventHandler = (ev) => { ev.preventDefault(); + // TODO 投稿完了したかどうかの検知をしたい props.onPost({ content: text() }); - // TODO 投稿完了したらなんかする + clearText(); }; const submitDisabled = createMemo(() => text().trim().length === 0); diff --git a/src/components/TextNote.tsx b/src/components/TextNote.tsx index f935fbf..e9d77eb 100644 --- a/src/components/TextNote.tsx +++ b/src/components/TextNote.tsx @@ -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 = (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,16 +52,31 @@ const TextNote: Component = (props) => { const handleRepost: JSX.EventHandler = (ev) => { ev.preventDefault(); - commands.publishDeprecatedRepost({}); + commands.publishDeprecatedRepost({ + relayUrls: config().relayUrls, + pubkey: pubkey(), + eventId: props.event.id, + notifyPubkey: props.event.pubkey, + }); }; const handleReaction: JSX.EventHandler = (ev) => { + if (isReactedByMe()) { + // TODO remove reaction + return; + } ev.preventDefault(); - commands.publishReaction({ - relayUrls: config().relayUrls, - pubkey: pubkeyHex, - eventId: props.event.id, - }); + commands + .publishReaction({ + relayUrls: config().relayUrls, + pubkey: pubkey(), + content: '+', + eventId: props.event.id, + notifyPubkey: props.event.pubkey, + }) + .then(() => { + reactionsQuery.refetch(); + }); }; return ( @@ -74,8 +96,8 @@ const TextNote: Component = (props) => {
{/* TODO link to author */} - 0}> -
{author()?.display_name}
+ 0}> +
{author()?.display_name}
@@ -89,9 +111,9 @@ const TextNote: Component = (props) => {
{'Replying to '} - {(pubkey: string) => ( + {(replyToPubkey: string) => ( - + )} @@ -100,16 +122,27 @@ const TextNote: Component = (props) => {
-
+
- +
+ +
{reactions().length}
+
diff --git a/src/components/notification/Reaction.tsx b/src/components/notification/Reaction.tsx index 3ddb521..574ec4c 100644 --- a/src/components/notification/Reaction.tsx +++ b/src/components/notification/Reaction.tsx @@ -19,31 +19,54 @@ const Reaction: Component = (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 ( -
-
-
- - - - - - - -
-
- {profile()?.display_name} - {' reacted'} -
-
+ // if the reacted event is not found, it should be a removed event +
- - - +
+
+ + + + + + + +
+
+
+ + icon + +
+
+ + + {profile()?.display_name} + + + {' reacted'} +
+
+
+
+ + + +
-
+ ); }; diff --git a/src/core/event.ts b/src/core/event.ts index e7368a7..8b30f26 100644 --- a/src/core/event.ts +++ b/src/core/event.ts @@ -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(); 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; diff --git a/src/hooks/useMessageBus.ts b/src/hooks/useMessageBus.ts index d2d1660..793d4d7 100644 --- a/src/hooks/useMessageBus.ts +++ b/src/hooks/useMessageBus.ts @@ -10,7 +10,12 @@ export type UseMessageChannelProps = { export type MessageChannelRequest = { requestId: string; - message: T; + request: T; +}; + +export type MessageChannelResponse = { + 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 | Record; -const useMessageChannel = (propsProvider: () => UseMessageChannelProps) => { - const channel = () => channels()[propsProvider().id]; - +const useMessageBus = ( + propsProvider: () => UseMessageChannelProps, +) => { onMount(() => { const { id } = propsProvider(); if (channel() == null) { @@ -37,40 +42,50 @@ const useMessageChannel = (propsProvider: () => UseMessageCh } }); - const listen = async (requestId: string, timeoutMs = 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 channel = () => channels()[propsProvider().id]; - const data = JSON.parse(event.data) as MessageChannelRequest; + const sendRequest = (requestId: string, message: Req) => { + const request: MessageChannelRequest = { requestId, request: message }; + const messageStr = JSON.stringify(request); + channel().port1.postMessage(messageStr); + }; + + const waitResponse = (requestId: string, timeoutMs = 1000): Promise => + new Promise((resolve, reject) => { + const listener = (event: MessageEvent) => { + const data = event.data as MessageChannelResponse; 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 { 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) { + channel().port2.addEventListener('message', (ev) => { + const request = event.data as MessageChannelRequest; + const res = handler(request.request).then((res) => {}); + }); + }, }; }; -export default useMessageChannel; +export default useMessageBus; diff --git a/src/hooks/useShortcutKeys.ts b/src/hooks/useShortcutKeys.ts index b42a94b..e1b23f9 100644 --- a/src/hooks/useShortcutKeys.ts +++ b/src/hooks/useShortcutKeys.ts @@ -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); + }); }); }; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a73f7d8..27e7641 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 = () => { - + diff --git a/types/global.d.ts b/src/types/nostr.d.ts similarity index 87% rename from types/global.d.ts rename to src/types/nostr.d.ts index fedae07..c2e2a1d 100644 --- a/types/global.d.ts +++ b/src/types/nostr.d.ts @@ -6,7 +6,7 @@ type NostrAPI = { /** returns a public key as hex */ getPublicKey(): Promise; /** takes an event object, adds `id`, `pubkey` and `sig` and returns it */ - signEvent(event: Event): Promise; + signEvent(event: NostrEvent): Promise; // Optional @@ -22,6 +22,8 @@ type NostrAPI = { }; }; -interface Window { - nostr?: NostrAPI; +declare global { + interface Window { + nostr?: NostrAPI; + } } diff --git a/tsconfig.json b/tsconfig.json index 76f15ee..1836ce4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,6 @@ "baseUrl": ".", "paths": { "@/*": ["src/*"], - "*": ["types/*"] }, "incremental": true },