authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 20:27:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 20:27:39-07:00
logc32ade41390be10783246fbbeed45de7e3add79b
tree0d5f8b3391f6e3268efe979ec5952f88859cae1f
parentb9ad3daf8d489604e0eca9d32a7e08af7c4e9fb7
signature Signed by PGP key B5690EEEBB952194

feat(mobile): stock tab bar, glass filter pills, and saved list filters (#18476)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

21 files changed, 326 insertions(+), 144 deletions(-)

apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz deleted
Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz and /dev/null differ
apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz created
Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz differ
apps/mobile/package.json+1-1
...@@ -126,7 +126,7 @@...@@ -126,7 +126,7 @@
126 "react-native-keyboard-controller": "1.22.6",126 "react-native-keyboard-controller": "1.22.6",
127 "react-native-reanimated": "4.7.0",127 "react-native-reanimated": "4.7.0",
128 "react-native-safe-area-context": "~5.9.1",128 "react-native-safe-area-context": "~5.9.1",
129 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.25.tgz",129 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.26.tgz",
130 "react-native-shiki-engine": "^0.3.12",130 "react-native-shiki-engine": "^0.3.12",
131 "react-native-svg": "15.15.5",131 "react-native-svg": "15.15.5",
132 "react-native-webview": "14.0.1",132 "react-native-webview": "14.0.1",
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-12
...@@ -127,15 +127,15 @@ export function HomeRouteScreen() {...@@ -127,15 +127,15 @@ export function HomeRouteScreen() {
127 } = useHomeListOptions(availableEnvironmentIds);127 } = useHomeListOptions(availableEnvironmentIds);
128 const selectedEnvironmentId = listOptions.selectedEnvironmentId;128 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
129 const projectScopeKey = listOptions.selectedProjectKey;129 const projectScopeKey = listOptions.selectedProjectKey;
130 const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {130 const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
131 sidebarProjectGroupingMode: listOptions.projectGroupingMode,131 sidebarProjectGroupingMode: listOptions.projectGroupingMode,
132 sidebarProjectGroupingOverrides: {},132 sidebarProjectGroupingOverrides: {},
133 });133 });
134 useEffect(() => {134 useEffect(() => {
135 if (selectedProjectKey !== projectScopeKey) {135 if (resolvedProjectKey !== projectScopeKey) {
136 setSelectedProjectKey(selectedProjectKey);136 setSelectedProjectKey(resolvedProjectKey);
137 }137 }
138 }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);138 }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]);
139 const projectFilterOptions = useMemo(139 const projectFilterOptions = useMemo(
140 () =>140 () =>
141 buildHomeProjectScopes({141 buildHomeProjectScopes({
...@@ -148,14 +148,12 @@ export function HomeRouteScreen() {...@@ -148,14 +148,12 @@ export function HomeRouteScreen() {
148 })),148 })),
149 [listOptions.projectGroupingMode, projects, selectedEnvironmentId],149 [listOptions.projectGroupingMode, projects, selectedEnvironmentId],
150 );150 );
151 useEffect(() => {151 // A saved project stays selected while its environment is offline or filtered out.
152 if (152 const selectedProjectKey = projectFilterOptions.some(
153 selectedProjectKey !== null &&153 (project) => project.key === resolvedProjectKey,
154 !projectFilterOptions.some((project) => project.key === selectedProjectKey)154 )
155 ) {155 ? resolvedProjectKey
156 setSelectedProjectKey(null);156 : null;
157 }
158 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
159157
160 // In split layouts the persistent sidebar IS the thread list — Home becomes158 // In split layouts the persistent sidebar IS the thread list — Home becomes
161 // an empty detail pane so selecting a thread never transitions layouts.159 // an empty detail pane so selecting a thread never transitions layouts.
apps/mobile/src/features/home/ThreadListFilters.tsx+2-2
...@@ -24,7 +24,7 @@ export function ThreadListFilters(...@@ -24,7 +24,7 @@ export function ThreadListFilters(
24 <ScrollView24 <ScrollView
25 horizontal25 horizontal
26 showsHorizontalScrollIndicator={false}26 showsHorizontalScrollIndicator={false}
27 contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}27 contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 }}
28 >28 >
29 <ListFilterMenu29 <ListFilterMenu
30 label={selectedEnvironment?.label ?? "All environments"}30 label={selectedEnvironment?.label ?? "All environments"}
...@@ -85,7 +85,7 @@ export function ThreadListFilters(...@@ -85,7 +85,7 @@ export function ThreadListFilters(
85 <Pressable85 <Pressable
86 accessibilityRole="button"86 accessibilityRole="button"
87 accessibilityLabel="Reset thread filters"87 accessibilityLabel="Reset thread filters"
88 className="h-11 w-8 items-center justify-center"88 className="h-8 w-8 items-center justify-center"
89 onPress={() => {89 onPress={() => {
90 props.onEnvironmentChange(null);90 props.onEnvironmentChange(null);
91 props.onProjectChange(null);91 props.onProjectChange(null);
apps/mobile/src/features/home/home-list-options.test.ts+40-5
...@@ -1,8 +1,24 @@...@@ -1,8 +1,24 @@
1import { RegistryContext } from "@effect/atom-react";
1import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/reactivity";
2import { act, createElement, useLayoutEffect } from "react";4import { act, createElement, useLayoutEffect } from "react";
3import { createRoot, type Root } from "react-dom/client";5import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
57
8const saved = vi.hoisted(() => [] as unknown[]);
9vi.mock("../../state/preferences", async () => {
10 const { Atom } = await import("effect/reactivity");
11 return {
12 updateMobilePreferencesAtom: Atom.writable(
13 () => null,
14 (_ctx, patch: unknown) => {
15 saved.push(patch);
16 },
17 ),
18 };
19});
20
21import type { ThreadListFilters } from "../../persistence/mobile-preferences";
6import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";22import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";
723
8describe("home list options across layout changes", () => {24describe("home list options across layout changes", () => {
...@@ -19,14 +35,20 @@ describe("home list options across layout changes", () => {...@@ -19,14 +35,20 @@ describe("home list options across layout changes", () => {
19 return null;35 return null;
20 }36 }
2137
22 async function showLayout(layout: "compact" | "sidebar") {38 const registry = AtomRegistry.make();
39
40 async function showLayout(layout: "compact" | "sidebar", savedFilters?: ThreadListFilters) {
23 await act(() => {41 await act(() => {
24 root.render(42 root.render(
25 createElement(43 createElement(
26 HomeListOptionsProvider,44 RegistryContext,
27 { projectGroupingMode: "repository" },45 { value: registry },
28 // Replace the list while keeping the workspace provider mounted.46 createElement(
29 createElement(ThreadList, { key: layout }),47 HomeListOptionsProvider,
48 { projectGroupingMode: "repository", savedFilters },
49 // Replace the list while keeping the workspace provider mounted.
50 createElement(ThreadList, { key: layout }),
51 ),
30 ),52 ),
31 );53 );
32 });54 });
...@@ -51,6 +73,7 @@ describe("home list options across layout changes", () => {...@@ -51,6 +73,7 @@ describe("home list options across layout changes", () => {
51 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });73 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
52 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);74 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
53 root = createRoot(container as unknown as HTMLElement);75 root = createRoot(container as unknown as HTMLElement);
76 saved.length = 0;
54 });77 });
5578
56 afterEach(async () => {79 afterEach(async () => {
...@@ -92,4 +115,16 @@ describe("home list options across layout changes", () => {...@@ -92,4 +115,16 @@ describe("home list options across layout changes", () => {
92 }115 }
93 },116 },
94 );117 );
118
119 it("restores saved filters on launch and saves only real changes", async () => {
120 await showLayout("compact", { environmentId, projectKey: "repository:one" });
121 expect(latest.options).toMatchObject({
122 selectedEnvironmentId: environmentId,
123 selectedProjectKey: "repository:one",
124 });
125 expect(saved).toEqual([]);
126
127 await act(() => latest.setSelectedProjectKey(null));
128 expect(saved).toEqual([{ threadListFilters: { environmentId, projectKey: null } }]);
129 });
95});130});
apps/mobile/src/features/home/home-list-options.ts+27-5
...@@ -1,17 +1,22 @@...@@ -1,17 +1,22 @@
1import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts";1import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts";
2import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts";2import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts";
3import { useAtomSet } from "@effect/atom-react";
3import {4import {
4 createContext,5 createContext,
5 createElement,6 createElement,
6 useCallback,7 useCallback,
7 useContext,8 useContext,
9 useEffect,
8 useMemo,10 useMemo,
11 useRef,
9 useState,12 useState,
10 type PropsWithChildren,13 type PropsWithChildren,
11 type Dispatch,14 type Dispatch,
12 type SetStateAction,15 type SetStateAction,
13} from "react";16} from "react";
1417
18import type { ThreadListFilters } from "../../persistence/mobile-preferences";
19import { updateMobilePreferencesAtom } from "../../state/preferences";
15import type { HomeProjectSortOrder } from "./homeThreadList";20import type { HomeProjectSortOrder } from "./homeThreadList";
1621
17export interface HomeListOptions {22export interface HomeListOptions {
...@@ -24,10 +29,10 @@ export interface ResolvedHomeListOptions extends HomeListOptions {...@@ -24,10 +29,10 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
24 readonly projectGroupingMode: SidebarProjectGroupingMode;29 readonly projectGroupingMode: SidebarProjectGroupingMode;
25}30}
2631
27function defaultHomeListOptions(): HomeListOptions {32function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions {
28 return {33 return {
29 selectedEnvironmentId: null,34 selectedEnvironmentId: filters?.environmentId ?? null,
30 selectedProjectKey: null,35 selectedProjectKey: filters?.projectKey ?? null,
31 projectSortOrder:36 projectSortOrder:
32 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"37 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
33 ? "updated_at"38 ? "updated_at"
...@@ -43,14 +48,31 @@ interface HomeListOptionsContextValue {...@@ -43,14 +48,31 @@ interface HomeListOptionsContextValue {
4348
44const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);49const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);
4550
46/** Keeps list preferences stable while the app moves between compact and split shells. */51/**
52 * Keeps list preferences stable while the app moves between compact and split shells.
53 * Filters start from the saved preference, and changes are saved for the next launch.
54 */
47export function HomeListOptionsProvider({55export function HomeListOptionsProvider({
48 children,56 children,
49 projectGroupingMode,57 projectGroupingMode,
58 savedFilters,
50}: PropsWithChildren<{59}: PropsWithChildren<{
51 readonly projectGroupingMode: SidebarProjectGroupingMode;60 readonly projectGroupingMode: SidebarProjectGroupingMode;
61 readonly savedFilters?: ThreadListFilters | undefined;
52}>) {62}>) {
53 const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);63 const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters));
64 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
65 const saved = useRef(savedFilters);
66 const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options;
67 useEffect(() => {
68 if (
69 (saved.current?.environmentId ?? null) === environmentId &&
70 (saved.current?.projectKey ?? null) === projectKey
71 )
72 return;
73 saved.current = { environmentId, projectKey };
74 savePreferences({ threadListFilters: saved.current });
75 }, [environmentId, projectKey, savePreferences]);
54 const value = useMemo(76 const value = useMemo(
55 () => ({ options, setOptions, projectGroupingMode }),77 () => ({ options, setOptions, projectGroupingMode }),
56 [options, projectGroupingMode],78 [options, projectGroupingMode],
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+11-2
...@@ -46,6 +46,7 @@ import {...@@ -46,6 +46,7 @@ import {
46 resolveThreadSelectionOverlayState,46 resolveThreadSelectionOverlayState,
47} from "../../lib/adaptive-navigation";47} from "../../lib/adaptive-navigation";
48import { scopedThreadKey } from "../../lib/scopedEntities";48import { scopedThreadKey } from "../../lib/scopedEntities";
49import type { ThreadListFilters } from "../../persistence/mobile-preferences";
49import { mobilePreferencesAtom } from "../../state/preferences";50import { mobilePreferencesAtom } from "../../state/preferences";
50import {51import {
51 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,52 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
...@@ -234,6 +235,7 @@ export function AdaptiveWorkspaceLayout(props: {...@@ -234,6 +235,7 @@ export function AdaptiveWorkspaceLayout(props: {
234 <AdaptiveWorkspaceLayoutContent235 <AdaptiveWorkspaceLayoutContent
235 {...props}236 {...props}
236 projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}237 projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}
238 savedThreadListFilters={preferencesResult.value.threadListFilters}
237 />239 />
238 );240 );
239}241}
...@@ -245,6 +247,7 @@ function AdaptiveWorkspaceLayoutContent(...@@ -245,6 +247,7 @@ function AdaptiveWorkspaceLayoutContent(
245 readonly workspaceRouteKey: string | undefined;247 readonly workspaceRouteKey: string | undefined;
246 } & {248 } & {
247 readonly projectGroupingMode: SidebarProjectGroupingMode;249 readonly projectGroupingMode: SidebarProjectGroupingMode;
250 readonly savedThreadListFilters?: ThreadListFilters | undefined;
248 },251 },
249) {252) {
250 const projectGroupingMode = props.projectGroupingMode;253 const projectGroupingMode = props.projectGroupingMode;
...@@ -680,7 +683,10 @@ function AdaptiveWorkspaceLayoutContent(...@@ -680,7 +683,10 @@ function AdaptiveWorkspaceLayoutContent(
680683
681 if (nativeWorkspace) {684 if (nativeWorkspace) {
682 return (685 return (
683 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>686 <HomeListOptionsProvider
687 projectGroupingMode={projectGroupingMode}
688 savedFilters={props.savedThreadListFilters}
689 >
684 <AdaptiveWorkspaceContext value={contextValue}>690 <AdaptiveWorkspaceContext value={contextValue}>
685 <NativeWorkspaceInspectorContext691 <NativeWorkspaceInspectorContext
686 value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }}692 value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }}
...@@ -692,7 +698,10 @@ function AdaptiveWorkspaceLayoutContent(...@@ -692,7 +698,10 @@ function AdaptiveWorkspaceLayoutContent(
692 );698 );
693 }699 }
694 return (700 return (
695 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>701 <HomeListOptionsProvider
702 projectGroupingMode={projectGroupingMode}
703 savedFilters={props.savedThreadListFilters}
704 >
696 <AdaptiveWorkspaceContext.Provider value={contextValue}>705 <AdaptiveWorkspaceContext.Provider value={contextValue}>
697 <View706 <View
698 testID="adaptive-workspace-layout"707 testID="adaptive-workspace-layout"
apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx+1-1
...@@ -196,7 +196,7 @@ export function PullRequestListFilters(props: {...@@ -196,7 +196,7 @@ export function PullRequestListFilters(props: {
196 <ScrollView196 <ScrollView
197 horizontal197 horizontal
198 showsHorizontalScrollIndicator={false}198 showsHorizontalScrollIndicator={false}
199 contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}199 contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 }}
200 >200 >
201 <ListFilterMenu201 <ListFilterMenu
202 label={count ? String(count) : "Filters"}202 label={count ? String(count) : "Filters"}
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+8-8
...@@ -2,7 +2,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native";...@@ -2,7 +2,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";3import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { ProjectId } from "@t3tools/contracts";4import { ProjectId } from "@t3tools/contracts";
5import { useAtomSet, useAtomValue } from "@effect/atom-react";5import { useAtomValue } from "@effect/atom-react";
6import {6import {
7 ActivityIndicator,7 ActivityIndicator,
8 Keyboard,8 Keyboard,
...@@ -24,7 +24,8 @@ import { useProjects } from "../../state/entities";...@@ -24,7 +24,8 @@ import { useProjects } from "../../state/entities";
24import { environmentServerConfigsAtom } from "../../state/server";24import { environmentServerConfigsAtom } from "../../state/server";
25import { PullRequestListRow } from "./PullRequestListRow";25import { PullRequestListRow } from "./PullRequestListRow";
26import { PullRequestListFilters } from "./PullRequestListFilters";26import { PullRequestListFilters } from "./PullRequestListFilters";
27import { pullRequestListControlsAtom, usePullRequestList } from "./usePullRequestList";27import { usePullRequestList } from "./usePullRequestList";
28import { usePullRequestListControls } from "./usePullRequestListControls";
28import { usePullRequestListActions } from "./usePullRequestListActions";29import { usePullRequestListActions } from "./usePullRequestListActions";
29import {30import {
30 filterPullRequestList,31 filterPullRequestList,
...@@ -74,7 +75,8 @@ export function PullRequestsScreen() {...@@ -74,7 +75,8 @@ export function PullRequestsScreen() {
74 const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);75 const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);
75 // Dismissal must restore the cached unfiltered list before restoring its offset.76 // Dismissal must restore the cached unfiltered list before restoring its offset.
76 const query = tab.searchMode === "resting" ? "" : debouncedQuery;77 const query = tab.searchMode === "resting" ? "" : debouncedQuery;
77 const selectedEnvironmentId = workspace.pullRequestEnvironmentId;78 const listControls = usePullRequestListControls();
79 const selectedEnvironmentId = listControls.environmentId;
78 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)80 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
79 ? selectedEnvironmentId81 ? selectedEnvironmentId
80 : null;82 : null;
...@@ -95,9 +97,7 @@ export function PullRequestsScreen() {...@@ -95,9 +97,7 @@ export function PullRequestsScreen() {
95 },97 },
96 [navigation, selectAndroidWorkspace],98 [navigation, selectAndroidWorkspace],
97 );99 );
98 const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");100 const controls = listControls.controlsFor(environmentId);
99 const controls = useAtomValue(controlsAtom);
100 const setControls = useAtomSet(controlsAtom);
101 const targets = useMemo(101 const targets = useMemo(
102 () =>102 () =>
103 environments103 environments
...@@ -269,10 +269,10 @@ export function PullRequestsScreen() {...@@ -269,10 +269,10 @@ export function PullRequestsScreen() {
269 label: item.environmentLabel,269 label: item.environmentLabel,
270 }))}270 }))}
271 environmentId={environmentId}271 environmentId={environmentId}
272 onEnvironmentChange={workspace.setPullRequestEnvironmentId}272 onEnvironmentChange={listControls.setEnvironmentId}
273 value={controls}273 value={controls}
274 onChange={(next) => {274 onChange={(next) => {
275 setControls(next);275 listControls.setControls(environmentId, next);
276 void listRef.current?.scrollToOffset({276 void listRef.current?.scrollToOffset({
277 offset: Platform.OS === "ios" ? -insets.top : 0,277 offset: Platform.OS === "ios" ? -insets.top : 0,
278 animated: false,278 animated: false,
apps/mobile/src/features/pull-requests/pullRequestList.ts+2-11
...@@ -1,22 +1,13 @@...@@ -1,22 +1,13 @@
1import type {1import type {
2 EnvironmentId,2 EnvironmentId,
3 PullRequestListEntry,3 PullRequestListEntry,
4 PullRequestListFilters,
5 PullRequestListInput,4 PullRequestListInput,
6 PullRequestListResult,5 PullRequestListResult,
7 PullRequestInvolvement,
8 PullRequestListState,
9} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import type { PullRequestListControls } from "../../persistence/mobile-preferences";
108
11export const PR_LIST_PAGE_SIZE = 30;9export const PR_LIST_PAGE_SIZE = 30;
12export interface PullRequestListControls {10export type { PullRequestListControls } from "../../persistence/mobile-preferences";
13 readonly state: PullRequestListState;
14 readonly involvement: PullRequestInvolvement;
15 readonly filters: PullRequestListFilters;
16 readonly projectId?: string;
17 readonly projectEnvironmentId?: string;
18 readonly sort: "updated" | "created" | "oldest";
19}
20export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = {11export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = {
21 state: "open",12 state: "open",
22 involvement: "all",13 involvement: "all",
apps/mobile/src/features/pull-requests/usePullRequestList.ts-5
...@@ -8,7 +8,6 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect...@@ -8,7 +8,6 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect
8import { composerPullRequests } from "../../state/pull-requests";8import { composerPullRequests } from "../../state/pull-requests";
9import {9import {
10 appendPullRequestListPage,10 appendPullRequestListPage,
11 DEFAULT_PR_LIST_CONTROLS,
12 mergeEnvironmentPullRequestPages,11 mergeEnvironmentPullRequestPages,
13 nextPullRequestListInput,12 nextPullRequestListInput,
14 pullRequestListHead,13 pullRequestListHead,
...@@ -28,10 +27,6 @@ const statsFamily = Atom.family((_environmentId: string) =>...@@ -28,10 +27,6 @@ const statsFamily = Atom.family((_environmentId: string) =>
28const statKey = (row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">) =>27const statKey = (row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">) =>
29 `${row.projectId}:${row.repository}:${row.number}`;28 `${row.projectId}:${row.repository}:${row.number}`;
3029
31export const pullRequestListControlsAtom = Atom.family((_environmentId: string) =>
32 Atom.make(DEFAULT_PR_LIST_CONTROLS),
33);
34
35type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput };30type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput };
36const sourcesFamily = Atom.family((key: string) => {31const sourcesFamily = Atom.family((key: string) => {
37 const targets: readonly ListTarget[] = JSON.parse(key);32 const targets: readonly ListTarget[] = JSON.parse(key);
apps/mobile/src/features/pull-requests/usePullRequestListControls.ts created+48
...@@ -0,0 +1,48 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import { useCallback } from "react";
4
5import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
6import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
7
8/** The pull request list's environment and its filters, restored on the next launch. */
9export function usePullRequestListControls() {
10 const preferences = useAtomValue(mobilePreferencesAtom);
11 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
12 const saved = AsyncResult.isSuccess(preferences)
13 ? preferences.value.pullRequestListFilters
14 : undefined;
15 const environmentId = saved?.environmentId ?? null;
16 const controlsFor = useCallback(
17 (environment: string | null) => saved?.controls[environment ?? ""] ?? DEFAULT_PR_LIST_CONTROLS,
18 [saved],
19 );
20 const setEnvironmentId = useCallback(
21 (next: string | null) =>
22 savePreferences({
23 transform: (current) => ({
24 pullRequestListFilters: {
25 controls: current.pullRequestListFilters?.controls ?? {},
26 environmentId: next,
27 },
28 }),
29 }),
30 [savePreferences],
31 );
32 const setControls = useCallback(
33 (environment: string | null, controls: PullRequestListControls) =>
34 savePreferences({
35 transform: (current) => ({
36 pullRequestListFilters: {
37 environmentId: current.pullRequestListFilters?.environmentId ?? null,
38 controls: {
39 ...current.pullRequestListFilters?.controls,
40 [environment ?? ""]: controls,
41 },
42 },
43 }),
44 }),
45 [savePreferences],
46 );
47 return { environmentId, controlsFor, setEnvironmentId, setControls } as const;
48}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+8-17
...@@ -208,15 +208,15 @@ function ThreadNavigationSidebarPane(...@@ -208,15 +208,15 @@ function ThreadNavigationSidebarPane(
208 [threadSearch.matches],208 [threadSearch.matches],
209 );209 );
210 const projectScopeKey = options.selectedProjectKey;210 const projectScopeKey = options.selectedProjectKey;
211 const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {211 const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
212 sidebarProjectGroupingMode: options.projectGroupingMode,212 sidebarProjectGroupingMode: options.projectGroupingMode,
213 sidebarProjectGroupingOverrides: {},213 sidebarProjectGroupingOverrides: {},
214 });214 });
215 useEffect(() => {215 useEffect(() => {
216 if (selectedProjectKey !== projectScopeKey) {216 if (resolvedProjectKey !== projectScopeKey) {
217 setSelectedProjectKey(selectedProjectKey);217 setSelectedProjectKey(resolvedProjectKey);
218 }218 }
219 }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);219 }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]);
220 const projectScopes = useMemo(220 const projectScopes = useMemo(
221 () =>221 () =>
222 buildHomeProjectScopes({222 buildHomeProjectScopes({
...@@ -250,20 +250,11 @@ function ThreadNavigationSidebarPane(...@@ -250,20 +250,11 @@ function ThreadNavigationSidebarPane(
250 [projectScopes],250 [projectScopes],
251 );251 );
252 const selectedProjectScope = useMemo(252 const selectedProjectScope = useMemo(
253 () =>253 () => projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null,
254 selectedProjectKey === null254 [projectScopes, resolvedProjectKey],
255 ? null
256 : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null),
257 [projectScopes, selectedProjectKey],
258 );255 );
259 useEffect(() => {256 // A saved project stays selected while its environment is offline or filtered out.
260 if (257 const selectedProjectKey = selectedProjectScope?.key ?? null;
261 selectedProjectKey !== null &&
262 !projectFilterOptions.some((project) => project.key === selectedProjectKey)
263 ) {
264 setSelectedProjectKey(null);
265 }
266 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
267 const selectedProjectRefs = useMemo(258 const selectedProjectRefs = useMemo(
268 () =>259 () =>
269 selectedProjectScope === null260 selectedProjectScope === null
apps/mobile/src/features/workspace/ListFilterMenu.tsx+51-30
...@@ -1,9 +1,20 @@...@@ -1,9 +1,20 @@
1import { GlassView } from "expo-glass-effect";
1import { Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
3import { withUniwind } from "uniwind";
2import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
3import { SymbolView } from "../../components/AppSymbol";5import { SymbolView } from "../../components/AppSymbol";
4import { ControlPillMenu } from "../../components/ControlPillMenu";6import { ControlPillMenu } from "../../components/ControlPillMenu";
5import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";7import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";
6import { cn } from "../../lib/cn";8import { cn } from "../../lib/cn";
9import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
10import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
11
12const FilterGlass = withUniwind(GlassView, {
13 style: { fromClassName: "className" },
14 tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" },
15});
16
17const PILL = "h-8 flex-row items-center gap-1.5 rounded-full px-3";
718
8/** A visible filter with a native menu containing only that filter's choices. */19/** A visible filter with a native menu containing only that filter's choices. */
9export function ListFilterMenu(props: {20export function ListFilterMenu(props: {
...@@ -14,6 +25,34 @@ export function ListFilterMenu(props: {...@@ -14,6 +25,34 @@ export function ListFilterMenu(props: {
14 readonly actions: ControlPillMenuProps["actions"];25 readonly actions: ControlPillMenuProps["actions"];
15 readonly onSelect: (id: string) => void;26 readonly onSelect: (id: string) => void;
16}) {27}) {
28 const { themeAppearance } = useAppearancePreferences();
29 const content = (
30 <>
31 {props.icon ? (
32 <SymbolView
33 name="line.3.horizontal.decrease"
34 size={15}
35 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
36 />
37 ) : null}
38 <Text
39 numberOfLines={1}
40 className={cn(
41 "max-w-[180px] text-sm font-t3-medium",
42 props.active ? "text-primary-foreground" : "text-foreground-muted",
43 )}
44 >
45 {props.label}
46 </Text>
47 {!props.icon ? (
48 <SymbolView
49 name="chevron.down"
50 size={11}
51 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
52 />
53 ) : null}
54 </>
55 );
17 return (56 return (
18 <ControlPillMenu57 <ControlPillMenu
19 title={props.accessibilityLabel}58 title={props.accessibilityLabel}
...@@ -23,38 +62,20 @@ export function ListFilterMenu(props: {...@@ -23,38 +62,20 @@ export function ListFilterMenu(props: {
23 <Pressable62 <Pressable
24 accessibilityRole="button"63 accessibilityRole="button"
25 accessibilityLabel={props.accessibilityLabel}64 accessibilityLabel={props.accessibilityLabel}
26 className="h-11 justify-center"65 hitSlop={6}
27 >66 >
28 <View67 {NATIVE_LIQUID_GLASS_SUPPORTED ? (
29 className={cn(68 <FilterGlass
30 "h-8 flex-row items-center gap-1.5 rounded-full px-3",69 isInteractive
31 props.active ? "bg-primary" : "bg-subtle",70 colorScheme={themeAppearance}
32 )}71 tintColorClassName={props.active ? "accent-primary" : undefined}
33 >72 className={PILL}
34 {props.icon ? (
35 <SymbolView
36 name="line.3.horizontal.decrease"
37 size={15}
38 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
39 />
40 ) : null}
41 <Text
42 numberOfLines={1}
43 className={cn(
44 "max-w-[180px] text-xs font-t3-medium",
45 props.active ? "text-primary-foreground" : "text-foreground-muted",
46 )}
47 >73 >
48 {props.label}74 {content}
49 </Text>75 </FilterGlass>
50 {!props.icon ? (76 ) : (
51 <SymbolView77 <View className={cn(PILL, props.active ? "bg-primary" : "bg-subtle")}>{content}</View>
52 name="chevron.down"78 )}
53 size={10}
54 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
55 />
56 ) : null}
57 </View>
58 </Pressable>79 </Pressable>
59 </ControlPillMenu>80 </ControlPillMenu>
60 );81 );
apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx+34-10
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";1import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
2import { useCallback, useMemo, useState, type ReactNode } from "react";2import { useCallback, useMemo, useState, type ReactNode } from "react";
3import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native";3import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native";
4import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens";4import {
5 Tabs,
6 type TabSelectedEvent,
7 type TabSelectionPreventedEvent,
8 type TabSelectionRejectedEvent,
9} from "react-native-screens";
510
6import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";11import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
7import type { WorkspaceTab } from "./workspace-tabs-state";12import type { WorkspaceTab } from "./workspace-tabs-state";
...@@ -10,6 +15,7 @@ import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";...@@ -10,6 +15,7 @@ import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
1015
11// Tabler's git-pull-request geometry, rasterized at 1x/2x/3x for UIKit template images.16// Tabler's git-pull-request geometry, rasterized at 1x/2x/3x for UIKit template images.
12const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png");17const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png");
18const COMPOSE_TAB = "compose";
1319
14/** Retain each native controller tree; removing its root can leave RNS's stack empty. */20/** Retain each native controller tree; removing its root can leave RNS's stack empty. */
15function RetainedWorkspaceTab(props: {21function RetainedWorkspaceTab(props: {
...@@ -39,7 +45,11 @@ function RetainedWorkspaceTab(props: {...@@ -39,7 +45,11 @@ function RetainedWorkspaceTab(props: {
39 ) : null;45 ) : null;
40}46}
4147
42/** UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class. */48/**
49 * UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class.
50 * On phones, compose is UIKit's own search-role tab, detached like Photos' search button; it never
51 * becomes selected and opens a new chat instead.
52 */
43export function WorkspaceNativeTabs(props: {53export function WorkspaceNativeTabs(props: {
44 readonly children: ReactNode;54 readonly children: ReactNode;
45 readonly hidden: boolean;55 readonly hidden: boolean;
...@@ -62,13 +72,11 @@ export function WorkspaceNativeTabs(props: {...@@ -62,13 +72,11 @@ export function WorkspaceNativeTabs(props: {
62 <Tabs.Host72 <Tabs.Host
63 navStateRequest={request}73 navStateRequest={request}
64 tabBarHidden={props.hidden || !systemTabBar}74 tabBarHidden={props.hidden || !systemTabBar}
65 ios={{75 ios={{ tabBarControllerMode: "tabBar", tabBarMinimizeBehavior: "never" }}
66 tabBarControllerMode: "tabBar",76 onTabSelectionPrevented={({
67 tabBarTrailingActionSystemImage: phoneBar ? "square.and.pencil" : undefined,77 nativeEvent,
68 tabBarTrailingActionAccessibilityLabel: "New chat",78 }: NativeSyntheticEvent<TabSelectionPreventedEvent>) => {
69 onTabBarTrailingActionPress: props.onNewTask,79 if (nativeEvent.preventedScreenKey === COMPOSE_TAB) props.onNewTask();
70 tabBarMinimizeBehavior: "never",
71 tabBarLeadingWidth: phoneBar ? 220 : 0,
72 }}80 }}
73 onTabSelectionRejected={({81 onTabSelectionRejected={({
74 nativeEvent,82 nativeEvent,
...@@ -89,7 +97,7 @@ export function WorkspaceNativeTabs(props: {...@@ -89,7 +97,7 @@ export function WorkspaceNativeTabs(props: {
89 <Tabs.Screen97 <Tabs.Screen
90 key={tab}98 key={tab}
91 screenKey={tab}99 screenKey={tab}
92 title=""100 title={tab === "threads" ? "Threads" : "Pulls"}
93 tabBarItemAccessibilityLabel={tab === "threads" ? "Threads" : "Pull requests"}101 tabBarItemAccessibilityLabel={tab === "threads" ? "Threads" : "Pull requests"}
94 ios={{102 ios={{
95 icon:103 icon:
...@@ -106,6 +114,22 @@ export function WorkspaceNativeTabs(props: {...@@ -106,6 +114,22 @@ export function WorkspaceNativeTabs(props: {
106 </RetainedWorkspaceTab>114 </RetainedWorkspaceTab>
107 </Tabs.Screen>115 </Tabs.Screen>
108 ))}116 ))}
117 {phoneBar ? (
118 <Tabs.Screen
119 key={COMPOSE_TAB}
120 screenKey={COMPOSE_TAB}
121 title="New chat"
122 preventNativeSelection
123 ios={{
124 role: "search",
125 // iOS 27 detaches a search tab only while it may activate search.
126 automaticallyActivatesSearch: true,
127 icon: { type: "sfSymbol", name: "square.and.pencil" },
128 }}
129 >
130 <View />
131 </Tabs.Screen>
132 ) : null}
109 </Tabs.Host>133 </Tabs.Host>
110 </View>134 </View>
111 );135 );
apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx+1-21
...@@ -6,7 +6,6 @@ import {...@@ -6,7 +6,6 @@ import {
6 useMemo,6 useMemo,
7 useReducer,7 useReducer,
8 useRef,8 useRef,
9 useState,
10 type Dispatch,9 type Dispatch,
11 type ReactNode,10 type ReactNode,
12} from "react";11} from "react";
...@@ -32,10 +31,6 @@ export const WorkspaceTabsContext = createContext<{...@@ -32,10 +31,6 @@ export const WorkspaceTabsContext = createContext<{
32 readonly recordScrollOffset: (tab: WorkspaceTab, offset: number) => void;31 readonly recordScrollOffset: (tab: WorkspaceTab, offset: number) => void;
33 readonly getScrollAnchor: (tab: WorkspaceTab) => WorkspaceScrollAnchor | undefined;32 readonly getScrollAnchor: (tab: WorkspaceTab) => WorkspaceScrollAnchor | undefined;
34 readonly recordScrollAnchor: (tab: WorkspaceTab, anchor: WorkspaceScrollAnchor) => void;33 readonly recordScrollAnchor: (tab: WorkspaceTab, anchor: WorkspaceScrollAnchor) => void;
35 readonly threadsProjectKey: string | null;
36 readonly setThreadsProjectKey: (key: string | null) => void;
37 readonly pullRequestEnvironmentId: string | null;
38 readonly setPullRequestEnvironmentId: (id: string | null) => void;
39} | null>(null);34} | null>(null);
4035
41export function useWorkspaceTabs() {36export function useWorkspaceTabs() {
...@@ -50,8 +45,6 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) {...@@ -50,8 +45,6 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) {
50 activeTab.current = state.selectedTab;45 activeTab.current = state.selectedTab;
51 }, [state.selectedTab]);46 }, [state.selectedTab]);
52 const isTabActive = useCallback((tab: WorkspaceTab) => activeTab.current === tab, []);47 const isTabActive = useCallback((tab: WorkspaceTab) => activeTab.current === tab, []);
53 const [threadsProjectKey, setThreadsProjectKey] = useState<string | null>(null);
54 const [pullRequestEnvironmentId, setPullRequestEnvironmentId] = useState<string | null>(null);
55 const scrollOffsets = useRef({ threads: 0, pullRequests: 0 });48 const scrollOffsets = useRef({ threads: 0, pullRequests: 0 });
56 const scrollAnchors = useRef<Partial<Record<WorkspaceTab, WorkspaceScrollAnchor>>>({});49 const scrollAnchors = useRef<Partial<Record<WorkspaceTab, WorkspaceScrollAnchor>>>({});
57 const getScrollOffset = useCallback((tab: WorkspaceTab) => scrollOffsets.current[tab], []);50 const getScrollOffset = useCallback((tab: WorkspaceTab) => scrollOffsets.current[tab], []);
...@@ -72,21 +65,8 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) {...@@ -72,21 +65,8 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) {
72 recordScrollOffset,65 recordScrollOffset,
73 getScrollAnchor,66 getScrollAnchor,
74 recordScrollAnchor,67 recordScrollAnchor,
75 threadsProjectKey,
76 setThreadsProjectKey,
77 pullRequestEnvironmentId,
78 setPullRequestEnvironmentId,
79 }),68 }),
80 [69 [state, isTabActive, getScrollOffset, recordScrollOffset, getScrollAnchor, recordScrollAnchor],
81 state,
82 isTabActive,
83 getScrollOffset,
84 recordScrollOffset,
85 getScrollAnchor,
86 recordScrollAnchor,
87 threadsProjectKey,
88 pullRequestEnvironmentId,
89 ],
90 );70 );
91 return <WorkspaceTabsContext value={context}>{props.children}</WorkspaceTabsContext>;71 return <WorkspaceTabsContext value={context}>{props.children}</WorkspaceTabsContext>;
92}72}
apps/mobile/src/lib/storage.test.ts+26
...@@ -276,6 +276,32 @@ describe("mobile connection storage", () => {...@@ -276,6 +276,32 @@ describe("mobile connection storage", () => {
276 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });276 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
277 });277 });
278278
279 it("keeps valid list filters and drops malformed ones", async () => {
280 const threadListFilters = { environmentId: "environment-1", projectKey: null };
281 const pullRequestListFilters = {
282 environmentId: null,
283 controls: {
284 "": {
285 state: "all",
286 involvement: "reviewing",
287 filters: { labels: [["size:S"]] },
288 sort: "oldest",
289 },
290 },
291 };
292 mocks.setPreferencesJson(JSON.stringify({ threadListFilters, pullRequestListFilters }), 10);
293 await expect(loadPreferences()).resolves.toEqual({ threadListFilters, pullRequestListFilters });
294
295 mocks.setPreferencesJson(
296 JSON.stringify({
297 threadListFilters: { environmentId: 1, projectKey: null },
298 pullRequestListFilters: { environmentId: null, controls: { "": { sort: "newest" } } },
299 }),
300 11,
301 );
302 await expect(loadPreferences()).resolves.toEqual({});
303 });
304
279 it("reconciles fallback preferences after SQLite recovers", async () => {305 it("reconciles fallback preferences after SQLite recovers", async () => {
280 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);306 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
281 await mocks.setItemAsync(307 await mocks.setItemAsync(
apps/mobile/src/persistence/mobile-preferences.ts+46-4
...@@ -5,10 +5,14 @@ import * as Option from "effect/Option";...@@ -5,10 +5,14 @@ import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";7import * as Semaphore from "effect/Semaphore";
8import type {8import {
9 ProviderInstanceId,9 EnvironmentId,
10 PullRequestMergeMethod,10 PullRequestInvolvement,
11 SidebarProjectGroupingMode,11 PullRequestListFilters,
12 PullRequestListState,
13 type ProviderInstanceId,
14 type PullRequestMergeMethod,
15 type SidebarProjectGroupingMode,
12} from "@t3tools/contracts";16} from "@t3tools/contracts";
13import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";17import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
14import type { FollowUpBehavior } from "../lib/followUpBehavior";18import type { FollowUpBehavior } from "../lib/followUpBehavior";
...@@ -21,6 +25,33 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto...@@ -21,6 +25,33 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto
21const PREFERENCES_KEY = "t3code.preferences";25const PREFERENCES_KEY = "t3code.preferences";
22const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";26const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
2327
28/** The pull request list's filters and sort for one environment scope. */
29export const PullRequestListControls = Schema.Struct({
30 state: PullRequestListState,
31 involvement: PullRequestInvolvement,
32 filters: PullRequestListFilters,
33 projectId: Schema.optional(Schema.String),
34 projectEnvironmentId: Schema.optional(Schema.String),
35 sort: Schema.Literals(["updated", "created", "oldest"]),
36});
37export type PullRequestListControls = typeof PullRequestListControls.Type;
38
39const ThreadListFilters = Schema.Struct({
40 environmentId: Schema.NullOr(EnvironmentId),
41 projectKey: Schema.NullOr(Schema.String),
42});
43export type ThreadListFilters = typeof ThreadListFilters.Type;
44
45/** Controls are keyed by environment id, with "" for all environments. */
46const PullRequestListPreferences = Schema.Struct({
47 environmentId: Schema.NullOr(Schema.String),
48 controls: Schema.Record(Schema.String, PullRequestListControls),
49});
50export type PullRequestListPreferences = typeof PullRequestListPreferences.Type;
51
52const isThreadListFilters = Schema.is(ThreadListFilters);
53const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
54
24export interface Preferences {55export interface Preferences {
25 readonly pullRequestHideTestFiles?: boolean;56 readonly pullRequestHideTestFiles?: boolean;
26 readonly pullRequestMergeMethod?: PullRequestMergeMethod;57 readonly pullRequestMergeMethod?: PullRequestMergeMethod;
...@@ -61,6 +92,9 @@ export interface Preferences {...@@ -61,6 +92,9 @@ export interface Preferences {
61 readonly threadListSettledShelfExpanded?: boolean;92 readonly threadListSettledShelfExpanded?: boolean;
62 readonly threadListSnoozedShelfExpanded?: boolean;93 readonly threadListSnoozedShelfExpanded?: boolean;
63 readonly threadListWorkingShelfExpanded?: boolean;94 readonly threadListWorkingShelfExpanded?: boolean;
95 /** Home list filters, restored on the next launch. */
96 readonly threadListFilters?: ThreadListFilters;
97 readonly pullRequestListFilters?: PullRequestListPreferences;
64}98}
6599
66export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(100export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(
...@@ -127,6 +161,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -127,6 +161,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
127 threadListSettledShelfExpanded?: boolean;161 threadListSettledShelfExpanded?: boolean;
128 threadListSnoozedShelfExpanded?: boolean;162 threadListSnoozedShelfExpanded?: boolean;
129 threadListWorkingShelfExpanded?: boolean;163 threadListWorkingShelfExpanded?: boolean;
164 threadListFilters?: ThreadListFilters;
165 pullRequestListFilters?: PullRequestListPreferences;
130 } = {};166 } = {};
131167
132 if (typeof parsed.pullRequestHideTestFiles === "boolean") {168 if (typeof parsed.pullRequestHideTestFiles === "boolean") {
...@@ -241,6 +277,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -241,6 +277,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {
241 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {277 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
242 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;278 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
243 }279 }
280 if (isThreadListFilters(parsed.threadListFilters)) {
281 preferences.threadListFilters = parsed.threadListFilters;
282 }
283 if (isPullRequestListPreferences(parsed.pullRequestListFilters)) {
284 preferences.pullRequestListFilters = parsed.pullRequestListFilters;
285 }
244 return preferences;286 return preferences;
245}287}
246288
docs/operations/mobile-screens-fork.md+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1# Building the mobile Screens dependency1# Building the mobile Screens dependency
22
3The mobile v5 stack uses a committed tarball from the3The mobile v5 stack uses a committed tarball from the
4[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.25).4[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.26).
5The fork owns the integration changes; the app does not patch this package.5The fork owns the integration changes; the app does not patch this package.
6Each archive includes compiled JavaScript, declarations, native source and6Each archive includes compiled JavaScript, declarations, native source and
7`t3-fork.json` recording its version, source commit and upstream base.7`t3-fork.json` recording its version, source commit and upstream base.
pnpm-lock.yaml+9-9
...@@ -348,7 +348,7 @@ importers:...@@ -348,7 +348,7 @@ importers:
348 version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)348 version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
349 '@react-navigation/native-stack':349 '@react-navigation/native-stack':
350 specifier: 7.17.6350 specifier: 7.17.6
351 version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(db03c4b8a562e81843953ea133ec37d5)351 version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(02486f76aa3d9c7d88119682fcbddcde)
352 '@shikijs/core':352 '@shikijs/core':
353 specifier: 4.2.0353 specifier: 4.2.0
354 version: 4.2.0354 version: 4.2.0
...@@ -548,8 +548,8 @@ importers:...@@ -548,8 +548,8 @@ importers:
548 specifier: ~5.9.1548 specifier: ~5.9.1
549 version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)549 version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
550 react-native-screens:550 react-native-screens:
551 specifier: file:deps/react-native-screens-5.0.0-t3.25.tgz551 specifier: file:deps/react-native-screens-5.0.0-t3.26.tgz
552 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)552 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
553 react-native-shiki-engine:553 react-native-shiki-engine:
554 specifier: ^0.3.12554 specifier: ^0.3.12
555 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)555 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
...@@ -10891,9 +10891,9 @@ packages:...@@ -10891,9 +10891,9 @@ packages:
10891 react: '*'10891 react: '*'
10892 react-native: '*'10892 react-native: '*'
1089310893
10894 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz:10894 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz:
10895 resolution: {integrity: sha512-ACFyV8nA8zECX5EjmenA3ABTnxKRjb+r5I1ZCX4+EALjhsYdqEEDoQ8/G7OAMjzdp3WEOvIyGmGDOinjGOjPWg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz}10895 resolution: {integrity: sha512-OD1yeZu1HKuvBaroReihFROaofzuF2w4ORtPJXJrpAGk8AgQNusy3KaRRpeiCwIzH2KQU52SnWxrtKaYMzzQ+A==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz}
10896 version: 5.0.0-t3.2510896 version: 5.0.0-t3.26
10897 peerDependencies:10897 peerDependencies:
10898 react: '*'10898 react: '*'
10899 react-native: '*'10899 react-native: '*'
...@@ -16096,7 +16096,7 @@ snapshots:...@@ -16096,7 +16096,7 @@ snapshots:
16096 optionalDependencies:16096 optionalDependencies:
16097 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)16097 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1609816098
16099 '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(db03c4b8a562e81843953ea133ec37d5)':16099 '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(02486f76aa3d9c7d88119682fcbddcde)':
16100 dependencies:16100 dependencies:
16101 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)16101 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)
16102 '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)16102 '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
...@@ -16104,7 +16104,7 @@ snapshots:...@@ -16104,7 +16104,7 @@ snapshots:
16104 react: 19.3.016104 react: 19.3.0
16105 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)16105 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
16106 react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)16106 react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16107 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)16107 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16108 sf-symbols-typescript: 2.2.016108 sf-symbols-typescript: 2.2.0
16109 warn-once: 0.1.116109 warn-once: 0.1.1
16110 transitivePeerDependencies:16110 transitivePeerDependencies:
...@@ -22097,7 +22097,7 @@ snapshots:...@@ -22097,7 +22097,7 @@ snapshots:
22097 react: 19.3.022097 react: 19.3.0
22098 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)22098 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
2209922099
22100 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):22100 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
22101 dependencies:22101 dependencies:
22102 react: 19.3.022102 react: 19.3.0
22103 react-freeze: 1.0.4(react@19.3.0)22103 react-freeze: 1.0.4(react@19.3.0)