diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index 2de9f131c2e..07d9d75e9d3 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -23,6 +23,7 @@ export * from './upload-confirmation-modal' export * from './edit-access-confirmation-modal' export * from './early-release-confirmation-modal' export * from './publish-confirmation-modal' +export * from './publish-hidden-tracks-confirmation-modal' export * from './hide-confirmation-modal' export * from './create-chat-blast-modal' export * from './delete-track-confirmation-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 25354fbe5d6..9a289409a03 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -56,6 +56,7 @@ export const initialState: BasicModalsState = { EditAccessConfirmation: { isOpen: false }, EarlyReleaseConfirmation: { isOpen: false }, PublishConfirmation: { isOpen: false }, + PublishHiddenTracksConfirmation: { isOpen: false }, HideContentConfirmation: { isOpen: false }, ReplaceTrackConfirmation: { isOpen: false }, ReplaceTrackProgress: { isOpen: false }, diff --git a/packages/common/src/store/ui/modals/publish-hidden-tracks-confirmation-modal/index.ts b/packages/common/src/store/ui/modals/publish-hidden-tracks-confirmation-modal/index.ts new file mode 100644 index 00000000000..8d0108fd9c7 --- /dev/null +++ b/packages/common/src/store/ui/modals/publish-hidden-tracks-confirmation-modal/index.ts @@ -0,0 +1,36 @@ +import { createAction } from '@reduxjs/toolkit' + +import { createModal } from '../createModal' + +export type PublishHiddenTracksConfirmationModalState = { + contentType: 'album' | 'playlist' + hiddenTrackCount: number +} + +const publishHiddenTracksConfirmationModal = + createModal({ + reducerPath: 'PublishHiddenTracksConfirmation', + initialState: { + isOpen: false, + contentType: 'playlist', + hiddenTrackCount: 0 + }, + sliceSelector: (state) => state.ui.modals + }) + +export const { + hook: usePublishHiddenTracksConfirmationModal, + reducer: publishHiddenTracksConfirmationModalReducer, + actions: publishHiddenTracksConfirmationModalActions +} = publishHiddenTracksConfirmationModal + +/** + * The publish sagas block on one of these after opening the modal. They carry no + * payload: the saga already knows which collection it is publishing. + */ +export const publishHiddenTracksConfirmed = createAction( + 'modals/PublishHiddenTracksConfirmation/confirmed' +) +export const keepHiddenTracksPrivate = createAction( + 'modals/PublishHiddenTracksConfirmation/keepPrivate' +) diff --git a/packages/common/src/store/ui/modals/reducers.ts b/packages/common/src/store/ui/modals/reducers.ts index b338b219b62..1462d93e6b3 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -29,6 +29,7 @@ import { notificationModalReducer } from './notification-modal' import parentReducer, { initialState } from './parentSlice' import { premiumContentPurchaseModalReducer } from './premium-content-purchase-modal' import { publishConfirmationModalReducer } from './publish-confirmation-modal' +import { publishHiddenTracksConfirmationModalReducer } from './publish-hidden-tracks-confirmation-modal' import { receiveTokensModalReducer } from './receive-tokens-modal' import { replaceTrackConfirmationModalReducer } from './replace-track-confirmation-modal' import { replaceTrackProgressModalReducer } from './replace-track-progress-modal' @@ -83,6 +84,7 @@ const combinedReducers = combineReducers({ ReplaceTrackConfirmation: replaceTrackConfirmationModalReducer, ReplaceTrackProgress: replaceTrackProgressModalReducer, PublishConfirmation: publishConfirmationModalReducer, + PublishHiddenTracksConfirmation: publishHiddenTracksConfirmationModalReducer, HideContentConfirmation: hideContentConfirmationModalReducer, ExternalWalletSignUp: externalWalletSignUpModalReducer, ConnectedWallets: connectedWalletsModalReducer, diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index 6e2787d3193..d55a3baad0b 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -23,6 +23,7 @@ import { InboxUnavailableModalState } from './inbox-unavailable-modal' import { LeavingAudiusModalState } from './leaving-audius-modal' import { PremiumContentPurchaseModalState } from './premium-content-purchase-modal' import { PublishConfirmationModalState } from './publish-confirmation-modal' +import { PublishHiddenTracksConfirmationModalState } from './publish-hidden-tracks-confirmation-modal' import { ReplaceTrackConfirmationModalState } from './replace-track-confirmation-modal' import { ReplaceTrackProgressModalState } from './replace-track-progress-modal' import { UploadConfirmationModalState } from './upload-confirmation-modal' @@ -93,6 +94,7 @@ export type Modals = | 'EditAccessConfirmation' | 'EarlyReleaseConfirmation' | 'PublishConfirmation' + | 'PublishHiddenTracksConfirmation' | 'HideContentConfirmation' | 'WithdrawUSDCModal' | 'USDCPurchaseDetailsModal' @@ -148,6 +150,7 @@ export type StatefulModalsState = { EditAccessConfirmation: EditAccessConfirmationModalState EarlyReleaseConfirmation: EarlyReleaseConfirmationModalState PublishConfirmation: PublishConfirmationModalState + PublishHiddenTracksConfirmation: PublishHiddenTracksConfirmationModalState HideContentConfirmation: HideContentConfirmationModalState DeleteTrackConfirmation: DeleteTrackConfirmationModalState ReplaceTrackConfirmation: ReplaceTrackConfirmationModalState diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts index d1b6b7b99cf..91ec7c5a8a8 100644 --- a/packages/web/src/app/registerNiceModals.ts +++ b/packages/web/src/app/registerNiceModals.ts @@ -29,6 +29,7 @@ import 'components/locked-content-modal/LockedContentModal' import 'components/payout-wallet-modal/PayoutWalletModal' import 'components/premium-content-purchase-modal/PremiumContentPurchaseModal' import 'components/publish-confirmation-modal/PublishConfirmationModal' +import 'components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal' import 'components/receive-tokens-modal/ReceiveTokensModal' import 'components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal' import 'components/replace-track-progress-modal/ReplaceTrackProgressModal' diff --git a/packages/web/src/common/store/cache/collections/commonSagas.ts b/packages/web/src/common/store/cache/collections/commonSagas.ts index 90f6b23aa54..0c0b46eabb9 100644 --- a/packages/web/src/common/store/cache/collections/commonSagas.ts +++ b/packages/web/src/common/store/cache/collections/commonSagas.ts @@ -17,6 +17,7 @@ import { PlaylistContents, ID, Collection, + Track, isContentUSDCPurchaseGated } from '@audius/common/models' import { @@ -27,7 +28,10 @@ import { getContext, confirmerActions, trackPageActions, - getSDK + getSDK, + publishHiddenTracksConfirmationModalActions, + publishHiddenTracksConfirmed, + keepHiddenTracksPrivate } from '@audius/common/store' import { squashNewLines, @@ -36,7 +40,15 @@ import { updatePlaylistArtwork } from '@audius/common/utils' import { Id, OptionalId } from '@audius/sdk' -import { all, call, put, takeEvery, takeLatest } from 'typed-redux-saga' +import { + all, + call, + put, + race, + take, + takeEvery, + takeLatest +} from 'typed-redux-saga' import { make } from 'common/store/analytics/actions' import watchTrackErrors from 'common/store/cache/collections/errorSagas' @@ -64,6 +76,78 @@ const messages = { reorderStale: 'This collection was updated elsewhere. Try again.' } +/** Tracks the user deliberately hid, as opposed to pre-releases. */ +const getHiddenTracks = (playlistTracks: Track[] | null | undefined) => + (playlistTracks ?? []).filter( + (track) => track.is_unlisted && !track.is_scheduled_release + ) + +/** + * Asks whether deliberately-hidden tracks should be published along with their + * collection. Resolves to true when there is nothing to ask about. + * + * Ask before kicking off the write: the publish path applies the result inside + * a confirmer callback, which serializes writes for the collection, so blocking + * there would hold that queue for as long as the modal is up. + */ +export function* confirmPublishHiddenTracks( + hiddenTrackCount: number, + isAlbum: boolean +) { + if (hiddenTrackCount === 0) return true + + // Mobile renders no drawer for this modal yet, so prompting there would block + // the publish forever. Until the drawer lands it keeps the previous + // publish-everything behavior. + const isNativeMobile = yield* getContext('isNativeMobile') + if (isNativeMobile) return true + + yield* put( + publishHiddenTracksConfirmationModalActions.open({ + contentType: isAlbum ? 'album' : 'playlist', + hiddenTrackCount + }) + ) + + const { confirmed } = yield* race({ + confirmed: take(publishHiddenTracksConfirmed.type), + keepPrivate: take(keepHiddenTracksPrivate.type) + }) + + return !!confirmed +} + +/** + * Publishes the hidden tracks of a collection that is being made public. + * + * Deliberately-hidden tracks are published only when `publishHiddenTracks` is + * set, which the caller obtains from `confirmPublishHiddenTracks`. Scheduled + * releases are separate: they are published only as an early release, which + * requires the collection itself to be a scheduled release whose tracks are all + * scheduled. + */ +export function* publishHiddenChildTracks( + playlist: Collection, + playlistTracks: Track[] | null | undefined, + publishHiddenTracks: boolean +) { + const tracks = playlistTracks ?? [] + const isEachTrackScheduled = tracks.every( + (track) => track.is_unlisted && track.is_scheduled_release + ) + const isEarlyRelease = !!playlist.is_scheduled_release && isEachTrackScheduled + + for (const track of tracks) { + if (!track.is_unlisted) continue + const shouldPublish = track.is_scheduled_release + ? isEarlyRelease + : publishHiddenTracks + if (shouldPublish) { + yield* put(trackPageActions.makeTrackPublic(track.track_id)) + } + } +} + /** Counts instances of trackId in a playlist. */ const countTrackIds = ( playlistContents: PlaylistContents | undefined, @@ -157,22 +241,16 @@ function* editPlaylistAsync( playlistId ) - // Publish all hidden tracks - // If the playlist is a scheduled release - // AND all tracks are scheduled releases, publish them all - const isEachTrackScheduled = playlistTracksForPublish?.every( - (track) => track.is_unlisted && track.is_scheduled_release + const publishHiddenTracks = yield* confirmPublishHiddenTracks( + getHiddenTracks(playlistTracksForPublish).length, + !!playlist.is_album + ) + + yield* publishHiddenChildTracks( + playlistBeforeEdit, + playlistTracksForPublish, + publishHiddenTracks ) - const isEarlyRelease = - playlistBeforeEdit.is_scheduled_release && isEachTrackScheduled - for (const track of playlistTracksForPublish ?? []) { - if ( - track.is_unlisted && - (!track.is_scheduled_release || isEarlyRelease) - ) { - yield* put(trackPageActions.makeTrackPublic(track.track_id)) - } - } } } catch (error) { if (onComplete) yield* call(onComplete, false, error as Error) @@ -482,15 +560,25 @@ function* publishPlaylistAsync( if (!playlist) return const playlistWithPublishing = { ...playlist, _is_publishing: true } + // Ask before the write starts, so the modal never holds the confirmer queue. + const tracksBeforePublish = yield* call( + queryCollectionTracks, + action.playlistId + ) + const publishHiddenTracks = yield* confirmPublishHiddenTracks( + getHiddenTracks(tracksBeforePublish).length, + !!action.isAlbum + ) + yield* call(updateCollectionData, [ { playlist_id: playlist.playlist_id, _is_publishing: true } ]) - yield* call( - confirmPublishPlaylist, + yield* confirmPublishPlaylist( userId, action.playlistId, playlistWithPublishing, + publishHiddenTracks, action.dismissToastKey, action.isAlbum ) @@ -500,6 +588,7 @@ function* confirmPublishPlaylist( userId: ID, playlistId: ID, playlist: Collection, + publishHiddenTracks: boolean, dismissToastKey?: string, isAlbum?: boolean ) { @@ -534,22 +623,12 @@ function* confirmPublishPlaylist( yield* call(updateCollectionData, [confirmedPlaylist]) const playlistTracks = yield* call(queryCollectionTracks, playlistId) - // Publish all hidden tracks - // If the playlist is a scheduled release - // AND all tracks are scheduled releases, publish them all - const isEachTrackScheduled = playlistTracks?.every( - (track) => track.is_unlisted && track.is_scheduled_release + + yield* publishHiddenChildTracks( + playlist, + playlistTracks, + publishHiddenTracks ) - const isEarlyRelease = - playlist.is_scheduled_release && isEachTrackScheduled - for (const track of playlistTracks ?? []) { - if ( - track.is_unlisted && - (!track.is_scheduled_release || isEarlyRelease) - ) { - yield* put(trackPageActions.makeTrackPublic(track.track_id)) - } - } if (dismissToastKey) { yield* put(manualClearToast({ key: dismissToastKey })) diff --git a/packages/web/src/common/store/cache/collections/publishHiddenChildTracks.test.ts b/packages/web/src/common/store/cache/collections/publishHiddenChildTracks.test.ts new file mode 100644 index 00000000000..8d9006e78f8 --- /dev/null +++ b/packages/web/src/common/store/cache/collections/publishHiddenChildTracks.test.ts @@ -0,0 +1,185 @@ +import { Collection, Track } from '@audius/common/models' +import { + publishHiddenTracksConfirmationModalActions, + trackPageActions +} from '@audius/common/store' +import { describe, it, expect } from 'vitest' + +import { + confirmPublishHiddenTracks, + publishHiddenChildTracks +} from './commonSagas' + +/** + * The web vitest setup stubs out redux-saga, and redux-saga-test-plan fails to + * load under vitest's ESM interop, so neither can run a saga here. These sagas + * are plain generators of plain effect objects though, so we drive them + * directly: resolve GET_CONTEXT from `context`, record PUT, and resolve the + * confirmation RACE with `raceOutcome`. + */ +type DriveOptions = { + context?: Record + raceOutcome?: 'confirmed' | 'keepPrivate' +} + +const drive = (gen: Generator, options: DriveOptions = {}) => { + const { context = {}, raceOutcome } = options + const puts: any[] = [] + + let next = gen.next() + while (!next.done) { + const { type, payload } = next.value ?? {} + let result: any + switch (type) { + case 'GET_CONTEXT': + result = context[payload as string] + break + case 'PUT': + puts.push(payload.action) + result = undefined + break + case 'RACE': + if (!raceOutcome) { + throw new Error('Saga raced but no raceOutcome was provided') + } + result = { [raceOutcome]: {} } + break + default: + throw new Error(`Unhandled effect: ${type}`) + } + next = gen.next(result) + } + return { puts, returned: next.value } +} + +const asTrack = (track: Partial & { track_id: number }) => track as Track +const asCollection = (collection: Partial) => + collection as Collection + +const publicTrack = asTrack({ track_id: 1, is_unlisted: false }) +const hiddenTrack = asTrack({ + track_id: 2, + is_unlisted: true, + is_scheduled_release: false +}) +const scheduledTrack = asTrack({ + track_id: 3, + is_unlisted: true, + is_scheduled_release: true +}) + +const playlist = asCollection({ is_scheduled_release: false }) +const scheduledPlaylist = asCollection({ is_scheduled_release: true }) + +const web = { context: { isNativeMobile: false } } +const mobile = { context: { isNativeMobile: true } } + +const makePublic = (trackId: number) => + trackPageActions.makeTrackPublic(trackId) +const openModal = ( + contentType: 'album' | 'playlist', + hiddenTrackCount: number +) => + publishHiddenTracksConfirmationModalActions.open({ + contentType, + hiddenTrackCount + }) + +describe('confirmPublishHiddenTracks', () => { + it('does not prompt when there are no hidden tracks', () => { + const { puts, returned } = drive(confirmPublishHiddenTracks(0, false), web) + expect(puts).toEqual([]) + expect(returned).toBe(true) + }) + + it('prompts with the hidden track count and returns true when confirmed', () => { + const { puts, returned } = drive(confirmPublishHiddenTracks(2, false), { + ...web, + raceOutcome: 'confirmed' + }) + expect(puts).toEqual([openModal('playlist', 2)]) + expect(returned).toBe(true) + }) + + it('returns false when the user keeps tracks private', () => { + const { puts, returned } = drive(confirmPublishHiddenTracks(1, false), { + ...web, + raceOutcome: 'keepPrivate' + }) + expect(puts).toEqual([openModal('playlist', 1)]) + expect(returned).toBe(false) + }) + + it('labels the prompt for albums', () => { + const { puts } = drive(confirmPublishHiddenTracks(1, true), { + ...web, + raceOutcome: 'keepPrivate' + }) + expect(puts).toEqual([openModal('album', 1)]) + }) + + it('keeps the previous publish-everything behavior on native mobile', () => { + // Mobile renders no drawer for the prompt yet, so it must not block. + const { puts, returned } = drive( + confirmPublishHiddenTracks(1, false), + mobile + ) + expect(puts).toEqual([]) + expect(returned).toBe(true) + }) +}) + +describe('publishHiddenChildTracks', () => { + it('publishes nothing when no tracks are hidden', () => { + const { puts } = drive( + publishHiddenChildTracks(playlist, [publicTrack], true) + ) + expect(puts).toEqual([]) + }) + + it('publishes hidden tracks when the decision is to publish', () => { + const { puts } = drive( + publishHiddenChildTracks(playlist, [publicTrack, hiddenTrack], true) + ) + expect(puts).toEqual([makePublic(2)]) + }) + + it('leaves hidden tracks alone when the decision is to keep them private', () => { + const { puts } = drive( + publishHiddenChildTracks(playlist, [publicTrack, hiddenTrack], false) + ) + expect(puts).toEqual([]) + }) + + it('keeps scheduled tracks hidden when a kept-private track sits alongside', () => { + // The collection is a scheduled release, but not every track is scheduled, + // so this is not an early release and the scheduled track stays hidden. + const { puts } = drive( + publishHiddenChildTracks( + scheduledPlaylist, + [hiddenTrack, scheduledTrack], + false + ) + ) + expect(puts).toEqual([]) + }) + + it('early-releases an all-scheduled collection', () => { + const { puts } = drive( + publishHiddenChildTracks(scheduledPlaylist, [scheduledTrack], false) + ) + expect(puts).toEqual([makePublic(3)]) + }) + + it('does not early-release scheduled tracks of a non-scheduled collection', () => { + const { puts } = drive( + publishHiddenChildTracks(playlist, [scheduledTrack], true) + ) + expect(puts).toEqual([]) + }) + + it('handles a collection with no tracks', () => { + const { puts } = drive(publishHiddenChildTracks(playlist, null, true)) + expect(puts).toEqual([]) + }) +}) diff --git a/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx b/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx new file mode 100644 index 00000000000..f94efb7bf98 --- /dev/null +++ b/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx @@ -0,0 +1,86 @@ +import { useCallback } from 'react' + +import { registerNiceModalId } from '@audius/common/services' +import { + keepHiddenTracksPrivate, + publishHiddenTracksConfirmed, + usePublishHiddenTracksConfirmationModal +} from '@audius/common/store' +import { + Button, + Modal, + ModalContent, + ModalContentText, + ModalHeader, + ModalTitle, + ModalFooter, + IconRocket +} from '@audius/harmony' +import NiceModal, { useModal } from '@ebay/nice-modal-react' +import { useDispatch } from 'react-redux' + +const getMessages = ( + contentType: 'album' | 'playlist', + hiddenTrackCount: number +) => { + const tracks = hiddenTrackCount === 1 ? 'track' : 'tracks' + return { + title: 'Publish Hidden Tracks?', + description: `This ${contentType} contains ${hiddenTrackCount} hidden ${tracks}. Making it public will also make ${ + hiddenTrackCount === 1 ? 'that track' : 'those tracks' + } public and notify your followers.`, + keepPrivate: 'Keep Tracks Hidden', + publishAll: `Publish ${contentType === 'album' ? 'Album' : 'Playlist'} & ${ + hiddenTrackCount === 1 ? 'Track' : 'Tracks' + }` + } +} + +export const PublishHiddenTracksConfirmationModal = NiceModal.create(() => { + const modal = useModal() + const dispatch = useDispatch() + const { data } = usePublishHiddenTracksConfirmationModal() + const { contentType, hiddenTrackCount } = data + + const messages = getMessages(contentType, hiddenTrackCount) + + // Both paths publish the collection itself; they differ only in whether the + // hidden tracks come along. Dismissing (esc / overlay / X) takes the + // conservative path so tracks are never published by accident. + const handleKeepPrivate = useCallback(() => { + dispatch(keepHiddenTracksPrivate()) + modal.hide() + }, [dispatch, modal]) + + const handlePublishAll = useCallback(() => { + dispatch(publishHiddenTracksConfirmed()) + modal.hide() + }, [dispatch, modal]) + + return ( + + + } title={messages.title} /> + + + + {messages.description} + + + + + + + + ) +}) + +NiceModal.register( + 'PublishHiddenTracksConfirmation', + PublishHiddenTracksConfirmationModal +) +registerNiceModalId('PublishHiddenTracksConfirmation')