+
-
- {formatTime(Math.floor(currentTime))}
+
+ {formatTime(Math.floor(currentTime))}
{getDuration() && (<>
- / {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
- {formatTime(Math.floor(getDuration()))}
+ /{/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
+ {formatTime(Math.floor(getDuration()))}
>)}
-
diff --git a/src/features/compose/components/search-zap-split.tsx b/src/features/compose/components/search-zap-split.tsx
index aa222f5e9..d2873f828 100644
--- a/src/features/compose/components/search-zap-split.tsx
+++ b/src/features/compose/components/search-zap-split.tsx
@@ -96,8 +96,6 @@ const SearchZapSplit = (props: ISearchZapSplit) => {
const getAccount = (accountId: string) => (dispatch: any, getState: () => RootState) => {
const account = selectAccount(getState(), accountId);
- console.log(account);
-
props.onChange(account!);
};
diff --git a/src/features/nostr-relays/index.tsx b/src/features/nostr-relays/index.tsx
index 345abeb0a..6b64adbaa 100644
--- a/src/features/nostr-relays/index.tsx
+++ b/src/features/nostr-relays/index.tsx
@@ -5,6 +5,7 @@ import { Button, Column, Form, FormActions, Stack } from 'soapbox/components/ui'
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq';
import { useOwnAccount } from 'soapbox/hooks';
+import { useSigner } from 'soapbox/hooks/nostr/useSigner';
import RelayEditor, { RelayData } from './components/relay-editor';
@@ -15,7 +16,8 @@ const messages = defineMessages({
const NostrRelays = () => {
const intl = useIntl();
const { account } = useOwnAccount();
- const { relay, signer } = useNostr();
+ const { relay } = useNostr();
+ const { signer } = useSigner();
const { events } = useNostrReq(
account?.nostr?.pubkey
diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts
index 3bbe67b90..078d22a83 100644
--- a/src/features/nostr/NBunker.ts
+++ b/src/features/nostr/NBunker.ts
@@ -1,73 +1,131 @@
-import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify';
+import {
+ NRelay,
+ NostrConnectRequest,
+ NostrConnectResponse,
+ NostrEvent,
+ NostrFilter,
+ NostrSigner,
+ NSchema as n,
+} from '@nostrify/nostrify';
-interface NBunkerOpts {
+/** Options passed to `NBunker`. */
+export interface NBunkerOpts {
+ /** Relay to subscribe to for NIP-46 requests. */
relay: NRelay;
- signer: NostrSigner;
- authorizedPubkey: string | undefined;
- onAuthorize(pubkey: string): void;
- onSubscribed(): void;
- getSecret(): string;
+ /** Signer to complete the actual NIP-46 requests, such as `get_public_key`, `sign_event`, `nip44_encrypt` etc. */
+ userSigner: NostrSigner;
+ /** Signer to sign, encrypt, and decrypt the kind 24133 transport events events. */
+ bunkerSigner: NostrSigner;
+ /**
+ * Callback when a `connect` request has been received.
+ * This is a good place to call `bunker.authorize()` with the remote client's pubkey.
+ * It's up to the caller to verify the request parameters and secret, and then return a response object.
+ * All other methods are handled by the bunker automatically.
+ *
+ * ```ts
+ * const bunker = new Bunker({
+ * ...opts,
+ * onConnect(request, event) {
+ * const [, secret] = request.params;
+ *
+ * if (secret === authorization.secret) {
+ * bunker.authorize(event.pubkey); // Authorize the pubkey for signer actions.
+ * return { id: request.id, result: 'ack' }; // Return a success response.
+ * } else {
+ * return { id: request.id, result: '', error: 'Invalid secret' };
+ * }
+ * },
+ * });
+ * ```
+ */
+ onConnect?(request: NostrConnectRequest, event: NostrEvent): Promise
| NostrConnectResponse;
+ /**
+ * Callback when an error occurs while parsing a request event.
+ * Client errors are not captured here, only errors that occur before arequest's `id` can be known,
+ * eg when decrypting the event content or parsing the request object.
+ */
+ onError?(error: unknown, event: NostrEvent): void;
}
+/**
+ * Modular NIP-46 remote signer bunker class.
+ *
+ * Runs a remote signer against a given relay, using `bunkerSigner` to sign transport events,
+ * and `userSigner` to complete NIP-46 requests.
+ */
export class NBunker {
- private relay: NRelay;
- private signer: NostrSigner;
- private authorizedPubkey: string | undefined;
- private onAuthorize: (pubkey: string) => void;
- private onSubscribed: () => void;
- private getSecret: () => string;
-
private controller = new AbortController();
+ private authorizedPubkeys = new Set();
- constructor(opts: NBunkerOpts) {
- this.relay = opts.relay;
- this.signer = opts.signer;
- this.authorizedPubkey = opts.authorizedPubkey;
- this.onAuthorize = opts.onAuthorize;
- this.onSubscribed = opts.onSubscribed;
- this.getSecret = opts.getSecret;
+ /** Wait for the bunker to be ready before sending requests. */
+ public waitReady: Promise;
+ private setReady!: () => void;
+ constructor(private opts: NBunkerOpts) {
+ this.waitReady = new Promise((resolve) => {
+ this.setReady = resolve;
+ });
this.open();
}
- async open() {
- const pubkey = await this.signer.getPublicKey();
- const signal = this.controller.signal;
+ /** Open the signer subscription to the relay. */
+ private async open() {
+ const { relay, bunkerSigner, onError } = this.opts;
- const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal });
- this.onSubscribed();
+ const signal = this.controller.signal;
+ const bunkerPubkey = await bunkerSigner.getPublicKey();
+
+ const filters: NostrFilter[] = [
+ { kinds: [24133], '#p': [bunkerPubkey], limit: 0 },
+ ];
+
+ const sub = relay.req(filters, { signal });
+ this.setReady();
for await (const msg of sub) {
if (msg[0] === 'EVENT') {
- const event = msg[2];
- this.handleEvent(event);
+ const [,, event] = msg;
+
+ try {
+ const decrypted = await this.decrypt(event.pubkey, event.content);
+ const request = n.json().pipe(n.connectRequest()).parse(decrypted);
+ await this.handleRequest(request, event);
+ } catch (error) {
+ onError?.(error, event);
+ }
}
}
}
- private async handleEvent(event: NostrEvent): Promise {
- const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content);
- const request = n.json().pipe(n.connectRequest()).safeParse(decrypted);
+ /**
+ * Handle NIP-46 requests.
+ *
+ * The `connect` method must be handled passing an `onConnect` option into the class
+ * and then calling `bunker.authorize()` within that callback to authorize the pubkey.
+ *
+ * All other methods are handled automatically, as long as the key is authorized,
+ * by invoking the appropriate method on the `userSigner`.
+ */
+ private async handleRequest(request: NostrConnectRequest, event: NostrEvent): Promise {
+ const { userSigner, onConnect } = this.opts;
+ const { pubkey } = event;
- if (!request.success) {
- console.warn(decrypted);
- console.warn(request.error);
- return;
- }
-
- await this.handleRequest(event.pubkey, request.data);
- }
-
- private async handleRequest(pubkey: string, request: NostrConnectRequest): Promise {
- // Connect is a special case. Any pubkey can try to request it.
if (request.method === 'connect') {
- return this.handleConnect(pubkey, request as NostrConnectRequest & { method: 'connect' });
+ if (onConnect) {
+ const response = await onConnect(request, event);
+ return this.sendResponse(pubkey, response);
+ }
+ return;
}
// Prevent unauthorized access.
- if (pubkey !== this.authorizedPubkey) {
- return;
+ if (!this.authorizedPubkeys.has(pubkey)) {
+ return this.sendResponse(pubkey, {
+ id: request.id,
+ result: '',
+ error: 'Unauthorized',
+ });
}
// Authorized methods.
@@ -75,7 +133,7 @@ export class NBunker {
case 'sign_event':
return this.sendResponse(pubkey, {
id: request.id,
- result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))),
+ result: JSON.stringify(await userSigner.signEvent(JSON.parse(request.params[0]))),
});
case 'ping':
return this.sendResponse(pubkey, {
@@ -85,32 +143,32 @@ export class NBunker {
case 'get_relays':
return this.sendResponse(pubkey, {
id: request.id,
- result: JSON.stringify(await this.signer.getRelays?.() ?? []),
+ result: JSON.stringify(await userSigner.getRelays?.() ?? []),
});
case 'get_public_key':
return this.sendResponse(pubkey, {
id: request.id,
- result: await this.signer.getPublicKey(),
+ result: await userSigner.getPublicKey(),
});
case 'nip04_encrypt':
return this.sendResponse(pubkey, {
id: request.id,
- result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]),
+ result: await userSigner.nip04!.encrypt(request.params[0], request.params[1]),
});
case 'nip04_decrypt':
return this.sendResponse(pubkey, {
id: request.id,
- result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]),
+ result: await userSigner.nip04!.decrypt(request.params[0], request.params[1]),
});
case 'nip44_encrypt':
return this.sendResponse(pubkey, {
id: request.id,
- result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]),
+ result: await userSigner.nip44!.encrypt(request.params[0], request.params[1]),
});
case 'nip44_decrypt':
return this.sendResponse(pubkey, {
id: request.id,
- result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]),
+ result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]),
});
default:
return this.sendResponse(pubkey, {
@@ -121,33 +179,49 @@ export class NBunker {
}
}
- private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) {
- const [remotePubkey, secret] = request.params;
+ /** Encrypt the response with the bunker key, then publish it to the relay. */
+ private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise {
+ const { bunkerSigner, relay } = this.opts;
- if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) {
- this.authorizedPubkey = pubkey;
- this.onAuthorize(pubkey);
+ const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response));
- await this.sendResponse(pubkey, {
- id: request.id,
- result: 'ack',
- });
- }
- }
-
- private async sendResponse(pubkey: string, response: NostrConnectResponse) {
- const event = await this.signer.signEvent({
+ const event = await bunkerSigner.signEvent({
kind: 24133,
- content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)),
+ content,
tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
- await this.relay.event(event);
+ await relay.event(event);
}
- close() {
+ /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
+ private async decrypt(pubkey: string, ciphertext: string): Promise {
+ const { bunkerSigner } = this.opts;
+ try {
+ return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext);
+ } catch {
+ return await bunkerSigner.nip04!.decrypt(pubkey, ciphertext);
+ }
+ }
+
+ /** Authorize the pubkey to perform signer actions (ie any other actions besides `connect`). */
+ authorize(pubkey: string): void {
+ this.authorizedPubkeys.add(pubkey);
+ }
+
+ /** Revoke authorization for the pubkey. */
+ revoke(pubkey: string): void {
+ this.authorizedPubkeys.delete(pubkey);
+ }
+
+ /** Stop the bunker and unsubscribe relay subscriptions. */
+ close(): void {
this.controller.abort();
}
+ [Symbol.dispose](): void {
+ this.close();
+ }
+
}
\ No newline at end of file
diff --git a/src/features/nostr/NKeyStorage.ts b/src/features/nostr/NKeyring.ts
similarity index 97%
rename from src/features/nostr/NKeyStorage.ts
rename to src/features/nostr/NKeyring.ts
index a17435e6a..81ac2b1ed 100644
--- a/src/features/nostr/NKeyStorage.ts
+++ b/src/features/nostr/NKeyring.ts
@@ -8,7 +8,7 @@ import { z } from 'zod';
* When instantiated, it will lock the storage key to prevent tampering.
* Changes to the object will sync to storage.
*/
-export class NKeyStorage implements ReadonlyMap {
+export class NKeyring implements ReadonlyMap {
#keypairs = new Map();
#storage: Storage;
diff --git a/src/features/nostr/hooks/useNostrReq.ts b/src/features/nostr/hooks/useNostrReq.ts
index 586f51536..1d4a01a6e 100644
--- a/src/features/nostr/hooks/useNostrReq.ts
+++ b/src/features/nostr/hooks/useNostrReq.ts
@@ -5,7 +5,13 @@ import { useEffect, useRef, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useForceUpdate } from 'soapbox/hooks/useForceUpdate';
-/** Streams events from the relay for the given filters. */
+/**
+ * Streams events from the relay for the given filters.
+ *
+ * @deprecated Add a custom HTTP endpoint to Ditto instead.
+ * Integrating Nostr directly has too many problems.
+ * Soapbox should only connect to the Nostr relay to sign events, because it's required for Nostr to work.
+ */
export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } {
const { relay } = useNostr();
diff --git a/src/features/nostr/keyring.ts b/src/features/nostr/keyring.ts
new file mode 100644
index 000000000..6ae33502a
--- /dev/null
+++ b/src/features/nostr/keyring.ts
@@ -0,0 +1,6 @@
+import { NKeyring } from './NKeyring';
+
+export const keyring = new NKeyring(
+ localStorage,
+ 'soapbox:nostr:keys',
+);
diff --git a/src/features/nostr/keys.ts b/src/features/nostr/keys.ts
deleted file mode 100644
index 92f9fc09f..000000000
--- a/src/features/nostr/keys.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { NKeyStorage } from './NKeyStorage';
-
-export const NKeys = new NKeyStorage(
- localStorage,
- 'soapbox:nostr:keys',
-);
diff --git a/src/features/placeholder/components/placeholder-media-gallery.tsx b/src/features/placeholder/components/placeholder-media-gallery.tsx
index 09dc505e7..247260d05 100644
--- a/src/features/placeholder/components/placeholder-media-gallery.tsx
+++ b/src/features/placeholder/components/placeholder-media-gallery.tsx
@@ -78,13 +78,13 @@ const PlaceholderMediaGallery: React.FC = ({ media, de
const float = dimensions.float as any || 'left';
const position = dimensions.pos as any || 'relative';
- return ;
+ return ;
};
const sizeData = getSizeData(media.size);
return (
-
+
{media.take(4).map((_, i) => renderItem(sizeData.get('itemsDimensions')[i], i))}
);
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
index 33cbccc3c..adc56b707 100644
--- 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
@@ -5,30 +5,52 @@ 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 { useNostr } from 'soapbox/contexts/nostr-context';
import { useAppDispatch } from 'soapbox/hooks';
const NostrExtensionIndicator: React.FC = () => {
const dispatch = useAppDispatch();
+ const { relay } = useNostr();
const onClick = () => {
- dispatch(nostrExtensionLogIn());
- dispatch(closeModal());
+ if (relay) {
+ dispatch(nostrExtensionLogIn(relay));
+ dispatch(closeModal());
+ }
};
+ function renderBody(): React.ReactNode {
+ if (window.nostr && window.nostr.nip44) {
+ return (
+
,
+ }}
+ />
+ );
+ } else if (window.nostr) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
return (
- {window.nostr ? (
- ,
- }}
- />
- ) : (
-
- )}
+ {renderBody()}
);
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
index b46df75d0..36688eb16 100644
--- 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
@@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, Text, Divider, HStack } from 'soapbox/components/ui';
+import { useNostr } from 'soapbox/contexts/nostr-context';
import { useAppDispatch, useInstance, useSoapboxConfig } from 'soapbox/hooks';
interface IExtensionStep {
@@ -18,6 +19,7 @@ const ExtensionStep: React.FC = ({ isLogin, onClickAlt, onClose
const dispatch = useAppDispatch();
const { instance } = useInstance();
const { logo } = useSoapboxConfig();
+ const { relay } = useNostr();
const handleClose = () => {
onClose();
@@ -25,8 +27,10 @@ const ExtensionStep: React.FC = ({ isLogin, onClickAlt, onClose
};
const onClick = () => {
- dispatch(nostrExtensionLogIn());
- onClose();
+ if (relay) {
+ dispatch(nostrExtensionLogIn(relay));
+ onClose();
+ }
};
return (
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
index a9fbec91a..44f5b8cf7 100644
--- 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
@@ -5,7 +5,8 @@ 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, Divider } from 'soapbox/components/ui';
-import { NKeys } from 'soapbox/features/nostr/keys';
+import { useNostr } from 'soapbox/contexts/nostr-context';
+import { keyring } from 'soapbox/features/nostr/keyring';
import { useAppDispatch } from 'soapbox/hooks';
import NostrExtensionIndicator from '../components/nostr-extension-indicator';
@@ -19,6 +20,7 @@ const KeyAddStep: React.FC = ({ onClose }) => {
const [error, setError] = useState();
const dispatch = useAppDispatch();
+ const { relay } = useNostr();
const handleChange = (e: React.ChangeEvent) => {
setNsec(e.target.value);
@@ -26,13 +28,13 @@ const KeyAddStep: React.FC = ({ onClose }) => {
};
const handleSubmit = async () => {
+ if (!relay) return;
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));
+ const signer = keyring.add(seckey);
+ dispatch(logInNostr(signer, relay));
onClose();
return;
}
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
index 0fb4e8178..7ae2e6164 100644
--- 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
@@ -8,7 +8,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack, Input } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
-import { NKeys } from 'soapbox/features/nostr/keys';
+import { keyring } from 'soapbox/features/nostr/keyring';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
import { download } from 'soapbox/utils/download';
@@ -43,8 +43,9 @@ const KeygenStep: React.FC = ({ onClose }) => {
};
const handleNext = async () => {
- const signer = NKeys.add(secretKey);
- const pubkey = await signer.getPublicKey();
+ if (!relay) return;
+
+ const signer = keyring.add(secretKey);
const now = Math.floor(Date.now() / 1000);
const [kind0, ...events] = await Promise.all([
@@ -57,12 +58,12 @@ const KeygenStep: React.FC = ({ onClose }) => {
signer.signEvent({ kind: 30078, content: '', tags: [['d', 'pub.ditto.pleroma_settings_store']], created_at: now }),
]);
- await relay?.event(kind0);
- await Promise.all(events.map((event) => relay?.event(event)));
+ await relay.event(kind0);
+ await Promise.all(events.map((event) => relay.event(event)));
onClose();
- await dispatch(logInNostr(pubkey));
+ await dispatch(logInNostr(signer, relay));
if (isMobile) {
dispatch(closeSidebar());
diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx
index 1aabb568f..217a3169f 100644
--- a/src/features/ui/index.tsx
+++ b/src/features/ui/index.tsx
@@ -424,7 +424,7 @@ const UI: React.FC = ({ children }) => {
if (account.staff) {
dispatch(fetchReports({ resolved: false }));
- dispatch(fetchUsers(['local', 'need_approval']));
+ dispatch(fetchUsers({ pending: true }));
}
if (account.admin) {
diff --git a/src/features/video/index.tsx b/src/features/video/index.tsx
index 028c1188a..d3e7a574a 100644
--- a/src/features/video/index.tsx
+++ b/src/features/video/index.tsx
@@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import Blurhash from 'soapbox/components/blurhash';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
+import { useIsMobile } from 'soapbox/hooks/useIsMobile';
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media-aspect-ratio';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
@@ -129,11 +130,13 @@ const Video: React.FC = ({
blurhash,
}) => {
const intl = useIntl();
+ const isMobile = useIsMobile();
const player = useRef(null);
const video = useRef(null);
const seek = useRef(null);
const slider = useRef(null);
+ const timeoutRef = useRef(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
@@ -144,6 +147,7 @@ const Video: React.FC = ({
const [containerWidth, setContainerWidth] = useState(width);
const [fullscreen, setFullscreen] = useState(false);
const [hovered, setHovered] = useState(false);
+ const [volumeHovered, setVolumeHovered] = useState(false);
const [seekHovered, setSeekHovered] = useState(false);
const [muted, setMuted] = useState(false);
const [buffer, setBuffer] = useState(0);
@@ -222,6 +226,9 @@ const Video: React.FC = ({
if (video.current) {
video.current.volume = slideamt;
+ const isMuted = slideamt <= 0;
+ video.current.muted = isMuted;
+ setMuted(isMuted);
}
setVolume(slideamt);
@@ -382,13 +389,6 @@ const Video: React.FC = ({
setFullscreen(isFullscreen());
}, []);
- const handleMouseEnter = () => {
- setHovered(true);
- };
-
- const handleMouseLeave = () => {
- setHovered(false);
- };
const handleSeekEnter = () => {
setSeekHovered(true);
};
@@ -397,10 +397,43 @@ const Video: React.FC = ({
setSeekHovered(false);
};
+ const handleVolumeEnter = (e: React.MouseEvent) => {
+ if (isMobile) return;
+
+ setVolumeHovered(true);
+ };
+
+ const handleVolumeLeave = (e: React.MouseEvent) => {
+ if (isMobile) return;
+
+ setVolumeHovered(false);
+ };
+
+ const handleClickStart = () => {
+ setHovered(true);
+
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ timeoutRef.current = setTimeout(() => {
+ setHovered(false);
+ }, 2 * 1000);
+
+ };
+
+ const handleOnMouseMove = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ handleClickStart();
+ };
+
const toggleMute = () => {
if (video.current) {
const muted = !video.current.muted;
- setMuted(!muted);
+ setMuted(muted);
video.current.muted = muted;
if (muted) {
@@ -434,9 +467,17 @@ const Video: React.FC = ({
}
};
+ const handleTogglePlay = () => {
+ if (!isMobile || paused || hovered) togglePlay();
+ };
+
const progress = (currentTime / duration) * 100;
const playerStyle: React.CSSProperties = {};
+ const startTimeout = () => {
+ timeoutRef.current = setTimeout(() => setHovered(false), 1000);
+ };
+
if (inline && containerWidth) {
width = containerWidth;
const minSize = containerWidth / (16 / 9);
@@ -481,10 +522,13 @@ const Video: React.FC = ({
return (
@@ -506,7 +550,7 @@ const Video: React.FC
= ({
})}
width={width}
height={height || DEFAULT_HEIGHT}
- onClick={togglePlay}
+ onClick={handleTogglePlay}
onKeyDown={handleVideoKeyDown}
onPlay={handlePlay}
onPause={handlePause}
@@ -516,7 +560,9 @@ const Video: React.FC = ({
onVolumeChange={handleVolumeChange}
/>
-
+
= ({
type='button'
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
- onMouseEnter={handleMouseEnter}
- onMouseLeave={handleMouseLeave}
+ onMouseEnter={handleVolumeEnter}
+ onMouseLeave={handleVolumeLeave}
className={clsx('inline-block flex-none border-0 bg-transparent px-[6px] py-[5px] text-[16px] text-white/75 opacity-75 outline-none hover:text-white hover:opacity-100 focus:text-white focus:opacity-100 active:text-white active:opacity-100 '
, { 'py-[10px]': fullscreen })}
onClick={toggleMute}
@@ -569,9 +615,9 @@ const Video: React.FC
= ({
= ({
/>
@@ -628,4 +674,4 @@ const Video: React.FC
= ({
);
};
-export default Video;
+export default Video;
\ No newline at end of file
diff --git a/src/hooks/nostr/useBunker.ts b/src/hooks/nostr/useBunker.ts
new file mode 100644
index 000000000..6cb7058ca
--- /dev/null
+++ b/src/hooks/nostr/useBunker.ts
@@ -0,0 +1,31 @@
+import { useEffect } from 'react';
+
+import { useNostr } from 'soapbox/contexts/nostr-context';
+import { NBunker } from 'soapbox/features/nostr/NBunker';
+import { useSigner } from 'soapbox/hooks/nostr/useSigner';
+
+function useBunker() {
+ const { relay } = useNostr();
+ const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
+
+ useEffect(() => {
+ if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return;
+
+ const bunker = new NBunker({
+ relay,
+ userSigner,
+ bunkerSigner,
+ onError(error, event) {
+ console.warn('Bunker error:', error, event);
+ },
+ });
+
+ bunker.authorize(authorizedPubkey);
+
+ return () => {
+ bunker.close();
+ };
+ }, [relay, userSigner, bunkerSigner, authorizedPubkey]);
+}
+
+export { useBunker };
diff --git a/src/hooks/nostr/useBunkerStore.ts b/src/hooks/nostr/useBunkerStore.ts
new file mode 100644
index 000000000..2608df303
--- /dev/null
+++ b/src/hooks/nostr/useBunkerStore.ts
@@ -0,0 +1,84 @@
+import { NSchema as n } from '@nostrify/nostrify';
+import { produce } from 'immer';
+import { z } from 'zod';
+import { create } from 'zustand';
+// eslint-disable-next-line import/extensions
+import { persist } from 'zustand/middleware';
+
+import { filteredArray, jsonSchema } from 'soapbox/schemas/utils';
+
+/**
+ * A bunker connection maps an OAuth token from Mastodon API to a user pubkey and bunker keypair.
+ * The user pubkey is used to determine whether to use keys from localStorage or a browser extension,
+ * and the bunker keypair is used to sign and encrypt NIP-46 messages.
+ */
+interface BunkerConnection {
+ /** User pubkey. Events will be signed by this pubkey. */
+ pubkey: string;
+ /** Mastodon API access token associated with this connection. */
+ accessToken: string;
+ /** Pubkey of the app authorized to sign events with this connection. */
+ authorizedPubkey: string;
+ /** Pubkey for this connection. Secret key is stored in the keyring. NIP-46 responses will be signed by this key. */
+ bunkerPubkey: string;
+}
+
+const connectionSchema: z.ZodType = z.object({
+ pubkey: n.id(),
+ accessToken: z.string(),
+ authorizedPubkey: n.id(),
+ bunkerPubkey: n.id(),
+});
+
+interface BunkerState {
+ connections: BunkerConnection[];
+ connect(connection: BunkerConnection): void;
+ revoke(accessToken: string): void;
+}
+
+export const useBunkerStore = create()(
+ persist(
+ (setState) => ({
+ connections: [],
+
+ /** Connect to a bunker using the authorization secret. */
+ connect(connection: BunkerConnection): void {
+ setState((state) => {
+ return produce(state, (draft) => {
+ draft.connections.push(connection);
+ });
+ });
+ },
+
+ /** Revoke any connections associated with the access token. */
+ revoke(accessToken: string): void {
+ setState((state) => {
+ return produce(state, (draft) => {
+ draft.connections = draft.connections.filter((conn) => conn.accessToken !== accessToken);
+ });
+ });
+ },
+ }),
+ {
+ name: 'soapbox:bunker',
+ storage: {
+ getItem(name) {
+ const value = localStorage.getItem(name);
+
+ const connections = jsonSchema()
+ .pipe(filteredArray(connectionSchema))
+ .catch([])
+ .parse(value);
+
+ return { state: { connections } };
+ },
+ setItem(name, { state }) {
+ localStorage.setItem(name, JSON.stringify(state.connections));
+ },
+ removeItem(name) {
+ localStorage.removeItem(name);
+ },
+ },
+ },
+ ),
+);
diff --git a/src/hooks/nostr/useSigner.ts b/src/hooks/nostr/useSigner.ts
new file mode 100644
index 000000000..8541a2430
--- /dev/null
+++ b/src/hooks/nostr/useSigner.ts
@@ -0,0 +1,49 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+
+import { keyring } from 'soapbox/features/nostr/keyring';
+import { useAppSelector } from 'soapbox/hooks';
+import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
+
+export function useSigner() {
+ const { connections } = useBunkerStore();
+
+ const connection = useAppSelector(({ auth }) => {
+ const accessToken = auth.me ? auth.users[auth.me]?.access_token : undefined;
+ if (accessToken) {
+ return connections.find((conn) => conn.accessToken === accessToken);
+ }
+ });
+
+ const { pubkey, bunkerPubkey, authorizedPubkey } = connection ?? {};
+
+ const { data: signer, ...rest } = useQuery({
+ queryKey: ['nostr', 'signer', pubkey ?? ''],
+ queryFn: async () => {
+ if (!pubkey) return null;
+
+ const signer = keyring.get(pubkey);
+ if (signer) return signer;
+
+ if (window.nostr && await window.nostr.getPublicKey() === pubkey) {
+ return window.nostr;
+ }
+
+ return null;
+ },
+ enabled: !!pubkey,
+ });
+
+ const bunkerSigner = useMemo(() => {
+ if (bunkerPubkey) {
+ return keyring.get(bunkerPubkey);
+ }
+ }, [bunkerPubkey]);
+
+ return {
+ signer: signer ?? undefined,
+ bunkerSigner,
+ authorizedPubkey,
+ ...rest,
+ };
+}
\ No newline at end of file
diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx
index 28808726e..7ad79e5a0 100644
--- a/src/init/soapbox-load.tsx
+++ b/src/init/soapbox-load.tsx
@@ -3,7 +3,6 @@ import { IntlProvider } from 'react-intl';
import { fetchMe } from 'soapbox/actions/me';
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
-import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream';
import LoadingScreen from 'soapbox/components/loading-screen';
import { useNostr } from 'soapbox/contexts/nostr-context';
import {
@@ -13,6 +12,8 @@ import {
useLocale,
useInstance,
} from 'soapbox/hooks';
+import { useBunker } from 'soapbox/hooks/nostr/useBunker';
+import { useSigner } from 'soapbox/hooks/nostr/useSigner';
import MESSAGES from 'soapbox/messages';
/** Load initial data from the backend */
@@ -44,10 +45,12 @@ const SoapboxLoad: React.FC = ({ children }) => {
const [localeLoading, setLocaleLoading] = useState(true);
const [isLoaded, setIsLoaded] = useState(false);
- const { hasNostr, isRelayOpen, signer } = useNostr();
- const { isSubscribed } = useSignerStream();
+ const nostr = useNostr();
+ const signer = useSigner();
- const nostrLoading = Boolean(hasNostr && signer && (!isRelayOpen || !isSubscribed));
+ const nostrLoading = Boolean(nostr.isRelayLoading || signer.isLoading);
+
+ useBunker();
/** Whether to display a loading indicator. */
const showLoading = [
diff --git a/src/init/soapbox-mount.tsx b/src/init/soapbox-mount.tsx
index 71942395c..2c915ebbf 100644
--- a/src/init/soapbox-mount.tsx
+++ b/src/init/soapbox-mount.tsx
@@ -2,12 +2,11 @@ import React, { Suspense, useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
-// @ts-ignore: it doesn't have types
-import { ScrollContext } from 'react-router-scroll-4';
import { openModal } from 'soapbox/actions/modals';
import * as BuildConfig from 'soapbox/build-config';
import LoadingScreen from 'soapbox/components/loading-screen';
+import { ScrollContext } from 'soapbox/components/scroll-context';
import SiteErrorBoundary from 'soapbox/components/site-error-boundary';
import {
ModalContainer,
@@ -51,16 +50,11 @@ const SoapboxMount = () => {
const { redirectRootNoLogin, gdpr } = soapboxConfig;
- // @ts-ignore: I don't actually know what these should be, lol
- const shouldUpdateScroll = (prevRouterProps, { location }) => {
- return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
- };
-
return (
-
+
{(!isLoggedIn && redirectRootNoLogin) && (
diff --git a/src/locales/en.json b/src/locales/en.json
index 541cddd3f..d75c5c114 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1167,6 +1167,7 @@
"new_group_panel.title": "Create Group",
"nostr_extension.found": "Sign in with browser extension.",
"nostr_extension.not_found": "Browser extension not found.",
+ "nostr_extension.not_supported": "Browser extension not supported. Please upgrade to the latest version.",
"nostr_login.siwe.action": "Log in with extension",
"nostr_login.siwe.alt": "Log in with key",
"nostr_login.siwe.sign_up": "Sign Up",
diff --git a/src/locales/es.json b/src/locales/es.json
index d6cdf475e..33b1c451e 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1154,6 +1154,8 @@
"new_group_panel.title": "Crear un Grupo",
"nostr_extension.found": "Iniciar sesión con la extensión del navegador.",
"nostr_extension.not_found": "No se ha encontrado la extensión del navegador.",
+ "nostr_extension.not_supported": "La extensión del navegador no es compatible. Actualice a la última versión.",
+ "nostr_login.siwe.action": "Iniciar sesión con la extensión",
"nostr_panel.message": "Conéctese con cualquier cliente de Nostr.",
"nostr_panel.title": "Relés de Nostr",
"nostr_relays.read_only": "Solo lectura",
diff --git a/src/main.tsx b/src/main.tsx
index c0f99b85c..7382d69b2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,3 +1,4 @@
+import { enableMapSet } from 'immer';
import React from 'react';
import { createRoot } from 'react-dom/client';
@@ -14,7 +15,7 @@ import '@fontsource/inter/700.css';
import '@fontsource/inter/900.css';
import '@fontsource/roboto-mono/400.css';
import 'line-awesome/dist/font-awesome-line-awesome/css/all.css';
-import 'soapbox/features/nostr/keys';
+import 'soapbox/features/nostr/keyring';
import './iframe';
import './styles/i18n/arabic.css';
@@ -25,6 +26,8 @@ import './styles/tailwind.css';
import ready from './ready';
import { registerSW, lockSW } from './utils/sw';
+enableMapSet();
+
if (BuildConfig.NODE_ENV === 'production') {
printConsoleWarning();
registerSW('/sw.js');
diff --git a/src/reducers/admin.ts b/src/reducers/admin.ts
index cb138095c..caee84551 100644
--- a/src/reducers/admin.ts
+++ b/src/reducers/admin.ts
@@ -1,11 +1,9 @@
import {
Map as ImmutableMap,
List as ImmutableList,
- Set as ImmutableSet,
Record as ImmutableRecord,
OrderedSet as ImmutableOrderedSet,
fromJS,
- is,
} from 'immutable';
import {
@@ -64,12 +62,8 @@ type SetKeys = keyof FilterConditionally>;
type APIReport = { id: string; state: string; statuses: any[] };
type APIUser = { id: string; email: string; nickname: string; registration_reason: string };
-type Filter = 'local' | 'need_approval' | 'active';
+type Filters = Record;
-const FILTER_UNAPPROVED: Filter[] = ['local', 'need_approval'];
-const FILTER_LATEST: Filter[] = ['local', 'active'];
-
-const filtersMatch = (f1: string[], f2: string[]) => is(ImmutableSet(f1), ImmutableSet(f2));
const toIds = (items: any[]) => items.map(item => item.id);
const mergeSet = (state: State, key: SetKeys, users: APIUser[]): State => {
@@ -82,16 +76,16 @@ const replaceSet = (state: State, key: SetKeys, users: APIUser[]): State => {
return state.set(key, ImmutableOrderedSet(newIds));
};
-const maybeImportUnapproved = (state: State, users: APIUser[], filters: Filter[]): State => {
- if (filtersMatch(FILTER_UNAPPROVED, filters)) {
+const maybeImportUnapproved = (state: State, users: APIUser[], filters: Filters): State => {
+ if (filters.pending) {
return mergeSet(state, 'awaitingApproval', users);
} else {
return state;
}
};
-const maybeImportLatest = (state: State, users: APIUser[], filters: Filter[], page: number): State => {
- if (page === 1 && filtersMatch(FILTER_LATEST, filters)) {
+const maybeImportLatest = (state: State, users: APIUser[], filters: Filters, page: number): State => {
+ if (page === 1 && !filters.pending) {
return replaceSet(state, 'latestUsers', users);
} else {
return state;
@@ -110,7 +104,7 @@ const fixUser = (user: APIEntity): ReducerAdminAccount => {
}) as ReducerAdminAccount;
};
-function importUsers(state: State, users: APIUser[], filters: Filter[], page: number): State {
+function importUsers(state: State, users: APIUser[], filters: Filters, page: number): State {
return state.withMutations(state => {
maybeImportUnapproved(state, users, filters);
maybeImportLatest(state, users, filters, page);
@@ -202,7 +196,7 @@ export default function admin(state: State = ReducerRecord(), action: AnyAction)
case ADMIN_REPORTS_PATCH_SUCCESS:
return handleReportDiffs(state, action.reports);
case ADMIN_USERS_FETCH_SUCCESS:
- return importUsers(state, action.users, action.filters, action.page);
+ return importUsers(state, action.accounts, action.filters, action.page);
case ADMIN_USERS_DELETE_REQUEST:
case ADMIN_USERS_DELETE_SUCCESS:
case ADMIN_USERS_REJECT_REQUEST:
diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts
index 64934a665..bb34609df 100644
--- a/src/reducers/auth.ts
+++ b/src/reducers/auth.ts
@@ -2,6 +2,8 @@ import { AxiosError } from 'axios';
import { produce } from 'immer';
import { z } from 'zod';
+import { keyring } from 'soapbox/features/nostr/keyring';
+import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
import { Account, accountSchema } from 'soapbox/schemas';
import { Application, applicationSchema } from 'soapbox/schemas/application';
import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth';
@@ -24,6 +26,17 @@ import type { UnknownAction } from 'redux';
const STORAGE_KEY = 'soapbox:auth';
const SESSION_KEY = 'soapbox:auth:me';
+// Log out legacy Nostr/Ditto users.
+for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+
+ if (key && /^soapbox:nostr:auth:[0-9a-f]{64}$/.test(key)) {
+ localStorage.clear();
+ sessionStorage.clear();
+ location.reload();
+ }
+}
+
/** Get current user's URL from session storage. */
function getSessionUser(): string | undefined {
const value = sessionStorage.getItem(SESSION_KEY);
@@ -37,7 +50,7 @@ function getSessionUser(): string | undefined {
/** Retrieve state from browser storage. */
function getLocalState(): SoapboxAuth | undefined {
const data = localStorage.getItem(STORAGE_KEY);
- const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data);
+ const result = jsonSchema().pipe(soapboxAuthSchema).safeParse(data);
if (!result.success) {
return undefined;
@@ -105,7 +118,26 @@ function importCredentials(auth: SoapboxAuth, accessToken: string, account: Acco
});
}
+/** Delete Nostr credentials when an access token is revoked. */
+// TODO: Rework auth so this can all be conrolled from one place.
+function revokeNostr(accessToken: string): void {
+ const { connections, revoke } = useBunkerStore.getState();
+
+ for (const conn of connections) {
+ if (conn.accessToken === accessToken) {
+ // Revoke the Bunker connection.
+ revoke(accessToken);
+ // Revoke the user's private key.
+ keyring.delete(conn.pubkey);
+ // Revoke the bunker's private key.
+ keyring.delete(conn.bunkerPubkey);
+ }
+ }
+}
+
function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth {
+ revokeNostr(accessToken);
+
return produce(auth, draft => {
delete draft.tokens[accessToken];
diff --git a/src/reducers/index.ts b/src/reducers/index.ts
index 65cbecfe4..2f5d286e6 100644
--- a/src/reducers/index.ts
+++ b/src/reducers/index.ts
@@ -56,7 +56,7 @@ import trending_statuses from './trending-statuses';
import trends from './trends';
import user_lists from './user-lists';
-const reducers = {
+export default combineReducers({
accounts_meta,
admin,
aliases,
@@ -111,6 +111,4 @@ const reducers = {
trending_statuses,
trends,
user_lists,
-};
-
-export default combineReducers(reducers);
\ No newline at end of file
+});
\ No newline at end of file
diff --git a/src/schemas/utils.ts b/src/schemas/utils.ts
index 7d46b664c..2110cd0bb 100644
--- a/src/schemas/utils.ts
+++ b/src/schemas/utils.ts
@@ -30,14 +30,16 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
}, {});
}
-const jsonSchema = z.string().transform((value, ctx) => {
- try {
- return JSON.parse(value) as unknown;
- } catch (_e) {
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
- return z.NEVER;
- }
-});
+function jsonSchema(reviver?: (this: any, key: string, value: any) => any) {
+ return z.string().transform((value, ctx) => {
+ try {
+ return JSON.parse(value, reviver) as unknown;
+ } catch (_e) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
+ return z.NEVER;
+ }
+ });
+}
/** MIME schema, eg `image/png`. */
const mimeSchema = z.string().regex(/^\w+\/[-+.\w]+$/);
diff --git a/src/utils/features.ts b/src/utils/features.ts
index d30905225..8a9b2b8dd 100644
--- a/src/utils/features.ts
+++ b/src/utils/features.ts
@@ -988,6 +988,7 @@ const getInstanceFeatures = (instance: InstanceV1 | InstanceV2) => {
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '2.8.0'),
v.software === PLEROMA && gte(v.version, '1.0.0'),
+ v.software === DITTO,
]),
/**
diff --git a/yarn.lock b/yarn.lock
index f44a44aae..b6f1e70d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5516,13 +5516,6 @@ intl-pluralrules@^2.0.0:
resolved "https://registry.yarnpkg.com/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz#de16c3df1e09437635829725e88ea70c9ad79569"
integrity sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==
-invariant@^2.2.4:
- version "2.2.4"
- resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
- integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
- dependencies:
- loose-envify "^1.0.0"
-
is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe"
@@ -7363,14 +7356,6 @@ react-router-dom@^5.3.0:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
-react-router-scroll-4@^1.0.0-beta.2:
- version "1.0.0-beta.2"
- resolved "https://registry.yarnpkg.com/react-router-scroll-4/-/react-router-scroll-4-1.0.0-beta.2.tgz#d887063ec0f66124aaf450158dd158ff7d3dc279"
- integrity sha512-K67Dnm75naSBs/WYc2CDNxqU+eE8iA3I0wSCArgGSHb0xR/7AUcgUEXtCxrQYVTogXvjVK60gmwYvOyRQ6fuBA==
- dependencies:
- scroll-behavior "^0.9.1"
- warning "^3.0.0"
-
react-router@5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d"
@@ -7817,14 +7802,6 @@ schema-utils@^4.0.0:
ajv-formats "^2.1.1"
ajv-keywords "^5.0.0"
-scroll-behavior@^0.9.1:
- version "0.9.12"
- resolved "https://registry.yarnpkg.com/scroll-behavior/-/scroll-behavior-0.9.12.tgz#1c22d273ec4ce6cd4714a443fead50227da9424c"
- integrity sha512-18sirtyq1P/VsBX6O/vgw20Np+ngduFXEMO4/NDFXabdOKBL2kjPVUpz1y0+jm99EWwFJafxf5/tCyMeXt9Xyg==
- dependencies:
- dom-helpers "^3.4.0"
- invariant "^2.2.4"
-
semver-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
@@ -9315,3 +9292,8 @@ zod@^3.23.4, zod@^3.23.5:
version "3.23.5"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f"
integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==
+
+zustand@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9"
+ integrity sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==