fix(native): re-probe the server on retry and rebuild the auth client before marking a new server reachable

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-05 12:59:59 -04:00
1 parent b46bc4cdcb
commit 786a4ba7c5
16 files changed
+119 -45

No files matched your search

@@ -3,30 +3,28 @@ import { IconWifiOff } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { Pressable, View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { probeServer, useHasServerUrl, useServerReachability } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function OfflineBanner() {
const [isOffline, setIsOffline] = useState(false);
const [isDeviceOffline, setIsDeviceOffline] = useState(false);
const hasServerUrl = useHasServerUrl();
const { isReachable } = useServerReachability();
const insets = useSafeAreaInsets();
const wasOnline = useRef(true);
const wasVisible = useRef(false);
useEffect(() => {
let mounted = true;
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
const offline = !state.isConnected || state.isInternetReachable === false;
setIsOffline(offline);
if (offline && wasOnline.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasOnline.current = !offline;
setIsDeviceOffline(!state.isConnected || state.isInternetReachable === false);
};
Network.getNetworkStateAsync().then(handleState);
@@ -39,10 +37,39 @@ export function OfflineBanner() {
};
}, []);
if (!isOffline) return null;
// The OS can report "no internet" while the Sofa server still answers (e.g. a LAN server on
// Android Wi-Fi without validated internet), so only claim offline when the server is
// unreachable too. Before a server is configured there is no reachability signal.
const visible = isDeviceOffline && (!hasServerUrl || !isReachable);
useEffect(() => {
if (visible && !wasVisible.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasVisible.current = visible;
}, [visible]);
if (!visible) return null;
const useGlass = isLiquidGlassAvailable();
const content = (
<>
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
<Text className="font-sans text-sm font-medium text-white">
<Trans>No internet connection</Trans>
</Text>
<Pressable
onPress={() => void probeServer()}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-sans text-xs font-medium text-white">
<Trans>Retry</Trans>
</Text>
</Pressable>
</>
);
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
@@ -70,17 +97,11 @@ export function OfflineBanner() {
paddingVertical: 10,
}}
>
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
<Text className="font-sans text-sm font-medium text-white">
<Trans>No internet connection</Trans>
</Text>
{content}
</GlassView>
) : (
<View className="bg-destructive mx-4 flex-row items-center justify-center gap-2 rounded-xl px-4 py-2.5">
<ScaledIcon icon={IconWifiOff} size={16} color="white" />
<Text className="font-sans text-sm font-medium text-white">
<Trans>No internet connection</Trans>
</Text>
{content}
</View>
)}
</Animated.View>
@@ -2,30 +2,28 @@ import { Trans } from "@lingui/react/macro";
import { IconWifiOff } from "@tabler/icons-react-native";
import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { Pressable, View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { probeServer, useHasServerUrl, useServerReachability } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function OfflineBanner() {
const [isOffline, setIsOffline] = useState(false);
const [isDeviceOffline, setIsDeviceOffline] = useState(false);
const hasServerUrl = useHasServerUrl();
const { isReachable } = useServerReachability();
const insets = useSafeAreaInsets();
const wasOnline = useRef(true);
const wasVisible = useRef(false);
useEffect(() => {
let mounted = true;
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
const offline = !state.isConnected || state.isInternetReachable === false;
setIsOffline(offline);
if (offline && wasOnline.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasOnline.current = !offline;
setIsDeviceOffline(!state.isConnected || state.isInternetReachable === false);
};
// Check initial state
@@ -40,7 +38,19 @@ export function OfflineBanner() {
};
}, []);
if (!isOffline) return null;
// The OS can report "no internet" while the Sofa server still answers (e.g. a LAN server on
// Android Wi-Fi without validated internet), so only claim offline when the server is
// unreachable too. Before a server is configured there is no reachability signal.
const visible = isDeviceOffline && (!hasServerUrl || !isReachable);
useEffect(() => {
if (visible && !wasVisible.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasVisible.current = visible;
}, [visible]);
if (!visible) return null;
return (
<Animated.View
@@ -59,6 +69,14 @@ export function OfflineBanner() {
<Text className="font-sans text-sm font-medium text-white">
<Trans>No internet connection</Trans>
</Text>
<Pressable
onPress={() => void probeServer()}
className="ml-1 rounded-md bg-white/20 px-2 py-0.5"
>
<Text className="font-sans text-xs font-medium text-white">
<Trans>Retry</Trans>
</Text>
</Pressable>
</View>
</Animated.View>
);
+3 -1
View File
@@ -1,6 +1,8 @@
import { onlineManager } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { probeServer } from "@/lib/server";
/** Longest the pull-to-refresh spinner stays up; refetches keep running after it hides. */
const MAX_SPINNER_MS = 15_000;
@@ -14,7 +16,7 @@ export function usePullToRefresh(refresh: () => Promise<unknown>) {
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
if (!onlineManager.isOnline()) {
void refresh();
void probeServer().then(() => refresh());
return;
}
setRefreshing(true);
+25 -16
View File
@@ -241,13 +241,16 @@ function syncOnlineState(state: Network.NetworkState | null = lastNetworkState)
}
// While the server is unreachable, queries are paused and never hit serverFetch, so probe the
// health endpoint directly; a success flips reachability (and the online flag) back.
function probeServerIfUnreachable() {
if (isReachable) return;
// health endpoint directly; a response flips reachability (and the online flag) back. `force`
// probes even while reachable — used when the OS reports the device offline, because reachability
// only changes on a request and an idle app would otherwise never notice.
export function probeServer({ force = false }: { force?: boolean } = {}): Promise<boolean> {
if (isReachable && !force) return Promise.resolve(true);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
serverFetch(`${getServerUrl()}/api/health`, { method: "GET", signal: controller.signal })
.catch(() => {})
return serverFetch(`${getServerUrl()}/api/health`, { method: "GET", signal: controller.signal })
.then(() => true)
.catch(() => false)
.finally(() => clearTimeout(timer));
}
@@ -298,21 +301,25 @@ export function onServerReachabilityChange(callback: (reachable: boolean) => voi
export function startReachabilityMonitor(): () => void {
syncAppFocus(AppState.currentState);
void Network.getNetworkStateAsync().then(syncOnlineState);
const networkSubscription = Network.addNetworkStateListener((state) => {
const syncNetworkState = (state: Network.NetworkState) => {
syncOnlineState(state);
probeServerIfUnreachable();
});
void probeServer({ force: !isDeviceOnline(state) });
};
void Network.getNetworkStateAsync().then(syncNetworkState);
const networkSubscription = Network.addNetworkStateListener(syncNetworkState);
const appStateSubscription = AppState.addEventListener("change", (nextState) => {
syncAppFocus(nextState);
if (nextState === "active") {
void Network.getNetworkStateAsync().then(syncOnlineState);
probeServerIfUnreachable();
void Network.getNetworkStateAsync().then(syncNetworkState);
}
});
const probeTimer = setInterval(() => {
if (AppState.currentState === "active") void probeServer();
}, 30_000);
const removeServerUrlListener = onServerUrlChange(() => {
setReachable(true);
void Network.getNetworkStateAsync().then(syncOnlineState);
@@ -322,6 +329,7 @@ export function startReachabilityMonitor(): () => void {
networkSubscription.remove();
appStateSubscription.remove();
removeServerUrlListener();
clearInterval(probeTimer);
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
};
@@ -484,11 +492,12 @@ export const serverManager = {
connectToServer(url: string, instanceId: string): void {
registerServer(url, instanceId);
setServerUrlInternal(url);
// Validation just succeeded, so mark the server as reachable before
// rebuilding. This prevents a banner flash between the monitor starting
// (once hasServerUrl becomes true) and the first successful fetch.
setReachable(true);
authClient = buildAuthClient();
// Validation just succeeded, so mark the server as reachable. Do it after the auth client is
// rebuilt: the reachability listeners refetch the session, which must hit the new server, not
// the old one. This also prevents a banner flash between the monitor starting (once
// hasServerUrl becomes true) and the first successful fetch.
setReachable(true);
for (const listener of serverUrlListeners) listener();
},
};
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "جارٍ استرداد سجل المشاهدة وقائمة المشا
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "Verlauf, Merkliste und Bewertungen werden abgerufen..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr ""
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "Obteniendo tu historial de visionado, lista y valoraciones..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "Récupération de votre historique, liste de suivi et notes..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "מאחזר את היסטוריית הצפייה, רשימת הצפייה
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "Recupero della cronologia, watchlist e valutazioni..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "視聴履歴、ウォッチリスト、評価を取得中..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "시청 기록, 시청 목록, 평점을 가져오는 중..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "Kijkgeschiedenis, watchlist en beoordelingen ophalen..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "Buscando seu histórico de exibição, lista de desejos e avaliações..
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts
+2
View File
@@ -2753,6 +2753,8 @@ msgstr "正在获取您的观看记录、观看列表和评分..."
#: apps/native/src/app/(tabs)/(search)/index.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/settings/integrations-section.tsx
#: apps/native/src/components/ui/offline-banner.ios.tsx
#: apps/native/src/components/ui/offline-banner.tsx
#: apps/native/src/components/ui/server-unreachable-banner.ios.tsx
#: apps/native/src/components/ui/server-unreachable-banner.tsx
#: apps/web/src/lib/orpc/client.ts