From 1b4a971fae47e43a8c1ece5925fca6ffec43b321 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Sun, 3 May 2026 19:31:48 -0700 Subject: [PATCH 01/13] Modernize modals: colocated state + nice-modal-react registry pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes two reference patterns to replace the global Redux modal/drawer registry, which everything else can migrate to incrementally: 1. Colocated (local useState) — for modals opened from a single feature. Reference: EditFolderModal (web), FeedFilterDrawer (mobile + web mobile). Removed their slices/registry entries; modal mounts inline next to its trigger via local state. 2. Registry via @ebay/nice-modal-react — for modals opened from many places, sagas, deep links, etc. Reference: ShareModal (web) + ShareDrawer (mobile). - New platform-agnostic bridge in common/services/nice-modal-bridge so sagas can call showNiceModal('Share') without depending on the React library directly. - Web/mobile app roots wire the bridge via setNiceModalAdapter() and mount deep enough in the tree to expose AudiusQueryProvider/ToastContext/NavigationContainer to NiceModal- managed modals. - Bridge saga in share-modal/sagas.ts translates legacy setVisibility('Share', true) calls into showNiceModal('Share'), so the existing ~10 trigger sites keep working unchanged. - registerNiceModals.ts at each app root collects the side-effect imports for NiceModal.register(...) — add new modals here as they migrate. Smoke-tested on web (localhost:3002) and iOS simulator: ShareModal/Drawer open via natural flow, and a stray clipping issue on FeedFilterDrawer is fixed by wrapping in . Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 12 +++ packages/common/src/services/index.ts | 1 + .../src/services/nice-modal-bridge/index.ts | 50 ++++++++++++ .../common/src/store/ui/modals/parentSlice.ts | 1 - packages/common/src/store/ui/modals/types.ts | 1 - .../common/src/store/ui/share-modal/sagas.ts | 22 +++++- packages/mobile/package.json | 1 + packages/mobile/src/app/App.tsx | 27 +++++-- packages/mobile/src/app/Drawers.tsx | 2 - packages/mobile/src/app/registerNiceModals.ts | 10 +++ .../components/share-drawer/ShareDrawer.tsx | 25 ++++-- packages/web/package.json | 1 + packages/web/src/app/AppProviders.tsx | 10 +++ packages/web/src/app/registerNiceModals.ts | 10 +++ packages/web/src/app/routes.tsx | 22 ++++-- .../edit-folder-modal/EditFolderModal.tsx | 77 +++++++------------ .../PlaylistLibrary/PlaylistFolderNavItem.tsx | 22 +++--- .../src/components/share-modal/ShareModal.tsx | 19 ++++- packages/web/src/pages/modals/Modals.tsx | 4 - .../ui/editFolderModal/selectors.ts | 7 -- .../application/ui/editFolderModal/slice.ts | 23 ------ packages/web/src/store/reducers.ts | 2 - packages/web/src/store/types.ts | 2 - 23 files changed, 221 insertions(+), 130 deletions(-) create mode 100644 packages/common/src/services/nice-modal-bridge/index.ts create mode 100644 packages/mobile/src/app/registerNiceModals.ts create mode 100644 packages/web/src/app/registerNiceModals.ts delete mode 100644 packages/web/src/store/application/ui/editFolderModal/selectors.ts delete mode 100644 packages/web/src/store/application/ui/editFolderModal/slice.ts diff --git a/package-lock.json b/package-lock.json index c8366acbe83..65fe58ea243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7087,6 +7087,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/@ebay/nice-modal-react": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@ebay/nice-modal-react/-/nice-modal-react-1.2.13.tgz", + "integrity": "sha512-jx8xIWe/Up4tpNuM02M+rbnLoxdngTGk3Y8LjJsLGXXcSoKd/+eZStZcAlIO/jwxyz/bhPZnpqPJZWAmhOofuA==", + "license": "MIT", + "peerDependencies": { + "react": ">16.8.0", + "react-dom": ">16.8.0" + } + }, "node_modules/@ecies/ciphers": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.2.tgz", @@ -134601,6 +134611,7 @@ "@audius/sdk": "*", "@bravemobile/react-native-code-push": "^12.3.2", "@coinflowlabs/react-native": "4.5.2", + "@ebay/nice-modal-react": "^1.2.13", "@emotion/native": "^11.11.0", "@emotion/react": "11.14.0", "@fingerprintjs/fingerprintjs-pro-react-native": "3.9.0", @@ -144050,6 +144061,7 @@ "@audius/sdk": "*", "@cloudflare/kv-asset-handler": "0.2.0", "@coinflowlabs/react": "5.9.1", + "@ebay/nice-modal-react": "^1.2.13", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", diff --git a/packages/common/src/services/index.ts b/packages/common/src/services/index.ts index dbe15a18ad0..bd4e3390c15 100644 --- a/packages/common/src/services/index.ts +++ b/packages/common/src/services/index.ts @@ -12,3 +12,4 @@ export * from './track-download' export * from './oauth' export * from './location' export * from './solana' +export * from './nice-modal-bridge' diff --git a/packages/common/src/services/nice-modal-bridge/index.ts b/packages/common/src/services/nice-modal-bridge/index.ts new file mode 100644 index 00000000000..aff8bfa75f4 --- /dev/null +++ b/packages/common/src/services/nice-modal-bridge/index.ts @@ -0,0 +1,50 @@ +/** + * Platform-agnostic bridge to nice-modal-react. + * + * The web and mobile apps install `@ebay/nice-modal-react` and call + * `setNiceModalAdapter({ show, hide })` at app init. Code in `common` + * (sagas, services) calls `showNiceModal(id, props)` / `hideNiceModal(id)` + * to drive nice-modal-react without taking a direct dependency on it. + * + * If a caller fires `showNiceModal` before the adapter is registered + * (e.g. during early app boot or in a test), the call is logged and + * resolves with `undefined` rather than throwing. + */ + +export type NiceModalShowFn = ( + id: string, + props?: Record +) => Promise + +export type NiceModalHideFn = (id: string) => Promise + +type NiceModalAdapter = { + show: NiceModalShowFn + hide: NiceModalHideFn +} + +let adapter: NiceModalAdapter | null = null + +export const setNiceModalAdapter = (next: NiceModalAdapter) => { + adapter = next +} + +export const showNiceModal: NiceModalShowFn = (id, props) => { + if (!adapter) { + console.warn( + `[nice-modal-bridge] showNiceModal('${id}') called before adapter init` + ) + return Promise.resolve() + } + return adapter.show(id, props) +} + +export const hideNiceModal: NiceModalHideFn = (id) => { + if (!adapter) { + console.warn( + `[nice-modal-bridge] hideNiceModal('${id}') called before adapter init` + ) + return Promise.resolve() + } + return adapter.hide(id) +} diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 53babad3cac..1eae8c0b466 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -28,7 +28,6 @@ export const initialState: BasicModalsState = { TrendingFilter: { isOpen: false }, TrendingRewardsExplainer: { isOpen: false }, SocialProof: { isOpen: false }, - EditFolder: { isOpen: false }, EditTrack: { isOpen: false }, SignOutConfirmation: { isOpen: false }, Overflow: { isOpen: false }, diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index b51748f77a3..98b156d7988 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -63,7 +63,6 @@ export type Modals = | 'TrendingFilter' | 'TrendingRewardsExplainer' | 'SocialProof' - | 'EditFolder' | 'EditTrack' | 'SignOutConfirmation' | 'Overflow' diff --git a/packages/common/src/store/ui/share-modal/sagas.ts b/packages/common/src/store/ui/share-modal/sagas.ts index 44bbb406970..c466350cd6c 100644 --- a/packages/common/src/store/ui/share-modal/sagas.ts +++ b/packages/common/src/store/ui/share-modal/sagas.ts @@ -8,6 +8,7 @@ import { } from '~/adapters' import { queryCollection, queryTrack, queryUser } from '~/api' import { TQCollection } from '~/api/tan-query/models' +import { showNiceModal } from '~/services/nice-modal-bridge' import { getSDK } from '~/store/sdkUtils' import { setVisibility } from '../modals/parentSlice' @@ -98,6 +99,25 @@ function* watchHandleRequestOpen() { yield takeEvery(requestOpen, handleRequestOpen) } +/** + * Bridge: any caller dispatching `setVisibility({ modal: 'Share', visible: true })` + * (legacy callers like `onCancelAction` from CreateChatModal, plus the + * `handleRequestOpen` saga above) gets translated into a `NiceModal.show('Share')` + * call. This lets the rest of the codebase migrate at its own pace — once all + * callers go through `showNiceModal` directly, this bridge can be removed. + */ +function* watchOpenShareViaSetVisibility() { + yield takeEvery( + setVisibility, + function* (action: ReturnType) { + const { modal, visible } = action.payload + if (modal === 'Share' && visible === true) { + yield call(showNiceModal, 'Share') + } + } + ) +} + export default function sagas() { - return [watchHandleRequestOpen] + return [watchHandleRequestOpen, watchOpenShareViaSetVisibility] } diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 9185f4acad8..fc9f5c20e93 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -50,6 +50,7 @@ "@audius/sdk": "*", "@bravemobile/react-native-code-push": "^12.3.2", "@coinflowlabs/react-native": "4.5.2", + "@ebay/nice-modal-react": "^1.2.13", "@emotion/native": "^11.11.0", "@emotion/react": "11.14.0", "@fingerprintjs/fingerprintjs-pro-react-native": "3.9.0", diff --git a/packages/mobile/src/app/App.tsx b/packages/mobile/src/app/App.tsx index e3aa48c72a2..1dfadc9ee5c 100644 --- a/packages/mobile/src/app/App.tsx +++ b/packages/mobile/src/app/App.tsx @@ -1,7 +1,9 @@ import { useState } from 'react' import { SyncLocalStorageUserProvider } from '@audius/common/api' +import { setNiceModalAdapter } from '@audius/common/services' import { playbackActions } from '@audius/common/store' +import NiceModal from '@ebay/nice-modal-react' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { PortalProvider, PortalHost } from '@gorhom/portal' import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' @@ -39,10 +41,15 @@ import { ConnectivityManager } from './ConnectivityManager' import { Drawers } from './Drawers' import ErrorBoundary from './ErrorBoundary' import { ThemeProvider } from './ThemeProvider' +import './registerNiceModals' import { initSentry, navigationIntegration } from './sentry' initSentry() +// Wire the platform-agnostic bridge so common (sagas/services) can drive +// nice-modal-react without depending on the package directly. +setNiceModalAdapter({ show: NiceModal.show, hide: NiceModal.hide }) + const Airplay = Platform.select({ ios: () => require('../components/audio/Airplay').default, android: () => () => null @@ -128,13 +135,19 @@ const App = () => { > - - - - - - - + {/* NiceModal-managed modals (e.g. + ShareDrawer) call useNavigation(), so + the Provider must mount inside + NavigationContainer. */} + + + + + + + + + diff --git a/packages/mobile/src/app/Drawers.tsx b/packages/mobile/src/app/Drawers.tsx index e49ca6ee369..4e407ca93ff 100644 --- a/packages/mobile/src/app/Drawers.tsx +++ b/packages/mobile/src/app/Drawers.tsx @@ -40,7 +40,6 @@ import { QueueDrawer } from 'app/components/queue-drawer' import { RateCtaDrawer } from 'app/components/rate-cta-drawer' import { ReceiveTokensDrawer } from 'app/components/receive-tokens-drawer' import { SendTokensDrawer } from 'app/components/send-tokens-drawer' -import { ShareDrawer } from 'app/components/share-drawer' import { SignOutConfirmationDrawer } from 'app/components/sign-out-confirmation-drawer' import { StripeOnrampDrawer } from 'app/components/stripe-onramp-drawer' import { TransferAudioMobileDrawer } from 'app/components/transfer-audio-mobile-drawer' @@ -113,7 +112,6 @@ const commonDrawersMap: { [Modal in Modals]?: ComponentType } = { ClaimAllRewards: ClaimAllRewardsDrawer, APIRewardsExplainer: ApiRewardsDrawer, TransferAudioMobileWarning: TransferAudioMobileDrawer, - Share: ShareDrawer, DeactivateAccountConfirmation: DeactivateAccountConfirmationDrawer, TrendingGenreSelection: TrendingFilterDrawer, TrendingFilter: TrendingCombinedFilterDrawer, diff --git a/packages/mobile/src/app/registerNiceModals.ts b/packages/mobile/src/app/registerNiceModals.ts new file mode 100644 index 00000000000..67e22d7b058 --- /dev/null +++ b/packages/mobile/src/app/registerNiceModals.ts @@ -0,0 +1,10 @@ +/** + * Side-effect imports for nice-modal-react registrations. + * + * Each imported module calls `NiceModal.register(id, Component)` at module + * scope. Importing this file from `App.tsx` ensures all registered modals + * are available before anything tries to `showNiceModal(id)`. + * + * Add new NiceModal-managed modals here as they migrate. + */ +import 'app/components/share-drawer/ShareDrawer' diff --git a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx index 00eba92d5f1..c2969452710 100644 --- a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx +++ b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx @@ -9,6 +9,7 @@ import { usersSocialActions, shareModalUISelectors } from '@audius/common/store' +import NiceModal, { useModal } from '@ebay/nice-modal-react' import Clipboard from '@react-native-clipboard/clipboard' import { Linking } from 'react-native' import ViewShot from 'react-native-view-shot' @@ -31,9 +32,8 @@ import { make, track } from 'app/services/analytics' import { makeStyles } from 'app/styles' import { useThemeColors } from 'app/utils/theme' -import ActionDrawer from '../action-drawer' +import { ActionDrawerWithoutRedux } from '../action-drawer/ActionDrawerWithoutRedux' import { Text } from '../core' -import { useDrawerState } from '../drawer/AppDrawer' import { ShareToStorySticker } from './ShareToStorySticker' import { messages } from './messages' @@ -71,13 +71,17 @@ const useStyles = makeStyles(({ spacing }) => ({ } })) -export const ShareDrawer = () => { +export const ShareDrawer = NiceModal.create(() => { const styles = useStyles() const viewShotRef = useRef(null) as React.RefObject const navigation = useNavigation() const sendShareAction = useShareAction() - const { onClose } = useDrawerState('Share') + const modal = useModal() + const isOpen = modal.visible + const onClose = useCallback(() => { + modal.hide() + }, [modal]) const { onClose: onCloseNowPlaying } = useDrawer('NowPlaying') const { secondary } = useThemeColors() @@ -277,9 +281,10 @@ export const ShareDrawer = () => { /> ) : null} - { {messages.hiddenPlaylistShareHelperText} ) : null} - + ) -} +}) + +// Register so saga code (and anything else outside React) can open it via +// `showNiceModal('Share')`. +NiceModal.register('Share', ShareDrawer) diff --git a/packages/web/package.json b/packages/web/package.json index 13434b2fab3..0cfdb9444a3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -75,6 +75,7 @@ "@audius/sdk": "*", "@cloudflare/kv-asset-handler": "0.2.0", "@coinflowlabs/react": "5.9.1", + "@ebay/nice-modal-react": "^1.2.13", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", diff --git a/packages/web/src/app/AppProviders.tsx b/packages/web/src/app/AppProviders.tsx index 663fbc63e85..3fdfe68e58d 100644 --- a/packages/web/src/app/AppProviders.tsx +++ b/packages/web/src/app/AppProviders.tsx @@ -1,7 +1,9 @@ import { ReactNode, useState, useMemo } from 'react' import { FrostedSurfaceIntensity, ThemePalette } from '@audius/common/models' +import { setNiceModalAdapter } from '@audius/common/services' import { MediaProvider } from '@audius/harmony/src/contexts' +import NiceModal from '@ebay/nice-modal-react' import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { Provider as ReduxProvider } from 'react-redux' @@ -26,8 +28,13 @@ import { } from 'utils/theme/theme' import { wagmiAdapter } from './ReownAppKitModal' +import './registerNiceModals' import { createRoutes } from './routes' +// Wire the platform-agnostic bridge so common (sagas/services) can drive +// nice-modal-react without depending on the package directly. +setNiceModalAdapter({ show: NiceModal.show, hide: NiceModal.hide }) + type AppProvidersProps = { children?: ReactNode } @@ -81,6 +88,9 @@ export const AppProviders = ({ children }: AppProvidersProps) => { + {/* is mounted inside routes.tsx, deeper + in the tree, so NiceModal-managed modals can read app + contexts (AudiusQueryProvider, ToastContext, etc). */} diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts new file mode 100644 index 00000000000..699b5aa1d4c --- /dev/null +++ b/packages/web/src/app/registerNiceModals.ts @@ -0,0 +1,10 @@ +/** + * Side-effect imports for nice-modal-react registrations. + * + * Each imported module calls `NiceModal.register(id, Component)` at module + * scope. Importing this file from `AppProviders` ensures all registered + * modals are available before anything tries to `showNiceModal(id)`. + * + * Add new NiceModal-managed modals here as they migrate. + */ +import 'components/share-modal/ShareModal' diff --git a/packages/web/src/app/routes.tsx b/packages/web/src/app/routes.tsx index 0d557b9b80f..9e643b9f085 100644 --- a/packages/web/src/app/routes.tsx +++ b/packages/web/src/app/routes.tsx @@ -3,6 +3,7 @@ import React, { lazy, Suspense, useEffect } from 'react' import { SyncLocalStorageUserProvider } from '@audius/common/api' import { route } from '@audius/common/utils' import { CoinflowPurchaseProtection } from '@coinflowlabs/react' +import NiceModal from '@ebay/nice-modal-react' import type { RouteObject } from 'react-router' import { Navigate, Outlet, useNavigate } from 'react-router' @@ -80,14 +81,19 @@ const RootLayout = () => { - - - - - + {/* NiceModal-managed modals (e.g. ShareModal) + read AudiusQueryProvider + ToastContext, so the + Provider must mount inside them. */} + + + + + + + diff --git a/packages/web/src/components/edit-folder-modal/EditFolderModal.tsx b/packages/web/src/components/edit-folder-modal/EditFolderModal.tsx index 696ef00c0ce..92a47f46c81 100644 --- a/packages/web/src/components/edit-folder-modal/EditFolderModal.tsx +++ b/packages/web/src/components/edit-folder-modal/EditFolderModal.tsx @@ -10,18 +10,14 @@ import { ModalTitle, IconFolder } from '@audius/harmony' -import { useDispatch } from 'react-redux' -import { useModalState } from 'common/hooks/useModalState' import { make, useRecord } from 'common/store/analytics/actions' import FolderForm from 'components/create-playlist/FolderForm' import { DeleteFolderConfirmationModal } from 'components/nav/desktop/PlaylistLibrary/DeleteFolderConfirmationModal' -import { getFolderId } from 'store/application/ui/editFolderModal/selectors' -import { setFolderId } from 'store/application/ui/editFolderModal/slice' -import { useSelector } from 'utils/reducer' import { zIndex } from 'utils/zIndex' import styles from './EditFolderModal.module.css' + const { renamePlaylistFolderInLibrary } = playlistLibraryHelpers const messages = { @@ -29,59 +25,42 @@ const messages = { folderEntity: 'Folder' } -const EditFolderModal = () => { +type EditFolderModalProps = { + isOpen: boolean + onClose: () => void + folder: PlaylistLibraryFolder +} + +export const EditFolderModal = (props: EditFolderModalProps) => { + const { isOpen, onClose, folder } = props const record = useRecord() - const folderId = useSelector(getFolderId) const { data: playlistLibrary } = useCurrentAccount({ select: (account) => account?.playlistLibrary }) - const [isOpen, setIsOpen] = useModalState('EditFolder') - const folder = - playlistLibrary == null || folderId == null - ? null - : (playlistLibrary.contents.find( - (item) => item.type === 'folder' && item.id === folderId - ) as PlaylistLibraryFolder | undefined) const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) const onCloseDeleteConfirmation = () => setShowDeleteConfirmation(false) - const dispatch = useDispatch() const { mutate: updatePlaylistLibrary } = useUpdatePlaylistLibrary() - const handleClose = useCallback(() => { - dispatch(setFolderId(null)) - setIsOpen(false) - }, [dispatch, setIsOpen]) - const handleCancel = useCallback(() => { record(make(Name.FOLDER_CANCEL_EDIT, {})) - handleClose() - }, [handleClose, record]) + onClose() + }, [onClose, record]) const handleSubmit = useCallback( (newName: string) => { - if ( - !(playlistLibrary == null || folderId == null || folder == null) && - newName !== folder.name - ) { + if (playlistLibrary != null && newName !== folder.name) { const newLibrary = renamePlaylistFolderInLibrary( playlistLibrary, - folderId, + folder.id, newName ) updatePlaylistLibrary(newLibrary) } record(make(Name.FOLDER_SUBMIT_EDIT, {})) - handleClose() + onClose() }, - [ - folder, - folderId, - handleClose, - playlistLibrary, - record, - updatePlaylistLibrary - ] + [folder, onClose, playlistLibrary, record, updatePlaylistLibrary] ) const handleConfirmDelete = useCallback(() => { @@ -90,19 +69,19 @@ const EditFolderModal = () => { const handleDelete = useCallback(() => { setShowDeleteConfirmation(false) - handleClose() - }, [handleClose]) + onClose() + }, [onClose]) return ( <> - + } title={messages.editFolderModalTitle} @@ -114,20 +93,16 @@ const EditFolderModal = () => { onSubmit={handleSubmit} onCancel={handleCancel} onDelete={handleConfirmDelete} - initialFolderName={folder?.name} + initialFolderName={folder.name} /> - {folder ? ( - - ) : null} + ) } - -export default EditFolderModal diff --git a/packages/web/src/components/nav/desktop/PlaylistLibrary/PlaylistFolderNavItem.tsx b/packages/web/src/components/nav/desktop/PlaylistLibrary/PlaylistFolderNavItem.tsx index 5cc628cd25f..5df0652d52f 100644 --- a/packages/web/src/components/nav/desktop/PlaylistLibrary/PlaylistFolderNavItem.tsx +++ b/packages/web/src/components/nav/desktop/PlaylistLibrary/PlaylistFolderNavItem.tsx @@ -9,7 +9,6 @@ import { PlaylistLibraryID, PlaylistLibraryFolder } from '@audius/common/models' -import { modalsActions } from '@audius/common/store' import { IconFolder, PopupMenuItem, @@ -21,12 +20,11 @@ import { IconTrash } from '@audius/harmony' import { ClassNames } from '@emotion/react' -import { useDispatch } from 'react-redux' import { useToggle } from 'react-use' import { make, useRecord } from 'common/store/analytics/actions' import { Draggable, Droppable } from 'components/dragndrop' -import { setFolderId as setEditFolderModalFolderId } from 'store/application/ui/editFolderModal/slice' +import { EditFolderModal } from 'components/edit-folder-modal/EditFolderModal' import { DragDropKind, selectDraggingKind } from 'store/dragndrop/slice' import { useSelector } from 'utils/reducer' @@ -34,8 +32,6 @@ import { DeleteFolderConfirmationModal } from './DeleteFolderConfirmationModal' import { NavItemKebabButton } from './NavItemKebabButton' import { PlaylistLibraryNavItem, keyExtractor } from './PlaylistLibraryNavItem' -const { setVisibility } = modalsActions - type PlaylistFolderNavItemProps = { folder: PlaylistLibraryFolder level: number @@ -71,11 +67,11 @@ export const PlaylistFolderNavItem = (props: PlaylistFolderNavItemProps) => { [setIsOpen] ) - const dispatch = useDispatch() const record = useRecord() const { mutate: addToPlaylistFolder } = useAddToPlaylistFolder() const [isDeleteConfirmationOpen, toggleDeleteConfirmationOpen] = useToggle(false) + const [isEditFolderOpen, setIsEditFolderOpen] = useState(false) const isDisabled = draggingKind && !acceptedKinds.includes(draggingKind) @@ -116,13 +112,16 @@ export const PlaylistFolderNavItem = (props: PlaylistFolderNavItemProps) => { (event: MouseEvent) => { event.preventDefault() event.stopPropagation() - dispatch(setEditFolderModalFolderId(id)) - dispatch(setVisibility({ modal: 'EditFolder', visible: true })) + setIsEditFolderOpen(true) record(make(Name.FOLDER_OPEN_EDIT, {})) }, - [dispatch, id, record] + [record] ) + const handleCloseEdit = useCallback(() => { + setIsEditFolderOpen(false) + }, []) + const kebabItems: PopupMenuItem[] = useMemo( () => [ { @@ -238,6 +237,11 @@ export const PlaylistFolderNavItem = (props: PlaylistFolderNavItemProps) => { visible={isDeleteConfirmationOpen} onCancel={toggleDeleteConfirmationOpen} /> + diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index ecd29a97823..25dc8f963fd 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -11,13 +11,13 @@ import { modalsActions, useCreateChatModal } from '@audius/common/store' +import NiceModal, { useModal } from '@ebay/nice-modal-react' import { useDispatch } from 'react-redux' import { make, useRecord } from 'common/store/analytics/actions' import * as embedModalActions from 'components/embed-modal/store/actions' import { ToastContext } from 'components/toast/ToastContext' import { useIsMobile } from 'hooks/useIsMobile' -import { useModalState } from 'pages/modals/useModalState' import { SHARE_TOAST_TIMEOUT_MILLIS } from 'utils/constants' import { useSelector } from 'utils/reducer' import { openXLink } from 'utils/xShare' @@ -33,8 +33,15 @@ const { shareTrack, shareContest } = tracksSocialActions const { shareCollection } = collectionsSocialActions const { setVisibility } = modalsActions -export const ShareModal = () => { - const { isOpen, onClose, onClosed } = useModalState('Share') +export const ShareModal = NiceModal.create(() => { + const modal = useModal() + const isOpen = modal.visible + const onClose = useCallback(() => { + modal.hide() + }, [modal]) + // NiceModal handles unmount via `remove()` after the close animation; + // there's no separate "fully closed" callback, so reuse onClose here. + const onClosed = onClose const sendShareAction = useShareAction() const { toast } = useContext(ToastContext) @@ -154,4 +161,8 @@ export const ShareModal = () => { if (isMobile) return return -} +}) + +// Register the modal so saga code (and anything else outside React) can +// open it via `showNiceModal('Share')`. +NiceModal.register('Share', ShareModal) diff --git a/packages/web/src/pages/modals/Modals.tsx b/packages/web/src/pages/modals/Modals.tsx index b31033a30c4..55b774149d9 100644 --- a/packages/web/src/pages/modals/Modals.tsx +++ b/packages/web/src/pages/modals/Modals.tsx @@ -18,7 +18,6 @@ import { DownloadTrackArchiveModal } from 'components/download-track-archive-mod import { DuplicateAddConfirmationModal } from 'components/duplicate-add-confirmation-modal' import { EarlyReleaseConfirmationModal } from 'components/early-release-confirmation-modal' import { EditAccessConfirmationModal } from 'components/edit-access-confirmation-modal' -import EditFolderModal from 'components/edit-folder-modal/EditFolderModal' import EmbedModal from 'components/embed-modal/EmbedModal' import { FeatureFlagOverrideModal } from 'components/feature-flag-override-modal' import { FinalizeWinnersConfirmationModal } from 'components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal' @@ -61,7 +60,6 @@ import { ClaimVestedCoinsModal } from 'pages/fan-club-detail-page/components/Cla import { ChallengeRewardsModal } from 'pages/rewards-page/components/modals/ChallengeRewardsModal' import AppModal from './AppModal' -const ShareModal = lazy(() => import('components/share-modal')) const StripeOnRampModal = lazy(() => import('components/stripe-on-ramp-modal')) @@ -78,8 +76,6 @@ const CommentSettingsModal = lazy( ) const commonModalsMap: { [Modal in ModalTypes]?: ComponentType } = { - Share: ShareModal, - EditFolder: EditFolderModal, AddToCollection: AddToCollectionModal, TiersExplainer: TierExplainerModal, DeletePlaylistConfirmation: DeletePlaylistConfirmationModal, diff --git a/packages/web/src/store/application/ui/editFolderModal/selectors.ts b/packages/web/src/store/application/ui/editFolderModal/selectors.ts deleted file mode 100644 index 32c916f8d09..00000000000 --- a/packages/web/src/store/application/ui/editFolderModal/selectors.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AppState } from 'store/types' - -const getBaseState = (state: AppState) => state.application.ui.editFolderModal - -export const getFolderId = (state: AppState) => { - return getBaseState(state).folderId -} diff --git a/packages/web/src/store/application/ui/editFolderModal/slice.ts b/packages/web/src/store/application/ui/editFolderModal/slice.ts deleted file mode 100644 index 960527c0487..00000000000 --- a/packages/web/src/store/application/ui/editFolderModal/slice.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' - -export type EditFolderModalState = { - folderId: string | null -} - -const initialState: EditFolderModalState = { - folderId: null -} - -const slice = createSlice({ - name: 'application/ui/editFolderModal', - initialState, - reducers: { - setFolderId: (state, action: PayloadAction) => { - state.folderId = action.payload - } - } -}) - -export const { setFolderId } = slice.actions - -export default slice.reducer diff --git a/packages/web/src/store/reducers.ts b/packages/web/src/store/reducers.ts index 69d554374ae..bb8f53410cc 100644 --- a/packages/web/src/store/reducers.ts +++ b/packages/web/src/store/reducers.ts @@ -14,7 +14,6 @@ import unfollowConfirmation from 'components/unfollow-confirmation-modal/store/r import visualizer from 'pages/visualizer/store/slice' import appCTAModal from 'store/application/ui/app-cta-modal/slice' import cookieBanner from 'store/application/ui/cookieBanner/reducer' -import editFolderModal from 'store/application/ui/editFolderModal/slice' import scrollLock from 'store/application/ui/scrollLock/reducer' import userListModal from 'store/application/ui/userListModal/slice' import dragndrop from 'store/dragndrop/slice' @@ -47,7 +46,6 @@ const createRootReducer = () => { ui: combineReducers({ appCTAModal, cookieBanner, - editFolderModal, embedModal, firstUploadModal, scrollLock, diff --git a/packages/web/src/store/types.ts b/packages/web/src/store/types.ts index df0e67641c1..1404665f751 100644 --- a/packages/web/src/store/types.ts +++ b/packages/web/src/store/types.ts @@ -26,7 +26,6 @@ import { ErrorState } from 'store/errors/reducers' import { BackendState } from '../common/store/backend/types' import { CookieBannerState } from './application/ui/cookieBanner/types' -import { EditFolderModalState } from './application/ui/editFolderModal/slice' import { ScrollLockState } from './application/ui/scrollLock/types' import { UserListModalState } from './application/ui/userListModal/types' import { DragnDropState } from './dragndrop/slice' @@ -52,7 +51,6 @@ export type AppState = CommonState & { appCTAModal: ReturnType averageColor: ReturnType cookieBanner: CookieBannerState - editFolderModal: EditFolderModalState embedModal: EmbedModalState firstUploadModal: FirstUploadModalState scrollLock: ScrollLockState From 73742ead18937cf7084e164ad9c00035a84b0fbb Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Sun, 3 May 2026 19:39:43 -0700 Subject: [PATCH 02/13] Modals: colocate LabelAccount Single-trigger modal opened only by LabelAccountSettingsCard (and auto-opened on a route match). Converts to local useState + inline mount, deletes the registry entry / parentSlice initial state / type union member. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/src/store/ui/modals/parentSlice.ts | 1 - packages/common/src/store/ui/modals/types.ts | 1 - .../label-account-modal/LabelAccountModal.tsx | 18 ++++++++++-------- packages/web/src/pages/modals/Modals.tsx | 2 -- .../LabelAccount/LabelAccountSettingsCard.tsx | 19 ++++++++++++------- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 1eae8c0b466..c9edbd4b658 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -40,7 +40,6 @@ export const initialState: BasicModalsState = { StripeOnRamp: { isOpen: false }, InboxSettings: { isOpen: false }, CommentSettings: { isOpen: false }, - LabelAccount: { isOpen: false }, PrivateKeyExporter: { isOpen: false }, LockedContent: { isOpen: false }, PlaybackRate: { isOpen: false }, diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index 98b156d7988..7a5076fb250 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -82,7 +82,6 @@ export type Modals = | 'PlaybackRate' | 'ProfileActions' | 'PublishContentModal' - | 'LabelAccount' | 'DuplicateAddConfirmation' | 'PremiumContentPurchaseModal' | 'CreateChatModal' diff --git a/packages/web/src/components/label-account-modal/LabelAccountModal.tsx b/packages/web/src/components/label-account-modal/LabelAccountModal.tsx index a7f74f0058f..480f3556605 100644 --- a/packages/web/src/components/label-account-modal/LabelAccountModal.tsx +++ b/packages/web/src/components/label-account-modal/LabelAccountModal.tsx @@ -12,8 +12,6 @@ import { Button } from '@audius/harmony' -import { useModalState } from 'common/hooks/useModalState' - const messages = { title: 'Label Account', error: 'Something went wrong. Please try again.', @@ -22,9 +20,13 @@ const messages = { done: 'Done' } -export const LabelAccountModal = () => { - const [isVisible, setIsVisible] = useModalState('LabelAccount') - const handleClose = useCallback(() => setIsVisible(false), [setIsVisible]) +type LabelAccountModalProps = { + isOpen: boolean + onClose: () => void +} + +export const LabelAccountModal = (props: LabelAccountModalProps) => { + const { isOpen, onClose } = props const { data: currentUserId } = useCurrentUserId() const { data: user } = useUser(currentUserId) const [isLabel, setIsLabel] = useState(user?.profile_type === 'label') @@ -41,8 +43,8 @@ export const LabelAccountModal = () => { }, [updateProfile, isLabel, user]) return ( - - + + } /> @@ -54,7 +56,7 @@ export const LabelAccountModal = () => { {messages.identifyAsLabel} - diff --git a/packages/web/src/pages/modals/Modals.tsx b/packages/web/src/pages/modals/Modals.tsx index 55b774149d9..acbda3429c5 100644 --- a/packages/web/src/pages/modals/Modals.tsx +++ b/packages/web/src/pages/modals/Modals.tsx @@ -25,7 +25,6 @@ import FirstUploadModal from 'components/first-upload-modal/FirstUploadModal' import { HideContentConfirmationModal } from 'components/hide-confirmation-modal' import { HostRemixContestModal } from 'components/host-remix-contest-modal/HostRemixContestModal' import { InboxUnavailableModal } from 'components/inbox-unavailable-modal/InboxUnavailableModal' -import { LabelAccountModal } from 'components/label-account-modal/LabelAccountModal' import { LeavingAudiusModal } from 'components/leaving-audius-modal/LeavingAudiusModal' import { LockedContentModal } from 'components/locked-content-modal/LockedContentModal' import { PasswordResetModal } from 'components/password-reset/PasswordResetModal' @@ -95,7 +94,6 @@ const commonModalsMap: { [Modal in ModalTypes]?: ComponentType } = { TransactionDetails: TransactionDetailsModal, InboxSettings: InboxSettingsModal, CommentSettings: CommentSettingsModal, - LabelAccount: LabelAccountModal, LockedContent: LockedContentModal, APIRewardsExplainer: TopAPIModal, ChallengeRewards: ChallengeRewardsModal, diff --git a/packages/web/src/pages/settings-page/components/desktop/LabelAccount/LabelAccountSettingsCard.tsx b/packages/web/src/pages/settings-page/components/desktop/LabelAccount/LabelAccountSettingsCard.tsx index e320ee47464..d6cb0571b21 100644 --- a/packages/web/src/pages/settings-page/components/desktop/LabelAccount/LabelAccountSettingsCard.tsx +++ b/packages/web/src/pages/settings-page/components/desktop/LabelAccount/LabelAccountSettingsCard.tsx @@ -1,29 +1,33 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { settingsMessages } from '@audius/common/messages' import { route } from '@audius/common/utils' import { Button, IconUserList } from '@audius/harmony' import { useMatch } from 'react-router' -import { useModalState } from 'common/hooks/useModalState' +import { LabelAccountModal } from 'components/label-account-modal/LabelAccountModal' import SettingsCard from '../SettingsCard' const { LABEL_ACCOUNT_SETTINGS_PAGE } = route export const LabelAccountSettingsCard = () => { - const [, setIsModalOpen] = useModalState('LabelAccount') + const [isOpen, setIsOpen] = useState(false) const match = useMatch(LABEL_ACCOUNT_SETTINGS_PAGE) useEffect(() => { if (match) { - setIsModalOpen(true) + setIsOpen(true) } - }, [match, setIsModalOpen]) + }, [match]) const handleOpen = useCallback(() => { - setIsModalOpen(true) - }, [setIsModalOpen]) + setIsOpen(true) + }, []) + + const handleClose = useCallback(() => { + setIsOpen(false) + }, []) return ( <> @@ -36,6 +40,7 @@ export const LabelAccountSettingsCard = () => { {settingsMessages.labelAccountButtonText} + ) } From 74ed83e3d4dbc11e0d0f41fa812a1d109b8954c8 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Sun, 3 May 2026 19:44:51 -0700 Subject: [PATCH 03/13] Modals: generalize nice-modal bridge saga + allowlist Replaces the share-modal-specific watchOpenShareViaSetVisibility saga with a generic bridge in services/nice-modal-bridge that translates setVisibility(id, true|false) into showNiceModal(id) / hideNiceModal(id) for any modal id that opts in via registerNiceModalId. ShareModal (web) and ShareDrawer (mobile) now call registerNiceModalId('Share') alongside their NiceModal.register(...) so the existing ~10 trigger sites keep working unchanged. This makes the per-modal NiceModal migration cost just three things: 1. Wrap with NiceModal.create(...) 2. NiceModal.register('X', Component) + registerNiceModalId('X') 3. Side-effect import in registerNiceModals.ts 4. Remove from web Modals.tsx / mobile Drawers.tsx (avoid double mount) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/nice-modal-bridge/index.ts | 22 +++++++++ .../src/services/nice-modal-bridge/sagas.ts | 48 +++++++++++++++++++ packages/common/src/store/sagas.ts | 4 +- .../common/src/store/ui/share-modal/sagas.ts | 24 ++-------- .../components/share-drawer/ShareDrawer.tsx | 6 ++- .../src/components/share-modal/ShareModal.tsx | 6 ++- 6 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 packages/common/src/services/nice-modal-bridge/sagas.ts diff --git a/packages/common/src/services/nice-modal-bridge/index.ts b/packages/common/src/services/nice-modal-bridge/index.ts index aff8bfa75f4..ee5d4f124a4 100644 --- a/packages/common/src/services/nice-modal-bridge/index.ts +++ b/packages/common/src/services/nice-modal-bridge/index.ts @@ -9,6 +9,13 @@ * If a caller fires `showNiceModal` before the adapter is registered * (e.g. during early app boot or in a test), the call is logged and * resolves with `undefined` rather than throwing. + * + * `registerNiceModalId(id)` adds a modal id to the bridge allowlist. The + * `share-modal` saga's bridge saga (`watchOpenViaSetVisibility`) reads + * this allowlist and translates legacy `setVisibility(id, true)` actions + * into `showNiceModal(id)` calls. Wave-D registry-pattern modals call + * `registerNiceModalId('Foo')` next to their `NiceModal.register('Foo', ...)` + * so existing redux trigger sites keep working unchanged. */ export type NiceModalShowFn = ( @@ -48,3 +55,18 @@ export const hideNiceModal: NiceModalHideFn = (id) => { } return adapter.hide(id) } + +/** + * Allowlist of modal ids that should bridge from `setVisibility(id, true)` + * to `showNiceModal(id)`. Modals call `registerNiceModalId('Foo')` at + * module scope, alongside their `NiceModal.register('Foo', Component)`. + * + * Backed by a Set so re-imports are idempotent. + */ +const niceModalIds = new Set() + +export const registerNiceModalId = (id: string) => { + niceModalIds.add(id) +} + +export const isNiceModalId = (id: string): boolean => niceModalIds.has(id) diff --git a/packages/common/src/services/nice-modal-bridge/sagas.ts b/packages/common/src/services/nice-modal-bridge/sagas.ts new file mode 100644 index 00000000000..272a8e9bbbe --- /dev/null +++ b/packages/common/src/services/nice-modal-bridge/sagas.ts @@ -0,0 +1,48 @@ +import { takeEvery, call } from 'typed-redux-saga' + +import { setVisibility } from '~/store/ui/modals/parentSlice' + +import { + hideNiceModal, + isNiceModalId, + showNiceModal +} from './index' + +/** + * Bridge saga: translates legacy redux-driven `setVisibility(id, true|false)` + * actions into `showNiceModal(id)` / `hideNiceModal(id)` for any modal id + * that has registered itself via `registerNiceModalId(...)`. + * + * This lets NiceModal-managed modals coexist with the legacy modal registry + * — every existing trigger site that dispatches `setVisibility` keeps + * working unchanged, and migrating a modal to NiceModal becomes: + * 1. Wrap with `NiceModal.create(...)` + * 2. `NiceModal.register('X', Component)` + * 3. `registerNiceModalId('X')` + * 4. Add a side-effect import to `registerNiceModals.ts` + * 5. Remove from web `Modals.tsx` / mobile `Drawers.tsx` (avoid double-mount) + * + * Once every caller has been moved to call `showNiceModal` directly, this + * bridge can be deleted. + */ +function* watchOpenViaSetVisibility() { + yield takeEvery( + setVisibility, + function* (action: ReturnType) { + const { modal, visible } = action.payload + if (!isNiceModalId(modal)) return + if (visible === true) { + yield call(showNiceModal, modal) + } else if (visible === false) { + yield call(hideNiceModal, modal) + } + // 'closing' is a transient state used by the legacy AppDrawer for + // its close animation; ignore it here — NiceModal handles its own + // exit animation. + } + ) +} + +export default function niceModalBridgeSagas() { + return [watchOpenViaSetVisibility] +} diff --git a/packages/common/src/store/sagas.ts b/packages/common/src/store/sagas.ts index e7d7cef398d..985b727cf29 100644 --- a/packages/common/src/store/sagas.ts +++ b/packages/common/src/store/sagas.ts @@ -6,6 +6,7 @@ // import recoveryEmailSagas from 'common/store/recovery-email/sagas' // import signOutSagas from 'common/store/sign-out/sagas' +import niceModalBridgeSagas from '~/services/nice-modal-bridge/sagas' import { accountSagas } from '~/store/account' import { buyUSDCSagas } from '~/store/buy-usdc' import { sagas as castSagas } from '~/store/cast/sagas' @@ -52,7 +53,8 @@ export const sagas = (_ctx: CommonStoreContext) => ({ duplidateAddConfirmationModalUI: duplicateAddConfirmationModalUISagas, playback: playbackSagas, playbackPosition: playbackPositionSagas, - withdrawUSDC: withdrawUSDCSagas + withdrawUSDC: withdrawUSDCSagas, + niceModalBridge: niceModalBridgeSagas // signOut: signOutSagas // recoveryEmail: recoveryEmailSagas diff --git a/packages/common/src/store/ui/share-modal/sagas.ts b/packages/common/src/store/ui/share-modal/sagas.ts index c466350cd6c..78e433a26a2 100644 --- a/packages/common/src/store/ui/share-modal/sagas.ts +++ b/packages/common/src/store/ui/share-modal/sagas.ts @@ -8,7 +8,6 @@ import { } from '~/adapters' import { queryCollection, queryTrack, queryUser } from '~/api' import { TQCollection } from '~/api/tan-query/models' -import { showNiceModal } from '~/services/nice-modal-bridge' import { getSDK } from '~/store/sdkUtils' import { setVisibility } from '../modals/parentSlice' @@ -99,25 +98,10 @@ function* watchHandleRequestOpen() { yield takeEvery(requestOpen, handleRequestOpen) } -/** - * Bridge: any caller dispatching `setVisibility({ modal: 'Share', visible: true })` - * (legacy callers like `onCancelAction` from CreateChatModal, plus the - * `handleRequestOpen` saga above) gets translated into a `NiceModal.show('Share')` - * call. This lets the rest of the codebase migrate at its own pace — once all - * callers go through `showNiceModal` directly, this bridge can be removed. - */ -function* watchOpenShareViaSetVisibility() { - yield takeEvery( - setVisibility, - function* (action: ReturnType) { - const { modal, visible } = action.payload - if (modal === 'Share' && visible === true) { - yield call(showNiceModal, 'Share') - } - } - ) -} +// The `setVisibility('Share', true)` → `showNiceModal('Share')` bridge now +// lives in the generalized `services/nice-modal-bridge/sagas.ts`, driven by +// the `registerNiceModalId` allowlist that ShareModal opts into. export default function sagas() { - return [watchHandleRequestOpen, watchOpenShareViaSetVisibility] + return [watchHandleRequestOpen] } diff --git a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx index c2969452710..2a4826560d1 100644 --- a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx +++ b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { useCurrentUserId } from '@audius/common/api' import { useShareAction } from '@audius/common/hooks' import { Name, ShareSource } from '@audius/common/models' +import { registerNiceModalId } from '@audius/common/services' import { collectionsSocialActions, tracksSocialActions, @@ -303,5 +304,8 @@ export const ShareDrawer = NiceModal.create(() => { }) // Register so saga code (and anything else outside React) can open it via -// `showNiceModal('Share')`. +// `showNiceModal('Share')`. The id is also added to the nice-modal bridge +// allowlist so legacy `setVisibility('Share', true)` dispatches translate +// to `showNiceModal('Share')`. NiceModal.register('Share', ShareDrawer) +registerNiceModalId('Share') diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index 25dc8f963fd..71dfcf5c466 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect } from 'react' import { useCurrentUserId } from '@audius/common/api' import { useIsManagedAccount, useShareAction } from '@audius/common/hooks' import { Name, PlayableType } from '@audius/common/models' +import { registerNiceModalId } from '@audius/common/services' import { collectionsSocialActions, tracksSocialActions, @@ -164,5 +165,8 @@ export const ShareModal = NiceModal.create(() => { }) // Register the modal so saga code (and anything else outside React) can -// open it via `showNiceModal('Share')`. +// open it via `showNiceModal('Share')`. The id is also added to the +// nice-modal bridge allowlist so legacy `setVisibility('Share', true)` +// dispatches translate to `showNiceModal('Share')`. NiceModal.register('Share', ShareModal) +registerNiceModalId('Share') From 196a58faf9d5f47e188009b2364a25832aac63f0 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Sun, 3 May 2026 19:58:37 -0700 Subject: [PATCH 04/13] Modals (Wave D batch 1): NiceModal-wrap 12 confirmation/info modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the simpler global modals to nice-modal-react: - LeavingAudiusModal - TierExplainerModal (TiersExplainer) - WelcomeModal - DeletePlaylistConfirmationModal - DeleteTrackConfirmationModal - DuplicateAddConfirmationModal - PublishConfirmationModal - UploadConfirmationModal - ReplaceTrackConfirmationModal - ReplaceTrackProgressModal - EarlyReleaseConfirmationModal - EditAccessConfirmationModal - HideContentConfirmationModal - AlbumTrackRemoveConfirmationModal - FinalizeWinnersConfirmationModal Each modal: - wraps with NiceModal.create() - reads visibility via useModal() (legacy createModal hook still provides `data` payloads — that part is unchanged) - calls NiceModal.register(id, Component) + registerNiceModalId(id) - removed from web Modals.tsx commonModalsMap to avoid double-mount - added to registerNiceModals.ts side-effect import list Trigger sites untouched — the generalized bridge saga translates existing setVisibility(id, true) dispatches to showNiceModal(id). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/app/registerNiceModals.ts | 22 ++++++++++-- .../AlbumTrackRemoveConfirmationModal.tsx | 27 +++++++++----- .../DeletePlaylistConfirmationModal.tsx | 19 ++++++---- .../DeleteTrackConfirmationModal.tsx | 28 +++++++++------ .../DuplicateAddConfirmationModal.tsx | 18 ++++++---- .../EarlyReleaseConfirmationModal.tsx | 26 +++++++++----- .../EditAccessConfirmationModal.tsx | 26 +++++++++----- .../FinalizeWinnersConfirmationModal.tsx | 29 ++++++++++----- .../HideContentConfirmationModal.tsx | 26 +++++++++----- .../LeavingAudiusModal.tsx | 36 ++++++++++++++----- .../PublishConfirmationModal.tsx | 24 +++++++++---- .../ReplaceTrackConfirmationModal.tsx | 26 +++++++++----- .../ReplaceTrackProgressModal.tsx | 16 ++++++--- .../UploadConfirmationModal.tsx | 24 +++++++++---- .../user-badges/TierExplainerModal.tsx | 19 +++++----- .../components/welcome-modal/WelcomeModal.tsx | 22 +++++++----- packages/web/src/pages/modals/Modals.tsx | 30 ---------------- 17 files changed, 268 insertions(+), 150 deletions(-) diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts index 699b5aa1d4c..0f0abb1a701 100644 --- a/packages/web/src/app/registerNiceModals.ts +++ b/packages/web/src/app/registerNiceModals.ts @@ -1,10 +1,26 @@ /** * Side-effect imports for nice-modal-react registrations. * - * Each imported module calls `NiceModal.register(id, Component)` at module - * scope. Importing this file from `AppProviders` ensures all registered - * modals are available before anything tries to `showNiceModal(id)`. + * Each imported module calls `NiceModal.register(id, Component)` and + * `registerNiceModalId(id)` at module scope. Importing this file from + * `AppProviders` ensures all registered modals are available before + * anything tries to `showNiceModal(id)`. * * Add new NiceModal-managed modals here as they migrate. */ +import 'components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal' +import 'components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal' +import 'components/delete-track-confirmation-modal/DeleteTrackConfirmationModal' +import 'components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal' +import 'components/early-release-confirmation-modal/EarlyReleaseConfirmationModal' +import 'components/edit-access-confirmation-modal/EditAccessConfirmationModal' +import 'components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal' +import 'components/hide-confirmation-modal/HideContentConfirmationModal' +import 'components/leaving-audius-modal/LeavingAudiusModal' +import 'components/publish-confirmation-modal/PublishConfirmationModal' +import 'components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal' +import 'components/replace-track-progress-modal/ReplaceTrackProgressModal' import 'components/share-modal/ShareModal' +import 'components/upload-confirmation-modal/UploadConfirmationModal' +import 'components/user-badges/TierExplainerModal' +import 'components/welcome-modal/WelcomeModal' diff --git a/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx b/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx index c84a4d424bd..fe932682f48 100644 --- a/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx +++ b/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react' +import { registerNiceModalId } from '@audius/common/services' import { useAlbumTrackRemoveConfirmationModal, cacheCollectionsActions @@ -13,6 +14,7 @@ import { ModalTitle, ModalFooter } from '@audius/harmony' +import NiceModal, { useModal } from '@ebay/nice-modal-react' import { useDispatch } from 'react-redux' const messages = { @@ -24,15 +26,18 @@ const messages = { release: 'Remove Track From Album' } -export const AlbumTrackRemoveConfirmationModal = () => { +export const AlbumTrackRemoveConfirmationModal = NiceModal.create(() => { + const modal = useModal() const { - isOpen, - onClose, data: { trackId, playlistId, timestamp } } = useAlbumTrackRemoveConfirmationModal() const dispatch = useDispatch() + const handleClose = useCallback(() => { + modal.hide() + }, [modal]) + const handleConfirm = useCallback(() => { if (trackId && playlistId && timestamp) { dispatch( @@ -43,11 +48,11 @@ export const AlbumTrackRemoveConfirmationModal = () => { ) ) } - onClose() - }, [dispatch, onClose, playlistId, timestamp, trackId]) + handleClose() + }, [dispatch, handleClose, playlistId, timestamp, trackId]) return ( - + @@ -56,7 +61,7 @@ export const AlbumTrackRemoveConfirmationModal = () => { {messages.description2} -