From 904c5a547c9cd7a26d91cbf523b12578ea215fbe Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Fri, 24 Mar 2023 22:30:39 +0900 Subject: [PATCH] update --- src/components/ProfileDisplay.tsx | 210 ++++++++++++++------ src/components/textNote/TextNoteDisplay.tsx | 88 +++++--- src/components/utils/Copy.tsx | 2 +- src/core/parseTextNote.ts | 2 +- src/nostr/useBatchedEvents.ts | 2 - src/nostr/useCommands.ts | 32 +-- src/nostr/useFollowers.ts | 26 +++ src/nostr/useSubscription.ts | 16 +- src/nostr/useVerification.ts | 35 ++++ src/pages/Home.tsx | 10 +- src/types/nostr.d.ts | 4 +- src/utils/epoch.ts | 3 + 12 files changed, 313 insertions(+), 117 deletions(-) create mode 100644 src/nostr/useFollowers.ts create mode 100644 src/nostr/useVerification.ts create mode 100644 src/utils/epoch.ts diff --git a/src/components/ProfileDisplay.tsx b/src/components/ProfileDisplay.tsx index a4a98f1..fe4ef0b 100644 --- a/src/components/ProfileDisplay.tsx +++ b/src/components/ProfileDisplay.tsx @@ -1,42 +1,78 @@ -import { Component, createSignal, createMemo, Show, Switch, Match } from 'solid-js'; +import { Component, createSignal, createMemo, Show, Switch, Match, createEffect } from 'solid-js'; import GlobeAlt from 'heroicons/24/outline/globe-alt.svg'; import XMark from 'heroicons/24/outline/x-mark.svg'; +import CheckCircle from 'heroicons/24/solid/check-circle.svg'; +import ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg'; +import ArrowPath from 'heroicons/24/outline/arrow-path.svg'; import Modal from '@/components/Modal'; +import Timeline from '@/components/Timeline'; import Copy from '@/components/utils/Copy'; import SafeLink from '@/components/utils/SafeLink'; -import useProfile from '@/nostr/useProfile'; -import npubEncodeFallback from '@/utils/npubEncodeFallback'; -import useFollowings from '@/nostr/useBatchedEvents'; -import ensureNonNull from '@/utils/ensureNonNull'; import usePubkey from '@/nostr/usePubkey'; -import useSubscription from '@/nostr/useSubscription'; +import useProfile from '@/nostr/useProfile'; +import useVerification from '@/nostr/useVerification'; +import useFollowings from '@/nostr/useFollowings'; +import useFollowers from '@/nostr/useFollowers'; import useConfig from '@/nostr/useConfig'; -import Timeline from './Timeline'; +import useSubscription from '@/nostr/useSubscription'; + +import npubEncodeFallback from '@/utils/npubEncodeFallback'; +import ensureNonNull from '@/utils/ensureNonNull'; +import epoch from '@/utils/epoch'; export type ProfileDisplayProps = { pubkey: string; onClose?: () => void; }; +const FollowersCount: Component<{ pubkey: string }> = (props) => { + const { followersPubkeys } = useFollowers(() => ({ + pubkey: props.pubkey, + })); + + return {followersPubkeys().length}; +}; + const ProfileDisplay: Component = (props) => { const { config } = useConfig(); const pubkey = usePubkey(); const [hoverFollowButton, setHoverFollowButton] = createSignal(false); + const [showFollowers, setShowFollowers] = createSignal(false); - const { profile, query } = useProfile(() => ({ + const { profile, query: profileQuery } = useProfile(() => ({ pubkey: props.pubkey, })); + const { verification, query: verificationQuery } = useVerification(() => + ensureNonNull([profile()?.nip05] as const)(([nip05]) => ({ nip05 })), + ); + const nip05Identifier = () => { + const ident = profile()?.nip05; + if (ident == null) return null; + const [user, domain] = ident.split('@'); + if (domain == null) return null; + if (user === '_') return { domain, ident: domain }; + return { user, domain, ident }; + }; + const isVerified = () => verification()?.pubkey === props.pubkey; const { followingPubkeys: myFollowingPubkeys } = useFollowings(() => ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({ pubkey: pubkeyNonNull, })), ); - const isFollowing = () => myFollowingPubkeys().includes(props.pubkey); + const following = () => myFollowingPubkeys().includes(props.pubkey); + + const { followingPubkeys: userFollowingPubkeys } = useFollowings(() => ({ + pubkey: props.pubkey, + })); + const followed = () => { + const p = pubkey(); + return p != null && userFollowingPubkeys().includes(p); + }; const npub = createMemo(() => npubEncodeFallback(props.pubkey)); @@ -47,7 +83,7 @@ const ProfileDisplay: Component = (props) => { kinds: [1, 6], authors: [props.pubkey], limit: 10, - until: Date.now() / 1000, + until: epoch(), }, ], })); @@ -64,17 +100,17 @@ const ProfileDisplay: Component = (props) => { -
- loading}> - } keyed> +
+ loading}> + } keyed> {(bannerUrl) => (
- header + header
)}
-
-
+
+
{(pictureUrl) => ( = (props) => { )}
-
-
-
{profile()?.display_name}
-
@{profile()?.name}
+
+
+ 0}> +
{profile()?.display_name}
+
+
+ 0}> +
@{profile()?.name}
+
+ 0}> +
+ {nip05Identifier()?.ident} + + + + } + > + + + + + + + + + + + +
+
+
+
+
{npub()}
+ +
-
-
{npub()}
- +
+ {/* + + フォロー + + } + > + + + + + + + + */} + +
フォローされています
+
- {/* -
- - フォロー - - } - > - - - - - - - -
- */}
0}> -
+
{profile()?.about}
+
+
+
フォロー
+
{userFollowingPubkeys().length}
+
+
+
フォロワー
+
+ setShowFollowers(true)}>読み込む} + keyed + > + + +
+
+
0}>
    diff --git a/src/components/textNote/TextNoteDisplay.tsx b/src/components/textNote/TextNoteDisplay.tsx index 6500b9e..3001d5a 100644 --- a/src/components/textNote/TextNoteDisplay.tsx +++ b/src/components/textNote/TextNoteDisplay.tsx @@ -1,4 +1,4 @@ -import { Show, For, createSignal, createMemo, type JSX, type Component } from 'solid-js'; +import { Show, For, createSignal, createMemo, onMount, type JSX, type Component } from 'solid-js'; import type { Event as NostrEvent } from 'nostr-tools'; import { createMutation } from '@tanstack/solid-query'; @@ -37,6 +37,8 @@ export type TextNoteDisplayProps = { }; const TextNoteDisplay: Component = (props) => { + let contentRef: HTMLDivElement | undefined; + const { config } = useConfig(); const formatDate = useFormatDate(); const pubkey = usePubkey(); @@ -44,6 +46,8 @@ const TextNoteDisplay: Component = (props) => { const [showReplyForm, setShowReplyForm] = createSignal(false); const closeReplyForm = () => setShowReplyForm(false); + const [showOverflow, setShowOverflow] = createSignal(false); + const [overflow, setOverflow] = createSignal(false); const [showMenu, setShowMenu] = createSignal(false); const event = createMemo(() => eventWrapper(props.event)); @@ -91,8 +95,14 @@ const TextNoteDisplay: Component = (props) => { }, }); - const isReactedByMe = createMemo(() => isReactedBy(pubkey())); - const isRepostedByMe = createMemo(() => isRepostedBy(pubkey())); + const isReactedByMe = createMemo(() => { + const p = pubkey(); + return p != null && isReactedBy(p); + }); + const isRepostedByMe = createMemo(() => { + const p = pubkey(); + return p != null && isRepostedBy(p); + }); const showReplyEvent = (): string | undefined => { const replyingToEvent = event().replyingToEvent(); @@ -141,6 +151,12 @@ const TextNoteDisplay: Component = (props) => { }); }; + onMount(() => { + if (contentRef != null) { + setOverflow(contentRef.scrollHeight > contentRef.clientHeight); + } + }); + return (
    @@ -177,33 +193,49 @@ const TextNoteDisplay: Component = (props) => {
    {createdAt()}
    - - {(id) => ( -
    - +
    + + {(id) => ( +
    + +
    + )} +
    + 0}> +
    + + {(replyToPubkey: string) => ( + + )} + + {'への返信'}
    - )} +
    + +
    + +
    +
    +
    + + - 0}> -
    - - {(replyToPubkey: string) => ( - - )} - - {'への返信'} -
    -
    - -
    - -
    -
    Copied! diff --git a/src/core/parseTextNote.ts b/src/core/parseTextNote.ts index d8d3f28..5921bdb 100644 --- a/src/core/parseTextNote.ts +++ b/src/core/parseTextNote.ts @@ -63,7 +63,7 @@ const parseTextNote = (event: NostrEvent): ParsedTextNote => { // nrelay and naddr is not supported by nostr-tools ...event.content.matchAll(/(?(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi), ...event.content.matchAll( - /(?(https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-\w.@%:]+|\/)*(?:\?[-\w=.@%:&]*)?(?:#[-\w=.%:&]*)?)/g, + /(?(https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-\w.@%:]+|\/)*(?:\?[-\w=.@%:&]+)?(?:#[-\w=.%:&]+)?)/g, ), ].sort((a, b) => (a.index as number) - (b.index as number)); let pos = 0; diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index cfc485e..da483ee 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -479,5 +479,3 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U return { followings, followingPubkeys, query }; }; - -export default useFollowings; diff --git a/src/nostr/useCommands.ts b/src/nostr/useCommands.ts index 3f5cf78..ed9381c 100644 --- a/src/nostr/useCommands.ts +++ b/src/nostr/useCommands.ts @@ -1,9 +1,15 @@ -import { getEventHash, type Event as NostrEvent, type Pub } from 'nostr-tools'; +import { + getEventHash, + type UnsignedEvent, + type Event as NostrEvent, + type Pub, + type Kind, +} from 'nostr-tools'; import '@/types/nostr.d'; import usePool from '@/nostr/usePool'; -const currentDate = (): number => Math.floor(Date.now() / 1000); +import epoch from '@/utils/epoch'; // NIP-20: Command Result const waitCommandResult = (pub: Pub, relayUrl: string): Promise => { @@ -22,8 +28,11 @@ const waitCommandResult = (pub: Pub, relayUrl: string): Promise => { const useCommands = () => { const pool = usePool(); - const publishEvent = async (relayUrls: string[], event: NostrEvent): Promise[]> => { - const preSignedEvent: NostrEvent = { ...event }; + const publishEvent = async ( + relayUrls: string[], + event: UnsignedEvent, + ): Promise[]> => { + const preSignedEvent: UnsignedEvent = { ...event }; preSignedEvent.id = getEventHash(preSignedEvent); if (window.nostr == null) { @@ -75,10 +84,10 @@ const useCommands = () => { const mergedTags = [...eTags, ...pTags, ...additionalTags]; - const preSignedEvent: NostrEvent = { + const preSignedEvent: UnsignedEvent = { kind: 1, pubkey, - created_at: currentDate(), + created_at: epoch(), tags: mergedTags, content, }; @@ -102,17 +111,16 @@ const useCommands = () => { notifyPubkey: string; }): Promise[]> { // TODO ensure that content is + or - or emoji. - const preSignedEvent: NostrEvent = { + const preSignedEvent: UnsignedEvent = { kind: 7, pubkey, - created_at: currentDate(), + created_at: epoch(), tags: [ ['e', eventId, ''], ['p', notifyPubkey], ], content, }; - console.log(preSignedEvent); return publishEvent(relayUrls, preSignedEvent); }, // NIP-18 @@ -127,10 +135,10 @@ const useCommands = () => { eventId: string; notifyPubkey: string; }): Promise[]> { - const preSignedEvent: NostrEvent = { - kind: 6, + const preSignedEvent: UnsignedEvent = { + kind: 6 as Kind, pubkey, - created_at: currentDate(), + created_at: epoch(), tags: [ ['e', eventId, ''], ['p', notifyPubkey], diff --git a/src/nostr/useFollowers.ts b/src/nostr/useFollowers.ts new file mode 100644 index 0000000..1b258f2 --- /dev/null +++ b/src/nostr/useFollowers.ts @@ -0,0 +1,26 @@ +import { createMemo, createSignal } from 'solid-js'; +import { Kind } from 'nostr-tools'; +import uniq from 'lodash/uniq'; + +import useConfig from '@/nostr/useConfig'; +import useSubscription from '@/nostr/useSubscription'; + +export type UseFollowersProps = { + pubkey: string; +}; + +export default function useFollowers(propsProvider: () => UseFollowersProps) { + const { config } = useConfig(); + const props = createMemo(propsProvider); + + const { events } = useSubscription(() => ({ + relayUrls: config().relayUrls, + filters: [{ kinds: [Kind.Contacts], '#p': [props().pubkey] }], + limit: Infinity, + continuous: true, + })); + + const followersPubkeys = () => uniq(events()?.map((ev) => ev.pubkey)); + + return { followersPubkeys }; +} diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index 084c229..4ed69cf 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -7,10 +7,16 @@ export type UseSubscriptionProps = { relayUrls: string[]; filters: Filter[]; options?: SubscriptionOptions; - // subscribe not only stored events but also new events published after the subscription - // default is true - clientEventFilter?: (event: NostrEvent) => boolean; + /** + * subscribe not only stored events but also new events published after the subscription + * default is true + */ continuous?: boolean; + /** + * limit the number of events + */ + limit?: number; + clientEventFilter?: (event: NostrEvent) => boolean; onEvent?: (event: NostrEvent & { id: string }) => void; onEOSE?: () => void; signal?: AbortSignal; @@ -32,6 +38,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { if (props == null) return; const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props; + const limit = props.limit ?? 50; const sub = pool().sub(relayUrls, filters, options); let subscribing = true; @@ -53,8 +60,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { storedEvents.push(event); } else { setEvents((current) => { - // いったん50件だけ保持 - const sorted = sortEvents([event, ...current].slice(0, 50)); + const sorted = sortEvents([event, ...current].slice(0, limit)); // FIXME なぜか重複して取得される問題があるが一旦uniqByで対処 // https://github.com/syusui-s/rabbit/issues/5 const deduped = uniqBy(sorted, (e) => e.id); diff --git a/src/nostr/useVerification.ts b/src/nostr/useVerification.ts new file mode 100644 index 0000000..7e22ee7 --- /dev/null +++ b/src/nostr/useVerification.ts @@ -0,0 +1,35 @@ +import { createMemo, type Accessor } from 'solid-js'; +import { createQuery, type CreateQueryResult } from '@tanstack/solid-query'; +import { nip05, nip19 } from 'nostr-tools'; + +export type UseVerificationProps = { + nip05: string; +}; + +export type UseVerification = { + verification: Accessor; + query: CreateQueryResult; +}; + +const useVerification = (propsProvider: () => UseVerificationProps | null): UseVerification => { + const props = createMemo(propsProvider); + const query = createQuery( + () => ['useVerification', props()] as const, + ({ queryKey, signal }) => { + const [, currentProps] = queryKey; + if (currentProps == null) return Promise.resolve(null); + const { nip05: nip05string } = currentProps; + return nip05.queryProfile(nip05string); + }, + { + staleTime: 30 * 60 * 1000, // 30 min + cacheTime: 24 * 60 * 60 * 1000, // 24 hour + }, + ); + + const verification = () => query?.data ?? null; + + return { verification, query }; +}; + +export default useVerification; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index b70c049..3c74909 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -15,6 +15,7 @@ import Column from '@/components/Column'; import SideBar from '@/components/SideBar'; import Timeline from '@/components/Timeline'; import Notification from '@/components/Notification'; +import ProfileDisplay from '@/components/ProfileDisplay'; import usePool from '@/nostr/usePool'; import useConfig from '@/nostr/useConfig'; @@ -24,10 +25,11 @@ import usePubkey from '@/nostr/usePubkey'; import { useMountShortcutKeys } from '@/hooks/useShortcutKeys'; import usePersistStatus from '@/hooks/usePersistStatus'; -import ensureNonNull from '@/utils/ensureNonNull'; -import ProfileDisplay from '@/components/ProfileDisplay'; import useModalState from '@/hooks/useModalState'; +import ensureNonNull from '@/utils/ensureNonNull'; +import epoch from '@/utils/epoch'; + const Home: Component = () => { useMountShortcutKeys(); const navigate = useNavigate(); @@ -118,7 +120,7 @@ const Home: Component = () => { { kinds: [1, 6], limit: 25, - since: Math.floor(Date.now() / 1000) - 12 * 60 * 60, + since: epoch() - 12 * 60 * 60, }, ], })); @@ -131,7 +133,7 @@ const Home: Component = () => { kinds: [1], search: '#nostrstudy', limit: 25, - since: Math.floor(Date.now() / 1000) - 12 * 60 * 60, + since: epoch() - 12 * 60 * 60, }, ], })); diff --git a/src/types/nostr.d.ts b/src/types/nostr.d.ts index 3888eb2..51a2595 100644 --- a/src/types/nostr.d.ts +++ b/src/types/nostr.d.ts @@ -1,12 +1,12 @@ // The original code was published under the public domain license (CC0-1.0). // https://gist.github.com/syusui-s/cd5482ddfc83792b54a756759acbda55 -import { type Event as NostrEvent } from 'nostr-tools'; +import { type UnsignedEvent, type Event as NostrEvent } from 'nostr-tools'; type NostrAPI = { /** returns a public key as hex */ getPublicKey(): Promise; /** takes an event object, adds `id`, `pubkey` and `sig` and returns it */ - signEvent(event: NostrEvent): Promise; + signEvent(event: UnsignedEvent): Promise; // Optional diff --git a/src/utils/epoch.ts b/src/utils/epoch.ts new file mode 100644 index 0000000..3e681f0 --- /dev/null +++ b/src/utils/epoch.ts @@ -0,0 +1,3 @@ +const epoch = (): number => Math.floor(Date.now() / 1000); + +export default epoch;