diff --git a/index.html b/index.html index 5fee5c8e5e..5e786dd976 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - + diff --git a/package.json b/package.json index 136cc34afd..5fdca8015b 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "@sentry/browser": "^7.74.1", "@sentry/react": "^7.74.1", "@soapbox.pub/wasmboy": "^0.8.0", + "@soapbox/nspec": "npm:@jsr/soapbox__nspec", + "@soapbox/weblock": "npm:@jsr/soapbox__weblock", "@tabler/icons": "^2.0.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", @@ -134,8 +136,7 @@ "lodash": "^4.7.11", "mini-css-extract-plugin": "^2.6.0", "nostr-machina": "^0.1.0", - "nostr-tools": "^1.14.2", - "nspec": "^0.1.0", + "nostr-tools": "^2.3.0", "path-browserify": "^1.0.1", "postcss": "^8.4.29", "process": "^0.11.10", @@ -185,7 +186,7 @@ "vite-plugin-require": "^1.1.10", "vite-plugin-static-copy": "^1.0.0", "wicg-inert": "^3.1.1", - "zod": "^3.21.4" + "zod": "^3.22.4" }, "devDependencies": { "@formatjs/cli": "^6.2.0", diff --git a/src/actions/accounts.ts b/src/actions/accounts.ts index 2099cb63ff..f1d0f6c32b 100644 --- a/src/actions/accounts.ts +++ b/src/actions/accounts.ts @@ -1,8 +1,5 @@ -import { nip19 } from 'nostr-tools'; - import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; -import { signer } from 'soapbox/features/nostr/sign'; import { selectAccount } from 'soapbox/selectors'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; @@ -132,14 +129,8 @@ const noOp = () => new Promise(f => f(undefined)); const createAccount = (params: Record) => async (dispatch: AppDispatch, getState: () => RootState) => { - const { instance } = getState(); - const { nostrSignup } = getFeatures(instance); - const pubkey = (signer && nostrSignup) ? await signer.getPublicKey() : undefined; - dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); - return api(getState, 'app').post('/api/v1/accounts', params, { - headers: pubkey ? { authorization: `Bearer ${nip19.npubEncode(pubkey)}` } : undefined, - }).then(({ data: token }) => { + return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => { return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); }).catch(error => { dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); @@ -154,7 +145,7 @@ const fetchAccount = (id: string) => const account = selectAccount(getState(), id); if (account) { - return null; + return Promise.resolve(null); } dispatch(fetchAccountRequest(id)); diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 21044140b4..6908f06b62 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,22 +1,26 @@ import { nip19 } from 'nostr-tools'; -import { signer } from 'soapbox/features/nostr/sign'; import { type AppDispatch } from 'soapbox/store'; import { verifyCredentials } from './auth'; /** Log in with a Nostr pubkey. */ -function nostrLogIn() { - return async (dispatch: AppDispatch) => { - if (!signer) { - throw new Error('No Nostr signer available'); - } - - const pubkey = await signer.getPublicKey(); +function logInNostr(pubkey: string) { + return (dispatch: AppDispatch) => { const npub = nip19.npubEncode(pubkey); - return dispatch(verifyCredentials(npub)); }; } -export { nostrLogIn }; \ No newline at end of file +/** Log in with a Nostr extension. */ +function nostrExtensionLogIn() { + return async (dispatch: AppDispatch) => { + if (!window.nostr) { + throw new Error('No Nostr signer available'); + } + const pubkey = await window.nostr.getPublicKey(); + return dispatch(logInNostr(pubkey)); + }; +} + +export { logInNostr, nostrExtensionLogIn }; \ No newline at end of file diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index cc5d6403db..460cfd9d6f 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,23 +1,12 @@ -import { NiceRelay } from 'nostr-machina'; -import { type NostrEvent } from 'nspec'; -import { useEffect, useMemo } from 'react'; +import { type NostrEvent } from '@soapbox/nspec'; +import { useEffect } from 'react'; -import { signer } from 'soapbox/features/nostr/sign'; -import { useInstance } from 'soapbox/hooks'; +import { useNostr } from 'soapbox/contexts/nostr-context'; import { connectRequestSchema, nwcRequestSchema } from 'soapbox/schemas/nostr'; import { jsonSchema } from 'soapbox/schemas/utils'; function useSignerStream() { - const instance = useInstance(); - - const relayUrl = instance.nostr?.relay; - const pubkey = instance.nostr?.pubkey; - - const relay = useMemo(() => { - if (relayUrl && signer) { - return new NiceRelay(relayUrl); - } - }, [relayUrl, !!signer]); + const { relay, pubkey, signer } = useNostr(); async function handleConnectEvent(event: NostrEvent) { if (!relay || !pubkey || !signer) return; @@ -42,7 +31,7 @@ function useSignerStream() { created_at: Math.floor(Date.now() / 1000), }); - relay.send(['EVENT', respEvent]); + relay.event(respEvent); } async function handleWalletEvent(event: NostrEvent) { @@ -61,28 +50,26 @@ function useSignerStream() { await window.webln?.sendPayment(reqMsg.data.params.invoice); } + async function handleEvent(event: NostrEvent) { + switch (event.kind) { + case 24133: + await handleConnectEvent(event); + break; + case 23194: + await handleWalletEvent(event); + break; + } + } + useEffect(() => { if (!relay || !pubkey) return; - const sub = relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }]); - const readEvents = async () => { - for await (const event of sub) { - switch (event.kind) { - case 24133: - await handleConnectEvent(event); - break; - case 23194: - await handleWalletEvent(event); - break; - } + (async() => { + for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }])) { + if (msg[0] === 'EVENT') handleEvent(msg[2]); } - }; + })(); - readEvents(); - - return () => { - relay?.close(); - }; }, [relay, pubkey]); } diff --git a/src/components/copyable-input.tsx b/src/components/copyable-input.tsx index 2422c8276b..90089927c5 100644 --- a/src/components/copyable-input.tsx +++ b/src/components/copyable-input.tsx @@ -6,10 +6,14 @@ import { Button, HStack, Input } from './ui'; interface ICopyableInput { /** Text to be copied. */ value: string; + /** Input type. */ + type?: 'text' | 'password'; + /** Callback after the value has been copied. */ + onCopy?(): void; } /** An input with copy abilities. */ -const CopyableInput: React.FC = ({ value }) => { +const CopyableInput: React.FC = ({ value, type = 'text', onCopy }) => { const input = useRef(null); const selectInput = () => { @@ -20,13 +24,15 @@ const CopyableInput: React.FC = ({ value }) => { } else { document.execCommand('copy'); } + + onCopy?.(); }; return ( = ({ emoji }) => { + return ( +
+
+ +
+
+ ); +}; + +export default EmojiGraphic; \ No newline at end of file diff --git a/src/components/ui/emoji-selector/emoji-selector.tsx b/src/components/ui/emoji-selector/emoji-selector.tsx index e27763bde1..2876aa0a44 100644 --- a/src/components/ui/emoji-selector/emoji-selector.tsx +++ b/src/components/ui/emoji-selector/emoji-selector.tsx @@ -2,7 +2,9 @@ import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating- import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; -import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui'; +import EmojiComponent from 'soapbox/components/ui/emoji/emoji'; +import HStack from 'soapbox/components/ui/hstack/hstack'; +import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown'; import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index dd4541ae35..85f8fc1680 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -8,6 +8,7 @@ import HStack from '../hstack/hstack'; import IconButton from '../icon-button/icon-button'; const messages = defineMessages({ + back: { id: 'card.back.label', defaultMessage: 'Back' }, close: { id: 'lightbox.close', defaultMessage: 'Close' }, confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, }); @@ -56,6 +57,7 @@ interface IModal { width?: keyof typeof widths; children?: React.ReactNode; className?: string; + onBack?: () => void; } /** Displays a modal dialog box. */ @@ -78,6 +80,7 @@ const Modal = React.forwardRef(({ title, width = 'xl', className, + onBack, }, ref) => { const intl = useIntl(); const buttonRef = React.useRef(null); @@ -102,6 +105,15 @@ const Modal = React.forwardRef(({ 'flex-row-reverse': closePosition === 'left', })} > + {onBack && ( + + )} +

{title}

diff --git a/src/components/ui/tooltip/tooltip.tsx b/src/components/ui/tooltip/tooltip.tsx index 9c86b94be7..08d270c5d9 100644 --- a/src/components/ui/tooltip/tooltip.tsx +++ b/src/components/ui/tooltip/tooltip.tsx @@ -15,13 +15,15 @@ interface ITooltip { children: React.ReactElement>; /** Text to display in the tooltip. */ text: string; + /** If disabled, it will render the children without wrapping them. */ + disabled?: boolean; } /** * Tooltip */ const Tooltip: React.FC = (props) => { - const { children, text } = props; + const { children, text, disabled = false } = props; const [isOpen, setIsOpen] = useState(false); @@ -55,6 +57,10 @@ const Tooltip: React.FC = (props) => { hover, ]); + if (disabled) { + return children; + } + return ( <> {React.cloneElement(children, { diff --git a/src/components/ui/widget/widget.tsx b/src/components/ui/widget/widget.tsx index 7398b8dc07..79c001674d 100644 --- a/src/components/ui/widget/widget.tsx +++ b/src/components/ui/widget/widget.tsx @@ -1,6 +1,9 @@ import React from 'react'; -import { HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; +import HStack from 'soapbox/components/ui/hstack/hstack'; +import IconButton from 'soapbox/components/ui/icon-button/icon-button'; +import Stack from 'soapbox/components/ui/stack/stack'; +import Text from 'soapbox/components/ui/text/text'; interface IWidgetTitle { /** Title text for the widget. */ diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx new file mode 100644 index 0000000000..4664068c6c --- /dev/null +++ b/src/contexts/nostr-context.tsx @@ -0,0 +1,54 @@ +import { NRelay, NRelay1, NostrSigner } from '@soapbox/nspec'; +import React, { createContext, useContext, useState, useEffect } from 'react'; + +import { NKeys } from 'soapbox/features/nostr/keys'; +import { useOwnAccount } from 'soapbox/hooks'; +import { useInstance } from 'soapbox/hooks/useInstance'; + +interface NostrContextType { + relay?: NRelay; + pubkey?: string; + signer?: NostrSigner; +} + +const NostrContext = createContext(undefined); + +interface NostrProviderProps { + children: React.ReactNode; +} + +export const NostrProvider: React.FC = ({ children }) => { + const instance = useInstance(); + const [relay, setRelay] = useState(); + + const { account } = useOwnAccount(); + + const url = instance.nostr?.relay; + const pubkey = instance.nostr?.pubkey; + const accountPubkey = account?.nostr.pubkey; + + const signer = (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr; + + useEffect(() => { + if (url) { + setRelay(new NRelay1(url)); + } + return () => { + relay?.close(); + }; + }, [url]); + + return ( + + {children} + + ); +}; + +export const useNostr = () => { + const context = useContext(NostrContext); + if (context === undefined) { + throw new Error('useNostr must be used within a NostrProvider'); + } + return context; +}; diff --git a/src/features/auth-login/components/login-page.tsx b/src/features/auth-login/components/login-page.tsx index 8817b6d39d..9ceec4a556 100644 --- a/src/features/auth-login/components/login-page.tsx +++ b/src/features/auth-login/components/login-page.tsx @@ -4,9 +4,9 @@ import { Redirect } from 'react-router-dom'; import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth'; import { fetchInstance } from 'soapbox/actions/instance'; -import { closeModal } from 'soapbox/actions/modals'; +import { closeModal, openModal } from 'soapbox/actions/modals'; import { BigCard } from 'soapbox/components/big-card'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { getRedirectUrl } from 'soapbox/utils/redirect'; import { isStandalone } from 'soapbox/utils/state'; @@ -21,6 +21,7 @@ const LoginPage = () => { const me = useAppSelector((state) => state.me); const standalone = useAppSelector((state) => isStandalone(state)); + const { nostrSignup } = useFeatures(); const token = new URLSearchParams(window.location.search).get('token'); @@ -62,6 +63,11 @@ const LoginPage = () => { event.preventDefault(); }; + if (nostrSignup) { + setTimeout(() => dispatch(openModal('NOSTR_LOGIN')), 100); + return ; + } + if (standalone) return ; if (shouldRedirect) { diff --git a/src/features/auth-login/components/registration-page.tsx b/src/features/auth-login/components/registration-page.tsx index db5f53a056..589fbeed2d 100644 --- a/src/features/auth-login/components/registration-page.tsx +++ b/src/features/auth-login/components/registration-page.tsx @@ -1,15 +1,24 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; +import { Redirect } from 'react-router-dom'; +import { openModal } from 'soapbox/actions/modals'; import { BigCard } from 'soapbox/components/big-card'; import { Text } from 'soapbox/components/ui'; -import { useInstance, useRegistrationStatus } from 'soapbox/hooks'; +import { useAppDispatch, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks'; import RegistrationForm from './registration-form'; const RegistrationPage: React.FC = () => { const instance = useInstance(); const { isOpen } = useRegistrationStatus(); + const { nostrSignup } = useFeatures(); + const dispatch = useAppDispatch(); + + if (nostrSignup) { + setTimeout(() => dispatch(openModal('NOSTR_SIGNUP')), 100); + return ; + } if (!isOpen) { return ( diff --git a/src/features/nostr/NKeyStorage.ts b/src/features/nostr/NKeyStorage.ts new file mode 100644 index 0000000000..e297edb96d --- /dev/null +++ b/src/features/nostr/NKeyStorage.ts @@ -0,0 +1,112 @@ +import { NSchema as n, NostrSigner, NSecSigner } from '@soapbox/nspec'; +import { WebLock } from '@soapbox/weblock'; +import { getPublicKey, nip19 } from 'nostr-tools'; +import { z } from 'zod'; + +/** + * Gets Nostr keypairs from storage and returns a `Map`-like object of signers. + * When instantiated, it will lock the storage key to prevent tampering. + * Changes to the object will sync to storage. + */ +export class NKeyStorage implements ReadonlyMap { + + #keypairs = new Map(); + #storage: Storage; + #storageKey: string; + + constructor(storage: Storage, storageKey: string) { + this.#storage = storage; + this.#storageKey = storageKey; + + const data = this.#storage.getItem(storageKey); + WebLock.storages.lockKey(storageKey); + + try { + const nsecs = new Set(this.#dataSchema().parse(data)); + + for (const nsec of nsecs) { + const { data: secretKey } = nip19.decode(nsec); + const pubkey = getPublicKey(secretKey); + this.#keypairs.set(pubkey, secretKey); + } + } catch (e) { + this.clear(); + } + } + + #dataSchema(): z.ZodType<`nsec1${string}`[]> { + return n.json().pipe(n.bech32('nsec').array()); + } + + #syncStorage() { + const secretKeys = [...this.#keypairs.values()].map(nip19.nsecEncode); + this.#storage.setItem(this.#storageKey, JSON.stringify(secretKeys)); + } + + get size(): number { + return this.#keypairs.size; + } + + clear(): void { + this.#keypairs.clear(); + this.#syncStorage(); + } + + delete(pubkey: string): boolean { + const result = this.#keypairs.delete(pubkey); + this.#syncStorage(); + return result; + } + + forEach(callbackfn: (signer: NostrSigner, pubkey: string, map: typeof this) => void, thisArg?: any): void { + for (const [pubkey] of this.#keypairs) { + const signer = this.get(pubkey); + if (signer) { + callbackfn.call(thisArg, signer, pubkey, this); + } + } + } + + get(pubkey: string): NostrSigner | undefined { + const secretKey = this.#keypairs.get(pubkey); + if (secretKey) { + return new NSecSigner(secretKey); + } + } + + has(pubkey: string): boolean { + return this.#keypairs.has(pubkey); + } + + add(secretKey: Uint8Array): NostrSigner { + const pubkey = getPublicKey(secretKey); + this.#keypairs.set(pubkey, secretKey); + this.#syncStorage(); + return this.get(pubkey)!; + } + + *entries(): IterableIterator<[string, NostrSigner]> { + for (const [pubkey] of this.#keypairs) { + yield [pubkey, this.get(pubkey)!]; + } + } + + *keys(): IterableIterator { + for (const pubkey of this.#keypairs.keys()) { + yield pubkey; + } + } + + *values(): IterableIterator { + for (const pubkey of this.#keypairs.keys()) { + yield this.get(pubkey)!; + } + } + + [Symbol.iterator](): IterableIterator<[string, NostrSigner]> { + return this.entries(); + } + + [Symbol.toStringTag] = 'NKeyStorage'; + +} \ No newline at end of file diff --git a/src/features/nostr/SoapboxSigner.ts b/src/features/nostr/SoapboxSigner.ts deleted file mode 100644 index 98e8db51e9..0000000000 --- a/src/features/nostr/SoapboxSigner.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { hexToBytes } from '@noble/hashes/utils'; -import { type NostrSigner, type NostrEvent, NSecSigner } from 'nspec'; - -/** Use key from `localStorage` if available, falling back to NIP-07. */ -export class SoapboxSigner implements NostrSigner { - - #signer: NostrSigner; - - constructor() { - const privateKey = localStorage.getItem('soapbox:nostr:privateKey'); - const signer = privateKey ? new NSecSigner(hexToBytes(privateKey)) : window.nostr; - - if (!signer) { - throw new Error('No Nostr signer available'); - } - - this.#signer = signer; - } - - async getPublicKey(): Promise { - return this.#signer.getPublicKey(); - } - - async signEvent(event: Omit): Promise { - return this.#signer.signEvent(event); - } - - nip04 = { - encrypt: (pubkey: string, plaintext: string): Promise => { - if (!this.#signer.nip04) { - throw new Error('NIP-04 not supported by signer'); - } - return this.#signer.nip04.encrypt(pubkey, plaintext); - }, - - decrypt: (pubkey: string, ciphertext: string): Promise => { - if (!this.#signer.nip04) { - throw new Error('NIP-04 not supported by signer'); - } - return this.#signer.nip04.decrypt(pubkey, ciphertext); - }, - }; - -} \ No newline at end of file diff --git a/src/features/nostr/hooks/useNostrReq.ts b/src/features/nostr/hooks/useNostrReq.ts new file mode 100644 index 0000000000..654c814f3b --- /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/nostr/keys.ts b/src/features/nostr/keys.ts new file mode 100644 index 0000000000..92f9fc09f7 --- /dev/null +++ b/src/features/nostr/keys.ts @@ -0,0 +1,6 @@ +import { NKeyStorage } from './NKeyStorage'; + +export const NKeys = new NKeyStorage( + localStorage, + 'soapbox:nostr:keys', +); diff --git a/src/features/nostr/sign.ts b/src/features/nostr/sign.ts deleted file mode 100644 index d33cd926a2..0000000000 --- a/src/features/nostr/sign.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type NostrSigner } from 'nspec'; - -import { SoapboxSigner } from './SoapboxSigner'; - -let signer: NostrSigner | undefined; - -try { - signer = new SoapboxSigner(); -} catch (_) { - // No signer available -} - -export { signer }; \ No newline at end of file diff --git a/src/features/ui/components/modal-loading.tsx b/src/features/ui/components/modal-loading.tsx index fbafa50717..61bccffb34 100644 --- a/src/features/ui/components/modal-loading.tsx +++ b/src/features/ui/components/modal-loading.tsx @@ -1,18 +1,11 @@ import React from 'react'; -import { Spinner } from 'soapbox/components/ui'; +import { Modal, Spinner } from 'soapbox/components/ui'; const ModalLoading = () => ( -
-
- -
-
-
-
-
-
+ + + ); export default ModalLoading; diff --git a/src/features/ui/components/modal-root.tsx b/src/features/ui/components/modal-root.tsx index 6a3f91fb97..36b099216b 100644 --- a/src/features/ui/components/modal-root.tsx +++ b/src/features/ui/components/modal-root.tsx @@ -30,6 +30,8 @@ import { MentionsModal, MissingDescriptionModal, MuteModal, + NostrLoginModal, + NostrSignupModal, ReactionsModal, ReblogsModal, ReplyMentionsModal, @@ -70,6 +72,8 @@ const MODAL_COMPONENTS: Record> = { 'MENTIONS': MentionsModal, 'MISSING_DESCRIPTION': MissingDescriptionModal, 'MUTE': MuteModal, + 'NOSTR_LOGIN': NostrLoginModal, + 'NOSTR_SIGNUP': NostrSignupModal, 'REACTIONS': ReactionsModal, 'REBLOGS': ReblogsModal, 'REPLY_MENTIONS': ReplyMentionsModal, diff --git a/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx b/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx new file mode 100644 index 0000000000..2c0258663e --- /dev/null +++ b/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { closeModal } from 'soapbox/actions/modals'; +import { nostrExtensionLogIn } from 'soapbox/actions/nostr'; +import Stack from 'soapbox/components/ui/stack/stack'; +import Text from 'soapbox/components/ui/text/text'; +import { useAppDispatch } from 'soapbox/hooks'; + +const NostrExtensionIndicator: React.FC = () => { + const dispatch = useAppDispatch(); + + const onClick = () => { + dispatch(nostrExtensionLogIn()); + dispatch(closeModal()); + }; + + return ( + + + {window.nostr ? ( + , + }} + /> + ) : ( + + )} + + + ); +}; + +export default NostrExtensionIndicator; \ No newline at end of file diff --git a/src/features/ui/components/modals/nostr-login-modal/nostr-login-modal.tsx b/src/features/ui/components/modals/nostr-login-modal/nostr-login-modal.tsx new file mode 100644 index 0000000000..fd81a6c9a4 --- /dev/null +++ b/src/features/ui/components/modals/nostr-login-modal/nostr-login-modal.tsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; + +import ExtensionStep from './steps/extension-step'; +import KeyAddStep from './steps/key-add-step'; + +type Step = 'extension' | 'key-add'; + +interface INostrLoginModal { + onClose: (type?: string) => void; + step?: Step; +} + +const NostrLoginModal: React.FC = ({ onClose, step: firstStep }) => { + const [step, setStep] = useState(firstStep ?? (window.nostr ? 'extension' : 'key-add')); + + const handleClose = () => onClose('NOSTR_LOGIN'); + + switch (step) { + case 'extension': + return setStep('key-add')} onClose={handleClose} />; + case 'key-add': + return ; + default: + return null; + } +}; + +export default NostrLoginModal; + +export type { Step }; diff --git a/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx b/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx new file mode 100644 index 0000000000..2b1af1f1e7 --- /dev/null +++ b/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { nostrExtensionLogIn } from 'soapbox/actions/nostr'; +import EmojiGraphic from 'soapbox/components/emoji-graphic'; +import { Button, Stack, Modal } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; + +interface IExtensionStep { + onClickAlt: () => void; + onClose(): void; +} + +const ExtensionStep: React.FC = ({ onClickAlt, onClose }) => { + const dispatch = useAppDispatch(); + + const onClick = () => { + dispatch(nostrExtensionLogIn()); + onClose(); + }; + + return ( + } onClose={onClose}> + + + + + + + + + + + ); +}; + +export default ExtensionStep; diff --git a/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx b/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx new file mode 100644 index 0000000000..371efffe69 --- /dev/null +++ b/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx @@ -0,0 +1,71 @@ +import { nip19 } from 'nostr-tools'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { logInNostr } from 'soapbox/actions/nostr'; +import EmojiGraphic from 'soapbox/components/emoji-graphic'; +import { Button, Stack, Modal, Input, FormGroup, Form } from 'soapbox/components/ui'; +import { NKeys } from 'soapbox/features/nostr/keys'; +import { useAppDispatch } from 'soapbox/hooks'; + +import NostrExtensionIndicator from '../components/nostr-extension-indicator'; + +interface IKeyAddStep { + onClose(): void; +} + +const KeyAddStep: React.FC = ({ onClose }) => { + const [nsec, setNsec] = useState(''); + const [error, setError] = useState(); + + const dispatch = useAppDispatch(); + + const handleChange = (e: React.ChangeEvent) => { + setNsec(e.target.value); + setError(undefined); + }; + + const handleSubmit = async () => { + try { + const result = nip19.decode(nsec); + if (result.type === 'nsec') { + const seckey = result.data; + const signer = NKeys.add(seckey); + const pubkey = await signer.getPublicKey(); + dispatch(logInNostr(pubkey)); + onClose(); + } + } catch (e) { + setError('Invalid nsec'); + } + }; + + return ( + } onClose={onClose}> + + + + + +
+ + + + + + + +
+
+
+ ); +}; + +export default KeyAddStep; diff --git a/src/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal.tsx b/src/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal.tsx new file mode 100644 index 0000000000..a5d47acce1 --- /dev/null +++ b/src/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; + +import ExtensionStep from '../nostr-login-modal/steps/extension-step'; + +import KeyStep from './steps/key-step'; +import KeygenStep from './steps/keygen-step'; + +type Step = 'extension' | 'key' | 'keygen'; + +interface INostrSignupModal { + onClose: (type?: string) => void; +} + +const NostrSigninModal: React.FC = ({ onClose }) => { + const [step, setStep] = useState(window.nostr ? 'extension' : 'key'); + + const handleClose = () => onClose('NOSTR_SIGNUP'); + + switch (step) { + case 'extension': + return setStep('key')} onClose={handleClose} />; + case 'key': + return ; + case 'keygen': + return ; + default: + return null; + } +}; + +export default NostrSigninModal; + +export type { Step }; diff --git a/src/features/ui/components/modals/nostr-signup-modal/steps/key-step.tsx b/src/features/ui/components/modals/nostr-signup-modal/steps/key-step.tsx new file mode 100644 index 0000000000..e66d71b69f --- /dev/null +++ b/src/features/ui/components/modals/nostr-signup-modal/steps/key-step.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { openModal } from 'soapbox/actions/modals'; +import EmojiGraphic from 'soapbox/components/emoji-graphic'; +import { Button, Stack, Modal } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; + +import NostrExtensionIndicator from '../../nostr-login-modal/components/nostr-extension-indicator'; +import { Step } from '../nostr-signup-modal'; + +interface IKeyStep { + setStep(step: Step): void; + onClose(): void; +} + +const KeyStep: React.FC = ({ setStep, onClose }) => { + const dispatch = useAppDispatch(); + + const onAltClick = () => { + onClose(); + dispatch(openModal('NOSTR_LOGIN', { step: 'key-add' })); + }; + + return ( + } onClose={onClose}> + + + + + + + + + + + + + ); +}; + +export default KeyStep; diff --git a/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx b/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx new file mode 100644 index 0000000000..4fe7869c2c --- /dev/null +++ b/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx @@ -0,0 +1,81 @@ +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import React, { useEffect, useMemo, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { fetchAccount } from 'soapbox/actions/accounts'; +import { logInNostr } from 'soapbox/actions/nostr'; +import CopyableInput from 'soapbox/components/copyable-input'; +import EmojiGraphic from 'soapbox/components/emoji-graphic'; +import { Button, Stack, Modal, FormGroup, Text, Tooltip } from 'soapbox/components/ui'; +import { NKeys } from 'soapbox/features/nostr/keys'; +import { useAppDispatch, useInstance } from 'soapbox/hooks'; +import { download } from 'soapbox/utils/download'; +import { slugify } from 'soapbox/utils/input'; + +interface IKeygenStep { + onClose(): void; +} + +const KeygenStep: React.FC = ({ onClose }) => { + const instance = useInstance(); + const dispatch = useAppDispatch(); + + const secretKey = useMemo(() => generateSecretKey(), []); + const pubkey = useMemo(() => getPublicKey(secretKey), [secretKey]); + + const nsec = useMemo(() => nip19.nsecEncode(secretKey), [secretKey]); + const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); + + const [downloaded, setDownloaded] = useState(false); + + useEffect(() => { + // Pre-fetch into cache. + dispatch(fetchAccount(pubkey)).catch(() => {}); + }, [pubkey]); + + const handleDownload = () => { + download(nsec, `${slugify(instance.title)}-${npub.slice(5, 9)}.nsec.txt`); + setDownloaded(true); + }; + + const handleCopy = () => setDownloaded(true); + + const handleNext = async () => { + const signer = NKeys.add(secretKey); + const pubkey = await signer.getPublicKey(); + dispatch(logInNostr(pubkey)); + onClose(); + }; + + return ( + } onClose={onClose}> + + + + + + + + + + + + + Back up your secret key in a secure place. If lost, your account cannot be recovered. Never share your secret key with anyone. + + + + + + + + + + ); +}; + +export default KeygenStep; diff --git a/src/features/ui/components/navbar.tsx b/src/features/ui/components/navbar.tsx index bdf05ce7b9..0be4e3948c 100644 --- a/src/features/ui/components/navbar.tsx +++ b/src/features/ui/components/navbar.tsx @@ -5,7 +5,7 @@ import { Link, Redirect } from 'react-router-dom'; import { logIn, verifyCredentials } from 'soapbox/actions/auth'; import { fetchInstance } from 'soapbox/actions/instance'; -import { nostrLogIn } from 'soapbox/actions/nostr'; +import { openModal } from 'soapbox/actions/modals'; import { openSidebar } from 'soapbox/actions/sidebar'; import SiteLogo from 'soapbox/components/site-logo'; import { Avatar, Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui'; @@ -40,9 +40,7 @@ const Navbar = () => { const onOpenSidebar = () => dispatch(openSidebar()); const handleNostrLogin = async () => { - setLoading(true); - await dispatch(nostrLogIn()).catch(console.error); - setLoading(false); + dispatch(openModal('NOSTR_LOGIN')); }; const handleSubmit: React.FormEventHandler = (event) => { diff --git a/src/features/ui/components/panels/sign-up-panel.tsx b/src/features/ui/components/panels/sign-up-panel.tsx index e12ae02c4a..eaf30b41cd 100644 --- a/src/features/ui/components/panels/sign-up-panel.tsx +++ b/src/features/ui/components/panels/sign-up-panel.tsx @@ -1,13 +1,16 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; +import { openModal } from 'soapbox/actions/modals'; import { Button, Stack, Text } from 'soapbox/components/ui'; -import { useAppSelector, useInstance, useRegistrationStatus } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks'; const SignUpPanel = () => { const instance = useInstance(); + const { nostrSignup } = useFeatures(); const { isOpen } = useRegistrationStatus(); const me = useAppSelector((state) => state.me); + const dispatch = useAppDispatch(); if (me || !isOpen) return null; @@ -23,7 +26,12 @@ const SignUpPanel = () => { - diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 952f3c6396..1364a89f25 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -162,3 +162,5 @@ export const EditAnnouncementModal = lazy(() => import('soapbox/features/ui/comp export const FollowedTags = lazy(() => import('soapbox/features/followed-tags')); export const AccountNotePanel = lazy(() => import('soapbox/features/ui/components/panels/account-note-panel')); export const ComposeEditor = lazy(() => import('soapbox/features/compose/editor')); +export const NostrSignupModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal')); +export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-login-modal/nostr-login-modal')); diff --git a/src/hooks/useAppSelector.ts b/src/hooks/useAppSelector.ts index 5769900796..4394afb21d 100644 --- a/src/hooks/useAppSelector.ts +++ b/src/hooks/useAppSelector.ts @@ -1,5 +1,5 @@ import { TypedUseSelectorHook, useSelector } from 'react-redux'; -import { RootState } from 'soapbox/store'; +import type { RootState } from 'soapbox/store'; export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/init/soapbox.tsx b/src/init/soapbox.tsx index e8b657d282..2b3cce2283 100644 --- a/src/init/soapbox.tsx +++ b/src/init/soapbox.tsx @@ -2,6 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { Provider } from 'react-redux'; +import { NostrProvider } from 'soapbox/contexts/nostr-context'; import { StatProvider } from 'soapbox/contexts/stat-context'; import { createGlobals } from 'soapbox/globals'; import { queryClient } from 'soapbox/queries/client'; @@ -29,11 +30,13 @@ const Soapbox: React.FC = () => { - - - - - + + + + + + + diff --git a/src/locales/en.json b/src/locales/en.json index ec2fd06a03..6739b3bc7a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1066,6 +1066,14 @@ "new_group_panel.action": "Create Group", "new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.", "new_group_panel.title": "Create Group", + "nostr_extension.found": "Sign in with browser extension.", + "nostr_extension.not_found": "Browser extension not found.", + "nostr_signup.key-add.title": "Import Key", + "nostr_signup.key.title": "You need a key to continue", + "nostr_signup.keygen.title": "Your new key", + "nostr_signup.siwe.action": "Sign in with extension", + "nostr_signup.siwe.alt": "Sign in with key", + "nostr_signup.siwe.title": "Sign in", "notification.favourite": "{name} liked your post", "notification.follow": "{name} followed you", "notification.follow_request": "{name} has requested to follow you", diff --git a/src/main.tsx b/src/main.tsx index 6bcd61e543..a204848da6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,7 @@ import '@fontsource/inter/900.css'; import '@fontsource/roboto-mono/400.css'; import 'line-awesome/dist/font-awesome-line-awesome/css/all.css'; import 'react-datepicker/dist/react-datepicker.css'; +import 'soapbox/features/nostr/keys'; import './iframe'; import './styles/i18n/arabic.css'; diff --git a/src/schemas/account.ts b/src/schemas/account.ts index b419b13cf2..725cffefa0 100644 --- a/src/schemas/account.ts +++ b/src/schemas/account.ts @@ -1,3 +1,4 @@ +import { NSchema as n } from '@soapbox/nspec'; import escapeTextContentForBrowser from 'escape-html'; import DOMPurify from 'isomorphic-dompurify'; import z from 'zod'; @@ -32,6 +33,7 @@ const baseAccountSchema = z.object({ display_name: z.string().catch(''), ditto: coerceObject({ accepts_zaps: z.boolean().catch(false), + is_registered: z.boolean().catch(false), }), emojis: filteredArray(customEmojiSchema), fields: filteredArray(fieldSchema), @@ -50,6 +52,9 @@ const baseAccountSchema = z.object({ z.string(), z.null(), ]).catch(null), + nostr: coerceObject({ + pubkey: n.id().optional().catch(undefined), + }), note: contentSchema, /** Fedibird extra settings. */ other_settings: z.object({ diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 549bd497ad..087b3f24e8 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,4 +1,4 @@ -import { verifySignature } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools'; import { z } from 'zod'; /** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ @@ -22,7 +22,7 @@ const eventSchema = eventTemplateSchema.extend({ }); /** Nostr event schema that also verifies the event's signature. */ -const signedEventSchema = eventSchema.refine(verifySignature); +const signedEventSchema = eventSchema.refine(verifyEvent); /** NIP-46 signer options. */ const signEventOptsSchema = z.object({ diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 71fc2587e8..b155a9f00e 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -1,4 +1,4 @@ -import type { NostrSigner } from 'nspec'; +import type { NostrSigner } from '@soapbox/nspec'; declare global { interface Window { diff --git a/src/utils/input.ts b/src/utils/input.ts index e9d8c2d851..8a91edfccc 100644 --- a/src/utils/input.ts +++ b/src/utils/input.ts @@ -8,6 +8,15 @@ const normalizeUsername = (username: string): string => { } }; +function slugify(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\w]/g, '-') // replace non-word characters with a hyphen + .replace(/-+/g, '-'); // replace multiple hyphens with a single hyphen +} + export { normalizeUsername, + slugify, }; \ No newline at end of file diff --git a/src/workers/pow.worker.ts b/src/workers/pow.worker.ts index dcfb948e5a..24c3420185 100644 --- a/src/workers/pow.worker.ts +++ b/src/workers/pow.worker.ts @@ -2,7 +2,7 @@ import * as Comlink from 'comlink'; import { nip13, type UnsignedEvent } from 'nostr-tools'; export const PowWorker = { - mine(event: UnsignedEvent, difficulty: number) { + mine(event: UnsignedEvent, difficulty: number) { return nip13.minePow(event, difficulty); }, }; diff --git a/tsconfig.json b/tsconfig.json index 7b6322fb10..806a33b464 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "target": "ESNext", "jsx": "react", "allowJs": true, - "moduleResolution": "node", + "moduleResolution": "Bundler", "resolveJsonModule": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/vite.config.ts b/vite.config.ts index aa5320e618..a174b06441 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({ }, assetsInclude: ['**/*.oga'], server: { - port: 3036, + port: Number(process.env.PORT ?? 3036), }, optimizeDeps: { exclude: command === 'serve' ? ['@soapbox.pub/wasmboy'] : [], diff --git a/yarn.lock b/yarn.lock index 46431ed94a..8a450c10bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1840,11 +1840,16 @@ lodash "^4.17.21" mousetrap "^1.6.5" -"@noble/ciphers@0.2.0", "@noble/ciphers@^0.2.0": +"@noble/ciphers@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.2.0.tgz#a12cda60f3cf1ab5d7c77068c3711d2366649ed7" integrity sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw== +"@noble/ciphers@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.1.tgz#292f388b69c9ed80d49dca1a5cbfd4ff06852111" + integrity sha512-aNE06lbe36ifvMbbWvmmF/8jx6EQPu2HVg70V95T+iGjOuYwPpAccwAQc2HlXO2D0aiQ3zavbMga4jjWnrpiPA== + "@noble/curves@1.1.0", "@noble/curves@~1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" @@ -2258,6 +2263,25 @@ raf "^3.4.0" responsive-gamepad "1.1.0" +"@soapbox/nspec@npm:@jsr/soapbox__nspec": + version "0.6.0" + resolved "https://npm.jsr.io/~/6/@jsr/soapbox__nspec/0.6.0.tgz#60A75BCDBEC1B76DFA91BEFDF5505CCB8ADDAD3B" + integrity sha512-HY+MssBjm532J9SAqLek8YGxBlEaXdT1Eek3bOWkq4uLJxipJhYkdQrW+NzXhfVvGZUt6YXBobeSqRQx1JFgkQ== + dependencies: + "@noble/hashes" "^1.3.3" + "@scure/base" "^1.1.5" + "@scure/bip32" "^1.3.3" + "@scure/bip39" "^1.2.2" + lru-cache "^10.2.0" + nostr-tools "^2.3.1" + websocket-ts "^2.1.5" + zod "^3.22.4" + +"@soapbox/weblock@npm:@jsr/soapbox__weblock": + version "0.1.0" + resolved "https://npm.jsr.io/~/7/@jsr/soapbox__weblock/0.1.0.tgz#749AEE0872D23CC4E37366D5F0D092B87986C5E1" + integrity sha512-FLLJL6xYk+k7f2bMXJ1nbcn3lhbEZXA0yboKLm8wns0hrcoEDOrWwmxkYF7xpVRndiAzFBctBGVbIAa3sA72ew== + "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" @@ -6571,7 +6595,7 @@ nostr-machina@^0.1.0: nostr-tools "^1.14.0" zod "^3.21.0" -nostr-tools@^1.14.0, nostr-tools@^1.14.2: +nostr-tools@^1.14.0: version "1.16.0" resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.16.0.tgz#5867f1d8bd055a5a3b27aadb199457dceb244314" integrity sha512-sx/aOl0gmkeHVoIVbyOhEQhzF88NsrBXMC8bsjhPASqA6oZ8uSOAyEGgRLMfC3SKgzQD5Gr6KvDoAahaD6xKcg== @@ -6583,12 +6607,26 @@ nostr-tools@^1.14.0, nostr-tools@^1.14.2: "@scure/bip32" "1.3.1" "@scure/bip39" "1.2.1" -nostr-tools@^2.1.4: - version "2.1.5" - resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.1.5.tgz#d38ac1139343cf13654841b8727bab8dd70563eb" - integrity sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA== +nostr-tools@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.3.0.tgz#687d1af486a21e3e4805f0d4167c01221d871e65" + integrity sha512-jWD71y9JJ7DJ5/Si/DhREkjwyCWgMmY7x8qXfA9xC1HeosoGnaXuyYtspfYuiy8B8B2969C1iR6rWt6Fyf3IaA== dependencies: - "@noble/ciphers" "0.2.0" + "@noble/ciphers" "^0.5.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" + optionalDependencies: + nostr-wasm v0.1.0 + +nostr-tools@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.3.1.tgz#348d3c4aab0ab00716f93d6e2a72333d8c7da982" + integrity sha512-qjKx2C3EzwiQOe2LPSPyCnp07pGz1pWaWjDXcm+L2y2c8iTECbvlzujDANm3nJUjWL5+LVRUVDovTZ1a/DC4Bg== + dependencies: + "@noble/ciphers" "^0.5.1" "@noble/curves" "1.2.0" "@noble/hashes" "1.3.1" "@scure/base" "1.1.1" @@ -6616,18 +6654,6 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" -nspec@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/nspec/-/nspec-0.1.0.tgz#abde817cf34cb042d7315a70cf515037e489401b" - integrity sha512-HPVyFFVR2x49K7HJzEjlvvBR7x5t79G6bh7/SQvfm25hXVFq9xvYBQ6i3nluwJkizcBxm+fvErM5yqJEnM/1tA== - dependencies: - "@scure/base" "^1.1.5" - "@scure/bip32" "^1.3.3" - "@scure/bip39" "^1.2.2" - lru-cache "^10.2.0" - nostr-tools "^2.1.4" - zod "^3.22.4" - nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -9276,6 +9302,11 @@ webpack-sources@^3.2.3: watchpack "^2.4.0" webpack-sources "^3.2.3" +websocket-ts@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/websocket-ts/-/websocket-ts-2.1.5.tgz#b6b51f0afca89d6bc7ff71c9e74540f19ae0262c" + integrity sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA== + whatwg-encoding@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" @@ -9649,7 +9680,7 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zod@^3.21.0, zod@^3.21.4: +zod@^3.21.0: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==