1import { useSyncExternalStore } from "react";
2import { AccessibilityInfo, AppState } from "react-native";
3
4type Listener = () => void;
5
6export function subscribeAppState(onChange: Listener): () => void {
7 const subscription = AppState.addEventListener("change", () => onChange());
8 return () => subscription.remove();
9}
10
11export function isAppActive(): boolean {
12 return AppState.currentState === "active";
13}
14
15/** Runs `onActive` each time the app returns to the foreground. */
16export function subscribeAppActive(onActive: Listener): () => void {
17 const subscription = AppState.addEventListener("change", (state) => {
18 if (state === "active") onActive();
19 });
20 return () => subscription.remove();
21}
22
23export function useAppActive(): boolean {
24 return useSyncExternalStore(subscribeAppState, isAppActive);
25}
26
27function isAppForeground(): boolean {
28 return AppState.currentState !== "background";
29}
30
31/**
32 * True unless the app is backgrounded. Unlike useAppActive, the transient
33 * "inactive" state (Control Center, the app switcher, a system prompt) still
34 * counts as foreground, so live streams do not reconnect around it.
35 */
36export function useAppForeground(): boolean {
37 return useSyncExternalStore(subscribeAppState, isAppForeground);
38}
39
40// Null until the native seed resolves; read as reduced so animated consumers
41// never start motion a reduce-motion user has turned off.
42let reduceMotionEnabled: boolean | null = null;
43const reduceMotionListeners = new Set<Listener>();
44let reduceMotionSubscription: { remove: () => void } | null = null;
45
46function setReduceMotionEnabled(next: boolean) {
47 if (next === reduceMotionEnabled) return;
48 reduceMotionEnabled = next;
49 for (const listener of reduceMotionListeners) listener();
50}
51
52export function subscribeReduceMotion(onChange: Listener): () => void {
53 reduceMotionListeners.add(onChange);
54 if (reduceMotionSubscription === null) {
55 const subscription = AccessibilityInfo.addEventListener(
56 "reduceMotionChanged",
57 setReduceMotionEnabled,
58 );
59 reduceMotionSubscription = subscription;
60 // The native event only reports changes; seed the current value once per
61 // observation, and drop a seed that resolves after observation stopped.
62 void AccessibilityInfo.isReduceMotionEnabled().then(
63 (enabled) => {
64 if (reduceMotionSubscription === subscription) setReduceMotionEnabled(enabled);
65 },
66 () => undefined,
67 );
68 }
69 return () => {
70 reduceMotionListeners.delete(onChange);
71 if (reduceMotionListeners.size === 0) {
72 reduceMotionSubscription?.remove();
73 reduceMotionSubscription = null;
74 // Changes made while nobody observed were never reported, so the cached
75 // value can be stale; the next subscriber reseeds from native.
76 reduceMotionEnabled = null;
77 }
78 };
79}
80
81export function readReduceMotionEnabled(): boolean {
82 return reduceMotionEnabled ?? true;
83}
84
85export function useReduceMotionEnabled(): boolean {
86 return useSyncExternalStore(subscribeReduceMotion, readReduceMotionEnabled);
87}