Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ public final class T3NativeControlsModule: Module {
AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in
try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise)
}.runOnQueue(.main)

Function("renderProjectIconImage") { (text: String, fontSize: Double, size: Double, tint: UIColor?, backgroundAlpha: Double) -> String? in
T3ProjectIconImage.render(
text: text,
fontSize: fontSize,
size: size,
tint: tint,
backgroundAlpha: backgroundAlpha
)
}
}

// UIKit presenters are main-actor isolated rather than run on the JS thread
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import UIKit

/// Draws a project's monogram or emoji tile as a PNG data URI. Native menus take
/// images rather than views, so the iOS thread filter hands them this instead of
/// the React tile ProjectFavicon draws elsewhere. The tint colors the text and,
/// at `backgroundAlpha`, the rounded background.
enum T3ProjectIconImage {
static func render(
text: String,
fontSize: Double,
size: Double,
tint: UIColor?,
backgroundAlpha: Double
) -> String? {
let bounds = CGRect(x: 0, y: 0, width: size, height: size)
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedSystemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: tint ?? UIColor.label,
]
let image = UIGraphicsImageRenderer(size: bounds.size).image { _ in
if let tint, backgroundAlpha > 0 {
tint.withAlphaComponent(backgroundAlpha).setFill()
UIBezierPath(roundedRect: bounds, cornerRadius: size * 0.25).fill()
}
let string = text as NSString
let textSize = string.size(withAttributes: attributes)
string.draw(
at: CGPoint(x: (size - textSize.width) / 2, y: (size - textSize.height) / 2),
withAttributes: attributes
)
}
return image.pngData().map { "data:image/png;base64,\($0.base64EncodedString())" }
}
}
10 changes: 5 additions & 5 deletions apps/mobile/src/components/AndroidAnchoredMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu";
import type { MenuComponentProps } from "@react-native-menu/menu";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { StyleProp, ViewInstance, ViewStyle } from "react-native";
Expand All @@ -8,7 +8,7 @@ import Animated, { FadeIn } from "react-native-reanimated";

import { OverlayPortal } from "./OverlayPortal";
import { useAndroidControlSizing } from "./useAndroidControlSizing";
import { MaterialMenuPopup } from "./MaterialMenuPopup";
import { MaterialMenuPopup, type AndroidMenuAction } from "./MaterialMenuPopup";

const SCREEN_MARGIN = 12;
const ANCHOR_GAP = 6;
Expand All @@ -34,7 +34,7 @@ type OverlayFrame = {
};

export type AndroidAnchoredMenuProps = {
readonly actions: readonly MenuAction[];
readonly actions: readonly AndroidMenuAction[];
readonly title?: string;
readonly onPressAction?: MenuComponentProps["onPressAction"];
/** Applied to the anchor wrapper — call sites flex these to fill toolbars. */
Expand All @@ -57,7 +57,7 @@ export type AndroidAnchoredMenuProps = {
export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing();
const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
const [path, setPath] = useState<readonly MenuAction[]>([]);
const [path, setPath] = useState<readonly AndroidMenuAction[]>([]);
// Height of the modal's root view, in the modal's own coordinate space.
// Menus that flip above their anchor are pinned by their BOTTOM edge
// (bottom = rootHeight - anchorTop), so drill-in height changes grow
Expand Down Expand Up @@ -158,7 +158,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const placeable = local !== null && rootHeight !== null;

const onPressItem = useCallback(
(action: MenuAction) => {
(action: AndroidMenuAction) => {
if ((action.subactions?.length ?? 0) > 0) {
setPath((current) => [...current, action]);
return;
Expand Down
21 changes: 19 additions & 2 deletions apps/mobile/src/components/MaterialMenuPopup.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ function MenuIcon(props: {
/** Native popup positioned at the original trigger, outside virtualized rows. */
export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences();
const { scale, menuItemHeight } = useAndroidControlSizing();
const { scale, menuItemHeight, iconSize } = useAndroidControlSizing();
const body = resolveScaledTextRole("body", appearance.baseFontSize);
const caption = resolveScaledTextRole("caption", appearance.baseFontSize);
const foreground = colors["--color-foreground"];
const muted = colors["--color-foreground-muted"];
// A fixed native item height clips wrapped labels; a minimum lets each row grow.
const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })];
// Rows without a leading view keep its slot so labels line up, as in Material menus.
const reserveLeading = props.actions.some((action) => action.leading);
const items = (
<>
{props.parent ? (
Expand Down Expand Up @@ -84,14 +86,29 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
modifiers={itemModifiers}
onClick={() => props.onPress(action)}
>
{action.image && isAppSymbolName(action.image) ? (
{action.leading ? (
<DropdownMenuItem.LeadingIcon>
<RNHostView matchContents modifiers={[size(iconSize, iconSize)]}>
<View
style={{ width: iconSize, height: iconSize }}
importantForAccessibility="no-hide-descendants"
>
{action.leading}
</View>
</RNHostView>
</DropdownMenuItem.LeadingIcon>
) : action.image && isAppSymbolName(action.image) ? (
<DropdownMenuItem.LeadingIcon>
<MenuIcon
name={action.image}
destructive={action.attributes?.destructive}
disabled={action.attributes?.disabled}
/>
</DropdownMenuItem.LeadingIcon>
) : reserveLeading ? (
<DropdownMenuItem.LeadingIcon>
<Box modifiers={[size(iconSize, iconSize)]} />
</DropdownMenuItem.LeadingIcon>
) : null}
<DropdownMenuItem.Text>
<Column>
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/components/MaterialMenuPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { MenuAction } from "@react-native-menu/menu";
import type { ReactNode } from "react";

/** A menu action whose `leading` view, such as a project favicon, replaces its symbol icon. */
export type AndroidMenuAction = Omit<MenuAction, "subactions"> & {
readonly leading?: ReactNode;
readonly subactions?: AndroidMenuAction[];
};

export interface MaterialMenuPopupProps {
readonly menuWidth: number;
Expand All @@ -8,10 +15,10 @@ export interface MaterialMenuPopupProps {
readonly width: number;
readonly height: number;
};
readonly actions: readonly MenuAction[];
readonly actions: readonly AndroidMenuAction[];
readonly title?: string;
readonly parent: MenuAction | null;
readonly onPress: (action: MenuAction) => void;
readonly parent: AndroidMenuAction | null;
readonly onPress: (action: AndroidMenuAction) => void;
readonly onBack: () => void;
readonly onClose: () => void;
/** Keep the editor's window focus and keyboard while showing native menu rows. */
Expand Down
33 changes: 23 additions & 10 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deriveProjectIdentity } from "@t3tools/client-runtime/project-identity";
import { SymbolView } from "./AppSymbol";
import { AppText } from "./AppText";
import { Image } from "expo-image";
Expand Down Expand Up @@ -32,7 +33,6 @@ const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
/* ─── Component ──────────────────────────────────────────────────────── */
export const ProjectFavicon = memo(function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
readonly open?: boolean;
readonly size?: number;
readonly projectTitle: string;
readonly workspaceRoot?: string | null;
Expand Down Expand Up @@ -83,7 +83,6 @@ export const ProjectFavicon = memo(function ProjectFavicon(props: {
key={cacheKey}
cacheKey={cacheKey}
faviconUrl={renderableFaviconUrl}
open={props.open}
projectTitle={props.projectTitle}
size={size}
/>
Expand Down Expand Up @@ -140,10 +139,30 @@ function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonl
);
}

/** Stands in for a missing or still-loading favicon, like web's automatic project icon. */
function ProjectFaviconFallback(props: { readonly projectTitle: string; readonly size: number }) {
if (props.projectTitle.trim().length === 0) {
return (
<SymbolView
name={{ ios: "folder.fill", android: "folder" }}
size={props.size}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
/>
);
}
const identity = deriveProjectIdentity(props.projectTitle);
return (
<ProjectIconGlyphView
glyph={{ kind: "monogram", text: identity.monogram, color: identity.color }}
size={props.size}
/>
);
}

function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
readonly open?: boolean;
readonly projectTitle: string;
readonly size: number;
}) {
Expand Down Expand Up @@ -178,14 +197,8 @@ function ProjectFaviconImage(props: {
justifyContent: "center",
}}
>
{/* Folder icon fallback (matches web's FolderIcon) */}
{!showImage ? (
<SymbolView
name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
size={props.size}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
/>
<ProjectFaviconFallback projectTitle={props.projectTitle} size={props.size} />
) : null}

{/* Favicon image (hidden until loaded) */}
Expand Down
27 changes: 23 additions & 4 deletions apps/mobile/src/features/home/HomeHeader.android.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { MenuAction } from "@react-native-menu/menu";
import { useCallback, useMemo } from "react";
import type { AndroidMenuAction } from "../../components/MaterialMenuPopup";
import { ProjectFavicon } from "../../components/ProjectFavicon";
import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
import type { HomeHeaderProps } from "./HomeHeader.types";
Expand All @@ -11,12 +13,13 @@ function checkedMenuState(checked: boolean) {
}

export function HomeHeader(props: HomeHeaderProps) {
const { iconSize } = useAndroidControlSizing();
// The list uses a fixed creation order and ignores sort/group options, so
// the filter menu only carries the filters and the "customized" icon state
// keys off those alone.
const hasCustomListOptions =
props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
const menuActions = useMemo<MenuAction[]>(
const menuActions = useMemo<AndroidMenuAction[]>(
() => [
{
id: "environment",
Expand Down Expand Up @@ -50,12 +53,28 @@ export function HomeHeader(props: HomeHeaderProps) {
id: `project:${project.key}`,
title: project.label,
state: checkedMenuState(props.selectedProjectKey === project.key),
leading: project.representative ? (
<ProjectFavicon
environmentId={project.representative.environmentId}
faviconPath={project.representative.faviconPath}
projectIcon={project.representative.projectIcon}
projectTitle={project.representative.title}
size={iconSize}
workspaceRoot={project.representative.workspaceRoot}
/>
) : undefined,
})),
],
},
] satisfies MenuAction[])),
] satisfies AndroidMenuAction[])),
],
[
iconSize,
props.environments,
props.projects,
props.selectedEnvironmentId,
props.selectedProjectKey,
],
[props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey],
);
const handleMenuAction = useCallback(
(event: { nativeEvent: { event: string } }) => {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function HomeRouteScreen() {
}).map((scope) => ({
key: scope.key,
label: scope.title,
representative: scope.representative,
})),
[listOptions.projectGroupingMode, projects, selectedEnvironmentId],
);
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,15 @@ export function HomeScreen(props: HomeScreenProps) {
}),
[props.projectGroupingMode, props.projects, props.selectedEnvironmentId],
);
const projectFilterOptions = useMemo(
() =>
projectScopes.map((scope) => ({
key: scope.key,
label: scope.title,
representative: scope.representative,
})),
[projectScopes],
);
const hasSearchQuery = props.searchQuery.trim().length > 0;
const projectByKey = useMemo(() => {
const map = new Map<string, EnvironmentProject>();
Expand Down Expand Up @@ -986,7 +995,7 @@ export function HomeScreen(props: HomeScreenProps) {
{Platform.OS === "ios" && workspace ? (
<ThreadListFilters
environments={props.environments}
projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))}
projects={projectFilterOptions}
selectedEnvironmentId={props.selectedEnvironmentId}
selectedProjectKey={props.selectedProjectKey}
onEnvironmentChange={props.onEnvironmentChange}
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/home/MaterialThreadListToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import {
type LayoutChangeEvent,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { MenuAction } from "@react-native-menu/menu";

import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
import { CompactBrandTitle } from "../../components/CompactBrandTitle";
import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
import type { AndroidMenuAction } from "../../components/MaterialMenuPopup";
import { ControlPillMenu } from "../../components/ControlPill";
import { MaterialSearchField } from "../../components/MaterialSearchField";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
Expand All @@ -27,7 +27,7 @@ import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLay
export function MaterialThreadListToolbar(props: {
readonly searchQuery: string;
readonly onSearchQueryChange: (query: string) => void;
readonly filterActions: MenuAction[];
readonly filterActions: AndroidMenuAction[];
readonly filterCustomized: boolean;
readonly onFilterAction: NonNullable<ComponentProps<typeof ControlPillMenu>["onPressAction"]>;
readonly onOpenSettings: () => void;
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/home/ThreadListFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Pressable, ScrollView } from "react-native";
import { SymbolView } from "../../components/AppSymbol";
import { ListFilterMenu } from "../workspace/ListFilterMenu";
import type { HomeHeaderProps } from "./HomeHeader.types";
import { useProjectFilterMenuIcons } from "./useProjectFilterMenuIcons";

export function ThreadListFilters(
props: Pick<
Expand All @@ -20,6 +21,7 @@ export function ThreadListFilters(
const selectedProject = props.projects.find(
(project) => project.key === props.selectedProjectKey,
);
const projects = useProjectFilterMenuIcons(props.projects);
return (
<ScrollView
horizontal
Expand Down Expand Up @@ -65,9 +67,10 @@ export function ThreadListFilters(
title: "All projects",
state: props.selectedProjectKey === null ? "on" : "off",
},
...props.projects.map((project) => ({
...projects.map((project) => ({
id: JSON.stringify(project.key),
title: project.label,
...(project.image ? { image: project.image } : {}),
state: project.key === props.selectedProjectKey ? ("on" as const) : ("off" as const),
})),
]}
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/home/home-list-filter-menu.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";

export interface HomeListFilterMenuEnvironment {
Expand All @@ -8,6 +9,8 @@ export interface HomeListFilterMenuEnvironment {
export interface HomeListFilterMenuProject {
readonly key: string;
readonly label: string;
/** Project whose icon stands for the scope in the project filter. */
readonly representative?: EnvironmentProject;
}

type HomeListFilterMenuAction = {
Expand Down
Loading
Loading