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";
2323import { AsyncResult } from "effect/unstable/reactivity";
2424import { useFocusEffect } from "@react-navigation/native";
2525import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
26import { ActivityIndicator, FlatList, Platform, View } from "react-native";
2727import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2828import { useSafeAreaInsets } from "react-native-safe-area-context";
2929
3030import { cn } from "../../lib/cn";
31import { AppText as Text } from "../../components/AppText";
3231import { EmptyState } from "../../components/EmptyState";
3332import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
3433import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
......@@ -53,6 +52,7 @@ import {
5352 ThreadListV2PendingRow,
5453 ThreadListV2Row,
5554 ThreadListV2SettledShelfHeader,
55 ThreadListV2ShowMoreRow,
5656 ThreadListV2SnoozedShelfHeader,
5757} from "../threads/thread-list-v2-items";
5858import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
......@@ -1234,17 +1234,10 @@ export function HomeScreen(props: HomeScreenProps) {
12341234 ListHeaderComponent={v2ListHeader}
12351235 ListFooterComponent={
12361236 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
1237 <Pressable
1238 accessibilityRole="button"
1239 accessibilityLabel={`Show ${Math.min(threadListV2Layout.hiddenSettledCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
1237 <ThreadListV2ShowMoreRow
1238 hiddenCount={threadListV2Layout.hiddenSettledCount}
12401239 onPress={showMoreSettled}
1241 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
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>
1240 />
12481241 ) : null
12491242 }
12501243 ListEmptyComponent={v2ListEmpty}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+3-12
......@@ -14,7 +14,7 @@ import { useAtomValue } from "@effect/atom-react";
1414import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
1515import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1616import type { LayoutChangeEvent } from "react-native";
17import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
17import { Platform, StyleSheet, TextInput, View } from "react-native";
1818import { Gesture, GestureDetector } from "react-native-gesture-handler";
1919import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2020import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -81,6 +81,7 @@ import {
8181 ThreadListV2PendingRow,
8282 ThreadListV2Row,
8383 ThreadListV2SettledShelfHeader,
84 ThreadListV2ShowMoreRow,
8485 ThreadListV2SnoozedShelfHeader,
8586} from "./thread-list-v2-items";
8687import { resolveThreadProviderInstance } from "./thread-provider-instance";
......@@ -990,17 +991,7 @@ function ThreadNavigationSidebarPane(
990991 );
991992 case "v2-show-more":
992993 return (
993 <Pressable
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>
994 <ThreadListV2ShowMoreRow hiddenCount={item.hiddenCount} onPress={showMoreSettled} />
1004995 );
1005996 case "header":
1006997 return (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+100-65
......@@ -31,6 +31,7 @@ import { useThreadPr } from "../../state/use-thread-pr";
3131import { ThreadSwipeable } from "../home/thread-swipe-actions";
3232import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
3333import {
34 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
3435 resolveThreadListV2SnoozeMenuSelection,
3536 resolveThreadListV2SnoozeGateExpiryMs,
3637 resolveThreadListV2Status,
......@@ -95,96 +96,130 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [
9596/** Rounded-row radius shared with the v1 sidebar rows. */
9697const SIDEBAR_V2_ROW_RADIUS = 12;
9798
98/** Section label + rule: the only structure in an otherwise flat list. */
99export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: {
99function ThreadListV2Section(props: {
100100 readonly label: string;
101101 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 };
102110}) {
103 return (
104 <View
105 className={cn(
106 "mb-1.5 mt-4 flex-row items-center gap-2.5",
107 props.pane === "sidebar" ? "px-3" : "px-5",
108 )}
109 >
110 <Text className="text-xs font-t3-medium text-foreground-tertiary">{props.label}</Text>
111 <View className="h-px flex-1 bg-border" />
112 </View>
111 const snoozed = props.tone === "snoozed";
112 const className = cn(
113 "mb-1.5 mt-4 flex-row items-center gap-2.5",
114 props.pane === "sidebar" ? "px-3" : "px-5",
115 );
116 const content = (
117 <>
118 <Text
119 className={cn(
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 </>
113137 );
114});
115138
116export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {
117 readonly count: number;
118 readonly disabled?: boolean;
119 readonly expanded: boolean;
120 readonly onToggle: () => void;
121 readonly pane?: "screen" | "sidebar";
122}) {
123 return (
139 return props.disclosure ? (
124140 <Pressable
125 accessibilityHint={
126 props.expanded ? "Collapses the snoozed threads." : "Expands the snoozed threads."
127 }
128 accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}
141 accessibilityHint={props.disclosure.accessibilityHint}
142 accessibilityLabel={props.disclosure.accessibilityLabel}
129143 accessibilityRole="button"
130 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
131 className={cn(
132 "mb-1.5 mt-4 flex-row items-center gap-2.5",
133 props.pane === "sidebar" ? "px-3" : "px-5",
134 )}
135 disabled={props.disabled}
136 onPress={props.onToggle}
144 accessibilityState={{
145 disabled: props.disclosure.disabled,
146 expanded: props.disclosure.expanded,
147 }}
148 className={className}
149 disabled={props.disclosure.disabled}
150 onPress={props.disclosure.onToggle}
137151 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
138152 >
139 <Text className="text-xs font-t3-medium text-foreground-secondary">
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 />
153 {content}
150154 </Pressable>
155 ) : (
156 <View className={className}>{content}</View>
151157 );
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} />;
152166});
153167
154export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {
168type ThreadListV2ShelfHeaderProps = {
155169 readonly count: number;
156170 readonly disabled?: boolean;
157171 readonly expanded: boolean;
158172 readonly onToggle: () => void;
159173 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;
160211}) {
161212 return (
162213 <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`}
167214 accessibilityRole="button"
168 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
169 className={cn(
170 "mb-1.5 mt-4 flex-row items-center gap-2.5",
171 props.pane === "sidebar" ? "px-3" : "px-5",
172 )}
173 disabled={props.disabled}
174 onPress={props.onToggle}
215 accessibilityLabel={`Show ${Math.min(props.hiddenCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
216 onPress={props.onPress}
217 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
175218 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
176219 >
177 <Text className="text-xs font-t3-medium text-foreground-tertiary">
178 {props.expanded ? "Settled" : `Settled (${props.count})`}
220 <Text className="text-xs font-t3-medium text-foreground-muted">
221 Show more ({props.hiddenCount} settled hidden)
179222 </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 />
188223 </Pressable>
189224 );
190225});