1import { AuthRelayWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
3import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
4import { useAuth } from "@clerk/expo";
5import { useAtomSet, useAtomValue } from "@effect/atom-react";
6import * as Notifications from "expo-notifications";
7import { useNavigation } from "@react-navigation/native";
8import * as Effect from "effect/Effect";
9import { AsyncResult } from "effect/reactivity";
10import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
11import { Alert, AppState, Linking, Platform } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13
14import { AppText as Text } from "../../components/AppText";
15import {
16 isAtomCommandInterrupted,
17 reportAtomCommandResult,
18 settleAsyncResult,
19 settlePromise,
20 squashAtomCommandFailure,
21} from "@t3tools/client-runtime/state/runtime";
22import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
23import {
24 openAndroidLiveUpdateSettings,
25 supportsAndroidLiveUpdateSettings,
26} from "../agent-awareness/androidNotifications";
27import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
28import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
29import {
30 getAgentAwarenessRegistrationStatus,
31 refreshAgentAwarenessRegistration,
32 subscribeAgentAwarenessRegistrationStatus,
33} from "../agent-awareness/remoteRegistration";
34import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
35import { resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
36import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
37import { runtime } from "../../lib/runtime";
38import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
40import { SettingsRow } from "./components/SettingsRow";
41import { SettingsSection } from "./components/SettingsSection";
42import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
43import { SettingsScreen } from "./components/SettingsScreen";
44import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
45
46type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
47type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
48
49// Reflects whether the relay actually accepted this device's registration.
50// The notification and Live Activity switches are gated on this so they can
51// never read as enabled when the device cannot receive anything (e.g. the
52// registration request timed out).
53function useDeviceRegistered(): boolean {
54 const status = useSyncExternalStore(
55 subscribeAgentAwarenessRegistrationStatus,
56 getAgentAwarenessRegistrationStatus,
57 () => "unknown" as const,
58 );
59 return status === "registered";
60}
61
62export function SettingsNotificationsRouteScreen() {
63 if (!useCloudAuthAvailable()) {
64 return (
65 <SettingsScreen title="Notifications">
66 <ScrollView
67 contentInsetAdjustmentBehavior="automatic"
68 contentContainerClassName="px-5 pt-4"
69 >
70 <Text className="text-base text-foreground-muted">
71 Notifications aren’t available through self-hosted servers. T3 Connect is required.
72 </Text>
73 </ScrollView>
74 </SettingsScreen>
75 );
76 }
77
78 return <ConfiguredSettingsNotificationsRouteScreen />;
79}
80
81function ConfiguredSettingsNotificationsRouteScreen() {
82 const preferencesResult = useAtomValue(mobilePreferencesAtom);
83 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
84 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();
85 const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS);
86 const agentAwarenessSubtitle =
87 Platform.OS === "android" && !agentAwarenessPushAvailable
88 ? "Install a newer app build to enable notifications"
89 : agentAwarenessPlatform.subtitle;
90 const insets = useSafeAreaInsets();
91 const navigation = useNavigation();
92 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
93 const { savedConnectionsById } = useSavedRemoteConnections();
94 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
95 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
96 const liveActivityWriteInFlight = useRef(false);
97 const deviceRegistered = useDeviceRegistered();
98 const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult)
99 ? preferencesResult.value.liveActivitiesEnabled !== false
100 : true;
101 const canClearLiveActivitiesPreference =
102 AsyncResult.isSuccess(preferencesResult) &&
103 preferencesResult.value.liveActivitiesEnabled !== false;
104
105 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
106 const environmentCount = connections.length;
107
108 const refreshNotifications = useCallback(async () => {
109 if (Platform.OS !== "ios" && Platform.OS !== "android") {
110 setNotificationStatus("unsupported");
111 return;
112 }
113 const result = await settlePromise(() => Notifications.getPermissionsAsync());
114 if (result._tag === "Failure") {
115 reportAtomCommandResult(result, { label: "notification permission refresh" });
116 setNotificationStatus("disabled");
117 return;
118 }
119 setNotificationStatus(result.value.granted ? "enabled" : "disabled");
120 }, []);
121
122 useEffect(() => {
123 void refreshNotifications();
124 const subscription = AppState.addEventListener("change", (state) => {
125 if (state === "active") void refreshNotifications();
126 });
127 return () => subscription.remove();
128 }, [refreshNotifications]);
129
130 useEffect(() => {
131 if (!isLoaded) {
132 setLiveActivityStatus("checking");
133 return;
134 }
135 if (!isSignedIn) {
136 setLiveActivityStatus("signed-out");
137 return;
138 }
139 if (!AsyncResult.isSuccess(preferencesResult)) {
140 if (AsyncResult.isFailure(preferencesResult)) {
141 reportAtomCommandResult(preferencesResult, { label: "live activity preference load" });
142 setLiveActivityStatus("enabled");
143 } else {
144 setLiveActivityStatus("checking");
145 }
146 return;
147 }
148 setLiveActivityStatus(
149 preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled",
150 );
151 }, [isLoaded, isSignedIn, preferencesResult]);
152
153 const requestNotifications = useCallback(async () => {
154 const result = await settleAsyncResult(() =>
155 runtime.runPromiseExit(
156 requestAgentNotificationPermission.pipe(
157 Effect.tap((permission) =>
158 permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void,
159 ),
160 ),
161 ),
162 );
163 if (result._tag === "Failure") {
164 if (!isAtomCommandInterrupted(result)) {
165 const error = squashAtomCommandFailure(result);
166 Alert.alert(
167 "Notifications unavailable",
168 error instanceof Error ? error.message : "Could not request notification permission.",
169 );
170 }
171 return;
172 }
173 if (result.value.type === "granted") {
174 setNotificationStatus("enabled");
175 // Permission alone is not enough: the switch stays off until the relay
176 // registration succeeds, so tell the user the truth about which happened.
177 if (getAgentAwarenessRegistrationStatus() === "registered") {
178 Alert.alert("Notifications enabled", "Agent notifications are enabled for this device.");
179 } else {
180 Alert.alert(
181 "Couldn't finish enabling notifications",
182 "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.",
183 );
184 }
185 return;
186 }
187 if (result.value.type === "unsupported") {
188 setNotificationStatus("unsupported");
189 Alert.alert(
190 "Notifications unavailable",
191 "Agent notifications are unavailable on this platform.",
192 );
193 return;
194 }
195 setNotificationStatus("disabled");
196 if (result.value.canAskAgain) {
197 Alert.alert("Notifications disabled", "Notifications were not enabled.");
198 return;
199 }
200 Alert.alert(
201 "Notifications disabled",
202 "Notifications were denied for this app. Open Settings to enable them.",
203 [
204 { text: "Cancel", style: "cancel" },
205 { text: "Open Settings", onPress: () => void Linking.openSettings() },
206 ],
207 );
208 }, []);
209
210 const promptSignIn = useCallback(() => {
211 Alert.alert(
212 "Sign in to T3 Connect",
213 "Live Activity updates require T3 Connect so relay can deliver updates to this device.",
214 [
215 { text: "Cancel", style: "cancel" },
216 {
217 text: "Continue",
218 onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }),
219 },
220 ],
221 );
222 }, [navigation]);
223
224 const linkEnvironments = useCallback(async () => {
225 if (!isSignedIn) {
226 promptSignIn();
227 return;
228 }
229
230 setLiveActivityStatus("linking");
231 if (Platform.OS === "android") {
232 const permission = await settleAsyncResult(() =>
233 runtime.runPromiseExit(requestAgentNotificationPermission),
234 );
235 if (permission._tag === "Failure") {
236 setLiveActivityStatus("disabled");
237 const error = squashAtomCommandFailure(permission);
238 Alert.alert(
239 "Ongoing activity unavailable",
240 error instanceof Error ? error.message : "Could not enable agent notifications.",
241 );
242 return;
243 }
244 if (permission.value.type !== "granted") {
245 setLiveActivityStatus("disabled");
246 Alert.alert(
247 "Notification permission needed",
248 "Enable notifications in system Settings to show ongoing agent activity.",
249 [
250 { text: "Cancel", style: "cancel" },
251 { text: "Open Settings", onPress: () => void Linking.openSettings() },
252 ],
253 );
254 return;
255 }
256 setNotificationStatus("enabled");
257 }
258 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
259 if (tokenResult._tag === "Failure") {
260 setLiveActivityStatus("disabled");
261 const error = squashAtomCommandFailure(tokenResult);
262 Alert.alert(
263 Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable",
264 error instanceof Error ? error.message : "Could not enable agent activity updates.",
265 );
266 return;
267 }
268 if (!tokenResult.value) {
269 promptSignIn();
270 setLiveActivityStatus("signed-out");
271 return;
272 }
273
274 const updateResult = await settleAsyncResult(() =>
275 runtime.runPromiseExit(
276 setLiveActivityUpdatesEnabled({
277 canConfigureEnvironment: (environmentId) =>
278 readEnvironmentScope(environmentId, AuthRelayWriteScope),
279 enabled: true,
280 previousEnabled: liveActivitiesPreferenceEnabled,
281 clerkToken: tokenResult.value,
282 connections,
283 }),
284 ),
285 );
286 if (updateResult._tag === "Failure") {
287 setLiveActivityStatus("disabled");
288 if (!isAtomCommandInterrupted(updateResult)) {
289 const error = squashAtomCommandFailure(updateResult);
290 Alert.alert(
291 Platform.OS === "android"
292 ? "Ongoing activity unavailable"
293 : "Live Activities unavailable",
294 error instanceof Error ? error.message : "Could not enable agent activity updates.",
295 );
296 }
297 return;
298 }
299
300 savePreferences({ liveActivitiesEnabled: true });
301 refreshManagedRelayEnvironments();
302 setLiveActivityStatus("enabled");
303 // The environment link can succeed while this device's own registration
304 // (the push-to-start token the relay needs) has not — don't claim Live
305 // Activities are live until the device is actually registered.
306 if (getAgentAwarenessRegistrationStatus() === "registered") {
307 Alert.alert(
308 Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled",
309 environmentCount > 0
310 ? "Agent activity updates are enabled for environments this connection can configure."
311 : "Agent activity updates are enabled. Add an environment to start receiving updates.",
312 );
313 } else {
314 Alert.alert(
315 "Couldn't finish enabling activity updates",
316 "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.",
317 );
318 }
319 }, [
320 connections,
321 environmentCount,
322 getToken,
323 isSignedIn,
324 liveActivitiesPreferenceEnabled,
325 promptSignIn,
326 savePreferences,
327 ]);
328
329 const handleDeviceNotificationsChange = useCallback(
330 (enabled: boolean) => {
331 if (enabled) {
332 if (!isSignedIn) {
333 promptSignIn();
334 return;
335 }
336 void requestNotifications();
337 return;
338 }
339
340 Alert.alert(
341 "Disable notifications",
342 "Open system Settings to disable notifications for The Knife.",
343 [
344 { text: "Cancel", style: "cancel" },
345 { text: "Open Settings", onPress: () => void Linking.openSettings() },
346 ],
347 );
348 },
349 [isSignedIn, promptSignIn, requestNotifications],
350 );
351
352 const handleLiveActivitiesChange = useCallback(
353 (enabled: boolean) => {
354 if (liveActivityWriteInFlight.current) return;
355 if (!enabled) {
356 liveActivityWriteInFlight.current = true;
357 setLiveActivityStatus("linking");
358 void (async () => {
359 try {
360 let token: string | null = null;
361 if (isSignedIn) {
362 const tokenResult = await settlePromise(() =>
363 getToken(resolveRelayClerkTokenOptions()),
364 );
365 if (tokenResult._tag === "Failure") {
366 setLiveActivityStatus("enabled");
367 reportAtomCommandResult(tokenResult, {
368 label: "live activity disable token lookup",
369 });
370 return;
371 }
372 token = tokenResult.value;
373 }
374
375 const updateResult = await settleAsyncResult(() =>
376 runtime.runPromiseExit(
377 setLiveActivityUpdatesEnabled({
378 canConfigureEnvironment: (environmentId) =>
379 readEnvironmentScope(environmentId, AuthRelayWriteScope),
380 enabled: false,
381 previousEnabled: liveActivitiesPreferenceEnabled,
382 clerkToken: token,
383 connections,
384 }),
385 ),
386 );
387 if (updateResult._tag === "Failure") {
388 setLiveActivityStatus(isSignedIn ? "enabled" : "signed-out");
389 reportAtomCommandResult(updateResult, {
390 label: "live activity disable",
391 });
392 return;
393 }
394 savePreferences({ liveActivitiesEnabled: false });
395 refreshManagedRelayEnvironments();
396 setLiveActivityStatus("disabled");
397 } finally {
398 liveActivityWriteInFlight.current = false;
399 }
400 })();
401 return;
402 }
403
404 if (!isSignedIn) {
405 promptSignIn();
406 return;
407 }
408
409 liveActivityWriteInFlight.current = true;
410 void linkEnvironments().finally(() => {
411 liveActivityWriteInFlight.current = false;
412 });
413 },
414 [
415 connections,
416 getToken,
417 isSignedIn,
418 linkEnvironments,
419 liveActivitiesPreferenceEnabled,
420 promptSignIn,
421 savePreferences,
422 ],
423 );
424
425 return (
426 <SettingsScreen title="Notifications">
427 <ScrollView
428 contentInsetAdjustmentBehavior="automatic"
429 showsVerticalScrollIndicator={false}
430 className="flex-1"
431 contentContainerClassName="gap-6 px-5 pt-4"
432 contentContainerStyle={{
433 paddingBottom: Math.max(insets.bottom, 18) + 18,
434 }}
435 >
436 <SettingsSection title="Agent activity">
437 <SettingsSwitchRow
438 icon="bell.badge"
439 label="Device Notifications"
440 disabled={
441 !agentAwarenessPlatform.supported ||
442 !agentAwarenessPushAvailable ||
443 notificationStatus === "checking" ||
444 notificationStatus === "unsupported"
445 }
446 subtitle={agentAwarenessSubtitle}
447 // Only reads as on when this device is actually registered with the
448 // relay; otherwise notifications cannot be delivered regardless of
449 // the local iOS permission.
450 value={
451 agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered
452 }
453 onValueChange={handleDeviceNotificationsChange}
454 />
455 <SettingsSwitchRow
456 disabled={
457 !agentAwarenessPlatform.supported ||
458 !agentAwarenessPushAvailable ||
459 !isLoaded ||
460 liveActivityStatus === "checking" ||
461 liveActivityStatus === "linking"
462 }
463 icon="bolt.circle"
464 label={
465 Platform.OS === "android"
466 ? supportsAndroidLiveUpdateSettings()
467 ? "Agent Live Updates"
468 : "Ongoing Agent Activity"
469 : "Live Activity Updates"
470 }
471 subtitle={agentAwarenessSubtitle}
472 // Same gate: a saved preference is meaningless until the device
473 // registration the relay needs to push updates has succeeded.
474 value={
475 agentAwarenessPushAvailable &&
476 (liveActivityStatus === "enabled" || liveActivityStatus === "linking") &&
477 deviceRegistered
478 }
479 onValueChange={handleLiveActivitiesChange}
480 />
481 {liveActivityStatus === "signed-out" && canClearLiveActivitiesPreference ? (
482 <SettingsRow
483 icon="bolt.circle"
484 label="Turn off Live Activity preference"
485 onPress={() => handleLiveActivitiesChange(false)}
486 />
487 ) : null}
488 {supportsAndroidLiveUpdateSettings() ? (
489 <SettingsRow
490 icon="bolt.circle"
491 label="Live Update Settings"
492 onPress={() => {
493 void openAndroidLiveUpdateSettings().catch(() => {
494 Alert.alert(
495 "Couldn't open Settings",
496 "Open Android Settings, select The Knife, then enable Live Updates in Notifications.",
497 );
498 });
499 }}
500 />
501 ) : null}
502 </SettingsSection>
503 </ScrollView>
504 </SettingsScreen>
505 );
506}