From 199a1a59cc3b837a2bae22b090b0ddf8f6bc3cfb Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Wed, 14 Feb 2024 12:19:46 -0500 Subject: [PATCH] [PAY-2485][PAY-2483] Adds download Retry behavior for mobile (#7583) Co-authored-by: Raymond Jacobson --- packages/common/src/store/downloads/index.ts | 5 ++ .../common/src/store/downloads/selectors.ts | 9 +++ .../src/store/downloads}/slice.ts | 23 +++++++- packages/common/src/store/index.ts | 1 + packages/common/src/store/reducers.ts | 5 +- .../DownloadTrackProgressDrawer.tsx | 4 +- .../WaitForDownloadDrawer.tsx | 57 ++++++++++++++++--- .../harmony-native/components/Hint/Hint.tsx | 8 +-- .../screens/track-screen/DownloadSection.tsx | 22 ++----- .../mobile/src/services/track-download.ts | 22 +++++-- .../mobile/src/store/download/selectors.ts | 7 --- packages/mobile/src/store/store.ts | 4 -- .../WaitForDownloadModal.module.css | 2 +- .../WaitForDownloadModal.tsx | 55 ++++++++++++++++-- packages/web/src/services/track-download.ts | 17 +++++- 15 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 packages/common/src/store/downloads/index.ts create mode 100644 packages/common/src/store/downloads/selectors.ts rename packages/{mobile/src/store/download => common/src/store/downloads}/slice.ts (69%) delete mode 100644 packages/mobile/src/store/download/selectors.ts diff --git a/packages/common/src/store/downloads/index.ts b/packages/common/src/store/downloads/index.ts new file mode 100644 index 00000000000..b4903fd09c4 --- /dev/null +++ b/packages/common/src/store/downloads/index.ts @@ -0,0 +1,5 @@ +export * as downloadsSelectors from './selectors' +export { + default as downloadsReducer, + actions as downloadsActions +} from './slice' diff --git a/packages/common/src/store/downloads/selectors.ts b/packages/common/src/store/downloads/selectors.ts new file mode 100644 index 00000000000..69dc80028e2 --- /dev/null +++ b/packages/common/src/store/downloads/selectors.ts @@ -0,0 +1,9 @@ +import { CommonState } from '../commonStore' + +export const getDownloadedPercentage = (state: CommonState) => + state.downloads.downloadedPercentage +export const getFileName = (state: CommonState) => state.downloads.fileName +export const getFetchCancel = (state: CommonState) => + state.downloads.fetchCancel +export const getTrackName = (state: CommonState) => state.downloads.trackName +export const getDownloadError = (state: CommonState) => state.downloads.error diff --git a/packages/mobile/src/store/download/slice.ts b/packages/common/src/store/downloads/slice.ts similarity index 69% rename from packages/mobile/src/store/download/slice.ts rename to packages/common/src/store/downloads/slice.ts index fd6d3ef1c46..4c14b581054 100644 --- a/packages/mobile/src/store/download/slice.ts +++ b/packages/common/src/store/downloads/slice.ts @@ -1,7 +1,8 @@ -import type { Nullable } from '@audius/common/utils' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' +import { Nullable } from '~/utils' + export type DownloadState = typeof initialState type State = { @@ -9,6 +10,7 @@ type State = { fetchCancel: Nullable<() => void> trackName: Nullable fileName: Nullable + error?: Error } const initialState: State = { @@ -22,9 +24,17 @@ const slice = createSlice({ name: 'downloadTrack', initialState, reducers: { + beginDownload: (state) => { + state.error = undefined + }, + setDownloadError: (state, action: PayloadAction) => { + state.error = action.payload + }, + // Mobile only setDownloadedPercentage: (state, action: PayloadAction) => { state.downloadedPercentage = action.payload }, + // Mobile only setFileInfo: ( state, action: PayloadAction<{ @@ -35,13 +45,20 @@ const slice = createSlice({ state.trackName = action.payload.trackName state.fileName = action.payload.fileName }, + // Mobile only setFetchCancel: (state, action: PayloadAction<() => void>) => { state.fetchCancel = action.payload } } }) -export const { setDownloadedPercentage, setFileInfo, setFetchCancel } = - slice.actions +export const { + beginDownload, + setDownloadedPercentage, + setFileInfo, + setFetchCancel, + setDownloadError +} = slice.actions +export const actions = slice.actions export default slice.reducer diff --git a/packages/common/src/store/index.ts b/packages/common/src/store/index.ts index 9c0e537e633..d3a03d13e15 100644 --- a/packages/common/src/store/index.ts +++ b/packages/common/src/store/index.ts @@ -36,3 +36,4 @@ export * from './solana' export * from './playlist-updates' export * from './saved-collections' export * from './confirmer' +export * from './downloads' diff --git a/packages/common/src/store/reducers.ts b/packages/common/src/store/reducers.ts index b4890e6e133..6fb692b655d 100644 --- a/packages/common/src/store/reducers.ts +++ b/packages/common/src/store/reducers.ts @@ -24,6 +24,7 @@ import { ChangePasswordState } from './change-password/types' import collectibles from './collectibles/slice' import confirmer from './confirmer/reducer' import { ConfirmerState } from './confirmer/types' +import downloads, { DownloadState } from './downloads/slice' import gatedContent from './gated-content/slice' import musicConfettiReducer, { MusicConfettiState @@ -282,7 +283,8 @@ export const reducers = ( collectibles, upload, - confirmer + confirmer, + downloads }) export type CommonState = { @@ -410,4 +412,5 @@ export type CommonState = { upload: UploadState confirmer: ConfirmerState + downloads: DownloadState } diff --git a/packages/mobile/src/components/download-track-progress-drawer/DownloadTrackProgressDrawer.tsx b/packages/mobile/src/components/download-track-progress-drawer/DownloadTrackProgressDrawer.tsx index 4ac75fe8371..54ee2b1798d 100644 --- a/packages/mobile/src/components/download-track-progress-drawer/DownloadTrackProgressDrawer.tsx +++ b/packages/mobile/src/components/download-track-progress-drawer/DownloadTrackProgressDrawer.tsx @@ -1,15 +1,17 @@ import { useCallback } from 'react' +import { downloadsSelectors } from '@audius/common/store' import { View } from 'react-native' import { useSelector } from 'react-redux' import { NativeDrawer } from 'app/components/drawer' import LoadingSpinner from 'app/components/loading-spinner' import Text from 'app/components/text' -import { getFileName, getFetchCancel } from 'app/store/download/selectors' import { makeStyles } from 'app/styles' import { useColor } from 'app/utils/theme' +const { getFileName, getFetchCancel } = downloadsSelectors + const useStyles = makeStyles(({ palette }) => ({ view: { display: 'flex', diff --git a/packages/mobile/src/components/wait-for-download-drawer/WaitForDownloadDrawer.tsx b/packages/mobile/src/components/wait-for-download-drawer/WaitForDownloadDrawer.tsx index 32f30df4826..971ecb39a1e 100644 --- a/packages/mobile/src/components/wait-for-download-drawer/WaitForDownloadDrawer.tsx +++ b/packages/mobile/src/components/wait-for-download-drawer/WaitForDownloadDrawer.tsx @@ -1,11 +1,12 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { DownloadQuality } from '@audius/common/models' import type { CommonState } from '@audius/common/store' import { cacheTracksSelectors, tracksSocialActions, - useWaitForDownloadModal + useWaitForDownloadModal, + downloadsSelectors } from '@audius/common/store' import { getDownloadFilename } from '@audius/common/utils' import { css } from '@emotion/native' @@ -13,18 +14,25 @@ import { useDispatch, useSelector } from 'react-redux' import { Divider, + Flex, + Hint, + IconError, IconReceive, Text, - Flex, + TextLink, useTheme } from '@audius/harmony-native' import Drawer from 'app/components/drawer' import LoadingSpinner from '../loading-spinner' const { getTrack } = cacheTracksSelectors +const { getDownloadError } = downloadsSelectors const messages = { - title: 'Downloading...' + title: 'Downloading...', + somethingWrong: + 'Something went wrong. Please check your connection and storage and try again.', + tryAgain: 'Try again.' } export const WaitForDownloadDrawer = () => { @@ -36,6 +44,8 @@ export const WaitForDownloadDrawer = () => { onClosed } = useWaitForDownloadModal() + const downloadError = useSelector(getDownloadError) + const { spacing } = useTheme() const track = useSelector((state: CommonState) => getTrack(state, { id: parentTrackId ?? trackIds[0] }) @@ -53,6 +63,20 @@ export const WaitForDownloadDrawer = () => { onClosed() }, [onClosed, dispatch]) + const performDownload = useCallback(() => { + dispatch( + tracksSocialActions.downloadTrack({ + trackIds, + parentTrackId, + original: quality === DownloadQuality.ORIGINAL + }) + ) + }, [parentTrackId, trackIds, quality, dispatch]) + + useEffect(() => { + performDownload() + }, [performDownload]) + return ( @@ -76,10 +100,27 @@ export const WaitForDownloadDrawer = () => { {trackName} - - + + {downloadError ? ( + + + + {messages.somethingWrong} + + + {messages.tryAgain} + + + + ) : ( + + )} diff --git a/packages/mobile/src/harmony-native/components/Hint/Hint.tsx b/packages/mobile/src/harmony-native/components/Hint/Hint.tsx index 21f755cf398..f356437e6ff 100644 --- a/packages/mobile/src/harmony-native/components/Hint/Hint.tsx +++ b/packages/mobile/src/harmony-native/components/Hint/Hint.tsx @@ -1,6 +1,5 @@ import type { IconComponent } from 'app/harmony-native/icons' -import { Text } from '../Text/Text' import type { PaperProps } from '../layout' import { Paper } from '../layout' @@ -21,12 +20,13 @@ export const Hint = (props: HintProps) => { backgroundColor='surface2' shadow='flat' border='strong' + // Width 100% is necessary to allow for text wrapping inside + // the flex container that wraps children + style={{ width: '100%' }} {...other} > - - {children} - + {children} ) } diff --git a/packages/mobile/src/screens/track-screen/DownloadSection.tsx b/packages/mobile/src/screens/track-screen/DownloadSection.tsx index 46e5ba348c4..a7e62ee47d3 100644 --- a/packages/mobile/src/screens/track-screen/DownloadSection.tsx +++ b/packages/mobile/src/screens/track-screen/DownloadSection.tsx @@ -5,26 +5,25 @@ import { useDownloadableContentAccess, useGatedContentAccess } from '@audius/common/hooks' -import { ModalSource, DownloadQuality } from '@audius/common/models' import type { ID } from '@audius/common/models' +import { DownloadQuality, ModalSource } from '@audius/common/models' +import type { CommonState } from '@audius/common/store' import { cacheTracksSelectors, usePremiumContentPurchaseModal, - useWaitForDownloadModal, - tracksSocialActions as socialTracksActions + useWaitForDownloadModal } from '@audius/common/store' -import type { CommonState } from '@audius/common/store' import { USDC } from '@audius/fixed-decimal' import { css } from '@emotion/native' import { LayoutAnimation } from 'react-native' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import { + Button, Flex, + IconLockUnlocked, IconReceive, Text, - Button, - IconLockUnlocked, useTheme } from '@audius/harmony-native' import { SegmentedControl } from 'app/components/core' @@ -53,7 +52,6 @@ const messages = { } export const DownloadSection = ({ trackId }: { trackId: ID }) => { - const dispatch = useDispatch() const { color } = useTheme() const { toast } = useToast() const { onOpen: openPremiumContentPurchaseModal } = @@ -111,17 +109,9 @@ export const DownloadSection = ({ trackId }: { trackId: ID }) => { trackIds, quality }) - dispatch( - socialTracksActions.downloadTrack({ - trackIds, - parentTrackId, - original: quality === DownloadQuality.ORIGINAL - }) - ) } }, [ - dispatch, openWaitForDownloadModal, quality, shouldDisplayDownloadFollowGated, diff --git a/packages/mobile/src/services/track-download.ts b/packages/mobile/src/services/track-download.ts index f466d13ad49..329e71f970e 100644 --- a/packages/mobile/src/services/track-download.ts +++ b/packages/mobile/src/services/track-download.ts @@ -1,6 +1,6 @@ import type { DownloadTrackArgs } from '@audius/common/services' import { TrackDownload as TrackDownloadBase } from '@audius/common/services' -import { tracksSocialActions } from '@audius/common/store' +import { tracksSocialActions, downloadsActions } from '@audius/common/store' import { Platform, Share } from 'react-native' import { zip } from 'react-native-zip-archive' import type { @@ -9,15 +9,16 @@ import type { StatefulPromise } from 'rn-fetch-blob' import RNFetchBlob from 'rn-fetch-blob' +import { dedupFilenames } from '~/utils' import { dispatch } from 'app/store' -import { setFetchCancel, setFileInfo } from 'app/store/download/slice' import { setVisibility } from 'app/store/drawers/slice' import { audiusBackendInstance } from './audius-backend-instance' -import { dedupFilenames } from '~/utils' const { downloadFinished } = tracksSocialActions +const { beginDownload, setDownloadError, setFetchCancel, setFileInfo } = + downloadsActions let fetchTasks: StatefulPromise[] = [] @@ -75,6 +76,11 @@ const downloadOne = async ({ await onFetchComplete?.(fetchRes.path()) } catch (err) { console.error(err) + dispatch( + setDownloadError( + err instanceof Error ? err : new Error(`Download failed: ${err}`) + ) + ) // On failure attempt to delete the file removePathIfExists(filePath) } @@ -113,6 +119,11 @@ const downloadMany = async ({ responses.forEach((response) => response.flush()) } catch (err) { console.error(err) + dispatch( + setDownloadError( + err instanceof Error ? err : new Error(`Download failed: ${err}`) + ) + ) } finally { // Remove source directory at the end of the process regardless of what happens removePathIfExists(directory) @@ -126,6 +137,8 @@ const download = async ({ }: DownloadTrackArgs) => { if (files.length === 0) return + dispatch(beginDownload()) + dispatch( setFileInfo({ trackName: rootDirectoryName ?? '', @@ -139,8 +152,7 @@ const download = async ({ } // TODO: Remove this method of canceling after the lossless // feature set launches. The abort signal should be the way to do - // this task cancellation going forward. The corresponding slice - // may also be deleted. + // this task cancellation going forward. dispatch(setFetchCancel(cancelDownloadTask)) const audiusDirectory = diff --git a/packages/mobile/src/store/download/selectors.ts b/packages/mobile/src/store/download/selectors.ts deleted file mode 100644 index d4ff9d60c1d..00000000000 --- a/packages/mobile/src/store/download/selectors.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AppState } from 'app/store' - -export const getDownloadedPercentage = (state: AppState) => - state.downloads.downloadedPercentage -export const getFileName = (state: AppState) => state.downloads.fileName -export const getFetchCancel = (state: AppState) => state.downloads.fetchCancel -export const getTrackName = (state: AppState) => state.downloads.trackName diff --git a/packages/mobile/src/store/store.ts b/packages/mobile/src/store/store.ts index 8fe41a8f10d..9db45bb9f7d 100644 --- a/packages/mobile/src/store/store.ts +++ b/packages/mobile/src/store/store.ts @@ -26,8 +26,6 @@ import thunk from 'redux-thunk' import { audiusSdk } from 'app/services/sdk/audius-sdk' import { reportToSentry } from 'app/utils/reportToSentry' -import type { DownloadState } from './download/slice' -import downloads from './download/slice' import type { DrawersState } from './drawers/slice' import drawers from './drawers/slice' import type { KeyboardState } from './keyboard/slice' @@ -58,7 +56,6 @@ export type AppState = CommonState & { searchBar: SearchBarState drawers: DrawersState - downloads: DownloadState keyboard: KeyboardState oauth: OAuthState offlineDownloads: OfflineDownloadsState @@ -119,7 +116,6 @@ const rootReducer = combineReducers({ searchBar, drawers, - downloads, keyboard, oauth, offlineDownloads, diff --git a/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.module.css b/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.module.css index 8bc36ed50a7..d6c004374c6 100644 --- a/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.module.css +++ b/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.module.css @@ -1,5 +1,5 @@ .modal { - width: 720px; + width: 480px; } .modalHeader.mobile { diff --git a/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx b/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx index 7620a41d353..569b8284875 100644 --- a/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx +++ b/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx @@ -1,14 +1,22 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { DownloadQuality } from '@audius/common/models' import { CommonState, useWaitForDownloadModal, cacheTracksSelectors, - tracksSocialActions + tracksSocialActions, + downloadsSelectors } from '@audius/common/store' import { getDownloadFilename } from '@audius/common/utils' -import { Flex, IconReceive, Text } from '@audius/harmony' +import { + Flex, + Hint, + IconError, + IconReceive, + Text, + TextLink +} from '@audius/harmony' import { ModalHeader } from '@audius/stems' import cn from 'classnames' import { shallowEqual, useDispatch, useSelector } from 'react-redux' @@ -21,9 +29,13 @@ import ModalDrawer from 'pages/audio-rewards-page/components/modals/ModalDrawer' import styles from './WaitForDownloadModal.module.css' const { getTrack } = cacheTracksSelectors +const { getDownloadError } = downloadsSelectors const messages = { - title: 'Downloading...' + title: 'Downloading...', + somethingWrong: + 'Something went wrong. Please check your connection and storage and try again.', + tryAgain: 'Try again.' } export const WaitForDownloadModal = () => { @@ -41,11 +53,27 @@ export const WaitForDownloadModal = () => { shallowEqual ) + const downloadError = useSelector(getDownloadError) + const handleClosed = useCallback(() => { dispatch(tracksSocialActions.cancelDownloads()) onClosed() }, [onClosed, dispatch]) + const performDownload = useCallback(() => { + dispatch( + tracksSocialActions.downloadTrack({ + trackIds, + parentTrackId, + original: quality === DownloadQuality.ORIGINAL + }) + ) + }, [trackIds, parentTrackId, quality, dispatch]) + + useEffect(() => { + performDownload() + }, [performDownload]) + const trackName = !parentTrackId && track?.orig_filename && track?.orig_filename?.length > 0 ? getDownloadFilename({ @@ -81,7 +109,24 @@ export const WaitForDownloadModal = () => { {trackName} - + {downloadError ? ( + + + + {messages.somethingWrong} + + + {messages.tryAgain} + + + + ) : ( + + )} ) diff --git a/packages/web/src/services/track-download.ts b/packages/web/src/services/track-download.ts index 7a8057df668..2c524a95477 100644 --- a/packages/web/src/services/track-download.ts +++ b/packages/web/src/services/track-download.ts @@ -3,7 +3,7 @@ import { TrackDownload as TrackDownloadBase, type DownloadTrackArgs } from '@audius/common/services' -import { tracksSocialActions } from '@audius/common/store' +import { tracksSocialActions, downloadsActions } from '@audius/common/store' import { dedupFilenames } from '@audius/common/utils' import { downloadZip } from 'client-zip' @@ -11,6 +11,8 @@ import { audiusBackendInstance } from './audius-backend/audius-backend-instance' const { downloadFinished } = tracksSocialActions +const { beginDownload, setDownloadError } = downloadsActions + function isMobileSafari() { if (!navigator) return false return ( @@ -42,6 +44,12 @@ class TrackDownload extends TrackDownloadBase { rootDirectoryName, abortSignal }: DownloadTrackArgs) { + if (files.length === 0) return + + const dispatch = window.store.dispatch + + dispatch(beginDownload()) + dedupFilenames(files) const responsePromises = files.map( async ({ url }) => await window.fetch(url, { signal: abortSignal }) @@ -71,11 +79,16 @@ class TrackDownload extends TrackDownloadBase { url = URL.createObjectURL(blob) } browserDownload({ url, filename }) - window.store.dispatch(downloadFinished()) + dispatch(downloadFinished()) } catch (e) { if ((e as Error).name === 'AbortError') { console.info('Download aborted by the user') } else { + dispatch( + setDownloadError( + e instanceof Error ? e : new Error(`Download failed: ${e}`) + ) + ) throw e } }