Skip to content
Merged
Show file tree
Hide file tree
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
6 changes: 6 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { checkForAppUpdateOnLaunch } from "../updates/app-updates";
import { AndroidHomeFabLayout } from "./AndroidHomeFab";
import { HomeScreen } from "./HomeScreen";
import { HomeHeader } from "./HomeHeader";
Expand All @@ -29,6 +30,11 @@ export function HomeRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const navigation = useNavigation();
const [searchQuery, setSearchQuery] = useState("");

useEffect(() => {
void checkForAppUpdateOnLaunch();
}, []);

const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
useThreadListActions();
const pendingTasks = usePendingNewTasks();
Expand Down
117 changes: 26 additions & 91 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol";
import * as Effect from "effect/Effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ActivityIndicator,
Alert,
Linking,
Platform,
Pressable,
ScrollView,
View,
} from "react-native";
import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import {
type AtomCommandResult,
isAtomCommandInterrupted,
reportAtomCommandResult,
settleAsyncResult,
Expand All @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
runAppUpdateCheck,
} from "../updates/app-updates";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
Expand Down Expand Up @@ -578,35 +574,23 @@ function BetaSettingsSection() {
);
}

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

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

const version = Constants.expoConfig?.version ?? "0.0.0";
// Fall back to "production" to match resolveAppVariant in app.config.ts, so a
// missing variant never mislabels a production build as development.
const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
const variantLabel = variant === "production" ? "" : capitalize(variant);
const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
// Which JS is actually running: the bundle shipped in the binary, or an OTA
// update downloaded on top of it. Surfacing this makes "am I even on the
// right build?" answerable at a glance.
const bundleLabel = Updates.isEnabled
? Updates.isEmbeddedLaunch
? "Embedded"
: Updates.updateId
? `OTA ${Updates.updateId.slice(0, 7)}`
: 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?".
// return the version row to its normal, deliberately quiet state.
useEffect(() => {
if (updateState !== "current") return;
const timer = setTimeout(() => setUpdateState("idle"), 3000);
Expand All @@ -619,12 +603,24 @@ function AppSettingsSection() {
if (updateInFlight.current) return;
updateInFlight.current = true;
try {
await runUpdateCheck(setUpdateState);
await runAppUpdateCheck({
onFailure: (message) => Alert.alert("Update failed", message),
onStateChange: setUpdateState,
});
} finally {
updateInFlight.current = false;
}
}, []);

const handleVersionPress = useCallback(() => {
if (!Updates.isEnabled || updateInFlight.current) return;
const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);
hiddenUpdateTapCount.current = tap.nextCount;
if (tap.shouldCheck) {
void checkForUpdate();
}
}, [checkForUpdate]);

const statusLabel =
updateState === "checking"
? "Checking…"
Expand All @@ -634,7 +630,7 @@ function AppSettingsSection() {
? "Restarting…"
: updateState === "current"
? "Up to date"
: bundleLabel;
: null;

const versionRow = (
<View className="flex-row items-center gap-4 p-4">
Expand All @@ -652,21 +648,6 @@ function AppSettingsSection() {
<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>
);

Expand All @@ -676,10 +657,10 @@ function AppSettingsSection() {
<SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
{Updates.isEnabled ? (
<Pressable
accessibilityLabel="Check for updates"
accessibilityRole="button"
accessibilityLabel={`Version ${versionLabel}`}
accessibilityRole="text"
disabled={busy}
onPress={() => void checkForUpdate()}
onPress={handleVersionPress}
>
{versionRow}
</Pressable>
Expand All @@ -690,52 +671,6 @@ function AppSettingsSection() {
);
}

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
Loading