An ATproto social media client -- with an independent Appview.
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}