An ATproto social media client -- with an independent Appview.
at main 5.8 kB view raw
1import {useRef, useState} from 'react' 2import {AppState, type AppStateStatus} from 'react-native' 3import AsyncStorage from '@react-native-async-storage/async-storage' 4import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 5import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query' 6import { 7 PersistQueryClientProvider, 8 type PersistQueryClientProviderProps, 9} from '@tanstack/react-query-persist-client' 10import type React from 'react' 11 12import {isNative} from '#/platform/detection' 13import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' 14 15// any query keys in this array will be persisted to AsyncStorage 16export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' 17const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] 18 19async function checkIsOnline(): Promise<boolean> { 20 try { 21 const controller = new AbortController() 22 setTimeout(() => { 23 controller.abort() 24 }, 15e3) 25 const res = await fetch('https://api.shatteredsky.net/xrpc/_health', { 26 cache: 'no-store', 27 signal: controller.signal, 28 }) 29 const json = await res.json() 30 if (json.version) { 31 return true 32 } else { 33 return false 34 } 35 } catch (e) { 36 return false 37 } 38} 39 40let receivedNetworkLost = false 41let receivedNetworkConfirmed = false 42let isNetworkStateUnclear = false 43 44listenNetworkLost(() => { 45 receivedNetworkLost = true 46 onlineManager.setOnline(false) 47}) 48 49listenNetworkConfirmed(() => { 50 receivedNetworkConfirmed = true 51 onlineManager.setOnline(true) 52}) 53 54let checkPromise: Promise<void> | undefined 55function checkIsOnlineIfNeeded() { 56 if (checkPromise) { 57 return 58 } 59 receivedNetworkLost = false 60 receivedNetworkConfirmed = false 61 checkPromise = checkIsOnline().then(nextIsOnline => { 62 checkPromise = undefined 63 if (nextIsOnline && receivedNetworkLost) { 64 isNetworkStateUnclear = true 65 } 66 if (!nextIsOnline && receivedNetworkConfirmed) { 67 isNetworkStateUnclear = true 68 } 69 if (!isNetworkStateUnclear) { 70 onlineManager.setOnline(nextIsOnline) 71 } 72 }) 73} 74 75setInterval(() => { 76 if (AppState.currentState === 'active') { 77 if (!onlineManager.isOnline() || isNetworkStateUnclear) { 78 checkIsOnlineIfNeeded() 79 } 80 } 81}, 2000) 82 83focusManager.setEventListener(onFocus => { 84 if (isNative) { 85 const subscription = AppState.addEventListener( 86 'change', 87 (status: AppStateStatus) => { 88 focusManager.setFocused(status === 'active') 89 }, 90 ) 91 92 return () => subscription.remove() 93 } else if (typeof window !== 'undefined' && window.addEventListener) { 94 // these handlers are a bit redundant but focus catches when the browser window 95 // is blurred/focused while visibilitychange seems to only handle when the 96 // window minimizes (both of them catch tab changes) 97 // there's no harm to redundant fires because refetchOnWindowFocus is only 98 // used with queries that employ stale data times 99 const handler = () => onFocus() 100 window.addEventListener('focus', handler, false) 101 window.addEventListener('visibilitychange', handler, false) 102 return () => { 103 window.removeEventListener('visibilitychange', handler) 104 window.removeEventListener('focus', handler) 105 } 106 } 107}) 108 109const createQueryClient = () => 110 new QueryClient({ 111 defaultOptions: { 112 queries: { 113 // NOTE 114 // refetchOnWindowFocus breaks some UIs (like feeds) 115 // so we only selectively want to enable this 116 // -prf 117 refetchOnWindowFocus: false, 118 // Structural sharing between responses makes it impossible to rely on 119 // "first seen" timestamps on objects to determine if they're fresh. 120 // Disable this optimization so that we can rely on "first seen" timestamps. 121 structuralSharing: false, 122 // We don't want to retry queries by default, because in most cases we 123 // want to fail early and show a response to the user. There are 124 // exceptions, and those can be made on a per-query basis. For others, we 125 // should give users controls to retry. 126 retry: false, 127 }, 128 }, 129 }) 130 131const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] = 132 { 133 shouldDehydrateMutation: (_: any) => false, 134 shouldDehydrateQuery: query => { 135 return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0])) 136 }, 137 } 138 139export function QueryProvider({ 140 children, 141 currentDid, 142}: { 143 children: React.ReactNode 144 currentDid: string | undefined 145}) { 146 return ( 147 <QueryProviderInner 148 // Enforce we never reuse cache between users. 149 // These two props MUST stay in sync. 150 key={currentDid} 151 currentDid={currentDid}> 152 {children} 153 </QueryProviderInner> 154 ) 155} 156 157function QueryProviderInner({ 158 children, 159 currentDid, 160}: { 161 children: React.ReactNode 162 currentDid: string | undefined 163}) { 164 const initialDid = useRef(currentDid) 165 if (currentDid !== initialDid.current) { 166 throw Error( 167 'Something is very wrong. Expected did to be stable due to key above.', 168 ) 169 } 170 // We create the query client here so that it's scoped to a specific DID. 171 // Do not move the query client creation outside of this component. 172 const [queryClient, _setQueryClient] = useState(() => createQueryClient()) 173 const [persistOptions, _setPersistOptions] = useState(() => { 174 const asyncPersister = createAsyncStoragePersister({ 175 storage: AsyncStorage, 176 key: 'queryClient-' + (currentDid ?? 'logged-out'), 177 }) 178 return { 179 persister: asyncPersister, 180 dehydrateOptions, 181 } 182 }) 183 return ( 184 <PersistQueryClientProvider 185 client={queryClient} 186 persistOptions={persistOptions}> 187 {children} 188 </PersistQueryClientProvider> 189 ) 190}