Skip to content

Commit f69cc4f

Browse files
committed
fix(mobile): make shelf preference toggles race-safe
1 parent 37ed440 commit f69cc4f

4 files changed

Lines changed: 74 additions & 47 deletions

File tree

apps/mobile/src/features/home/HomeScreen.tsx

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
5656
type ThreadListV2ListItem,
5757
} from "../threads/threadListV2";
58+
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
5859
import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
5960
import {
6061
buildHomeListLayout,
@@ -555,26 +556,13 @@ export function HomeScreen(props: HomeScreenProps) {
555556
() => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
556557
[],
557558
);
558-
const snoozedShelfExpanded =
559-
AsyncResult.isSuccess(preferencesResult) &&
560-
preferencesResult.value.threadListV2SnoozedShelfExpanded === true;
561-
const toggleSnoozedShelf = useCallback(
562-
() =>
563-
savePreferences({
564-
threadListV2SnoozedShelfExpanded: !snoozedShelfExpanded,
565-
}),
566-
[savePreferences, snoozedShelfExpanded],
567-
);
568-
const settledShelfExpanded =
569-
!AsyncResult.isSuccess(preferencesResult) ||
570-
preferencesResult.value.threadListV2SettledShelfExpanded !== false;
571-
const toggleSettledShelf = useCallback(
572-
() =>
573-
savePreferences({
574-
threadListV2SettledShelfExpanded: !settledShelfExpanded,
575-
}),
576-
[savePreferences, settledShelfExpanded],
577-
);
559+
const {
560+
loaded: shelfPreferencesLoaded,
561+
settledShelfExpanded,
562+
snoozedShelfExpanded,
563+
toggleSettledShelf,
564+
toggleSnoozedShelf,
565+
} = useThreadListV2ShelfPreferences();
578566
// now is quantized to the minute and ticks so the inactivity auto-settle
579567
// boundary is actually crossed while the app stays open (mirrors web);
580568
// without a clock dependency the partition memoizes a frozen "now".
@@ -773,6 +761,7 @@ export function HomeScreen(props: HomeScreenProps) {
773761
return (
774762
<ThreadListV2SnoozedShelfHeader
775763
count={item.count}
764+
disabled={!shelfPreferencesLoaded}
776765
expanded={item.expanded}
777766
onToggle={toggleSnoozedShelf}
778767
/>
@@ -782,6 +771,7 @@ export function HomeScreen(props: HomeScreenProps) {
782771
return (
783772
<ThreadListV2SettledShelfHeader
784773
count={item.count}
774+
disabled={!shelfPreferencesLoaded}
785775
expanded={item.expanded}
786776
onToggle={toggleSettledShelf}
787777
/>
@@ -875,6 +865,7 @@ export function HomeScreen(props: HomeScreenProps) {
875865
props.onSelectThread,
876866
props.savedConnectionsById,
877867
serverConfigs,
868+
shelfPreferencesLoaded,
878869
settlementEnvironmentIds,
879870
snoozeEnvironmentIds,
880871
threadListV2Items,

apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ import {
99
} from "@t3tools/client-runtime/state/thread-search";
1010
import { LegendList } from "@legendapp/list/react-native";
1111
import type { MenuAction } from "@react-native-menu/menu";
12-
import { useAtomSet, useAtomValue } from "@effect/atom-react";
13-
import { AsyncResult } from "effect/unstable/reactivity";
12+
import { useAtomValue } from "@effect/atom-react";
1413
import type { EnvironmentId } from "@t3tools/contracts";
1514
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1615
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
@@ -30,9 +29,9 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
3029
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3130
import { useThemeColor } from "../../lib/useThemeColor";
3231
import { useProjects, useThreadShells } from "../../state/entities";
33-
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3432
import { useThreadSearch } from "../../state/queries";
3533
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
34+
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3635
import { environmentServerConfigsAtom } from "../../state/server";
3736
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
3837
import { useWorkspaceState } from "../../state/workspace";
@@ -191,8 +190,6 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
191190
function ThreadNavigationSidebarPane(
192191
props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
193192
) {
194-
const preferencesResult = useAtomValue(mobilePreferencesAtom);
195-
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
196193
const insets = useSafeAreaInsets();
197194
const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
198195
const projects = useProjects();
@@ -449,26 +446,13 @@ function ThreadNavigationSidebarPane(
449446
() => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
450447
[],
451448
);
452-
const snoozedShelfExpanded =
453-
AsyncResult.isSuccess(preferencesResult) &&
454-
preferencesResult.value.threadListV2SnoozedShelfExpanded === true;
455-
const toggleSnoozedShelf = useCallback(
456-
() =>
457-
savePreferences({
458-
threadListV2SnoozedShelfExpanded: !snoozedShelfExpanded,
459-
}),
460-
[savePreferences, snoozedShelfExpanded],
461-
);
462-
const settledShelfExpanded =
463-
!AsyncResult.isSuccess(preferencesResult) ||
464-
preferencesResult.value.threadListV2SettledShelfExpanded !== false;
465-
const toggleSettledShelf = useCallback(
466-
() =>
467-
savePreferences({
468-
threadListV2SettledShelfExpanded: !settledShelfExpanded,
469-
}),
470-
[savePreferences, settledShelfExpanded],
471-
);
449+
const {
450+
loaded: shelfPreferencesLoaded,
451+
settledShelfExpanded,
452+
snoozedShelfExpanded,
453+
toggleSettledShelf,
454+
toggleSnoozedShelf,
455+
} = useThreadListV2ShelfPreferences();
472456
// now ticks per minute so the inactivity auto-settle boundary is actually
473457
// crossed while the pane stays open; without a clock dependency the
474458
// partition memoizes a frozen "now".
@@ -1000,6 +984,7 @@ function ThreadNavigationSidebarPane(
1000984
return (
1001985
<ThreadListV2SnoozedShelfHeader
1002986
count={item.count}
987+
disabled={!shelfPreferencesLoaded}
1003988
expanded={item.expanded}
1004989
onToggle={toggleSnoozedShelf}
1005990
pane="sidebar"
@@ -1009,6 +994,7 @@ function ThreadNavigationSidebarPane(
1009994
return (
1010995
<ThreadListV2SettledShelfHeader
1011996
count={item.count}
997+
disabled={!shelfPreferencesLoaded}
1012998
expanded={item.expanded}
1013999
onToggle={toggleSettledShelf}
10141000
pane="sidebar"
@@ -1129,6 +1115,7 @@ function ThreadNavigationSidebarPane(
11291115
props.width,
11301116
savedConnectionsById,
11311117
serverConfigs,
1118+
shelfPreferencesLoaded,
11321119
threadSearchMatchByKey,
11331120
settleThread,
11341121
settlementEnvironmentIds,

apps/mobile/src/features/threads/thread-list-v2-items.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa";
115115

116116
export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {
117117
readonly count: number;
118+
readonly disabled?: boolean;
118119
readonly expanded: boolean;
119120
readonly onToggle: () => void;
120121
readonly pane?: "screen" | "sidebar";
@@ -127,11 +128,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
127128
}
128129
accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}
129130
accessibilityRole="button"
130-
accessibilityState={{ expanded: props.expanded }}
131+
accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
131132
className={cn(
132133
"mb-1.5 mt-4 flex-row items-center gap-2.5",
133134
props.pane === "sidebar" ? "px-3" : "px-5",
134135
)}
136+
disabled={props.disabled}
135137
onPress={props.onToggle}
136138
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
137139
>
@@ -151,6 +153,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
151153

152154
export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {
153155
readonly count: number;
156+
readonly disabled?: boolean;
154157
readonly expanded: boolean;
155158
readonly onToggle: () => void;
156159
readonly pane?: "screen" | "sidebar";
@@ -163,11 +166,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
163166
}
164167
accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}
165168
accessibilityRole="button"
166-
accessibilityState={{ expanded: props.expanded }}
169+
accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
167170
className={cn(
168171
"mb-1.5 mt-4 flex-row items-center gap-2.5",
169172
props.pane === "sidebar" ? "px-3" : "px-5",
170173
)}
174+
disabled={props.disabled}
171175
onPress={props.onToggle}
172176
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
173177
>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useAtomSet, useAtomValue } from "@effect/atom-react";
2+
import { AsyncResult } from "effect/unstable/reactivity";
3+
import { useCallback, useRef } from "react";
4+
5+
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
6+
7+
/**
8+
* Shared persisted shelf state for the compact Home list and iPad sidebar.
9+
* Refs advance before persistence starts so consecutive presses always toggle
10+
* the latest value, even if React has not rendered the optimistic patch yet.
11+
*/
12+
export function useThreadListV2ShelfPreferences() {
13+
const preferencesResult = useAtomValue(mobilePreferencesAtom);
14+
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
15+
const loaded = AsyncResult.isSuccess(preferencesResult);
16+
const snoozedShelfExpanded =
17+
loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true;
18+
const settledShelfExpanded =
19+
!loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false;
20+
const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded);
21+
const settledShelfExpandedRef = useRef(settledShelfExpanded);
22+
snoozedShelfExpandedRef.current = snoozedShelfExpanded;
23+
settledShelfExpandedRef.current = settledShelfExpanded;
24+
25+
const toggleSnoozedShelf = useCallback(() => {
26+
if (!loaded) return;
27+
const expanded = !snoozedShelfExpandedRef.current;
28+
snoozedShelfExpandedRef.current = expanded;
29+
savePreferences({ threadListV2SnoozedShelfExpanded: expanded });
30+
}, [loaded, savePreferences]);
31+
const toggleSettledShelf = useCallback(() => {
32+
if (!loaded) return;
33+
const expanded = !settledShelfExpandedRef.current;
34+
settledShelfExpandedRef.current = expanded;
35+
savePreferences({ threadListV2SettledShelfExpanded: expanded });
36+
}, [loaded, savePreferences]);
37+
38+
return {
39+
loaded,
40+
settledShelfExpanded,
41+
snoozedShelfExpanded,
42+
toggleSettledShelf,
43+
toggleSnoozedShelf,
44+
} as const;
45+
}

0 commit comments

Comments
 (0)