import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import { useNavigation } from "@react-navigation/native"; 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, useRef, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, subscribeAgentAwarenessRegistrationStatus, } from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; 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, isAppUpdateCheckAvailable, registerHiddenUpdateTap, runAppUpdateCheck, } from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; // Reflects whether the relay actually accepted this device's registration. // The notification and Live Activity switches are gated on this so they can // never read as enabled when the device cannot receive anything (e.g. the // registration request timed out). function useDeviceRegistered(): boolean { const status = useSyncExternalStore( subscribeAgentAwarenessRegistrationStatus, getAgentAwarenessRegistrationStatus, () => "unknown" as const, ); return status === "registered"; } export function SettingsRouteScreen() { const navigation = useNavigation(); return ( <> {Platform.OS === "android" ? ( <> {/* Android renders its own in-screen header instead of the native bar. */} navigation.goBack()} /> ) : ( [ withNativeGlassHeaderItem({ accessibilityLabel: "Close settings", icon: { name: "xmark", type: "sfSymbol" } as const, identifier: "settings-close", label: "", onPress: () => navigation.goBack(), type: "button", }), ] : undefined, }} /> )} {hasCloudPublicConfig() ? : } ); } function LocalSettingsRouteScreen() { const insets = useSafeAreaInsets(); const { savedConnectionsById } = useSavedRemoteConnections(); const environmentCount = Object.keys(savedConnectionsById).length; return ( ); } function ConfiguredSettingsRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useSavedRemoteConnections(); const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); const deviceRegistered = useDeviceRegistered(); const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.liveActivitiesEnabled !== false : true; const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); const environmentCount = connections.length; const accountLabel = useMemo(() => { if (!isLoaded) return "Checking"; if (!isSignedIn) return "Sign in"; return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); const refreshNotifications = useCallback(async () => { if (process.env.EXPO_OS !== "ios") { setNotificationStatus("unsupported"); return; } const result = await settlePromise(() => Notifications.getPermissionsAsync()); if (result._tag === "Failure") { reportAtomCommandResult(result, { label: "notification permission refresh" }); setNotificationStatus("disabled"); return; } setNotificationStatus(result.value.granted ? "enabled" : "disabled"); }, []); useEffect(() => { void refreshNotifications(); }, [refreshNotifications]); useEffect(() => { if (!isLoaded) { setLiveActivityStatus("checking"); return; } if (!isSignedIn) { setLiveActivityStatus("signed-out"); return; } if (!AsyncResult.isSuccess(preferencesResult)) { if (AsyncResult.isFailure(preferencesResult)) { reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); setLiveActivityStatus("enabled"); } else { setLiveActivityStatus("checking"); } return; } setLiveActivityStatus( preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", ); }, [isLoaded, isSignedIn, preferencesResult]); const requestNotifications = useCallback(async () => { const result = await settleAsyncResult(() => runtime.runPromiseExit( requestAgentNotificationPermission.pipe( Effect.tap((permission) => permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, ), ), ), ); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); Alert.alert( "Notifications unavailable", error instanceof Error ? error.message : "Could not request notification permission.", ); } return; } if (result.value.type === "granted") { setNotificationStatus("enabled"); // Permission alone is not enough: the switch stays off until the relay // registration succeeds, so tell the user the truth about which happened. if (getAgentAwarenessRegistrationStatus() === "registered") { Alert.alert( "Notifications enabled", "Live Activity notifications are enabled for this device.", ); } else { Alert.alert( "Couldn't finish enabling notifications", "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", ); } return; } if (result.value.type === "unsupported") { setNotificationStatus("unsupported"); Alert.alert( "Notifications unavailable", "Live Activity notifications are only available on iOS.", ); return; } setNotificationStatus("disabled"); if (result.value.canAskAgain) { Alert.alert("Notifications disabled", "Notifications were not enabled."); return; } Alert.alert( "Notifications disabled", "Notifications were denied for this app. Open Settings to enable them.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); }, []); const promptSignIn = useCallback(() => { Alert.alert( "Sign in to T3 Connect", "Live Activity updates require T3 Connect so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { text: "Continue", onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), }, ], ); }, [navigation]); const linkEnvironments = useCallback(async () => { if (!isSignedIn) { promptSignIn(); return; } setLiveActivityStatus("linking"); const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); if (tokenResult._tag === "Failure") { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(tokenResult); Alert.alert( "Live Activities unavailable", error instanceof Error ? error.message : "Could not enable Live Activity updates.", ); return; } if (!tokenResult.value) { promptSignIn(); setLiveActivityStatus("signed-out"); return; } const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, connections, }), ), ); if (updateResult._tag === "Failure") { setLiveActivityStatus("disabled"); if (!isAtomCommandInterrupted(updateResult)) { const error = squashAtomCommandFailure(updateResult); Alert.alert( "Live Activities unavailable", error instanceof Error ? error.message : "Could not enable Live Activity updates.", ); } return; } savePreferences({ liveActivitiesEnabled: true }); refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); // The environment link can succeed while this device's own registration // (the push-to-start token the relay needs) has not — don't claim Live // Activities are live until the device is actually registered. if (getAgentAwarenessRegistrationStatus() === "registered") { Alert.alert( "Live Activities enabled", environmentCount > 0 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` : "Live Activity updates are enabled. Add an environment to start receiving updates.", ); } else { Alert.alert( "Couldn't finish enabling Live Activities", "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", ); } }, [ connections, environmentCount, getToken, isSignedIn, liveActivitiesPreferenceEnabled, promptSignIn, savePreferences, ]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { if (enabled) { void requestNotifications(); return; } Alert.alert( "Disable notifications", "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); }, [requestNotifications], ); const handleLiveActivitiesChange = useCallback( (enabled: boolean) => { if (!enabled) { setLiveActivityStatus("disabled"); void (async () => { let token: string | null = null; if (isSignedIn) { const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()), ); if (tokenResult._tag === "Failure") { reportAtomCommandResult(tokenResult, { label: "live activity disable token lookup", }); return; } token = tokenResult.value; } const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, connections, }), ), ); if (updateResult._tag === "Failure") { setLiveActivityStatus("enabled"); reportAtomCommandResult(updateResult, { label: "live activity disable", }); return; } savePreferences({ liveActivitiesEnabled: false }); refreshManagedRelayEnvironments(); })(); return; } if (!isSignedIn) { promptSignIn(); return; } void linkEnvironments(); }, [ connections, getToken, isSignedIn, linkEnvironments, liveActivitiesPreferenceEnabled, promptSignIn, savePreferences, ], ); const openAccount = useCallback(() => { if (!isLoaded) return; navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); }, [isLoaded, navigation]); return ( T3 Code works locally without signing in. Cloud features are optional. ); } function GeneralSettingsSection() { return ( ); } /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by * mobile preferences. */ function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); return ( savePreferences({ legacyThreadListEnabled: value })} /> Brings back the original grouped thread list. The default list is flat, in creation order: active work renders as cards; settled threads collapse to compact rows. ); } function AppSettingsSection() { const icon = useThemeColor("--color-icon"); const [updateState, setUpdateState] = useState("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; const updateCheckAvailable = isAppUpdateCheckAvailable(); const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; // "Up to date" is a transient acknowledgement, not a state worth persisting — // return the version row to its normal, deliberately quiet state. 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 runAppUpdateCheck({ onFailure: (message) => Alert.alert("Update failed", message), onStateChange: setUpdateState, }); } finally { updateInFlight.current = false; } }, []); const handleVersionPress = useCallback(() => { if (!updateCheckAvailable || updateInFlight.current) return; const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); hiddenUpdateTapCount.current = tap.nextCount; if (tap.shouldCheck) { void checkForUpdate(); } }, [checkForUpdate, updateCheckAvailable]); const statusLabel = updateState === "checking" ? "Checking…" : updateState === "downloading" ? "Downloading…" : updateState === "restarting" ? "Restarting…" : updateState === "current" ? "Up to date" : null; const versionRow = ( Version {versionLabel} {statusLabel ? ( {statusLabel} ) : null} ); return ( {updateCheckAvailable ? ( {versionRow} ) : ( versionRow )} ); } function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } function ArchivedThreadsSettingsSection() { return ( ); }