From 9fddde927837a22e1c10fcf05001c7d096cd01a1 Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Sun, 23 Aug 2026 07:11:48 +0100 Subject: [PATCH] fix(mobile): show project favicons in filters --- .../src/components/AndroidAnchoredMenu.tsx | 4 ++ apps/mobile/src/components/ControlPill.tsx | 11 ++++- .../home/AndroidProjectFilterIcon.tsx | 31 +++++++++++++ apps/mobile/src/features/home/HomeHeader.tsx | 21 ++++----- .../src/features/home/HomeRouteScreen.tsx | 3 ++ .../android-home-list-filter-menu.test.ts | 44 +++++++++++++++++++ .../home/android-home-list-filter-menu.ts | 34 ++++++++++++++ .../home/home-list-filter-menu.test.ts | 17 ++++++- .../features/home/home-list-filter-menu.ts | 3 ++ .../threads/ThreadNavigationSidebar.tsx | 12 ++++- 10 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/home/AndroidProjectFilterIcon.tsx create mode 100644 apps/mobile/src/features/home/android-home-list-filter-menu.test.ts create mode 100644 apps/mobile/src/features/home/android-home-list-filter-menu.ts diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 7a27e0c3b131..0ea660cef0fc 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -42,6 +42,8 @@ export type AndroidAnchoredMenuProps = { readonly actions: readonly MenuAction[]; readonly title?: string; readonly onPressAction?: MenuComponentProps["onPressAction"]; + /** Optional Android-only content rendered before the standard trailing state glyph. */ + readonly renderActionTrailing?: (action: MenuAction) => ReactNode; /** Applied to the anchor wrapper — call sites flex these to fill toolbars. */ readonly className?: string; readonly style?: StyleProp; @@ -276,6 +278,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const destructive = action.attributes?.destructive ?? false; const disabled = action.attributes?.disabled ?? false; const hasSubmenu = (action.subactions?.length ?? 0) > 0; + const customTrailing = props.renderActionTrailing?.(action); return ( ) : null} + {customTrailing} {hasSubmenu ? ( , "children" | "themeVariant"> & { readonly children: ReactNode; readonly className?: string; + readonly renderActionTrailing?: AndroidAnchoredMenuProps["renderActionTrailing"]; }, ) { const { themeAppearance } = useAppearancePreferences(); @@ -134,6 +135,7 @@ export function ControlPillMenu( title={props.title} style={props.style} onPressAction={props.onPressAction} + renderActionTrailing={props.renderActionTrailing} > {(open) => cloneElement(child, { @@ -153,13 +155,18 @@ export function ControlPillMenu( title={props.title} style={props.style} onPressAction={props.onPressAction} + renderActionTrailing={props.renderActionTrailing} > {props.children} ); } - const { className: _className, ...menuProps } = props; + const { + className: _className, + renderActionTrailing: _renderActionTrailing, + ...menuProps + } = props; let children = menuProps.children; // In long-press mode the wrapped pressable still receives the touch (the // patched MenuView button is touch-transparent) and RN's Fabric touch diff --git a/apps/mobile/src/features/home/AndroidProjectFilterIcon.tsx b/apps/mobile/src/features/home/AndroidProjectFilterIcon.tsx new file mode 100644 index 000000000000..b1c1c2d134e5 --- /dev/null +++ b/apps/mobile/src/features/home/AndroidProjectFilterIcon.tsx @@ -0,0 +1,31 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { ReactNode } from "react"; + +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { SymbolView } from "../../components/AppSymbol"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { resolveAndroidProjectFilterProject } from "./android-home-list-filter-menu"; +import type { HomeListFilterMenuProject } from "./home-list-filter-menu"; + +export function AndroidProjectFilterIcon(props: { + readonly action: MenuAction; + readonly projects: ReadonlyArray; +}): ReactNode { + const iconColor = useThemeColor("--color-icon"); + if (props.action.id === "project:all") { + return ; + } + + const project = resolveAndroidProjectFilterProject(props.action.id, props.projects); + if (project === null) return null; + + return ( + + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e7ce41cb43bd..d716724583ac 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -21,6 +21,8 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; +import { buildAndroidProjectFilterActions } from "./android-home-list-filter-menu"; +import { AndroidProjectFilterIcon } from "./AndroidProjectFilterIcon"; import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; import { buildHomeListFilterMenu, @@ -101,18 +103,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { { id: "project", title: "Project", - subactions: [ - { - id: "project:all", - title: "All projects", - state: checkedMenuState(props.selectedProjectKey === null), - }, - ...props.projects.map((project) => ({ - id: `project:${project.key}`, - title: project.label, - state: checkedMenuState(props.selectedProjectKey === project.key), - })), - ], + subactions: buildAndroidProjectFilterActions( + props.projects, + props.selectedProjectKey, + ), }, ] satisfies MenuAction[])), ...(threadListV2Enabled @@ -235,6 +229,9 @@ function AndroidHomeHeader(props: HomeHeaderProps) { actions={menuActions} isAnchoredToRight onPressAction={handleMenuAction} + renderActionTrailing={(action) => ( + + )} > ({ key: scope.key, label: scope.title, + environmentId: scope.representative.environmentId, + workspaceRoot: scope.representative.workspaceRoot, + faviconPath: scope.representative.faviconPath ?? null, })), [listOptions.projectGroupingMode, projects, selectedEnvironmentId], ); diff --git a/apps/mobile/src/features/home/android-home-list-filter-menu.test.ts b/apps/mobile/src/features/home/android-home-list-filter-menu.test.ts new file mode 100644 index 000000000000..fe02cd77a064 --- /dev/null +++ b/apps/mobile/src/features/home/android-home-list-filter-menu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + buildAndroidProjectFilterActions, + resolveAndroidProjectFilterProject, +} from "./android-home-list-filter-menu"; + +const projects = [ + { + key: "environment-1:project-1", + label: "Codething", + environmentId: EnvironmentId.make("environment-1"), + workspaceRoot: "/workspace/codething", + faviconPath: "brand/icon.svg", + }, + { + key: "environment-1:project-2", + label: "Website", + environmentId: EnvironmentId.make("environment-1"), + workspaceRoot: "/workspace/website", + faviconPath: null, + }, +] as const; + +describe("buildAndroidProjectFilterActions", () => { + it("keeps project action identity separate from the selected state", () => { + const actions = buildAndroidProjectFilterActions(projects, "environment-1:project-1"); + + expect(actions).toMatchObject([ + { id: "project:all" }, + { id: "project:environment-1:project-1", state: "on" }, + { id: "project:environment-1:project-2" }, + ]); + }); + + it("resolves the matching project data for its recognized favicon", () => { + expect(resolveAndroidProjectFilterProject("project:environment-1:project-1", projects)).toEqual( + projects[0], + ); + expect(resolveAndroidProjectFilterProject("project:all", projects)).toBeNull(); + expect(resolveAndroidProjectFilterProject("environment:all", projects)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/home/android-home-list-filter-menu.ts b/apps/mobile/src/features/home/android-home-list-filter-menu.ts new file mode 100644 index 000000000000..61dbd75c16bd --- /dev/null +++ b/apps/mobile/src/features/home/android-home-list-filter-menu.ts @@ -0,0 +1,34 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +import type { HomeListFilterMenuProject } from "./home-list-filter-menu"; + +function checkedMenuState(checked: boolean) { + return checked ? ("on" as const) : undefined; +} + +export function buildAndroidProjectFilterActions( + projects: ReadonlyArray, + selectedProjectKey: string | null, +): MenuAction[] { + return [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(selectedProjectKey === null), + }, + ...projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(selectedProjectKey === project.key), + })), + ]; +} + +export function resolveAndroidProjectFilterProject( + actionId: string | undefined, + projects: ReadonlyArray, +): HomeListFilterMenuProject | null { + if (!actionId?.startsWith("project:") || actionId === "project:all") return null; + const projectKey = actionId.slice("project:".length); + return projects.find((project) => project.key === projectKey) ?? null; +} diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 99e3cb36c072..cd55febbbcc3 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; @@ -8,8 +9,20 @@ describe("buildHomeListFilterMenu", () => { const menu = buildHomeListFilterMenu({ environments: [], projects: [ - { key: "environment-1:project-1", label: "Codething" }, - { key: "environment-1:project-2", label: "Website" }, + { + key: "environment-1:project-1", + label: "Codething", + environmentId: EnvironmentId.make("environment-1"), + workspaceRoot: "/workspace/codething", + faviconPath: null, + }, + { + key: "environment-1:project-2", + label: "Website", + environmentId: EnvironmentId.make("environment-1"), + workspaceRoot: "/workspace/website", + faviconPath: null, + }, ], selectedEnvironmentId: null, selectedProjectKey: "environment-1:project-1", diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index edd0176f8627..98524ae27af5 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -11,6 +11,9 @@ export interface HomeListFilterMenuEnvironment { export interface HomeListFilterMenuProject { readonly key: string; readonly label: string; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly faviconPath: string | null; } type HomeListFilterMenuAction = { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2e8186fa8e25..01422d653566 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -47,6 +47,7 @@ import { useHomeListOptions, } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; +import { AndroidProjectFilterIcon } from "../home/AndroidProjectFilterIcon"; import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, @@ -284,6 +285,9 @@ function ThreadNavigationSidebarPane( projectScopes.map((scope) => ({ key: scope.key, label: scope.title, + environmentId: scope.representative.environmentId, + workspaceRoot: scope.representative.workspaceRoot, + faviconPath: scope.representative.faviconPath ?? null, })), [projectScopes], ); @@ -1383,7 +1387,13 @@ function ThreadNavigationSidebarPane( } /> - + ( + + )} + >