diff --git a/apps/audius-client/packages/common/src/store/reducers.ts b/apps/audius-client/packages/common/src/store/reducers.ts index 31a9c380a1a..05bfe08519d 100644 --- a/apps/audius-client/packages/common/src/store/reducers.ts +++ b/apps/audius-client/packages/common/src/store/reducers.ts @@ -89,6 +89,8 @@ import createPlaylistModalReducer from './ui/createPlaylistModal/reducer' import { CreatePlaylistModalState } from './ui/createPlaylistModal/types' import deletePlaylistConfirmationReducer from './ui/delete-playlist-confirmation-modal/slice' import { DeletePlaylistConfirmationModalState } from './ui/delete-playlist-confirmation-modal/types' +import duplicateAddConfirmationReducer from './ui/duplicate-add-confirmation-modal/slice' +import { DuplicateAddConfirmationModalState } from './ui/duplicate-add-confirmation-modal/types' import mobileOverflowModalReducer from './ui/mobile-overflow-menu/slice' import { MobileOverflowModalState } from './ui/mobile-overflow-menu/types' import modalsReducer from './ui/modals/slice' @@ -180,6 +182,7 @@ export const reducers = () => ({ createPlaylistModal: createPlaylistModalReducer, collectibleDetails: collectibleDetailsReducer, deletePlaylistConfirmationModal: deletePlaylistConfirmationReducer, + duplicateAddConfirmationModal: duplicateAddConfirmationReducer, publishPlaylistConfirmationModal: publishPlaylistConfirmationReducer, mobileOverflowModal: mobileOverflowModalReducer, modals: modalsReducer, @@ -298,6 +301,7 @@ export type CommonState = { createPlaylistModal: CreatePlaylistModalState collectibleDetails: CollectibleDetailsState deletePlaylistConfirmationModal: DeletePlaylistConfirmationModalState + duplicateAddConfirmationModal: DuplicateAddConfirmationModalState publishPlaylistConfirmationModal: PublishPlaylistConfirmationModalState mobileOverflowModal: MobileOverflowModalState modals: ModalsState diff --git a/apps/audius-client/packages/common/src/store/sagas.ts b/apps/audius-client/packages/common/src/store/sagas.ts index e0a28dbaa82..bd53dea2480 100644 --- a/apps/audius-client/packages/common/src/store/sagas.ts +++ b/apps/audius-client/packages/common/src/store/sagas.ts @@ -17,6 +17,7 @@ import { searchUsersModalSagas, toastSagas, deletePlaylistConfirmationModalUISagas, + duplicateAddConfirmationModalUISagas, publishPlaylistConfirmationModalUISagas, mobileOverflowMenuUISagas, shareModalUISagas @@ -48,6 +49,7 @@ export const sagas = (_ctx: CommonStoreContext) => ({ shareModalUI: shareModalUISagas, mobileOverflowMenuUI: mobileOverflowMenuUISagas, deletePlaylistConfirmationModalUI: deletePlaylistConfirmationModalUISagas, + duplidateAddConfirmationModalUI: duplicateAddConfirmationModalUISagas, publishPlaylistConfirmationModalUI: publishPlaylistConfirmationModalUISagas, player: playerSagas, playbackPosition: playbackPositionSagas, diff --git a/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/sagas.ts b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/sagas.ts new file mode 100644 index 00000000000..4f389c21222 --- /dev/null +++ b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/sagas.ts @@ -0,0 +1,22 @@ +import { takeEvery } from 'redux-saga/effects' +import { put } from 'typed-redux-saga' + +import { setVisibility } from '../modals/slice' + +import { open, OpenPayload, requestOpen } from './slice' + +function* handleRequestOpen(action: OpenPayload) { + const { payload } = action + yield* put(open(payload)) + yield* put( + setVisibility({ modal: 'DuplicateAddConfirmation', visible: true }) + ) +} + +function* watchHandleRequestOpen() { + yield takeEvery(requestOpen, handleRequestOpen) +} + +export default function sagas() { + return [watchHandleRequestOpen] +} diff --git a/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/selectors.ts b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/selectors.ts new file mode 100644 index 00000000000..0ffa395a360 --- /dev/null +++ b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/selectors.ts @@ -0,0 +1,7 @@ +import { CommonState } from 'store/commonStore' + +export const getPlaylistId = (state: CommonState) => + state.ui.duplicateAddConfirmationModal.playlistId + +export const getTrackId = (state: CommonState) => + state.ui.duplicateAddConfirmationModal.trackId diff --git a/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/slice.ts b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/slice.ts new file mode 100644 index 00000000000..49711ae1e8e --- /dev/null +++ b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/slice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import { Nullable } from 'utils/typeUtils' + +import { ID } from '../../../models/Identifiers' + +type DuplicateAddConfirmationState = { + playlistId: Nullable + trackId: Nullable +} + +export type OpenPayload = PayloadAction<{ playlistId: ID; trackId: ID }> + +const initialState: DuplicateAddConfirmationState = { + trackId: null, + playlistId: null +} + +const slice = createSlice({ + name: 'applications/ui/duplicateAddConfirmation', + initialState, + reducers: { + requestOpen: (_state, _action: OpenPayload) => {}, + open: (state, action: OpenPayload) => { + state.playlistId = action.payload.playlistId + state.trackId = action.payload.trackId + } + } +}) + +export const { open, requestOpen } = slice.actions +export const actions = slice.actions +export default slice.reducer diff --git a/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/types.ts b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/types.ts new file mode 100644 index 00000000000..d7180ba0a65 --- /dev/null +++ b/apps/audius-client/packages/common/src/store/ui/duplicate-add-confirmation-modal/types.ts @@ -0,0 +1,7 @@ +import { ID } from '../../../models/Identifiers' + +export type DuplicateAddConfirmationModalState = { + isOpen: boolean + playlistId: ID | null + trackId: ID | null +} diff --git a/apps/audius-client/packages/common/src/store/ui/index.ts b/apps/audius-client/packages/common/src/store/ui/index.ts index f1728abaf55..89128463e9a 100644 --- a/apps/audius-client/packages/common/src/store/ui/index.ts +++ b/apps/audius-client/packages/common/src/store/ui/index.ts @@ -28,6 +28,14 @@ export { export { default as deletePlaylistConfirmationModalUISagas } from './delete-playlist-confirmation-modal/sagas' export * from './delete-playlist-confirmation-modal/types' +export * as duplicateAddConfirmationModalUISelectors from './duplicate-add-confirmation-modal/selectors' +export { + default as duplicateAddConfirmationModalUIReducer, + actions as duplicateAddConfirmationModalUIActions +} from './duplicate-add-confirmation-modal/slice' +export { default as duplicateAddConfirmationModalUISagas } from './duplicate-add-confirmation-modal/sagas' +export * from './duplicate-add-confirmation-modal/types' + export * as mobileOverflowMenuUISelectors from './mobile-overflow-menu/selectors' export { default as mobileOverflowMenuUIReducer, diff --git a/apps/audius-client/packages/common/src/store/ui/modals/slice.ts b/apps/audius-client/packages/common/src/store/ui/modals/slice.ts index 134120724bf..03b1d5304bc 100644 --- a/apps/audius-client/packages/common/src/store/ui/modals/slice.ts +++ b/apps/audius-client/packages/common/src/store/ui/modals/slice.ts @@ -26,6 +26,7 @@ const initialState: ModalsState = { Overflow: false, AddToPlaylist: false, DeletePlaylistConfirmation: false, + DuplicateAddConfirmation: false, FeatureFlagOverride: false, BuyAudio: false, BuyAudioRecovery: false, diff --git a/apps/audius-client/packages/common/src/store/ui/modals/types.ts b/apps/audius-client/packages/common/src/store/ui/modals/types.ts index 05f2afa7453..a997c0f4f0d 100644 --- a/apps/audius-client/packages/common/src/store/ui/modals/types.ts +++ b/apps/audius-client/packages/common/src/store/ui/modals/types.ts @@ -35,5 +35,6 @@ export type Modals = | 'ProfileActions' | 'PublishPlaylistConfirmation' | 'AiAttributionSettings' + | 'DuplicateAddConfirmation' export type ModalsState = { [modal in Modals]: boolean | 'closing' } diff --git a/apps/audius-client/packages/mobile/src/app/Drawers.tsx b/apps/audius-client/packages/mobile/src/app/Drawers.tsx index 454fc142359..e8359cf0d9a 100644 --- a/apps/audius-client/packages/mobile/src/app/Drawers.tsx +++ b/apps/audius-client/packages/mobile/src/app/Drawers.tsx @@ -15,6 +15,7 @@ import { DeactivateAccountConfirmationDrawer } from 'app/components/deactivate-a import { DeleteChatDrawer } from 'app/components/delete-chat-drawer' import { DeletePlaylistConfirmationDrawer } from 'app/components/delete-playlist-confirmation-drawer' import { DownloadTrackProgressDrawer } from 'app/components/download-track-progress-drawer' +import { DuplicateAddConfirmationDrawer } from 'app/components/duplicate-add-confirmation-drawer' import { EditCollectiblesDrawer } from 'app/components/edit-collectibles-drawer' import { EnablePushNotificationsDrawer } from 'app/components/enable-push-notifications-drawer' import { FeedFilterDrawer } from 'app/components/feed-filter-drawer' @@ -100,6 +101,7 @@ const commonDrawersMap: { [Modal in Modals]?: ComponentType } = { AddToPlaylist: AddToPlaylistDrawer, AudioBreakdown: AudioBreakdownDrawer, DeletePlaylistConfirmation: DeletePlaylistConfirmationDrawer, + DuplicateAddConfirmation: DuplicateAddConfirmationDrawer, VipDiscord: VipDiscordDrawer, ProfileActions: ProfileActionsDrawer, PlaybackRate: PlaybackRateDrawer, diff --git a/apps/audius-client/packages/mobile/src/components/add-to-playlist-drawer/AddToPlaylistDrawer.tsx b/apps/audius-client/packages/mobile/src/components/add-to-playlist-drawer/AddToPlaylistDrawer.tsx index 5295df0b369..c634dd686eb 100644 --- a/apps/audius-client/packages/mobile/src/components/add-to-playlist-drawer/AddToPlaylistDrawer.tsx +++ b/apps/audius-client/packages/mobile/src/components/add-to-playlist-drawer/AddToPlaylistDrawer.tsx @@ -1,7 +1,8 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import type { Collection } from '@audius/common' import { + duplicateAddConfirmationModalUIActions, SquareSizes, CreatePlaylistSource, accountSelectors, @@ -28,6 +29,8 @@ const { addTrackToPlaylist, createPlaylist } = cacheCollectionsActions const { getTrackId, getTrackTitle, getTrackIsUnlisted } = addToPlaylistUISelectors const { getAccountWithOwnPlaylists } = accountSelectors +const { requestOpen: openDuplicateAddConfirmation } = + duplicateAddConfirmationModalUIActions const messages = { title: 'Add To Playlist', @@ -77,6 +80,15 @@ export const AddToPlaylistDrawer = () => { const userPlaylists = user?.playlists ?? [] + const playlistTrackIdMap = useMemo(() => { + const playlists = user?.playlists ?? [] + return playlists.reduce((acc, playlist) => { + const trackIds = playlist.playlist_contents.track_ids.map((t) => t.track) + acc[playlist.playlist_id] = trackIds + return acc + }, {}) + }, [user?.playlists]) + const addToNewPlaylist = useCallback(() => { const metadata = { playlist_name: trackTitle ?? 'New Playlist' } dispatch( @@ -107,13 +119,28 @@ export const AddToPlaylistDrawer = () => { primaryText={item.playlist_name} secondaryText={user?.name} onPress={() => { + if (!trackId) return + // Don't add if the track is hidden, but playlist is public if (isTrackUnlisted && !item.is_private) { toast({ content: messages.hiddenAdd }) return } - toast({ content: messages.addedToast }) - dispatch(addTrackToPlaylist(trackId!, item.playlist_id)) + + const doesPlaylistContainTrack = + playlistTrackIdMap[item.playlist_id]?.includes(trackId) + + if (isPlaylistUpdatesEnabled && doesPlaylistContainTrack) { + dispatch( + openDuplicateAddConfirmation({ + playlistId: item.playlist_id, + trackId + }) + ) + } else { + toast({ content: messages.addedToast }) + dispatch(addTrackToPlaylist(trackId, item.playlist_id)) + } onClose() }} renderImage={renderImage(item)} @@ -122,8 +149,10 @@ export const AddToPlaylistDrawer = () => { [ addToNewPlaylist, dispatch, + isPlaylistUpdatesEnabled, isTrackUnlisted, onClose, + playlistTrackIdMap, renderImage, toast, trackId, diff --git a/apps/audius-client/packages/mobile/src/components/duplicate-add-confirmation-drawer/DuplicateAddConfirmationDrawer.tsx b/apps/audius-client/packages/mobile/src/components/duplicate-add-confirmation-drawer/DuplicateAddConfirmationDrawer.tsx new file mode 100644 index 00000000000..39fc53a494d --- /dev/null +++ b/apps/audius-client/packages/mobile/src/components/duplicate-add-confirmation-drawer/DuplicateAddConfirmationDrawer.tsx @@ -0,0 +1,114 @@ +import { useCallback } from 'react' + +import { + cacheCollectionsActions, + cacheCollectionsSelectors, + duplicateAddConfirmationModalUISelectors, + fillString +} from '@audius/common' +import { View } from 'react-native' +import { useDispatch, useSelector } from 'react-redux' + +import { Text, Button } from 'app/components/core' +import { useToast } from 'app/hooks/useToast' +import { makeStyles } from 'app/styles' + +import { useDrawerState } from '../drawer' +import Drawer from '../drawer/Drawer' +const { getPlaylistId, getTrackId } = duplicateAddConfirmationModalUISelectors +const { addTrackToPlaylist } = cacheCollectionsActions +const { getCollection } = cacheCollectionsSelectors + +const messages = { + drawerTitle: 'Already Added', + drawerBody: 'This is already in your%0 playlist.', + buttonAddText: 'Add Anyway', + buttonCancelText: "Don't Add", + addedToast: 'Added To Playlist!' +} + +const useStyles = makeStyles(({ palette, spacing }) => ({ + title: { + flexDirection: 'row', + justifyContent: 'center', + gap: spacing(2), + alignItems: 'center', + paddingVertical: spacing(6), + borderBottomColor: palette.neutralLight8, + borderBottomWidth: 1 + }, + titleText: { + textTransform: 'uppercase' + }, + container: { + marginHorizontal: spacing(4) + }, + body: { + margin: spacing(4), + lineHeight: spacing(6), + textAlign: 'center' + }, + buttonContainer: { + gap: spacing(2), + marginBottom: spacing(8) + } +})) + +export const DuplicateAddConfirmationDrawer = () => { + const playlistId = useSelector(getPlaylistId) + const trackId = useSelector(getTrackId) + const playlist = useSelector((state) => + getCollection(state, { id: playlistId }) + ) + const dispatch = useDispatch() + const styles = useStyles() + const { toast } = useToast() + const { isOpen, onClose } = useDrawerState('DuplicateAddConfirmation') + + const handleAdd = useCallback(() => { + if (playlistId && trackId) { + toast({ content: messages.addedToast }) + dispatch(addTrackToPlaylist(trackId, playlistId)) + } + onClose() + }, [playlistId, trackId, onClose, toast, dispatch]) + + return ( + + + + + {messages.drawerTitle} + + + + {fillString( + messages.drawerBody, + playlist ? ` "${playlist.playlist_name}"` : '' + )} + + +