From ce28129674fadce357b1b74049fb6cff083aa608 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 19:39:26 +0300 Subject: [PATCH] Add nicer loading screen while OTA is loading (#9499) --- src/lib/hooks/useOTAUpdates.test.ts | 6 +++ src/lib/hooks/useOTAUpdates.ts | 53 +++++++++++++++++---- src/screens/Settings/components/OTAInfo.tsx | 7 ++- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts index da0fe0fb56..871cac0e63 100644 --- a/src/lib/hooks/useOTAUpdates.test.ts +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -45,6 +45,12 @@ jest.mock('#/storage', () => ({ }, })) +jest.mock('#/alf', () => ({ + useTheme: jest.fn().mockImplementation(() => ({ + scheme: 'light', + })), +})) + /** * `channel` here is the build-time constant baked into the native build, not the * channel of the running bundle. `channel` is passed as the manifest metadata diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 7b7224bcb0..180102c2cd 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -1,5 +1,10 @@ import {useCallback, useEffect, useRef, useState} from 'react' -import {Alert, AppState, type AppStateStatus} from 'react-native' +import { + Alert, + AppState, + type AppStateStatus, + Image as RNImage, +} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, @@ -7,6 +12,7 @@ import { fetchUpdateAsync, isEnabled, reloadAsync, + type ReloadScreenOptions, setExtraParamAsync, UpdateCheckResultNotAvailableReason, useUpdates, @@ -14,6 +20,7 @@ import { import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {useTheme} from '#/alf' import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env' import {device} from '#/storage' @@ -83,7 +90,7 @@ async function setExtraParamsPullRequest(channel: string) { await setExtraParamAsync('channel', channel) } -async function updateTestflight() { +async function updateTestflight(scheme: 'light' | 'dark') { await setExtraParams() const res = await checkForUpdateAsync() @@ -101,7 +108,9 @@ async function updateTestflight() { text: 'Relaunch', style: 'default', onPress: async () => { - await reloadAsync() + await reloadAsync({ + reloadScreenOptions: splash(scheme), + }) }, }, ], @@ -110,6 +119,7 @@ async function updateTestflight() { } export function useApplyPullRequestOTAUpdate() { + const t = useTheme() const {currentlyRunning} = useUpdates() const [pending, setPending] = useState(false) const currentChannel = getRunningChannel(currentlyRunning) @@ -174,7 +184,9 @@ export function useApplyPullRequestOTAUpdate() { * after the reload. */ // Linking.clearInitialURL() - await reloadAsync() + await reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) } catch (e) { device.remove(['pendingOTAUpdate']) throw e @@ -335,6 +347,7 @@ export function useOTAUpdateRecovery() { export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ + const t = useTheme() const appState = useRef('active') const lastMinimize = useRef(0) const ranInitialCheck = useRef(false) @@ -366,13 +379,13 @@ export function useOTAUpdates() { const onIsTestFlight = useCallback(async () => { try { - await updateTestflight() + await updateTestflight(t.scheme) } catch (err: any) { if (!isNetworkError(err)) { logger.error('Internal OTA Update Error', {safeMessage: err}) } } - }, []) + }, [t.scheme]) useEffect(() => { // We don't need to check anything if the current update is a PR update @@ -414,7 +427,9 @@ export function useOTAUpdates() { // chances are that there isn't anything important going on in the current session. if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { if (isUpdatePending) { - await reloadAsync() + await reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) } else { setCheckTimeout() } @@ -431,5 +446,27 @@ export function useOTAUpdates() { clearTimeout(timeout.current) subscription.remove() } - }, [isUpdatePending, currentChannel, setCheckTimeout]) + }, [isUpdatePending, currentChannel, setCheckTimeout, t.scheme]) +} + +/** + * Splash screen for while the app is updating + */ +export const splash = (scheme: 'light' | 'dark') => { + const source = + scheme === 'light' + ? require('../../../assets/splash/splash.png') + : require('../../../assets/splash/splash-dark.png') + + return { + image: RNImage.resolveAssetSource(source).uri, + imageFullScreen: true, + imageResizeMode: 'cover', + backgroundColor: scheme === 'light' ? '#006AFF' : '#002861', + spinner: { + enabled: true, + color: '#ffffff', + size: 'large', + }, + } satisfies ReloadScreenOptions } diff --git a/src/screens/Settings/components/OTAInfo.tsx b/src/screens/Settings/components/OTAInfo.tsx index 9ab1962b97..25b2c828d9 100644 --- a/src/screens/Settings/components/OTAInfo.tsx +++ b/src/screens/Settings/components/OTAInfo.tsx @@ -4,6 +4,8 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQuery} from '@tanstack/react-query' +import {splash} from '#/lib/hooks/useOTAUpdates' +import {useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' @@ -13,6 +15,7 @@ import * as SettingsList from '../components/SettingsList' export function OTAInfo() { const {_} = useLingui() + const t = useTheme() const { data: isAvailable, isPending: isPendingInfo, @@ -31,7 +34,9 @@ export function OTAInfo() { useMutation({ mutationFn: async () => { await Updates.fetchUpdateAsync() - await Updates.reloadAsync() + await Updates.reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) }, onError: error => Toast.show(`Failed to update: ${error.message}`, {