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, AppTextInput as TextInput } 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 { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironments } from "../../state/environments"; import { DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, type ServerSettingsPatch, } from "@t3tools/contracts"; import { filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; 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"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; 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 agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); const agentAwarenessSubtitle = Platform.OS === "android" && !agentAwarenessPushAvailable ? "Install a newer app build to enable notifications" : agentAwarenessPlatform.subtitle; 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 (Platform.OS !== "ios" && Platform.OS !== "android") { 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", "Agent 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", "Agent notifications are unavailable on this platform.", ); 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"); if (Platform.OS === "android") { const permission = await settleAsyncResult(() => runtime.runPromiseExit(requestAgentNotificationPermission), ); if (permission._tag === "Failure") { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(permission); Alert.alert( "Ongoing activity unavailable", error instanceof Error ? error.message : "Could not enable agent notifications.", ); return; } if (permission.value.type !== "granted") { setLiveActivityStatus("disabled"); Alert.alert( "Notification permission needed", "Enable notifications in system Settings to show ongoing agent activity.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); return; } setNotificationStatus("enabled"); } const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); if (tokenResult._tag === "Failure") { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(tokenResult); Alert.alert( Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", error instanceof Error ? error.message : "Could not enable agent 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( Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", error instanceof Error ? error.message : "Could not enable agent 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( Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", environmentCount > 0 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` : "Agent activity updates are enabled. Add an environment to start receiving updates.", ); } else { Alert.alert( "Couldn't finish enabling activity updates", "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", ); } }, [ connections, environmentCount, getToken, isSignedIn, liveActivitiesPreferenceEnabled, promptSignIn, savePreferences, ]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { if (enabled) { if (!isSignedIn) { promptSignIn(); return; } void requestNotifications(); return; } Alert.alert( "Disable notifications", "Open system Settings to disable notifications for T3 Code.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); }, [isSignedIn, promptSignIn, 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 ( ); } const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; /** * Auto-settlement is a user preference that every server has to hold. Mobile * has no primary environment, so the first eligible sync target provides the * reference value. Edits fan out to every eligible target, and a mismatch row * lets the user push the reference out. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "server settings update", reportFailure: true, }); const syncTargets = environments.filter(supportsSharedSettingsSync); const reference = syncTargets[0] ?? null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); if (reference === null || referenceSettings === null) { return null; } const writeToAll = (patch: ServerSettingsPatch) => { for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: reference.environmentId, primarySettings: referenceSettings, primaryCapabilities: reference.serverConfig?.environment.capabilities, environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, capabilities: environment.serverConfig?.environment.capabilities, })), }); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; const commitDays = () => { const draft = (daysDraft ?? "").trim(); setDaysDraft(null); // Whole-string check so "3.5" and "3days" are rejected instead of // silently becoming 3 on every eligible sync target. const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; if ( Number.isInteger(parsed) && parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && parsed !== afterDays ) { writeToAll({ sidebarAutoSettleAfterDays: parsed }); } }; return ( <> writeToAll({ sidebarAutoSettleOnMerge: value })} /> writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) } /> {afterDays !== null ? ( Days before auto-settle ) : null} {mismatches.length > 0 ? ( Settings differ {mismatches.map((mismatch) => mismatch.label).join(", ")} { const patch = pickSharedServerSettings( referenceSettings, reference.serverConfig?.environment.capabilities, ); for (const mismatch of mismatches) { const target = environments.find( (candidate) => candidate.environmentId === mismatch.environmentId, ); void updateSettings({ environmentId: mismatch.environmentId, input: { patch: filterSharedServerPatch( patch, target?.serverConfig?.environment.capabilities, target?.serverConfig?.settings, referenceSettings, ), }, }); } }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > Apply to all ) : null} ); } /** * 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 preferences = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); const planModeEnabled = AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; return ( savePreferences({ legacyThreadListEnabled: value })} /> savePreferences({ planModeEnabled: value })} /> Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan control; otherwise every task runs in Build mode. ); } function AppSettingsSection() { 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 { // The user asked for this restart by tapping the version row, so it may // apply immediately instead of prompting. await runAppUpdateCheck({ applyMode: "immediate", 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…" : // "ready" appears only when this check joined an in-flight background-mode // check; that download installs at the next backgrounding. updateState === "ready" ? "Update ready" : 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 ( ); }