Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import React from 'react'
2import {Alert, AppState, type AppStateStatus} from 'react-native'
3import {nativeBuildVersion} from 'expo-application'
4import {
5 checkForUpdateAsync,
6 fetchUpdateAsync,
7 isEnabled,
8 reloadAsync,
9 setExtraParamAsync,
10 useUpdates,
11} from 'expo-updates'
12
13import {isNetworkError} from '#/lib/strings/errors'
14import {logger} from '#/logger'
15import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
16
17const MINIMUM_MINIMIZE_TIME = 15 * 60e3
18
19async function setExtraParams() {
20 await setExtraParamAsync(
21 IS_IOS ? 'ios-build-number' : 'android-build-number',
22 // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
23 // This just ensures it gets passed as a string
24 `${nativeBuildVersion}`,
25 )
26 await setExtraParamAsync(
27 'channel',
28 IS_TESTFLIGHT ? 'testflight' : 'production',
29 )
30}
31
32async function setExtraParamsPullRequest(channel: string) {
33 await setExtraParamAsync(
34 IS_IOS ? 'ios-build-number' : 'android-build-number',
35 // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
36 // This just ensures it gets passed as a string
37 `${nativeBuildVersion}`,
38 )
39 await setExtraParamAsync('channel', channel)
40}
41
42async function updateTestflight() {
43 await setExtraParams()
44
45 const res = await checkForUpdateAsync()
46 if (res.isAvailable) {
47 await fetchUpdateAsync()
48 Alert.alert(
49 'Update Available',
50 'A new version of the app is available. Relaunch now?',
51 [
52 {
53 text: 'No',
54 style: 'cancel',
55 },
56 {
57 text: 'Relaunch',
58 style: 'default',
59 onPress: async () => {
60 await reloadAsync()
61 },
62 },
63 ],
64 )
65 }
66}
67
68export function useApplyPullRequestOTAUpdate() {
69 const {currentlyRunning} = useUpdates()
70 const [pending, setPending] = React.useState(false)
71 const currentChannel = currentlyRunning?.channel
72 const isCurrentlyRunningPullRequestDeployment =
73 currentChannel?.startsWith('pull-request')
74
75 const tryApplyUpdate = async (channel: string) => {
76 setPending(true)
77 await setExtraParamsPullRequest(channel)
78 const res = await checkForUpdateAsync()
79 if (res.isAvailable) {
80 Alert.alert(
81 'Deployment Available',
82 `A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`,
83 [
84 {
85 text: 'No',
86 style: 'cancel',
87 },
88 {
89 text: 'Relaunch',
90 style: 'default',
91 onPress: async () => {
92 await fetchUpdateAsync()
93 await reloadAsync()
94 },
95 },
96 ],
97 )
98 } else {
99 Alert.alert(
100 'No Deployment Available',
101 `No new deployments of ${channel} are currently available for your current native build.`,
102 )
103 }
104 setPending(false)
105 }
106
107 const revertToEmbedded = async () => {
108 try {
109 await updateTestflight()
110 } catch (e: any) {
111 logger.error('Internal OTA Update Error', {error: `${e}`})
112 }
113 }
114
115 return {
116 tryApplyUpdate,
117 revertToEmbedded,
118 isCurrentlyRunningPullRequestDeployment,
119 currentChannel,
120 pending,
121 }
122}
123
124export function useOTAUpdates() {
125 const shouldReceiveUpdates = isEnabled && !__DEV__
126
127 const appState = React.useRef<AppStateStatus>('active')
128 const lastMinimize = React.useRef(0)
129 const ranInitialCheck = React.useRef(false)
130 const timeout = React.useRef<NodeJS.Timeout>(undefined)
131 const {currentlyRunning, isUpdatePending} = useUpdates()
132 const currentChannel = currentlyRunning?.channel
133
134 const setCheckTimeout = React.useCallback(() => {
135 timeout.current = setTimeout(async () => {
136 try {
137 await setExtraParams()
138
139 logger.debug('Checking for update...')
140 const res = await checkForUpdateAsync()
141
142 if (res.isAvailable) {
143 logger.debug('Attempting to fetch update...')
144 await fetchUpdateAsync()
145 } else {
146 logger.debug('No update available.')
147 }
148 } catch (err) {
149 if (!isNetworkError(err)) {
150 logger.error('OTA Update Error', {safeMessage: err})
151 }
152 }
153 }, 10e3)
154 }, [])
155
156 const onIsTestFlight = React.useCallback(async () => {
157 try {
158 await updateTestflight()
159 } catch (err: any) {
160 if (!isNetworkError(err)) {
161 logger.error('Internal OTA Update Error', {safeMessage: err})
162 }
163 }
164 }, [])
165
166 React.useEffect(() => {
167 // We don't need to check anything if the current update is a PR update
168 if (currentChannel?.startsWith('pull-request')) {
169 return
170 }
171
172 // We use this setTimeout to allow analytics to initialize before we check for an update
173 // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
174 // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
175 // immediately.
176 if (IS_TESTFLIGHT) {
177 onIsTestFlight()
178 return
179 } else if (!shouldReceiveUpdates || ranInitialCheck.current) {
180 return
181 }
182
183 setCheckTimeout()
184 ranInitialCheck.current = true
185 }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates])
186
187 // After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
188 // or B check for an update again.
189 React.useEffect(() => {
190 // We also don't start this timeout if the user is on a pull request update
191 if (!isEnabled || currentChannel?.startsWith('pull-request')) {
192 return
193 }
194
195 // TEMP: disable wake-from-background OTA loading on Android.
196 // This is causing a crash when the thread view is open due to
197 // `maintainVisibleContentPosition`. See repro repo for more details:
198 // https://github.com/mozzius/ota-crash-repro
199 // Old Arch only - re-enable once we're on the New Archictecture! -sfn
200 if (IS_ANDROID) return
201
202 const subscription = AppState.addEventListener(
203 'change',
204 async nextAppState => {
205 if (
206 appState.current.match(/inactive|background/) &&
207 nextAppState === 'active'
208 ) {
209 // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
210 // chances are that there isn't anything important going on in the current session.
211 if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
212 if (isUpdatePending) {
213 await reloadAsync()
214 } else {
215 setCheckTimeout()
216 }
217 }
218 } else {
219 lastMinimize.current = Date.now()
220 }
221
222 appState.current = nextAppState
223 },
224 )
225
226 return () => {
227 clearTimeout(timeout.current)
228 subscription.remove()
229 }
230 }, [isUpdatePending, currentChannel, setCheckTimeout])
231}