mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user