Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 141 additions & 18 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { SymbolView } from "../../components/AppSymbol";
import * as Effect from "effect/Effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Alert, Linking, Platform, ScrollView, View } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ActivityIndicator,
Alert,
Linking,
Platform,
Pressable,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import {
type AtomCommandResult,
isAtomCommandInterrupted,
reportAtomCommandResult,
settleAsyncResult,
Expand Down Expand Up @@ -570,8 +579,12 @@ function BetaSettingsSection() {
);
}

type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current";

function AppSettingsSection() {
const icon = useThemeColor("--color-icon");
const [updateState, setUpdateState] = useState<UpdateCheckState>("idle");
const updateInFlight = useRef(false);

const version = Constants.expoConfig?.version ?? "0.0.0";
// Fall back to "production" to match resolveAppVariant in app.config.ts, so a
Expand All @@ -590,30 +603,140 @@ function AppSettingsSection() {
: null
: null;

const busy =
updateState === "checking" || updateState === "downloading" || updateState === "restarting";

// "Up to date" is a transient acknowledgement, not a state worth persisting —
// drop back to the bundle label so the row keeps answering "what am I running?".
useEffect(() => {
if (updateState !== "current") return;
const timer = setTimeout(() => setUpdateState("idle"), 3000);
return () => clearTimeout(timer);
}, [updateState]);

const checkForUpdate = useCallback(async () => {
// `disabled={busy}` only takes effect on the next render, so two taps in the
// same frame would both get through. The ref closes that window.
if (updateInFlight.current) return;
updateInFlight.current = true;
try {
await runUpdateCheck(setUpdateState);
} finally {
updateInFlight.current = false;
}
}, []);
Comment thread
cursor[bot] marked this conversation as resolved.

const statusLabel =
updateState === "checking"
? "Checking…"
: updateState === "downloading"
? "Downloading…"
: updateState === "restarting"
? "Restarting…"
: updateState === "current"
? "Up to date"
: bundleLabel;

const versionRow = (
<View className="flex-row items-center gap-4 p-4">
<SymbolView
name="info.circle"
size={22}
tintColor={icon}
type="monochrome"
weight="regular"
/>
<Text className="flex-1 text-lg text-foreground">Version</Text>
<View className="items-end">
<Text className="text-lg text-foreground-muted">{versionLabel}</Text>
{statusLabel ? (
<Text className="text-xs text-foreground-muted/70">{statusLabel}</Text>
) : null}
</View>
{Updates.isEnabled ? (
<View className="w-[22px] items-center">
{busy ? (
<ActivityIndicator color={icon} size="small" />
) : (
<SymbolView
name="arrow.clockwise"
size={18}
tintColor={icon}
type="monochrome"
weight="semibold"
/>
)}
</View>
) : null}
</View>
);

return (
<SettingsSection title="App">
<SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
<SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
<View className="flex-row items-center gap-4 p-4">
<SymbolView
name="info.circle"
size={22}
tintColor={icon}
type="monochrome"
weight="regular"
/>
<Text className="flex-1 text-lg text-foreground">Version</Text>
<View className="items-end">
<Text className="text-lg text-foreground-muted">{versionLabel}</Text>
{bundleLabel ? (
<Text className="text-xs text-foreground-muted/70">{bundleLabel}</Text>
) : null}
</View>
</View>
{Updates.isEnabled ? (
<Pressable
accessibilityLabel="Check for updates"
accessibilityRole="button"
disabled={busy}
onPress={() => void checkForUpdate()}
>
{versionRow}
</Pressable>
) : (
versionRow
)}
</SettingsSection>
);
}

async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise<void> {
setUpdateState("checking");
const check = await settlePromise(() => Updates.checkForUpdateAsync());
if (check._tag === "Failure") {
reportUpdateFailure(check, "Could not check for updates.");
setUpdateState("idle");
return;
}
// A rollback directive (`eas update:rollback`) arrives as isAvailable: false
// with isRollBackToEmbedded: true — there is nothing newer to install, but the
// running OTA still has to be dropped for the embedded bundle.
if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) {
setUpdateState("current");
return;
}

setUpdateState("downloading");
const fetched = await settlePromise(() => Updates.fetchUpdateAsync());
if (fetched._tag === "Failure") {
reportUpdateFailure(fetched, "Could not download the update.");
setUpdateState("idle");
return;
}
// isNew is always false for a rollback, so it can't be the sole gate here either.
if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) {
setUpdateState("current");
return;
}

setUpdateState("restarting");
// reloadAsync never resolves on success — the JS context is torn down — so
// reaching the failure branch below is the only way this returns.
const reloaded = await settlePromise(() => Updates.reloadAsync());
if (reloaded._tag === "Failure") {
reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.");
setUpdateState("idle");
}
}

function reportUpdateFailure(result: AtomCommandResult<unknown, unknown>, fallback: string): void {
reportAtomCommandResult(result, { label: "app update check" });
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
const error = squashAtomCommandFailure(result);
Alert.alert("Update failed", error instanceof Error ? error.message : fallback);
}

function capitalize(value: string): string {
return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
}
Expand Down
Loading