diff --git a/src/features/nostr/hooks/useNostrReq.ts b/src/features/nostr/hooks/useNostrReq.ts new file mode 100644 index 000000000..654c814f3 --- /dev/null +++ b/src/features/nostr/hooks/useNostrReq.ts @@ -0,0 +1,59 @@ +import { NostrEvent, NostrFilter } from '@soapbox/nspec'; +import isEqual from 'lodash/isEqual'; +import { useEffect, useRef, useState } from 'react'; + +import { useNostr } from 'soapbox/contexts/nostr-context'; + +/** Streams events from the relay for the given filters. */ +export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } { + const { relay } = useNostr(); + + const [events, setEvents] = useState([]); + const [closed, setClosed] = useState(false); + const [eose, setEose] = useState(false); + + const controller = useRef(new AbortController()); + const signal = controller.current.signal; + const value = useValue(filters); + + useEffect(() => { + if (relay && value.length) { + (async () => { + for await (const msg of relay.req(value, { signal })) { + if (msg[0] === 'EVENT') { + setEvents((prev) => [msg[2], ...prev]); + } else if (msg[0] === 'EOSE') { + setEose(true); + } else if (msg[0] === 'CLOSED') { + setClosed(true); + break; + } + } + })(); + } + + return () => { + controller.current.abort(); + controller.current = new AbortController(); + setEose(false); + setClosed(false); + }; + }, [relay, value]); + + return { + events, + eose, + closed, + }; +} + +/** Preserves the memory reference of a value across re-renders. */ +function useValue(value: T): T { + const ref = useRef(value); + + if (!isEqual(ref.current, value)) { + ref.current = value; + } + + return ref.current; +} \ No newline at end of file diff --git a/src/features/ui/components/modals/nostr-signin-modal/DittoSignup.ts b/src/features/ui/components/modals/nostr-signin-modal/DittoSignup.ts new file mode 100644 index 000000000..efe4f46f4 --- /dev/null +++ b/src/features/ui/components/modals/nostr-signin-modal/DittoSignup.ts @@ -0,0 +1,55 @@ +import { NRelay, NostrEvent, NostrSigner } from '@soapbox/nspec'; + +interface DittoSignupRequestOpts { + dvm: string; + url: string; + relay: NRelay; + signer: NostrSigner; + signal?: AbortSignal; +} + +export class DittoSignup { + + static async request(opts: DittoSignupRequestOpts): Promise { + const { dvm, url, relay, signer, signal } = opts; + + const pubkey = await signer.getPublicKey(); + const event = await signer.signEvent({ + kind: 5951, + content: '', + tags: [ + ['i', url, 'text'], + ['p', dvm], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + const subscription = relay.req( + [{ kinds: [7000, 6951], authors: [dvm], '#p': [pubkey], '#e': [event.id] }], + { signal }, + ); + + await relay.event(event, { signal }); + + for await (const msg of subscription) { + if (msg[0] === 'EVENT') { + return msg[2]; + } + } + + throw new Error('DittoSignup: no response'); + } + + static async check(opts: Omit): Promise { + const { dvm, relay, signer, signal } = opts; + + const pubkey = await signer.getPublicKey(); + const [event] = await relay.query( + [{ kinds: [7000, 6951], authors: [dvm], '#p': [pubkey] }], + { signal }, + ); + + return event; + } + +} \ No newline at end of file diff --git a/src/features/ui/components/modals/nostr-signin-modal/steps/account-step.tsx b/src/features/ui/components/modals/nostr-signin-modal/steps/account-step.tsx index 712e8911a..3dd9d1457 100644 --- a/src/features/ui/components/modals/nostr-signin-modal/steps/account-step.tsx +++ b/src/features/ui/components/modals/nostr-signin-modal/steps/account-step.tsx @@ -1,9 +1,11 @@ import { NSchema as n } from '@soapbox/nspec'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { useAccount } from 'soapbox/api/hooks'; import { Avatar, Text, Stack, Emoji, Button, Tooltip, Modal } from 'soapbox/components/ui'; +import { useNostr } from 'soapbox/contexts/nostr-context'; +import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq'; import ModalLoading from 'soapbox/features/ui/components/modal-loading'; import { useInstance } from 'soapbox/hooks'; @@ -16,9 +18,41 @@ interface IAccountStep { } const AccountStep: React.FC = ({ accountId, setStep, onClose }) => { + const { relay, signer } = useNostr(); const { account } = useAccount(accountId); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); const instance = useInstance(); + const { events } = useNostrReq((instance.nostr && account?.nostr) ? [{ + kinds: [7000, 6951], + authors: [instance.nostr.pubkey], + '#p': [account.nostr.pubkey], + }] : []); + + const success = events.find((event) => event.kind === 6951); + const feedback = events.find((event) => event.kind === 7000); + + const handleJoin = async () => { + if (!relay || !signer || !instance.nostr) return; + setSubmitting(true); + + const event = await signer.signEvent({ + kind: 5951, + content: '', + tags: [ + ['i', instance.nostr.relay, 'text'], + ['p', instance.nostr.pubkey, 'text'], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await relay.event(event); + + setSubmitting(false); + setSubmitted(true); + }; + const username = useMemo( () => n.bech32().safeParse(account?.acct).success ? account?.acct.slice(0, 13) : account?.acct, [account?.acct], @@ -63,11 +97,23 @@ const AccountStep: React.FC = ({ accountId, setStep, onClose }) => - You need an account on {instance.title} to continue. + {(success || feedback) ? ( + JSON.stringify(success || feedback, null, 2) + ) : ( + <>You need an account on {instance.title} to continue. + )} - + )}