prompt for OTAs
This commit is contained in:
@@ -171,7 +171,6 @@ module.exports = function (config) {
|
|||||||
updates: {
|
updates: {
|
||||||
url: 'https://updates.bsky.app/manifest',
|
url: 'https://updates.bsky.app/manifest',
|
||||||
enabled: UPDATES_ENABLED,
|
enabled: UPDATES_ENABLED,
|
||||||
fallbackToCacheTimeout: 30000,
|
|
||||||
codeSigningCertificate: UPDATES_ENABLED
|
codeSigningCertificate: UPDATES_ENABLED
|
||||||
? './code-signing/certificate.pem'
|
? './code-signing/certificate.pem'
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {clearInterval} from 'node:timers'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Alert, AppState, AppStateStatus} from 'react-native'
|
import {Alert, AppState, AppStateStatus} from 'react-native'
|
||||||
import {nativeBuildVersion} from 'expo-application'
|
import {nativeBuildVersion} from 'expo-application'
|
||||||
@@ -9,12 +11,16 @@ import {
|
|||||||
setExtraParamAsync,
|
setExtraParamAsync,
|
||||||
useUpdates,
|
useUpdates,
|
||||||
} from 'expo-updates'
|
} from 'expo-updates'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||||
import {isIOS} from 'platform/detection'
|
import {isIOS} from 'platform/detection'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {useIsComposerOpen} from 'state/shell/composer'
|
||||||
|
|
||||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
const MINIMUM_MINIMIZE_TIME = 10 * 60e3
|
||||||
|
|
||||||
async function setExtraParams() {
|
async function setExtraParams() {
|
||||||
await setExtraParamAsync(
|
await setExtraParamAsync(
|
||||||
@@ -30,86 +36,59 @@ async function setExtraParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useOTAUpdates() {
|
export function useOTAUpdates() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const shouldReceiveUpdates = isEnabled && !__DEV__
|
const shouldReceiveUpdates = isEnabled && !__DEV__
|
||||||
|
|
||||||
const appState = React.useRef<AppStateStatus>('active')
|
const appState = React.useRef<AppStateStatus>('active')
|
||||||
const lastMinimize = React.useRef(0)
|
const lastMinimize = React.useRef(0)
|
||||||
const ranInitialCheck = React.useRef(false)
|
const ranInitialCheck = React.useRef(false)
|
||||||
const timeout = React.useRef<NodeJS.Timeout>()
|
const alertedUser = React.useRef(false)
|
||||||
const {isUpdatePending} = useUpdates()
|
const {isUpdatePending, isDownloading, downloadedUpdate} = useUpdates()
|
||||||
|
const isComposerOpen = useIsComposerOpen()
|
||||||
|
|
||||||
const setCheckTimeout = React.useCallback(() => {
|
const checkForUpdate = React.useCallback(async () => {
|
||||||
timeout.current = setTimeout(async () => {
|
if (isDownloading) return
|
||||||
try {
|
|
||||||
await setExtraParams()
|
|
||||||
|
|
||||||
logger.debug('Checking for update...')
|
|
||||||
const res = await checkForUpdateAsync()
|
|
||||||
|
|
||||||
if (res.isAvailable) {
|
|
||||||
logger.debug('Attempting to fetch update...')
|
|
||||||
await fetchUpdateAsync()
|
|
||||||
} else {
|
|
||||||
logger.debug('No update available.')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('OTA Update Error', {error: `${e}`})
|
|
||||||
}
|
|
||||||
}, 10e3)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onIsTestFlight = React.useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
await setExtraParams()
|
await setExtraParams()
|
||||||
|
|
||||||
|
logger.debug('Checking for update...')
|
||||||
const res = await checkForUpdateAsync()
|
const res = await checkForUpdateAsync()
|
||||||
|
|
||||||
if (res.isAvailable) {
|
if (res.isAvailable) {
|
||||||
|
logger.debug('Attempting to fetch update...')
|
||||||
await fetchUpdateAsync()
|
await fetchUpdateAsync()
|
||||||
|
} else {
|
||||||
Alert.alert(
|
logger.debug('No update available.')
|
||||||
'Update Available',
|
|
||||||
'A new version of the app is available. Relaunch now?',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: 'No',
|
|
||||||
style: 'cancel',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Relaunch',
|
|
||||||
style: 'default',
|
|
||||||
onPress: async () => {
|
|
||||||
await reloadAsync()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
logger.error('Internal OTA Update Error', {error: `${e}`})
|
logger.error('OTA Update Error', {error: `${e}`})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isDownloading])
|
||||||
|
|
||||||
|
// Run the initial check. This happens immediately after launch.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// We use this setTimeout to allow Statsig to initialize before we check for an update
|
if (!shouldReceiveUpdates || ranInitialCheck.current) {
|
||||||
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
|
|
||||||
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
|
|
||||||
// immediately.
|
|
||||||
if (IS_TESTFLIGHT) {
|
|
||||||
onIsTestFlight()
|
|
||||||
return
|
|
||||||
} else if (!shouldReceiveUpdates || ranInitialCheck.current) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
checkForUpdate()
|
||||||
setCheckTimeout()
|
|
||||||
ranInitialCheck.current = true
|
ranInitialCheck.current = true
|
||||||
}, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
|
}, [checkForUpdate, shouldReceiveUpdates])
|
||||||
|
|
||||||
// After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
|
|
||||||
// or B check for an update again.
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isEnabled) return
|
if (!shouldReceiveUpdates) return
|
||||||
|
|
||||||
|
// Check for an update every 10 minutes
|
||||||
|
let checkInterval: NodeJS.Timer
|
||||||
|
const setCheckInterval = () => {
|
||||||
|
checkInterval = setInterval(() => {
|
||||||
|
checkForUpdate()
|
||||||
|
}, 10 * 60e3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for an update whenver coming back to the app after being minimized for 10 minutes, or install the update
|
||||||
|
// if one is available
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
'change',
|
'change',
|
||||||
async nextAppState => {
|
async nextAppState => {
|
||||||
@@ -123,10 +102,12 @@ export function useOTAUpdates() {
|
|||||||
if (isUpdatePending) {
|
if (isUpdatePending) {
|
||||||
await reloadAsync()
|
await reloadAsync()
|
||||||
} else {
|
} else {
|
||||||
setCheckTimeout()
|
checkForUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setCheckInterval()
|
||||||
} else {
|
} else {
|
||||||
|
clearInterval(checkInterval)
|
||||||
lastMinimize.current = Date.now()
|
lastMinimize.current = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +116,50 @@ export function useOTAUpdates() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout.current)
|
clearInterval(checkInterval)
|
||||||
subscription.remove()
|
subscription.remove()
|
||||||
}
|
}
|
||||||
}, [isUpdatePending, setCheckTimeout])
|
}, [checkForUpdate, isUpdatePending, shouldReceiveUpdates])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// We only want to alert once per app session. We'll tell them once they finish composing instead.
|
||||||
|
if (!downloadedUpdate || alertedUser.current) return
|
||||||
|
|
||||||
|
// Don't interrupt the user while composing a post
|
||||||
|
if (isComposerOpen) return
|
||||||
|
|
||||||
|
alertedUser.current = true
|
||||||
|
|
||||||
|
// If there wasn't an account loaded when the download completed, don't ever alert the user. This is their first
|
||||||
|
// time using the app, i.e. a new onboard.
|
||||||
|
if (!currentAccount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
Alert.alert(
|
||||||
|
_(msg`An Update is Available!`),
|
||||||
|
_(
|
||||||
|
msg`There's an update waiting to be installed. Would you like to relaunch the app and install it now?`,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: _(msg`No`),
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: _(msg`Relaunch now`),
|
||||||
|
style: 'default',
|
||||||
|
onPress: async () => {
|
||||||
|
await reloadAsync()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}, 5e3)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [_, downloadedUpdate, currentAccount, isComposerOpen])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyRichtextFacet,
|
AppBskyRichtextFacet,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
AppBskyActorDefs,
|
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
|
||||||
export interface ComposerOptsPostRef {
|
export interface ComposerOptsPostRef {
|
||||||
@@ -46,6 +47,7 @@ type ControlsContext = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stateContext = React.createContext<StateContext>(undefined)
|
const stateContext = React.createContext<StateContext>(undefined)
|
||||||
|
const openContext = React.createContext<boolean>(false)
|
||||||
const controlsContext = React.createContext<ControlsContext>({
|
const controlsContext = React.createContext<ControlsContext>({
|
||||||
openComposer(_opts: ComposerOpts) {},
|
openComposer(_opts: ComposerOpts) {},
|
||||||
closeComposer() {
|
closeComposer() {
|
||||||
@@ -55,14 +57,17 @@ const controlsContext = React.createContext<ControlsContext>({
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [state, setState] = React.useState<StateContext>()
|
const [state, setState] = React.useState<StateContext>()
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false)
|
||||||
|
|
||||||
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
|
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
|
||||||
setState(opts)
|
setState(opts)
|
||||||
|
setIsOpen(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
const closeComposer = useNonReactiveCallback(() => {
|
const closeComposer = useNonReactiveCallback(() => {
|
||||||
let wasOpen = !!state
|
let wasOpen = !!state
|
||||||
setState(undefined)
|
setState(undefined)
|
||||||
|
setIsOpen(false)
|
||||||
return wasOpen
|
return wasOpen
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,9 +81,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<stateContext.Provider value={state}>
|
<stateContext.Provider value={state}>
|
||||||
<controlsContext.Provider value={api}>
|
<openContext.Provider value={isOpen}>
|
||||||
{children}
|
<controlsContext.Provider value={api}>
|
||||||
</controlsContext.Provider>
|
{children}
|
||||||
|
</controlsContext.Provider>
|
||||||
|
</openContext.Provider>
|
||||||
</stateContext.Provider>
|
</stateContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -90,3 +97,7 @@ export function useComposerState() {
|
|||||||
export function useComposerControls() {
|
export function useComposerControls() {
|
||||||
return React.useContext(controlsContext)
|
return React.useContext(controlsContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIsComposerOpen() {
|
||||||
|
return React.useContext(openContext)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user