| 1 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; |
| 2 | import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; |
| 3 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; |
| 4 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 5 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 6 | import { AsyncResult } from "effect/reactivity"; |
| 7 | import { useRef, useState } from "react"; |
| 8 | import { Pressable, View } from "react-native"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | |
| 11 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; |
| 12 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; |
| 13 | import { AppText as Text } from "../../components/AppText"; |
| 14 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 15 | import { serverEnvironment } from "../../state/server"; |
| 16 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 17 | import { SettingsSection } from "./components/SettingsSection"; |
| 18 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; |
| 19 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 20 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 21 | import { |
| 22 | AndroidSettingsEnvironmentFilter, |
| 23 | SettingsEnvironmentFilterHeader, |
| 24 | } from "./components/SettingsEnvironmentFilterHeader"; |
| 25 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; |
| 26 | import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; |
| 27 | import { |
| 28 | planMobileScopedSettingsClear, |
| 29 | planMobileScopedSettingsPatch, |
| 30 | resolveMobileSettingsTargets, |
| 31 | uniformMobileSetting, |
| 32 | type ScopedMobileSettingsTarget, |
| 33 | } from "./settings-scoped-server"; |
| 34 | |
| 35 | export function SettingsThreadsRouteScreen() { |
| 36 | const insets = useSafeAreaInsets(); |
| 37 | |
| 38 | return ( |
| 39 | <> |
| 40 | <SettingsEnvironmentFilterHeader /> |
| 41 | <SettingsScreen title="Thread behavior" trailing={<AndroidSettingsEnvironmentFilter />}> |
| 42 | <ScrollView |
| 43 | contentInsetAdjustmentBehavior="automatic" |
| 44 | showsVerticalScrollIndicator={false} |
| 45 | className="flex-1" |
| 46 | contentContainerClassName="gap-6 px-5 pt-4" |
| 47 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 48 | > |
| 49 | <AutoSettleSettingsRows /> |
| 50 | <BetaSettingsSection /> |
| 51 | <LegacySettingsSection /> |
| 52 | </ScrollView> |
| 53 | </SettingsScreen> |
| 54 | </> |
| 55 | ); |
| 56 | } |
| 57 | |
| 58 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; |
| 59 | |
| 60 | /** |
| 61 | * Mobile edits auto-settle defaults across selected capable targets. |
| 62 | */ |
| 63 | function AutoSettleSettingsRows() { |
| 64 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); |
| 65 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); |
| 66 | const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); |
| 67 | const projectSelected = selectedProjectKey !== null; |
| 68 | const [pendingWrites, setPendingWrites] = useState(0); |
| 69 | const writeInFlight = useRef(false); |
| 70 | const [pendingTargets, setPendingTargets] = useState< |
| 71 | readonly ScopedMobileSettingsTarget[] | null |
| 72 | >(null); |
| 73 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 74 | label: "server settings update", |
| 75 | reportFailure: true, |
| 76 | }); |
| 77 | |
| 78 | const syncEnvironments = selectedTargets.filter(supportsSharedSettingsSync); |
| 79 | const syncTargets = resolveMobileSettingsTargets( |
| 80 | syncEnvironments, |
| 81 | projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, |
| 82 | ); |
| 83 | const canWriteSettings = |
| 84 | syncTargets.length > 0 && |
| 85 | syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId)); |
| 86 | const displayTargets = |
| 87 | pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets; |
| 88 | const reference = displayTargets[0] ?? null; |
| 89 | const referenceSettings = reference?.settings ?? null; |
| 90 | |
| 91 | if (reference === null || referenceSettings === null) { |
| 92 | return null; |
| 93 | } |
| 94 | |
| 95 | const writeToAll = ( |
| 96 | patch: Partial<AutoSettleSettings> & { |
| 97 | autoResumeLimitedThreads?: boolean; |
| 98 | snoozeLimitedThreads?: boolean; |
| 99 | }, |
| 100 | ) => { |
| 101 | if ( |
| 102 | writeInFlight.current || |
| 103 | !syncTargets.every((target) => |
| 104 | readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), |
| 105 | ) |
| 106 | ) |
| 107 | return; |
| 108 | const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); |
| 109 | if (writes.length === 0) return; |
| 110 | writeInFlight.current = true; |
| 111 | setPendingTargets(syncTargets); |
| 112 | setPendingWrites((count) => count + 1); |
| 113 | void Promise.allSettled( |
| 114 | writes.map((entry) => |
| 115 | updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), |
| 116 | ), |
| 117 | ).finally(() => { |
| 118 | writeInFlight.current = false; |
| 119 | setPendingTargets(null); |
| 120 | setPendingWrites((count) => count - 1); |
| 121 | }); |
| 122 | }; |
| 123 | |
| 124 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( |
| 125 | { |
| 126 | environmentId: reference.environment.environmentId, |
| 127 | projectId: reference.projectId, |
| 128 | settings: referenceSettings, |
| 129 | }, |
| 130 | displayTargets.map((target) => ({ |
| 131 | environmentId: target.environment.environmentId, |
| 132 | projectId: target.projectId, |
| 133 | label: target.environment.label, |
| 134 | settings: target.settings, |
| 135 | })), |
| 136 | ); |
| 137 | |
| 138 | const supportsProjectOverrides = syncTargets.every( |
| 139 | (target) => |
| 140 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, |
| 141 | ); |
| 142 | const disabled = |
| 143 | !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); |
| 144 | const hasProjectOverrides = |
| 145 | projectSelected && |
| 146 | syncTargets.some( |
| 147 | (target) => |
| 148 | target.sources.sidebarAutoSettleOnMerge === "project" || |
| 149 | target.sources.sidebarAutoSettleAfterDays === "project", |
| 150 | ); |
| 151 | const clearProjectOverrides = () => { |
| 152 | if ( |
| 153 | writeInFlight.current || |
| 154 | !syncTargets.every((target) => |
| 155 | readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), |
| 156 | ) |
| 157 | ) |
| 158 | return; |
| 159 | const writes = planMobileScopedSettingsClear(syncTargets, [ |
| 160 | "sidebarAutoSettleOnMerge", |
| 161 | "sidebarAutoSettleAfterDays", |
| 162 | ]); |
| 163 | if (writes.length === 0) return; |
| 164 | writeInFlight.current = true; |
| 165 | setPendingTargets(syncTargets); |
| 166 | setPendingWrites((count) => count + 1); |
| 167 | void Promise.allSettled( |
| 168 | writes.map((entry) => |
| 169 | updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), |
| 170 | ), |
| 171 | ).finally(() => { |
| 172 | writeInFlight.current = false; |
| 173 | setPendingTargets(null); |
| 174 | setPendingWrites((count) => count - 1); |
| 175 | }); |
| 176 | }; |
| 177 | |
| 178 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; |
| 179 | |
| 180 | return ( |
| 181 | <View className="gap-6"> |
| 182 | {projectSelected ? ( |
| 183 | <SettingsProjectOverridesSection |
| 184 | projectLabel={selectedProject?.label ?? "Unavailable project"} |
| 185 | hasOverrides={hasProjectOverrides} |
| 186 | supportsOverrides={supportsProjectOverrides} |
| 187 | pending={pendingWrites > 0} |
| 188 | disabled={!canWriteSettings} |
| 189 | onClear={clearProjectOverrides} |
| 190 | /> |
| 191 | ) : null} |
| 192 | {!projectSelected ? ( |
| 193 | <SettingsSection title="Usage limits"> |
| 194 | <SettingsSwitchRow |
| 195 | icon="clock" |
| 196 | label="Auto-resume limited threads" |
| 197 | value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")} |
| 198 | disabled={disabled} |
| 199 | onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })} |
| 200 | /> |
| 201 | <SettingsSwitchRow |
| 202 | icon="clock" |
| 203 | label="Snooze limited threads" |
| 204 | value={uniformMobileSetting(displayTargets, "snoozeLimitedThreads")} |
| 205 | disabled={disabled} |
| 206 | onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })} |
| 207 | /> |
| 208 | </SettingsSection> |
| 209 | ) : null} |
| 210 | <SettingsSection title="Auto-settle"> |
| 211 | <SettingsSwitchRow |
| 212 | icon="arrow.triangle.branch" |
| 213 | label="Auto-settle merged threads" |
| 214 | value={referenceSettings.sidebarAutoSettleOnMerge} |
| 215 | disabled={disabled} |
| 216 | onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} |
| 217 | /> |
| 218 | <SettingsSwitchRow |
| 219 | icon="clock" |
| 220 | label="Auto-settle inactive threads" |
| 221 | value={afterDays !== null} |
| 222 | disabled={disabled} |
| 223 | onValueChange={(value) => |
| 224 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) |
| 225 | } |
| 226 | /> |
| 227 | {afterDays !== null ? ( |
| 228 | <View className="flex-row items-center gap-4 px-4 py-4 android:min-h-14 android:py-3"> |
| 229 | <View className="w-[22px] android:w-6" /> |
| 230 | <Text className="flex-1 text-foreground text-lg android:text-base">Inactive days</Text> |
| 231 | <AutoSettleDaysField |
| 232 | value={afterDays} |
| 233 | disabled={disabled} |
| 234 | onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })} |
| 235 | /> |
| 236 | </View> |
| 237 | ) : null} |
| 238 | </SettingsSection> |
| 239 | {pendingWrites === 0 && mismatches.length > 0 ? ( |
| 240 | <SettingsSection title="Across environments"> |
| 241 | <View className="gap-3 p-4"> |
| 242 | <Text className="text-base text-foreground">Auto-settle defaults differ</Text> |
| 243 | <Text className="text-sm text-foreground-muted"> |
| 244 | {mismatches.map((mismatch) => mismatch.label).join(", ")} |
| 245 | </Text> |
| 246 | <Pressable |
| 247 | accessibilityRole="button" |
| 248 | disabled={disabled} |
| 249 | onPress={() => writeToAll(autoSettlePatch)} |
| 250 | className="self-start rounded-full bg-subtle px-4 py-2 active:opacity-70" |
| 251 | > |
| 252 | <Text className="text-sm font-t3-medium text-foreground"> |
| 253 | Apply auto-settle defaults |
| 254 | </Text> |
| 255 | </Pressable> |
| 256 | </View> |
| 257 | </SettingsSection> |
| 258 | ) : null} |
| 259 | </View> |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * Device-local beta toggles, the counterpart of web's Working section (beta) |
| 265 | * in Settings → General. |
| 266 | */ |
| 267 | function BetaSettingsSection() { |
| 268 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 269 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 270 | const workingShelfEnabled = |
| 271 | AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true; |
| 272 | |
| 273 | return ( |
| 274 | <View className="gap-3"> |
| 275 | <SettingsSection title="Beta"> |
| 276 | <SettingsSwitchRow |
| 277 | icon="bolt.circle" |
| 278 | label="Working section" |
| 279 | value={workingShelfEnabled} |
| 280 | onValueChange={(value) => savePreferences({ workingShelfEnabled: value })} |
| 281 | /> |
| 282 | </SettingsSection> |
| 283 | <Text className="px-2 text-sm text-foreground-muted"> |
| 284 | Fold working and monitoring threads into a Working section. They return to the top of the |
| 285 | list when they need you. While this is on, active threads are ordered by time and cannot be |
| 286 | moved. |
| 287 | </Text> |
| 288 | </View> |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is |
| 294 | * the counterpart of web's Settings → General → Legacy features backed by |
| 295 | * mobile preferences. |
| 296 | */ |
| 297 | function LegacySettingsSection() { |
| 298 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 299 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 300 | const planModeEnabled = |
| 301 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; |
| 302 | |
| 303 | return ( |
| 304 | <View className="gap-3"> |
| 305 | <SettingsSection title="Legacy"> |
| 306 | <SettingsSwitchRow |
| 307 | icon="hammer" |
| 308 | label="Plan Mode" |
| 309 | value={planModeEnabled} |
| 310 | onValueChange={(value) => savePreferences({ planModeEnabled: value })} |
| 311 | /> |
| 312 | </SettingsSection> |
| 313 | <Text className="px-2 text-sm text-foreground-muted"> |
| 314 | Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan |
| 315 | control; otherwise every task runs in Build mode. |
| 316 | </Text> |
| 317 | </View> |
| 318 | ); |
| 319 | } |