diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz deleted file mode 100644 index 5b24c6710157c811e1988e4c0f0de38b7d5ca548..0000000000000000000000000000000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ee21d1ffd5c1f15a57326e2508665bcf8050ebb0 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 66a92abb93ab0e45ba727861da4597c6bfad1173..c4175c317d3b426f85b9da9444f7fcc4656069f2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -126,7 +126,7 @@ "react-native-keyboard-controller": "1.22.6", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.25.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.26.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7b75e4ae69b21213b451d1fee9fdbe4ff2e933b4..8ce82e9518209aa2a8b99776d18991bb904bfd38 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -127,15 +127,15 @@ export function HomeRouteScreen() { } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const projectScopeKey = listOptions.selectedProjectKey; - const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { + const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { sidebarProjectGroupingMode: listOptions.projectGroupingMode, sidebarProjectGroupingOverrides: {}, }); useEffect(() => { - if (selectedProjectKey !== projectScopeKey) { - setSelectedProjectKey(selectedProjectKey); + if (resolvedProjectKey !== projectScopeKey) { + setSelectedProjectKey(resolvedProjectKey); } - }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]); + }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ @@ -148,14 +148,12 @@ export function HomeRouteScreen() { })), [listOptions.projectGroupingMode, projects, selectedEnvironmentId], ); - useEffect(() => { - if ( - selectedProjectKey !== null && - !projectFilterOptions.some((project) => project.key === selectedProjectKey) - ) { - setSelectedProjectKey(null); - } - }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); + // A saved project stays selected while its environment is offline or filtered out. + const selectedProjectKey = projectFilterOptions.some( + (project) => project.key === resolvedProjectKey, + ) + ? resolvedProjectKey + : null; // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index a03e07ed1dee36785ad481976876f9d65abc6be7..b0215dd5b494f56591c189c2bd5ef85d8607dfac 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -24,7 +24,7 @@ export function ThreadListFilters( { props.onEnvironmentChange(null); props.onProjectChange(null); diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 4cdb5972224662030d7e4149f6381197a80e8883..c49d9c786ba98a0a5bd79bf5d718a9bac7c02b49 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -1,8 +1,24 @@ +import { RegistryContext } from "@effect/atom-react"; import { EnvironmentId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/reactivity"; import { act, createElement, useLayoutEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +const saved = vi.hoisted(() => [] as unknown[]); +vi.mock("../../state/preferences", async () => { + const { Atom } = await import("effect/reactivity"); + return { + updateMobilePreferencesAtom: Atom.writable( + () => null, + (_ctx, patch: unknown) => { + saved.push(patch); + }, + ), + }; +}); + +import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options"; describe("home list options across layout changes", () => { @@ -19,14 +35,20 @@ describe("home list options across layout changes", () => { return null; } - async function showLayout(layout: "compact" | "sidebar") { + const registry = AtomRegistry.make(); + + async function showLayout(layout: "compact" | "sidebar", savedFilters?: ThreadListFilters) { await act(() => { root.render( createElement( - HomeListOptionsProvider, - { projectGroupingMode: "repository" }, - // Replace the list while keeping the workspace provider mounted. - createElement(ThreadList, { key: layout }), + RegistryContext, + { value: registry }, + createElement( + HomeListOptionsProvider, + { projectGroupingMode: "repository", savedFilters }, + // Replace the list while keeping the workspace provider mounted. + createElement(ThreadList, { key: layout }), + ), ), ); }); @@ -51,6 +73,7 @@ describe("home list options across layout changes", () => { vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); root = createRoot(container as unknown as HTMLElement); + saved.length = 0; }); afterEach(async () => { @@ -92,4 +115,16 @@ describe("home list options across layout changes", () => { } }, ); + + it("restores saved filters on launch and saves only real changes", async () => { + await showLayout("compact", { environmentId, projectKey: "repository:one" }); + expect(latest.options).toMatchObject({ + selectedEnvironmentId: environmentId, + selectedProjectKey: "repository:one", + }); + expect(saved).toEqual([]); + + await act(() => latest.setSelectedProjectKey(null)); + expect(saved).toEqual([{ threadListFilters: { environmentId, projectKey: null } }]); + }); }); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index c9f8e3277def1cdf14b3e560e461cfece38eb09a..422ea23c17d62a199e07c84ce8c864c885b6687d 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,17 +1,22 @@ import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; import { createContext, createElement, useCallback, useContext, + useEffect, useMemo, + useRef, useState, type PropsWithChildren, type Dispatch, type SetStateAction, } from "react"; +import type { ThreadListFilters } from "../../persistence/mobile-preferences"; +import { updateMobilePreferencesAtom } from "../../state/preferences"; import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { @@ -24,10 +29,10 @@ export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } -function defaultHomeListOptions(): HomeListOptions { +function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions { return { - selectedEnvironmentId: null, - selectedProjectKey: null, + selectedEnvironmentId: filters?.environmentId ?? null, + selectedProjectKey: filters?.projectKey ?? null, projectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" @@ -43,14 +48,31 @@ interface HomeListOptionsContextValue { const HomeListOptionsContext = createContext(null); -/** Keeps list preferences stable while the app moves between compact and split shells. */ +/** + * Keeps list preferences stable while the app moves between compact and split shells. + * Filters start from the saved preference, and changes are saved for the next launch. + */ export function HomeListOptionsProvider({ children, projectGroupingMode, + savedFilters, }: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly savedFilters?: ThreadListFilters | undefined; }>) { - const [options, setOptions] = useState(defaultHomeListOptions); + const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters)); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const saved = useRef(savedFilters); + const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options; + useEffect(() => { + if ( + (saved.current?.environmentId ?? null) === environmentId && + (saved.current?.projectKey ?? null) === projectKey + ) + return; + saved.current = { environmentId, projectKey }; + savePreferences({ threadListFilters: saved.current }); + }, [environmentId, projectKey, savePreferences]); const value = useMemo( () => ({ options, setOptions, projectGroupingMode }), [options, projectGroupingMode], diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c0572321e892a5a76d4ceebbebb515f517b3b39b..b750fd5f94a83869cb27d5c661ee895f03e2d198 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -46,6 +46,7 @@ import { resolveThreadSelectionOverlayState, } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { mobilePreferencesAtom } from "../../state/preferences"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, @@ -234,6 +235,7 @@ export function AdaptiveWorkspaceLayout(props: { ); } @@ -245,6 +247,7 @@ function AdaptiveWorkspaceLayoutContent( readonly workspaceRouteKey: string | undefined; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly savedThreadListFilters?: ThreadListFilters | undefined; }, ) { const projectGroupingMode = props.projectGroupingMode; @@ -680,7 +683,10 @@ function AdaptiveWorkspaceLayoutContent( if (nativeWorkspace) { return ( - + 0 }} @@ -692,7 +698,10 @@ function AdaptiveWorkspaceLayoutContent( ); } return ( - + item.environmentId === selectedEnvironmentId) ? selectedEnvironmentId : null; @@ -95,9 +97,7 @@ export function PullRequestsScreen() { }, [navigation, selectAndroidWorkspace], ); - const controlsAtom = pullRequestListControlsAtom(environmentId ?? ""); - const controls = useAtomValue(controlsAtom); - const setControls = useAtomSet(controlsAtom); + const controls = listControls.controlsFor(environmentId); const targets = useMemo( () => environments @@ -269,10 +269,10 @@ export function PullRequestsScreen() { label: item.environmentLabel, }))} environmentId={environmentId} - onEnvironmentChange={workspace.setPullRequestEnvironmentId} + onEnvironmentChange={listControls.setEnvironmentId} value={controls} onChange={(next) => { - setControls(next); + listControls.setControls(environmentId, next); void listRef.current?.scrollToOffset({ offset: Platform.OS === "ios" ? -insets.top : 0, animated: false, diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index e6f2d653f69e6f748b01141428f5ffe29e53940f..922755e00306f11b8cd3ab1c2285d9a7411f260b 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -1,22 +1,13 @@ import type { EnvironmentId, PullRequestListEntry, - PullRequestListFilters, PullRequestListInput, PullRequestListResult, - PullRequestInvolvement, - PullRequestListState, } from "@t3tools/contracts"; +import type { PullRequestListControls } from "../../persistence/mobile-preferences"; export const PR_LIST_PAGE_SIZE = 30; -export interface PullRequestListControls { - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly filters: PullRequestListFilters; - readonly projectId?: string; - readonly projectEnvironmentId?: string; - readonly sort: "updated" | "created" | "oldest"; -} +export type { PullRequestListControls } from "../../persistence/mobile-preferences"; export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = { state: "open", involvement: "all", diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 510c2cc622225f10d4ac43ffe7848c683ced1a81..5852cf65bd8f4072c9790fbf3b346cb30621bc30 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -8,7 +8,6 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect import { composerPullRequests } from "../../state/pull-requests"; import { appendPullRequestListPage, - DEFAULT_PR_LIST_CONTROLS, mergeEnvironmentPullRequestPages, nextPullRequestListInput, pullRequestListHead, @@ -28,10 +27,6 @@ const statsFamily = Atom.family((_environmentId: string) => const statKey = (row: Pick) => `${row.projectId}:${row.repository}:${row.number}`; -export const pullRequestListControlsAtom = Atom.family((_environmentId: string) => - Atom.make(DEFAULT_PR_LIST_CONTROLS), -); - type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput }; const sourcesFamily = Atom.family((key: string) => { const targets: readonly ListTarget[] = JSON.parse(key); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts b/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts new file mode 100644 index 0000000000000000000000000000000000000000..b64eed69fa1096392f2d4ab2f5a1ddd7e889c793 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts @@ -0,0 +1,48 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; +import { useCallback } from "react"; + +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; + +/** The pull request list's environment and its filters, restored on the next launch. */ +export function usePullRequestListControls() { + const preferences = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const saved = AsyncResult.isSuccess(preferences) + ? preferences.value.pullRequestListFilters + : undefined; + const environmentId = saved?.environmentId ?? null; + const controlsFor = useCallback( + (environment: string | null) => saved?.controls[environment ?? ""] ?? DEFAULT_PR_LIST_CONTROLS, + [saved], + ); + const setEnvironmentId = useCallback( + (next: string | null) => + savePreferences({ + transform: (current) => ({ + pullRequestListFilters: { + controls: current.pullRequestListFilters?.controls ?? {}, + environmentId: next, + }, + }), + }), + [savePreferences], + ); + const setControls = useCallback( + (environment: string | null, controls: PullRequestListControls) => + savePreferences({ + transform: (current) => ({ + pullRequestListFilters: { + environmentId: current.pullRequestListFilters?.environmentId ?? null, + controls: { + ...current.pullRequestListFilters?.controls, + [environment ?? ""]: controls, + }, + }, + }), + }), + [savePreferences], + ); + return { environmentId, controlsFor, setEnvironmentId, setControls } as const; +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d920336111da4be3c34211da87ce098bf5aea9c7..92cc958a8c90bc75d8665d2480ea22f994c83a3b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -208,15 +208,15 @@ function ThreadNavigationSidebarPane( [threadSearch.matches], ); const projectScopeKey = options.selectedProjectKey; - const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { + const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { sidebarProjectGroupingMode: options.projectGroupingMode, sidebarProjectGroupingOverrides: {}, }); useEffect(() => { - if (selectedProjectKey !== projectScopeKey) { - setSelectedProjectKey(selectedProjectKey); + if (resolvedProjectKey !== projectScopeKey) { + setSelectedProjectKey(resolvedProjectKey); } - }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]); + }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); const projectScopes = useMemo( () => buildHomeProjectScopes({ @@ -250,20 +250,11 @@ function ThreadNavigationSidebarPane( [projectScopes], ); const selectedProjectScope = useMemo( - () => - selectedProjectKey === null - ? null - : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null), - [projectScopes, selectedProjectKey], + () => projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null, + [projectScopes, resolvedProjectKey], ); - useEffect(() => { - if ( - selectedProjectKey !== null && - !projectFilterOptions.some((project) => project.key === selectedProjectKey) - ) { - setSelectedProjectKey(null); - } - }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); + // A saved project stays selected while its environment is offline or filtered out. + const selectedProjectKey = selectedProjectScope?.key ?? null; const selectedProjectRefs = useMemo( () => selectedProjectScope === null diff --git a/apps/mobile/src/features/workspace/ListFilterMenu.tsx b/apps/mobile/src/features/workspace/ListFilterMenu.tsx index dcc455fc5e48d110c6d6b1d681fb2d99a4232b3e..b645722f53dd5a38bc78eaaffc16d5371d111625 100644 --- a/apps/mobile/src/features/workspace/ListFilterMenu.tsx +++ b/apps/mobile/src/features/workspace/ListFilterMenu.tsx @@ -1,9 +1,20 @@ +import { GlassView } from "expo-glass-effect"; import { Pressable, View } from "react-native"; +import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { ControlPillMenu } from "../../components/ControlPillMenu"; import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; import { cn } from "../../lib/cn"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +const FilterGlass = withUniwind(GlassView, { + style: { fromClassName: "className" }, + tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, +}); + +const PILL = "h-8 flex-row items-center gap-1.5 rounded-full px-3"; /** A visible filter with a native menu containing only that filter's choices. */ export function ListFilterMenu(props: { @@ -14,6 +25,34 @@ export function ListFilterMenu(props: { readonly actions: ControlPillMenuProps["actions"]; readonly onSelect: (id: string) => void; }) { + const { themeAppearance } = useAppearancePreferences(); + const content = ( + <> + {props.icon ? ( + + ) : null} + + {props.label} + + {!props.icon ? ( + + ) : null} + + ); return ( - - {props.icon ? ( - - ) : null} - - {props.label} - - {!props.icon ? ( - - ) : null} - + {content} + + ) : ( + {content} + )} ); diff --git a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx index f5490402a5eb718fa4d599d1af6511643a369115..d78470b67da729eb57fe63a9a3793c199929c31f 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx @@ -1,7 +1,12 @@ import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native"; -import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens"; +import { + Tabs, + type TabSelectedEvent, + type TabSelectionPreventedEvent, + type TabSelectionRejectedEvent, +} from "react-native-screens"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; @@ -10,6 +15,7 @@ import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; // Tabler's git-pull-request geometry, rasterized at 1x/2x/3x for UIKit template images. const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); +const COMPOSE_TAB = "compose"; /** Retain each native controller tree; removing its root can leave RNS's stack empty. */ function RetainedWorkspaceTab(props: { @@ -39,7 +45,11 @@ function RetainedWorkspaceTab(props: { ) : null; } -/** UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class. */ +/** + * UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class. + * On phones, compose is UIKit's own search-role tab, detached like Photos' search button; it never + * becomes selected and opens a new chat instead. + */ export function WorkspaceNativeTabs(props: { readonly children: ReactNode; readonly hidden: boolean; @@ -62,13 +72,11 @@ export function WorkspaceNativeTabs(props: { ) => { + if (nativeEvent.preventedScreenKey === COMPOSE_TAB) props.onNewTask(); }} onTabSelectionRejected={({ nativeEvent, @@ -89,7 +97,7 @@ export function WorkspaceNativeTabs(props: { ))} + {phoneBar ? ( + + + + ) : null} ); diff --git a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx index ae705c9b158802200aff90d5b1e53e636ab23c7c..c875a7f0e7157af456a26491ffc6503d9eb294a8 100644 --- a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx @@ -6,7 +6,6 @@ import { useMemo, useReducer, useRef, - useState, type Dispatch, type ReactNode, } from "react"; @@ -32,10 +31,6 @@ export const WorkspaceTabsContext = createContext<{ readonly recordScrollOffset: (tab: WorkspaceTab, offset: number) => void; readonly getScrollAnchor: (tab: WorkspaceTab) => WorkspaceScrollAnchor | undefined; readonly recordScrollAnchor: (tab: WorkspaceTab, anchor: WorkspaceScrollAnchor) => void; - readonly threadsProjectKey: string | null; - readonly setThreadsProjectKey: (key: string | null) => void; - readonly pullRequestEnvironmentId: string | null; - readonly setPullRequestEnvironmentId: (id: string | null) => void; } | null>(null); export function useWorkspaceTabs() { @@ -50,8 +45,6 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) { activeTab.current = state.selectedTab; }, [state.selectedTab]); const isTabActive = useCallback((tab: WorkspaceTab) => activeTab.current === tab, []); - const [threadsProjectKey, setThreadsProjectKey] = useState(null); - const [pullRequestEnvironmentId, setPullRequestEnvironmentId] = useState(null); const scrollOffsets = useRef({ threads: 0, pullRequests: 0 }); const scrollAnchors = useRef>>({}); const getScrollOffset = useCallback((tab: WorkspaceTab) => scrollOffsets.current[tab], []); @@ -72,21 +65,8 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) { recordScrollOffset, getScrollAnchor, recordScrollAnchor, - threadsProjectKey, - setThreadsProjectKey, - pullRequestEnvironmentId, - setPullRequestEnvironmentId, }), - [ - state, - isTabActive, - getScrollOffset, - recordScrollOffset, - getScrollAnchor, - recordScrollAnchor, - threadsProjectKey, - pullRequestEnvironmentId, - ], + [state, isTabActive, getScrollOffset, recordScrollOffset, getScrollAnchor, recordScrollAnchor], ); return {props.children}; } diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index f56c3a7eff0efc05112e892a166eafbb5ef22a1e..b9f7a17b4d5ff09fff52b2e40c7bd5f70738c9e8 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -276,6 +276,32 @@ describe("mobile connection storage", () => { await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); }); + it("keeps valid list filters and drops malformed ones", async () => { + const threadListFilters = { environmentId: "environment-1", projectKey: null }; + const pullRequestListFilters = { + environmentId: null, + controls: { + "": { + state: "all", + involvement: "reviewing", + filters: { labels: [["size:S"]] }, + sort: "oldest", + }, + }, + }; + mocks.setPreferencesJson(JSON.stringify({ threadListFilters, pullRequestListFilters }), 10); + await expect(loadPreferences()).resolves.toEqual({ threadListFilters, pullRequestListFilters }); + + mocks.setPreferencesJson( + JSON.stringify({ + threadListFilters: { environmentId: 1, projectKey: null }, + pullRequestListFilters: { environmentId: null, controls: { "": { sort: "newest" } } }, + }), + 11, + ); + await expect(loadPreferences()).resolves.toEqual({}); + }); + it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 090f1fdb7ca3329fca631189fdc77976aa7adcbe..808383183e4c17b3cf025c763e7a70de38b7f72b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,10 +5,14 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { - ProviderInstanceId, - PullRequestMergeMethod, - SidebarProjectGroupingMode, +import { + EnvironmentId, + PullRequestInvolvement, + PullRequestListFilters, + PullRequestListState, + type ProviderInstanceId, + type PullRequestMergeMethod, + type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import type { FollowUpBehavior } from "../lib/followUpBehavior"; @@ -21,6 +25,33 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; +/** The pull request list's filters and sort for one environment scope. */ +export const PullRequestListControls = Schema.Struct({ + state: PullRequestListState, + involvement: PullRequestInvolvement, + filters: PullRequestListFilters, + projectId: Schema.optional(Schema.String), + projectEnvironmentId: Schema.optional(Schema.String), + sort: Schema.Literals(["updated", "created", "oldest"]), +}); +export type PullRequestListControls = typeof PullRequestListControls.Type; + +const ThreadListFilters = Schema.Struct({ + environmentId: Schema.NullOr(EnvironmentId), + projectKey: Schema.NullOr(Schema.String), +}); +export type ThreadListFilters = typeof ThreadListFilters.Type; + +/** Controls are keyed by environment id, with "" for all environments. */ +const PullRequestListPreferences = Schema.Struct({ + environmentId: Schema.NullOr(Schema.String), + controls: Schema.Record(Schema.String, PullRequestListControls), +}); +export type PullRequestListPreferences = typeof PullRequestListPreferences.Type; + +const isThreadListFilters = Schema.is(ThreadListFilters); +const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); + export interface Preferences { readonly pullRequestHideTestFiles?: boolean; readonly pullRequestMergeMethod?: PullRequestMergeMethod; @@ -61,6 +92,9 @@ export interface Preferences { readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; readonly threadListWorkingShelfExpanded?: boolean; + /** Home list filters, restored on the next launch. */ + readonly threadListFilters?: ThreadListFilters; + readonly pullRequestListFilters?: PullRequestListPreferences; } export class MobilePreferencesLoadError extends Schema.TaggedError()( @@ -127,6 +161,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; threadListWorkingShelfExpanded?: boolean; + threadListFilters?: ThreadListFilters; + pullRequestListFilters?: PullRequestListPreferences; } = {}; if (typeof parsed.pullRequestHideTestFiles === "boolean") { @@ -241,6 +277,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListWorkingShelfExpanded === "boolean") { preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded; } + if (isThreadListFilters(parsed.threadListFilters)) { + preferences.threadListFilters = parsed.threadListFilters; + } + if (isPullRequestListPreferences(parsed.pullRequestListFilters)) { + preferences.pullRequestListFilters = parsed.pullRequestListFilters; + } return preferences; } diff --git a/docs/operations/mobile-screens-fork.md b/docs/operations/mobile-screens-fork.md index d789159b07f1d80e1cc47770e1aeb6231c7d34fd..712edaeb11a11c59eb003e91e46c835a0e39d5ea 100644 --- a/docs/operations/mobile-screens-fork.md +++ b/docs/operations/mobile-screens-fork.md @@ -1,7 +1,7 @@ # Building the mobile Screens dependency The mobile v5 stack uses a committed tarball from the -[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.25). +[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.26). The fork owns the integration changes; the app does not patch this package. Each archive includes compiled JavaScript, declarations, native source and `t3-fork.json` recording its version, source commit and upstream base. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 772f580c1402ff6fa745708373eed32158ef2bb5..a944d1fc91e8de7094a358d006e66584879b80ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -348,7 +348,7 @@ importers: 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) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(db03c4b8a562e81843953ea133ec37d5) + version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(02486f76aa3d9c7d88119682fcbddcde) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -548,8 +548,8 @@ importers: specifier: ~5.9.1 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) react-native-screens: - specifier: file:deps/react-native-screens-5.0.0-t3.25.tgz - 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) + specifier: file:deps/react-native-screens-5.0.0-t3.26.tgz + 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) react-native-shiki-engine: specifier: ^0.3.12 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: react: '*' react-native: '*' - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz: - resolution: {integrity: sha512-ACFyV8nA8zECX5EjmenA3ABTnxKRjb+r5I1ZCX4+EALjhsYdqEEDoQ8/G7OAMjzdp3WEOvIyGmGDOinjGOjPWg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz} - version: 5.0.0-t3.25 + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz: + resolution: {integrity: sha512-OD1yeZu1HKuvBaroReihFROaofzuF2w4ORtPJXJrpAGk8AgQNusy3KaRRpeiCwIzH2KQU52SnWxrtKaYMzzQ+A==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz} + version: 5.0.0-t3.26 peerDependencies: react: '*' react-native: '*' @@ -16096,7 +16096,7 @@ snapshots: optionalDependencies: '@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) - '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(db03c4b8a562e81843953ea133ec37d5)': + '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(02486f76aa3d9c7d88119682fcbddcde)': dependencies: '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) '@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: react: 19.3.0 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-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) - 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) + 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) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -22097,7 +22097,7 @@ snapshots: react: 19.3.0 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-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): + 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): dependencies: react: 19.3.0 react-freeze: 1.0.4(react@19.3.0)