From 5f8f4da48e97b6967bfd7674d77ebfac1e16542e Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 16 Jul 2026 12:18:17 -0700 Subject: [PATCH] fix(web): confirm before publishing a collection's hidden tracks Making a hidden playlist or album public force-published every hidden child track, with no distinction between a track the user deliberately hid and a scheduled pre-release, and no confirmation. Hidden tracks could go public unintentionally. Publishing now prompts when the collection contains deliberately-hidden tracks, offering to publish them alongside the collection or to keep them hidden. The collection is published either way. Scheduled releases are unaffected: they still publish only as an early release. The prompt is raised in the collections sagas so it covers every publish entry point (the publish button, the overflow menus, the collection page and the edit form) rather than each caller having to remember it. Mobile shares these sagas but renders no drawer for the prompt yet, so it keeps the previous behavior until that lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/common/src/store/ui/modals/index.ts | 1 + .../common/src/store/ui/modals/parentSlice.ts | 1 + .../index.ts | 36 ++++ .../common/src/store/ui/modals/reducers.ts | 2 + packages/common/src/store/ui/modals/types.ts | 3 + packages/web/src/app/registerNiceModals.ts | 1 + .../store/cache/collections/commonSagas.ts | 147 ++++++++++---- .../publishHiddenChildTracks.test.ts | 185 ++++++++++++++++++ .../PublishHiddenTracksConfirmationModal.tsx | 86 ++++++++ 9 files changed, 428 insertions(+), 34 deletions(-) create mode 100644 packages/common/src/store/ui/modals/publish-hidden-tracks-confirmation-modal/index.ts create mode 100644 packages/web/src/common/store/cache/collections/publishHiddenChildTracks.test.ts create mode 100644 packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx 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')