Archived
Add offline caching and refine Live Activity
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import * as Network from "expo-network";
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
|
||||
function isOnline({
|
||||
isConnected,
|
||||
isInternetReachable,
|
||||
}: Pick<Network.NetworkState, "isConnected" | "isInternetReachable">) {
|
||||
if (isConnected === false) return false;
|
||||
if (isInternetReachable === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
let configured = false;
|
||||
|
||||
export function configureReactQueryOnlineManager() {
|
||||
if (configured) return;
|
||||
configured = true;
|
||||
|
||||
void Network.getNetworkStateAsync()
|
||||
.then((state) => {
|
||||
onlineManager.setOnline(isOnline(state));
|
||||
})
|
||||
.catch(() => {
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
const subscription = Network.addNetworkStateListener((state) => {
|
||||
setOnline(isOnline(state));
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
dehydrate,
|
||||
hydrate,
|
||||
type DehydratedState,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
const CACHE_PREFIX = "beenvoice:query-cache:v1";
|
||||
const CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7;
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
|
||||
type PersistedQueryCache = {
|
||||
timestamp: number;
|
||||
state: DehydratedState;
|
||||
};
|
||||
|
||||
const SKIPPED_QUERY_KEY_PARTS = ["previewPdf"];
|
||||
|
||||
function cacheScopeKey(accountId: string | null, apiUrl: string) {
|
||||
const scope = `${accountId ?? "guest"}:${apiUrl}`;
|
||||
return encodeURIComponent(scope).replace(/[!'()*]/g, (char) =>
|
||||
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function offlineQueryCacheKey(accountId: string | null, apiUrl: string) {
|
||||
return `${CACHE_PREFIX}:${cacheScopeKey(accountId, apiUrl)}`;
|
||||
}
|
||||
|
||||
export async function restoreOfflineQueryCache({
|
||||
queryClient,
|
||||
accountId,
|
||||
apiUrl,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
accountId: string | null;
|
||||
apiUrl: string;
|
||||
}) {
|
||||
const key = offlineQueryCacheKey(accountId, apiUrl);
|
||||
const cached = await AsyncStorage.getItem(key);
|
||||
if (!cached) return;
|
||||
|
||||
try {
|
||||
const persisted = SuperJSON.parse<PersistedQueryCache>(cached);
|
||||
if (Date.now() - persisted.timestamp > CACHE_MAX_AGE_MS) {
|
||||
await AsyncStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
hydrate(queryClient, persisted.state);
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistOfflineQueryCache({
|
||||
queryClient,
|
||||
accountId,
|
||||
apiUrl,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
accountId: string | null;
|
||||
apiUrl: string;
|
||||
}) {
|
||||
const key = offlineQueryCacheKey(accountId, apiUrl);
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const save = async () => {
|
||||
const state = dehydrate(queryClient, {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" &&
|
||||
query.state.fetchStatus === "idle" &&
|
||||
query.state.fetchFailureCount === 0 &&
|
||||
!SKIPPED_QUERY_KEY_PARTS.some((part) =>
|
||||
JSON.stringify(query.queryKey).includes(part),
|
||||
),
|
||||
});
|
||||
if (state.queries.length === 0) return;
|
||||
|
||||
const existing = await AsyncStorage.getItem(key).then((cached) => {
|
||||
if (!cached) return null;
|
||||
try {
|
||||
return SuperJSON.parse<PersistedQueryCache>(cached);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const queriesByHash = new Map(
|
||||
existing?.state.queries.map((query) => [query.queryHash, query]),
|
||||
);
|
||||
for (const query of state.queries) {
|
||||
queriesByHash.set(query.queryHash, query);
|
||||
}
|
||||
|
||||
const persisted: PersistedQueryCache = {
|
||||
timestamp: Date.now(),
|
||||
state: {
|
||||
mutations: state.mutations,
|
||||
queries: Array.from(queriesByHash.values()),
|
||||
},
|
||||
};
|
||||
|
||||
await AsyncStorage.setItem(key, SuperJSON.stringify(persisted));
|
||||
};
|
||||
|
||||
const scheduleSave = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(save, SAVE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(scheduleSave);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
void save();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearOfflineQueryCacheForAccount(accountId: string) {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const accountPrefix = `${CACHE_PREFIX}:${encodeURIComponent(`${accountId}:`)}`;
|
||||
const matchingKeys = keys.filter((key) => key.startsWith(accountPrefix));
|
||||
if (matchingKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(matchingKeys);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export function createAppQueryClient(onUnauthorized: () => void) {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
gcTime: 1000 * 60 * 60 * 24 * 7,
|
||||
refetchOnReconnect: true,
|
||||
retry: (failureCount, error) => {
|
||||
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
|
||||
return failureCount < 1;
|
||||
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
} from "react";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
|
||||
import { configureReactQueryOnlineManager } from "@/lib/network-status";
|
||||
import {
|
||||
persistOfflineQueryCache,
|
||||
restoreOfflineQueryCache,
|
||||
} from "@/lib/offline-cache";
|
||||
import { performAuthReset } from "@/lib/auth-session";
|
||||
import { createAppQueryClient } from "@/lib/query-client";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
@@ -31,6 +37,7 @@ export function TRPCProvider({
|
||||
const { refetch } = useSession();
|
||||
const authStoragePrefixRef = useRef(authStoragePrefix);
|
||||
authStoragePrefixRef.current = authStoragePrefix;
|
||||
const [cacheReady, setCacheReady] = useState(false);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const resettingRef = useRef(false);
|
||||
@@ -87,6 +94,43 @@ export function TRPCProvider({
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
configureReactQueryOnlineManager();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCacheReady(false);
|
||||
|
||||
restoreOfflineQueryCache({
|
||||
queryClient,
|
||||
accountId: activeAccountId,
|
||||
apiUrl,
|
||||
}).finally(() => {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setCacheReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeAccountId, apiUrl, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheReady) return;
|
||||
|
||||
return persistOfflineQueryCache({
|
||||
queryClient,
|
||||
accountId: activeAccountId,
|
||||
apiUrl,
|
||||
});
|
||||
}, [activeAccountId, apiUrl, cacheReady, queryClient]);
|
||||
|
||||
if (!cacheReady) {
|
||||
return <LoadingScreen message="Loading saved data…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user