diff --git a/package.json b/package.json index d7098c29a..8ada79d3e 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "license": "AGPL-3.0-or-later", "browserslist": [ - "> 0.5%", + "> 1%", "last 2 versions", "not dead" ], @@ -137,7 +137,6 @@ "react-redux": "^9.0.4", "react-router-dom": "^5.3.0", "react-router-dom-v5-compat": "^6.6.2", - "react-router-scroll-4": "^1.0.0-beta.2", "react-simple-pull-to-refresh": "^1.3.3", "react-sparklines": "^1.7.0", "react-sticky-box": "^2.0.0", @@ -156,7 +155,8 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-require": "^1.2.14", "vite-plugin-static-copy": "^1.0.6", - "zod": "^3.23.5" + "zod": "^3.23.5", + "zustand": "^5.0.0" }, "devDependencies": { "@formatjs/cli": "^6.2.0", diff --git a/src/actions/about.test.ts b/src/actions/about.test.ts index 282e80c37..e05a7f643 100644 --- a/src/actions/about.test.ts +++ b/src/actions/about.test.ts @@ -1,5 +1,4 @@ import MockAdapter from 'axios-mock-adapter'; -import { Map as ImmutableMap } from 'immutable'; import { staticClient } from 'soapbox/api'; import { mockStore } from 'soapbox/jest/test-helpers'; @@ -23,7 +22,7 @@ describe('fetchAboutPage()', () => { { type: FETCH_ABOUT_PAGE_REQUEST, slug: 'index' }, { type: FETCH_ABOUT_PAGE_SUCCESS, slug: 'index', html: '

Hello world

' }, ]; - const store = mockStore(ImmutableMap()); + const store = mockStore({}); return store.dispatch(fetchAboutPage()).then(() => { expect(store.getActions()).toEqual(expectedActions); @@ -35,7 +34,7 @@ describe('fetchAboutPage()', () => { { type: FETCH_ABOUT_PAGE_REQUEST, slug: 'asdf' }, { type: FETCH_ABOUT_PAGE_FAIL, slug: 'asdf', error: new Error('Request failed with status code 404') }, ]; - const store = mockStore(ImmutableMap()); + const store = mockStore({}); return store.dispatch(fetchAboutPage('asdf')).catch(() => { expect(store.getActions()).toEqual(expectedActions); diff --git a/src/actions/admin.ts b/src/actions/admin.ts index 834b34861..e7b2d35cb 100644 --- a/src/actions/admin.ts +++ b/src/actions/admin.ts @@ -149,18 +149,15 @@ function closeReports(ids: string[]) { return patchReports(ids, 'closed'); } -function fetchUsers(filters: string[] = [], page = 1, query?: string | null, pageSize = 50, url?: string | null) { +function fetchUsers(filters: Record, page = 1, query?: string | null, pageSize = 50, url?: string | null) { return async (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize }); const params: Record = { + ...filters, username: query, }; - if (filters.includes('local')) params.local = true; - if (filters.includes('active')) params.active = true; - if (filters.includes('need_approval')) params.pending = true; - try { const { data: accounts, ...response } = await api(getState).get(url || '/api/v1/admin/accounts', { params }); const next = getLinks(response as AxiosResponse).refs.find(link => link.rel === 'next')?.uri; diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index d204fcd62..b05ccb461 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,4 +1,10 @@ -import { RootState, type AppDispatch } from 'soapbox/store'; +import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey } from 'nostr-tools'; + +import { NBunker } from 'soapbox/features/nostr/NBunker'; +import { keyring } from 'soapbox/features/nostr/keyring'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; +import { type AppDispatch } from 'soapbox/store'; import { authLoggedIn, verifyCredentials } from './auth'; import { obtainOAuthToken } from './oauth'; @@ -6,42 +12,83 @@ import { obtainOAuthToken } from './oauth'; const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ -function logInNostr(pubkey: string) { - return async (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(setNostrPubkey(pubkey)); +function logInNostr(signer: NostrSigner, relay: NRelay1) { + return async (dispatch: AppDispatch) => { + const authorization = generateBunkerAuth(); - const secret = sessionStorage.getItem('soapbox:nip46:secret'); - if (!secret) { - throw new Error('No secret found in session storage'); - } + const pubkey = await signer.getPublicKey(); + const bunkerPubkey = await authorization.signer.getPublicKey(); - const relay = getState().instance.nostr?.relay; + let authorizedPubkey: string | undefined; - // HACK: waits 1 second to ensure the relay subscription is open - await new Promise((resolve) => setTimeout(resolve, 1000)); + const bunker = new NBunker({ + relay, + userSigner: signer, + bunkerSigner: authorization.signer, + onConnect(request, event) { + const [, secret] = request.params; + + if (secret === authorization.secret) { + bunker.authorize(event.pubkey); + authorizedPubkey = event.pubkey; + return { id: request.id, result: 'ack' }; + } else { + return { id: request.id, result: '', error: 'Invalid secret' }; + } + }, + }); + + await bunker.waitReady; const token = await dispatch(obtainOAuthToken({ grant_type: 'nostr_bunker', - pubkey, - relays: relay ? [relay] : undefined, - secret, + pubkey: bunkerPubkey, + relays: [relay.socket.url], + secret: authorization.secret, })); - const { access_token } = dispatch(authLoggedIn(token)); - await dispatch(verifyCredentials(access_token as string)); + if (!authorizedPubkey) { + throw new Error('Authorization failed'); + } - dispatch(setNostrPubkey(undefined)); + const accessToken = dispatch(authLoggedIn(token)).access_token as string; + const bunkerState = useBunkerStore.getState(); + + keyring.add(authorization.seckey); + + bunkerState.connect({ + pubkey, + accessToken, + authorizedPubkey, + bunkerPubkey, + }); + + await dispatch(verifyCredentials(accessToken)); + + // TODO: get rid of `vite-plugin-require` and switch to `using` for the bunker. :( + bunker.close(); }; } /** Log in with a Nostr extension. */ -function nostrExtensionLogIn() { +function nostrExtensionLogIn(relay: NRelay1) { return async (dispatch: AppDispatch) => { if (!window.nostr) { throw new Error('No Nostr signer available'); } - const pubkey = await window.nostr.getPublicKey(); - return dispatch(logInNostr(pubkey)); + return dispatch(logInNostr(window.nostr, relay)); + }; +} + +/** Generate a bunker authorization object. */ +function generateBunkerAuth() { + const secret = crypto.randomUUID(); + const seckey = generateSecretKey(); + + return { + secret, + seckey, + signer: new NSecSigner(seckey), }; } diff --git a/src/actions/preload.test.ts b/src/actions/preload.test.ts index 8e4b30f5c..24a5f86b3 100644 --- a/src/actions/preload.test.ts +++ b/src/actions/preload.test.ts @@ -1,5 +1,3 @@ -import { Map as ImmutableMap } from 'immutable'; - import { __stub } from 'soapbox/api'; import { mockStore } from 'soapbox/jest/test-helpers'; @@ -19,7 +17,7 @@ describe('preloadMastodon()', () => { .reply(200, {}); }); - const store = mockStore(ImmutableMap()); + const store = mockStore({}); store.dispatch(preloadMastodon(data)); const actions = store.getActions(); diff --git a/src/api/hooks/admin/useAdminAccounts.ts b/src/api/hooks/admin/useAdminAccounts.ts index cb329ced6..585a16710 100644 --- a/src/api/hooks/admin/useAdminAccounts.ts +++ b/src/api/hooks/admin/useAdminAccounts.ts @@ -3,16 +3,25 @@ import { useEntities } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks'; import { adminAccountSchema } from 'soapbox/schemas/admin-account'; -type Filter = 'local' | 'remote' | 'active' | 'pending' | 'disabled' | 'silenced' | 'suspended' | 'sensitized'; +interface MastodonAdminFilters { + local?: boolean; + remote?: boolean; + active?: boolean; + pending?: boolean; + disabled?: boolean; + silenced?: boolean; + suspended?: boolean; + sensitized?: boolean; +} /** https://docs.joinmastodon.org/methods/admin/accounts/#v1 */ -export function useAdminAccounts(filters: Filter[] = [], limit?: number) { +export function useAdminAccounts(filters: MastodonAdminFilters, limit?: number) { const api = useApi(); const searchParams = new URLSearchParams(); - for (const filter of filters) { - searchParams.append(filter, 'true'); + for (const [name, value] of Object.entries(filters)) { + searchParams.append(name, value.toString()); } if (typeof limit === 'number') { diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts deleted file mode 100644 index 15662c262..000000000 --- a/src/api/hooks/nostr/useSignerStream.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useNostr } from 'soapbox/contexts/nostr-context'; -import { NBunker } from 'soapbox/features/nostr/NBunker'; - -const secretStorageKey = 'soapbox:nip46:secret'; - -sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); - -function useSignerStream() { - const [isSubscribed, setIsSubscribed] = useState(false); - const [isSubscribing, setIsSubscribing] = useState(true); - - const { relay, signer, hasNostr } = useNostr(); - const [pubkey, setPubkey] = useState(undefined); - - const authStorageKey = `soapbox:nostr:auth:${pubkey}`; - - useEffect(() => { - let isCancelled = false; - - if (signer && hasNostr) { - signer.getPublicKey().then((newPubkey) => { - if (!isCancelled) { - setPubkey(newPubkey); - } - }).catch(console.warn); - } - - return () => { - isCancelled = true; - }; - }, [signer, hasNostr]); - - useEffect(() => { - if (!relay || !signer || !pubkey) return; - - const bunker = new NBunker({ - relay, - signer, - onAuthorize(authorizedPubkey) { - localStorage.setItem(authStorageKey, authorizedPubkey); - sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); - }, - onSubscribed() { - setIsSubscribed(true); - setIsSubscribing(false); - }, - authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined, - getSecret: () => sessionStorage.getItem(secretStorageKey)!, - }); - - return () => { - bunker.close(); - }; - }, [relay, signer, pubkey]); - - return { - isSubscribed, - isSubscribing, - }; -} - -export { useSignerStream }; diff --git a/src/components/attachment-thumbs.tsx b/src/components/attachment-thumbs.tsx index c5a017408..6ac0c0e30 100644 --- a/src/components/attachment-thumbs.tsx +++ b/src/components/attachment-thumbs.tsx @@ -17,11 +17,11 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => { const { media, onClick, sensitive } = props; const dispatch = useAppDispatch(); - const fallback =
; + const fallback =
; const onOpenMedia = (media: ImmutableList, index: number) => dispatch(openModal('MEDIA', { media, index })); return ( -
+
{ {onClick && ( -
+
); diff --git a/src/components/media-gallery.tsx b/src/components/media-gallery.tsx index aae7d3ca3..0edf7528c 100644 --- a/src/components/media-gallery.tsx +++ b/src/components/media-gallery.tsx @@ -2,7 +2,6 @@ import clsx from 'clsx'; import React, { useState, useRef, useLayoutEffect } from 'react'; import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still-image'; import { MIMETYPE_ICONS } from 'soapbox/components/upload'; import { useSettings, useSoapboxConfig } from 'soapbox/hooks'; @@ -12,6 +11,8 @@ import { truncateFilename } from 'soapbox/utils/media'; import { isIOS } from '../is-mobile'; import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio'; +import SvgIcon from './ui/icon/svg-icon'; + import type { Property } from 'csstype'; import type { List as ImmutableList } from 'immutable'; @@ -60,6 +61,7 @@ interface IItem { dimensions: Dimensions; last?: boolean; total: number; + compact?: boolean; } const Item: React.FC = ({ @@ -71,6 +73,7 @@ const Item: React.FC = ({ dimensions, last, total, + compact, }) => { const { autoPlayGif } = useSettings(); const { mediaPreview } = useSoapboxConfig(); @@ -111,16 +114,21 @@ const Item: React.FC = ({ e.stopPropagation(); }; - const handleVideoHover: React.MouseEventHandler = ({ currentTarget: video }) => { + const handleVideoHover = (event: React.SyntheticEvent) => { + const video = event.currentTarget; video.playbackRate = 3.0; video.play(); }; - const handleVideoLeave: React.MouseEventHandler = ({ currentTarget: video }) => { + const handleVideoLeave = (event: React.SyntheticEvent) => { + const video = event.currentTarget; video.pause(); video.currentTime = 0; }; + const handleFocus: React.FocusEventHandler = handleVideoHover; + const handleBlur: React.FocusEventHandler = handleVideoLeave; + let width: Dimensions['w'] = 100; let height: Dimensions['h'] = '100%'; let top: Dimensions['t'] = 'auto'; @@ -144,43 +152,29 @@ const Item: React.FC = ({ let thumbnail: React.ReactNode = ''; const ext = attachment.url.split('.').pop()?.toLowerCase(); - /*if (attachment.type === 'unknown' && ['gb', 'gbc'].includes(ext!)) { - return ( -
1, - })} - key={attachment.id} - style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} - > - }> - - -
- ); - } else */if (attachment.type === 'unknown') { + if (attachment.type === 'unknown') { const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH); const attachmentIcon = ( - ); return (
1, + '!size-[50px] !inset-auto !float-left !mr-[50px]': compact, })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} > - - - {attachmentIcon} - {filename} + + + {attachmentIcon} + {filename}
); @@ -189,7 +183,7 @@ const Item: React.FC = ({ thumbnail = ( = ({ } thumbnail = ( -
+
); } else if (attachment.type === 'audio') { thumbnail = (
- - {ext} + + {ext} ); } else if (attachment.type === 'video') { thumbnail = ( - {ext} + {ext} ); } return (
1, + '!size-[50px] !inset-auto !float-left !mr-[50px]': compact, })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} > {last && total > ATTACHMENT_LIMIT && ( -
{/* eslint-disable-line formatjs/no-literal-string-in-jsx */} +
{/* eslint-disable-line formatjs/no-literal-string-in-jsx */} +{total - ATTACHMENT_LIMIT + 1}
)} {visible && thumbnail}
@@ -561,6 +559,7 @@ const MediaGallery: React.FC = (props) => { dimensions={sizeData.itemsDimensions[i]} last={i === ATTACHMENT_LIMIT - 1} total={media.size} + compact={compact} /> )); @@ -578,7 +577,7 @@ const MediaGallery: React.FC = (props) => { return (
diff --git a/src/components/scroll-context.tsx b/src/components/scroll-context.tsx new file mode 100644 index 000000000..707ef7e76 --- /dev/null +++ b/src/components/scroll-context.tsx @@ -0,0 +1,18 @@ +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +interface IScrollContext { + children: React.ReactNode; +} + +export const ScrollContext: React.FC = ({ children }) => { + const location = useLocation<{ soapboxModalKey?: number } | undefined>(); + + useEffect(() => { + if (!location.state?.soapboxModalKey) { + window.scrollTo(0, 0); + } + }, [location]); + + return children; +}; \ No newline at end of file diff --git a/src/components/sidebar-navigation-link.tsx b/src/components/sidebar-navigation-link.tsx index 336aac5ae..e5aca128e 100644 --- a/src/components/sidebar-navigation-link.tsx +++ b/src/components/sidebar-navigation-link.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import React from 'react'; -import { NavLink } from 'react-router-dom'; +import { NavLink, useLocation } from 'react-router-dom'; import { Icon, Text } from './ui'; @@ -24,7 +24,9 @@ interface ISidebarNavigationLink { /** Desktop sidebar navigation link. */ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef): JSX.Element => { const { icon, activeIcon, text, to = '', count, countMax, onClick } = props; - const isActive = location.pathname === to; + const { pathname } = useLocation(); + + const isActive = pathname === to; const handleClick: React.EventHandler = (e) => { if (onClick) { diff --git a/src/components/upload.tsx b/src/components/upload.tsx index 910452ef0..aa61483bc 100644 --- a/src/components/upload.tsx +++ b/src/components/upload.tsx @@ -166,7 +166,7 @@ const Upload: React.FC = ({ onDragEnter={onDragEnter} onDragEnd={onDragEnd} > - + {({ scale }) => (
(undefined); @@ -20,37 +16,32 @@ interface NostrProviderProps { export const NostrProvider: React.FC = ({ children }) => { const { instance } = useInstance(); - const hasNostr = !!instance.nostr; const [relay, setRelay] = useState(); - const [isRelayOpen, setIsRelayOpen] = useState(false); + const [isRelayLoading, setIsRelayLoading] = useState(true); - const url = instance.nostr?.relay; - const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id); - - const signer = useMemo( - () => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined, - [accountPubkey, window.nostr], - ); + const relayUrl = instance.nostr?.relay; const handleRelayOpen = () => { - setIsRelayOpen(true); + setIsRelayLoading(false); }; useEffect(() => { - if (url) { - const relay = new NRelay1(url); + if (relayUrl) { + const relay = new NRelay1(relayUrl); relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen); setRelay(relay); + } else { + setIsRelayLoading(false); } return () => { relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen); relay?.close(); }; - }, [url]); + }, [relayUrl]); return ( - + {children} ); diff --git a/src/entity-store/reducer.ts b/src/entity-store/reducer.ts index a9444c1d7..2e53656af 100644 --- a/src/entity-store/reducer.ts +++ b/src/entity-store/reducer.ts @@ -1,4 +1,4 @@ -import { produce, enableMapSet } from 'immer'; +import { produce } from 'immer'; import { ENTITIES_IMPORT, @@ -17,8 +17,6 @@ import { createCache, createList, updateStore, updateList } from './utils'; import type { DeleteEntitiesOpts } from './actions'; import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types'; -enableMapSet(); - /** Entity reducer state. */ interface State { [entityType: string]: EntityCache | undefined; diff --git a/src/features/account-gallery/components/media-item.tsx b/src/features/account-gallery/components/media-item.tsx index 1df70c965..b7c77a7d3 100644 --- a/src/features/account-gallery/components/media-item.tsx +++ b/src/features/account-gallery/components/media-item.tsx @@ -2,8 +2,8 @@ import clsx from 'clsx'; import React, { useState } from 'react'; import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still-image'; +import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import { useSettings } from 'soapbox/hooks'; import { isIOS } from 'soapbox/is-mobile'; @@ -80,9 +80,9 @@ const MediaItem: React.FC = ({ attachment, onOpenMedia }) => { conditionalAttributes.autoPlay = true; } thumbnail = ( -
+
); } else if (attachment.type === 'audio') { @@ -102,28 +102,28 @@ const MediaItem: React.FC = ({ attachment, onOpenMedia }) => { const fileExtensionLastIndex = remoteURL.lastIndexOf('.'); const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase(); thumbnail = ( -
- - {fileExtension} +
+ + {fileExtension}
); } if (!visible) { icon = ( - - + + ); } return (
- + {visible && thumbnail} diff --git a/src/features/admin/components/latest-accounts-panel.tsx b/src/features/admin/components/latest-accounts-panel.tsx index c179afaf3..adb16495e 100644 --- a/src/features/admin/components/latest-accounts-panel.tsx +++ b/src/features/admin/components/latest-accounts-panel.tsx @@ -18,7 +18,14 @@ const LatestAccountsPanel: React.FC = ({ limit = 5 }) => { const intl = useIntl(); const history = useHistory(); - const { accounts } = useAdminAccounts(['local', 'active'], limit); + const { accounts } = useAdminAccounts({ + local: true, + active: true, + pending: false, + disabled: false, + silenced: false, + suspended: false, + }, limit); const handleAction = () => { history.push('/soapbox/admin/users'); diff --git a/src/features/admin/components/unapproved-account.tsx b/src/features/admin/components/unapproved-account.tsx index b10d9428c..9bc42c1cf 100644 --- a/src/features/admin/components/unapproved-account.tsx +++ b/src/features/admin/components/unapproved-account.tsx @@ -26,7 +26,7 @@ const UnapprovedAccount: React.FC = ({ accountId }) => { { const [isLoading, setLoading] = useState(true); useEffect(() => { - dispatch(fetchUsers(['local', 'need_approval'])) + dispatch(fetchUsers({ pending: true })) .then(() => setLoading(false)) .catch(() => {}); }, []); diff --git a/src/features/admin/user-index.tsx b/src/features/admin/user-index.tsx index 93d418d96..a80f3298d 100644 --- a/src/features/admin/user-index.tsx +++ b/src/features/admin/user-index.tsx @@ -15,7 +15,14 @@ const messages = defineMessages({ const UserIndex: React.FC = () => { const intl = useIntl(); - const { accounts, isLoading, hasNextPage, fetchNextPage } = useAdminAccounts(['local']); + const { accounts, isLoading, hasNextPage, fetchNextPage } = useAdminAccounts({ + local: true, + active: true, + pending: false, + disabled: false, + silenced: false, + suspended: false, + }); const handleLoadMore = () => { if (!isLoading) { diff --git a/src/features/audio/index.tsx b/src/features/audio/index.tsx index 366a6a076..61a43d89b 100644 --- a/src/features/audio/index.tsx +++ b/src/features/audio/index.tsx @@ -4,7 +4,7 @@ import throttle from 'lodash/throttle'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import Icon from 'soapbox/components/icon'; +import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import { formatTime, getPointerPosition } from 'soapbox/features/video'; import Visualizer from './visualizer'; @@ -64,10 +64,11 @@ const Audio: React.FC = (props) => { const [duration, setDuration] = useState(undefined); const [paused, setPaused] = useState(true); const [muted, setMuted] = useState(false); + const [preVolume, setPreVolume] = useState(0); const [volume, setVolume] = useState(0.5); const [dragging, setDragging] = useState(false); const [hovered, setHovered] = useState(false); - + const [seekHovered, setSeekHovered] = useState(false); const visualizer = useRef(new Visualizer(TICK_SIZE)); const audioContext = useRef(null); @@ -150,12 +151,20 @@ const Audio: React.FC = (props) => { }; const toggleMute = () => { - const nextMuted = !muted; - - setMuted(nextMuted); - if (audio.current) { - audio.current.muted = nextMuted; + const muted = !audio.current.muted; + setMuted(muted); + audio.current.muted = muted; + + if (muted) { + setPreVolume(audio.current.volume); + audio.current.volume = 0; + setVolume(0); + } else { + audio.current.volume = preVolume; + setVolume(preVolume); + } + } }; @@ -259,6 +268,14 @@ const Audio: React.FC = (props) => { setHovered(false); }; + const handleSeekEnter = () => { + setSeekHovered(true); + }; + + const handleSeekLeave = () => { + setSeekHovered(false); + }; + const handleLoadedData = () => { if (audio.current) { setDuration(audio.current.duration); @@ -438,7 +455,8 @@ const Audio: React.FC = (props) => { return (
= (props) => { width: '100%', height: fullscreen ? '100%' : (height || props.height), }} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} tabIndex={0} onKeyDown={handleKeyDown} onClick={e => e.stopPropagation()} @@ -466,7 +482,7 @@ const Audio: React.FC = (props) => { = (props) => { /> )} -
+
-
+
-
-
-
+
+
+
- +
- - {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==