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 = () => {
-