authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-17 20:19:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 20:19:56-07:00
log767a5dbd00d0a2050745a2ae2e29050c5282b8c0
tree1411c568c2f3f04d2295088a060d2a02853db1c7
parent8eb1f6e74869130b7602e0c6b0ab56b7805c0889
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): share thread list section controls (#12363)


3 files changed, 108 insertions(+), 89 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+5-12
...@@ -23,12 +23,11 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";...@@ -23,12 +23,11 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
23import { AsyncResult } from "effect/unstable/reactivity";23import { AsyncResult } from "effect/unstable/reactivity";
24import { useFocusEffect } from "@react-navigation/native";24import { useFocusEffect } from "@react-navigation/native";
25import { useCallback, useEffect, useMemo, useRef, useState } from "react";25import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";26import { ActivityIndicator, FlatList, Platform, View } from "react-native";
27import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";27import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
28import { useSafeAreaInsets } from "react-native-safe-area-context";28import { useSafeAreaInsets } from "react-native-safe-area-context";
2929
30import { cn } from "../../lib/cn";30import { cn } from "../../lib/cn";
31import { AppText as Text } from "../../components/AppText";
32import { EmptyState } from "../../components/EmptyState";31import { EmptyState } from "../../components/EmptyState";
33import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";32import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
34import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";33import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
...@@ -53,6 +52,7 @@ import {...@@ -53,6 +52,7 @@ import {
53 ThreadListV2PendingRow,52 ThreadListV2PendingRow,
54 ThreadListV2Row,53 ThreadListV2Row,
55 ThreadListV2SettledShelfHeader,54 ThreadListV2SettledShelfHeader,
55 ThreadListV2ShowMoreRow,
56 ThreadListV2SnoozedShelfHeader,56 ThreadListV2SnoozedShelfHeader,
57} from "../threads/thread-list-v2-items";57} from "../threads/thread-list-v2-items";
58import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";58import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
...@@ -1234,17 +1234,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1234,17 +1234,10 @@ export function HomeScreen(props: HomeScreenProps) {
1234 ListHeaderComponent={v2ListHeader}1234 ListHeaderComponent={v2ListHeader}
1235 ListFooterComponent={1235 ListFooterComponent={
1236 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (1236 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
1237 <Pressable1237 <ThreadListV2ShowMoreRow
1238 accessibilityRole="button"1238 hiddenCount={threadListV2Layout.hiddenSettledCount}
1239 accessibilityLabel={`Show ${Math.min(threadListV2Layout.hiddenSettledCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
1240 onPress={showMoreSettled}1239 onPress={showMoreSettled}
1241 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"1240 />
1242 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
1243 >
1244 <Text className="text-xs font-t3-medium text-foreground-muted">
1245 Show more ({threadListV2Layout.hiddenSettledCount} settled hidden)
1246 </Text>
1247 </Pressable>
1248 ) : null1241 ) : null
1249 }1242 }
1250 ListEmptyComponent={v2ListEmpty}1243 ListEmptyComponent={v2ListEmpty}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+3-12
...@@ -14,7 +14,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -14,7 +14,7 @@ import { useAtomValue } from "@effect/atom-react";
14import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";14import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
15import { useCallback, useEffect, useMemo, useRef, useState } from "react";15import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16import type { LayoutChangeEvent } from "react-native";16import type { LayoutChangeEvent } from "react-native";
17import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";17import { Platform, StyleSheet, TextInput, View } from "react-native";
18import { Gesture, GestureDetector } from "react-native-gesture-handler";18import { Gesture, GestureDetector } from "react-native-gesture-handler";
19import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";19import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
20import { useSafeAreaInsets } from "react-native-safe-area-context";20import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -81,6 +81,7 @@ import {...@@ -81,6 +81,7 @@ import {
81 ThreadListV2PendingRow,81 ThreadListV2PendingRow,
82 ThreadListV2Row,82 ThreadListV2Row,
83 ThreadListV2SettledShelfHeader,83 ThreadListV2SettledShelfHeader,
84 ThreadListV2ShowMoreRow,
84 ThreadListV2SnoozedShelfHeader,85 ThreadListV2SnoozedShelfHeader,
85} from "./thread-list-v2-items";86} from "./thread-list-v2-items";
86import { resolveThreadProviderInstance } from "./thread-provider-instance";87import { resolveThreadProviderInstance } from "./thread-provider-instance";
...@@ -990,17 +991,7 @@ function ThreadNavigationSidebarPane(...@@ -990,17 +991,7 @@ function ThreadNavigationSidebarPane(
990 );991 );
991 case "v2-show-more":992 case "v2-show-more":
992 return (993 return (
993 <Pressable994 <ThreadListV2ShowMoreRow hiddenCount={item.hiddenCount} onPress={showMoreSettled} />
994 accessibilityRole="button"
995 accessibilityLabel={`Show ${Math.min(item.hiddenCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
996 onPress={showMoreSettled}
997 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
998 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
999 >
1000 <Text className="text-xs font-t3-medium text-foreground-muted">
1001 Show more ({item.hiddenCount} settled hidden)
1002 </Text>
1003 </Pressable>
1004 );995 );
1005 case "header":996 case "header":
1006 return (997 return (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+100-65
...@@ -31,6 +31,7 @@ import { useThreadPr } from "../../state/use-thread-pr";...@@ -31,6 +31,7 @@ import { useThreadPr } from "../../state/use-thread-pr";
31import { ThreadSwipeable } from "../home/thread-swipe-actions";31import { ThreadSwipeable } from "../home/thread-swipe-actions";
32import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";32import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
33import {33import {
34 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
34 resolveThreadListV2SnoozeMenuSelection,35 resolveThreadListV2SnoozeMenuSelection,
35 resolveThreadListV2SnoozeGateExpiryMs,36 resolveThreadListV2SnoozeGateExpiryMs,
36 resolveThreadListV2Status,37 resolveThreadListV2Status,
...@@ -95,96 +96,130 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [...@@ -95,96 +96,130 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [
95/** Rounded-row radius shared with the v1 sidebar rows. */96/** Rounded-row radius shared with the v1 sidebar rows. */
96const SIDEBAR_V2_ROW_RADIUS = 12;97const SIDEBAR_V2_ROW_RADIUS = 12;
9798
98/** Section label + rule: the only structure in an otherwise flat list. */99function ThreadListV2Section(props: {
99export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: {
100 readonly label: string;100 readonly label: string;
101 readonly pane?: "screen" | "sidebar";101 readonly pane?: "screen" | "sidebar";
102 readonly tone?: "default" | "snoozed";
103 readonly disclosure?: {
104 readonly expanded: boolean;
105 readonly disabled?: boolean;
106 readonly onToggle: () => void;
107 readonly accessibilityLabel: string;
108 readonly accessibilityHint: string;
109 };
102}) {110}) {
103 return (111 const snoozed = props.tone === "snoozed";
104 <View112 const className = cn(
105 className={cn(113 "mb-1.5 mt-4 flex-row items-center gap-2.5",
106 "mb-1.5 mt-4 flex-row items-center gap-2.5",114 props.pane === "sidebar" ? "px-3" : "px-5",
107 props.pane === "sidebar" ? "px-3" : "px-5",115 );
108 )}116 const content = (
109 >117 <>
110 <Text className="text-xs font-t3-medium text-foreground-tertiary">{props.label}</Text>118 <Text
111 <View className="h-px flex-1 bg-border" />119 className={cn(
112 </View>120 "text-xs font-t3-medium",
121 snoozed ? "text-foreground-secondary" : "text-foreground-tertiary",
122 )}
123 >
124 {props.label}
125 </Text>
126 <View className={cn("h-px flex-1", snoozed ? "bg-primary/20" : "bg-border")} />
127 {props.disclosure ? (
128 <SymbolView
129 name="chevron.down"
130 size={10}
131 tintColorClassName={snoozed ? "accent-icon-muted" : "accent-foreground-muted"}
132 type="monochrome"
133 style={{ transform: [{ rotate: props.disclosure.expanded ? "180deg" : "0deg" }] }}
134 />
135 ) : null}
136 </>
113 );137 );
114});
115138
116export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {139 return props.disclosure ? (
117 readonly count: number;
118 readonly disabled?: boolean;
119 readonly expanded: boolean;
120 readonly onToggle: () => void;
121 readonly pane?: "screen" | "sidebar";
122}) {
123 return (
124 <Pressable140 <Pressable
125 accessibilityHint={141 accessibilityHint={props.disclosure.accessibilityHint}
126 props.expanded ? "Collapses the snoozed threads." : "Expands the snoozed threads."142 accessibilityLabel={props.disclosure.accessibilityLabel}
127 }
128 accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}
129 accessibilityRole="button"143 accessibilityRole="button"
130 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}144 accessibilityState={{
131 className={cn(145 disabled: props.disclosure.disabled,
132 "mb-1.5 mt-4 flex-row items-center gap-2.5",146 expanded: props.disclosure.expanded,
133 props.pane === "sidebar" ? "px-3" : "px-5",147 }}
134 )}148 className={className}
135 disabled={props.disabled}149 disabled={props.disclosure.disabled}
136 onPress={props.onToggle}150 onPress={props.disclosure.onToggle}
137 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}151 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
138 >152 >
139 <Text className="text-xs font-t3-medium text-foreground-secondary">153 {content}
140 {props.expanded ? "Snoozed" : `Snoozed (${props.count})`}
141 </Text>
142 <View className="h-px flex-1 bg-primary/20" />
143 <SymbolView
144 name="chevron.down"
145 size={10}
146 tintColorClassName="accent-icon-muted"
147 type="monochrome"
148 style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}
149 />
150 </Pressable>154 </Pressable>
155 ) : (
156 <View className={className}>{content}</View>
151 );157 );
158}
159
160/** Section label + rule: the only structure in an otherwise flat list. */
161export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: {
162 readonly label: string;
163 readonly pane?: "screen" | "sidebar";
164}) {
165 return <ThreadListV2Section {...props} />;
152});166});
153167
154export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {168type ThreadListV2ShelfHeaderProps = {
155 readonly count: number;169 readonly count: number;
156 readonly disabled?: boolean;170 readonly disabled?: boolean;
157 readonly expanded: boolean;171 readonly expanded: boolean;
158 readonly onToggle: () => void;172 readonly onToggle: () => void;
159 readonly pane?: "screen" | "sidebar";173 readonly pane?: "screen" | "sidebar";
174};
175
176function ThreadListV2ShelfHeader(
177 props: ThreadListV2ShelfHeaderProps & { readonly kind: "snoozed" | "settled" },
178) {
179 const label = props.kind === "snoozed" ? "Snoozed" : "Settled";
180 return (
181 <ThreadListV2Section
182 label={props.expanded ? label : `${label} (${props.count})`}
183 pane={props.pane}
184 tone={props.kind === "snoozed" ? "snoozed" : "default"}
185 disclosure={{
186 expanded: props.expanded,
187 disabled: props.disabled,
188 onToggle: props.onToggle,
189 accessibilityLabel: `${props.count} ${props.kind} ${props.count === 1 ? "thread" : "threads"}`,
190 accessibilityHint: `${props.expanded ? "Collapses" : "Expands"} the ${props.kind} threads.`,
191 }}
192 />
193 );
194}
195
196export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(
197 props: ThreadListV2ShelfHeaderProps,
198) {
199 return <ThreadListV2ShelfHeader {...props} kind="snoozed" />;
200});
201
202export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(
203 props: ThreadListV2ShelfHeaderProps,
204) {
205 return <ThreadListV2ShelfHeader {...props} kind="settled" />;
206});
207
208export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: {
209 readonly hiddenCount: number;
210 readonly onPress: () => void;
160}) {211}) {
161 return (212 return (
162 <Pressable213 <Pressable
163 accessibilityHint={
164 props.expanded ? "Collapses the settled threads." : "Expands the settled threads."
165 }
166 accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}
167 accessibilityRole="button"214 accessibilityRole="button"
168 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}215 accessibilityLabel={`Show ${Math.min(props.hiddenCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
169 className={cn(216 onPress={props.onPress}
170 "mb-1.5 mt-4 flex-row items-center gap-2.5",217 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
171 props.pane === "sidebar" ? "px-3" : "px-5",
172 )}
173 disabled={props.disabled}
174 onPress={props.onToggle}
175 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}218 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
176 >219 >
177 <Text className="text-xs font-t3-medium text-foreground-tertiary">220 <Text className="text-xs font-t3-medium text-foreground-muted">
178 {props.expanded ? "Settled" : `Settled (${props.count})`}221 Show more ({props.hiddenCount} settled hidden)
179 </Text>222 </Text>
180 <View className="h-px flex-1 bg-border" />
181 <SymbolView
182 name="chevron.down"
183 size={10}
184 tintColorClassName="accent-foreground-muted"
185 type="monochrome"
186 style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}
187 />
188 </Pressable>223 </Pressable>
189 );224 );
190});225});