Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
15 changes: 14 additions & 1 deletion .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,20 @@ jobs:
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: eas env:pull production --non-interactive
run: |
eas env:pull production --non-interactive
# EAS commands skip dotenv, and the Android fingerprint hashes this
# file. Without it every merge misses the build's hash and rebuilds.
node --input-type=module <<'NODE'
import { appendFileSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
const env = parseEnv(readFileSync(".env.local", "utf8"));
const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE;
if (!googleServicesFile) {
throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment");
}
appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`);
NODE

- name: Apply manual version override
if: steps.version_app_token.outcome == 'success'
Expand Down
48 changes: 47 additions & 1 deletion .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,29 @@ jobs:
env:
pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata

# The CLI archive and the staged app install without a lockfile, so they
# ask the registry about every package and fetch platform packages the
# workspace store lacks. They share this registry metadata and store. A
# new week starts cold, which bounds how stale cached ranges can get.
- name: Resolve staged install cache
id: stage_pnpm
shell: bash
run: |
echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT"
echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT"

- name: Restore staged install cache
id: stage_pnpm_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ steps.stage_pnpm.outputs.dir }}
key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: ${{ steps.stage_pnpm.outputs.prefix }}

- name: Mark staged install cache
shell: bash
run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker"

- name: Cache resource monitor
id: resource_monitor_cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
Expand Down Expand Up @@ -371,6 +394,9 @@ jobs:
if: inputs.cli_archive
shell: bash
env:
pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache
pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store
pnpm_config_prefer_offline: "true"
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
Expand Down Expand Up @@ -490,7 +516,10 @@ jobs:
- name: Build desktop artifact
shell: bash
env:
pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
# pnpm only goes to the registry for packages or versions the cache lacks.
pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache
pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store
pnpm_config_prefer_offline: "true"
T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true
T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }}
CSC_LINK: ${{ secrets.CSC_LINK }}
Expand Down Expand Up @@ -575,6 +604,23 @@ jobs:

vp run dist:desktop:artifact "${args[@]}"

# Warm installs only touch the store index, so new content means a fetch.
- name: Check staged install cache for new content
id: stage_pnpm_changed
shell: bash
run: |
dir="${{ steps.stage_pnpm.outputs.dir }}"
new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ')
echo "Staged installs added $new cached files."
echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"

- name: Save staged install cache
if: steps.stage_pnpm_changed.outputs.changed == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ steps.stage_pnpm.outputs.dir }}
key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }}

# A build can package fine and still not open (a missing module in the
# backend or the bundle). This launches the app from the shipped
# AppImage or DMG.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
boringSidebarEnabled: false,
boringSidebarMultilineRows: false,
legacySidebarEnabled: false,
sidebarWorkingShelfEnabled: false,
loadBalancingEnabled: false,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/assets/icons/chevron-down.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6z" />
</vector>
11 changes: 9 additions & 2 deletions apps/mobile/src/components/ui/MenuPopup.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ function MenuIcon(props: {
);
}

export function MenuPopup(props: MenuPopupProps) {
export function MenuPopupItems(
props: Pick<MenuPopupProps, "menuWidth" | "actions" | "title" | "parent" | "onPress" | "onBack">,
) {
const { appearance, themeVariables: colors } = useAppearancePreferences();
const { scale, menuItemHeight } = useAndroidControlSizing();
const body = resolveScaledTextRole("body", appearance.baseFontSize);
Expand All @@ -64,7 +66,7 @@ export function MenuPopup(props: MenuPopupProps) {
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 })];
const items = (
return (
<>
{props.parent ? (
<DropdownMenuItem onClick={props.onBack} modifiers={itemModifiers}>
Expand Down Expand Up @@ -146,6 +148,11 @@ export function MenuPopup(props: MenuPopupProps) {
))}
</>
);
}

export function MenuPopup(props: MenuPopupProps) {
const { themeVariables: colors } = useAppearancePreferences();
const items = <MenuPopupItems {...props} />;
if (props.inline) {
return (
<NativeThemeHost
Expand Down
145 changes: 145 additions & 0 deletions apps/mobile/src/components/ui/SplitButton.android.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
Box,
DropdownMenu,
Icon,
LoadingIndicator,
RNHostView,
Row,
SplitButtonLayout,
Text,
} from "@expo/ui/jetpack-compose";
import {
defaultMinSize,
fillMaxWidth,
rotate,
semantics,
size,
} from "@expo/ui/jetpack-compose/modifiers";
import { useState } from "react";
import { Pressable } from "react-native";

import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole";
import { SymbolView } from "./AppSymbol";
import { Button } from "./Button";
import { MenuPopupItems } from "./MenuPopup.android";
import { NativeThemeHost } from "./NativeThemeHost.android";
import type { SplitButtonProps } from "./SplitButton.types";
import { useAndroidControlSizing } from "./useAndroidControlSizing";
import { useControlTheme } from "./useControlTheme";

export function SplitButton(props: SplitButtonProps) {
const { colors } = useControlTheme();
const typography = useScaledTextRole("footnote");
const { scale, iconSize, mediumIconSize, menuWidth } = useAndroidControlSizing();
const [expanded, setExpanded] = useState(false);
if (props.actions.length === 0) return <Button {...props} tone="primary" fullWidth />;
const enabled = !props.disabled && !props.loading;
const menuEnabled = props.actions.some((action) => !action.attributes?.disabled);
const buttonColors = {
containerColor: colors["--color-primary"],
contentColor: colors["--color-primary-foreground"],
disabledContainerColor: colors["--color-subtle-strong"],
disabledContentColor: colors["--color-foreground-muted"],
};
// A usable menu beside a disabled action still takes the disabled palette, so the pair reads as one control.
const trailingColors = enabled
? buttonColors
: {
...buttonColors,
containerColor: buttonColors.disabledContainerColor,
contentColor: buttonColors.disabledContentColor,
};
const label = (
<Row verticalAlignment="center">
{props.loading ? (
<>
<LoadingIndicator
modifiers={[size(mediumIconSize, mediumIconSize)]}
color={colors["--color-foreground-muted"]}
/>
<Box modifiers={[size(8 * scale, 1)]} />
</>
) : props.icon ? (
<>
{/* The glyph host consumes touches, so it runs the action itself. */}
<RNHostView matchContents modifiers={[size(mediumIconSize, mediumIconSize)]}>
<Pressable
accessible={false}
disabled={!enabled}
onPress={props.onPress}
importantForAccessibility="no-hide-descendants"
style={{ width: mediumIconSize, height: mediumIconSize }}
>
<SymbolView
name={props.icon}
size={mediumIconSize}
tintColor={enabled ? buttonColors.contentColor : buttonColors.disabledContentColor}
/>
</Pressable>
</RNHostView>
<Box modifiers={[size(8 * scale, 1)]} />
</>
) : null}
<Text style={{ ...typography, fontWeight: "500" }}>{props.label}</Text>
</Row>
);
return (
<NativeThemeHost
matchContents={{ vertical: true }}
ignoreSafeAreaKeyboardInsets
style={{ width: "100%" }}
>
<SplitButtonLayout modifiers={[fillMaxWidth()]}>
<SplitButtonLayout.LeadingButton
enabled={enabled}
onClick={props.onPress}
colors={buttonColors}
modifiers={[
fillMaxWidth(),
defaultMinSize({ minHeight: 48 }),
semantics({ contentDescription: props.accessibilityLabel ?? props.label }),
]}
>
{label}
</SplitButtonLayout.LeadingButton>
<SplitButtonLayout.TrailingButton
checked={expanded}
onCheckedChange={setExpanded}
enabled={menuEnabled}
colors={trailingColors}
modifiers={[
defaultMinSize({ minWidth: 48, minHeight: 48 }),
semantics({ contentDescription: props.menuTitle }),
]}
>
<DropdownMenu
expanded={expanded && menuEnabled}
onDismissRequest={() => setExpanded(false)}
color={colors["--color-card-alt"]}
>
<DropdownMenu.Trigger>
<Icon
source={require("../../../assets/icons/chevron-down.xml")}
size={iconSize}
modifiers={[rotate(expanded ? 180 : 0)]}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Items>
<MenuPopupItems
menuWidth={menuWidth}
actions={props.actions}
title={props.menuTitle}
parent={null}
onPress={(action) => {
setExpanded(false);
if (action.id !== undefined) props.onPressAction(action.id);
}}
onBack={() => {}}
/>
</DropdownMenu.Items>
</DropdownMenu>
</SplitButtonLayout.TrailingButton>
</SplitButtonLayout>
</NativeThemeHost>
);
}
65 changes: 65 additions & 0 deletions apps/mobile/src/components/ui/SplitButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Pressable, View } from "react-native";
import { AppActivityIndicator } from "./AppActivityIndicator";
import { AppText } from "./AppText";
import { SymbolView } from "./AppSymbol";
import { Menu } from "./Menu";
import type { SplitButtonProps } from "./SplitButton.types";

export function SplitButton(props: SplitButtonProps) {
const active = !props.disabled;
const menuEnabled = props.actions.some((action) => !action.attributes?.disabled);
return (
<View
className={`flex-row overflow-hidden rounded-xl border-continuous ${active ? "bg-prominent-action" : "bg-subtle-strong"}`}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityState={{ disabled: Boolean(props.disabled), busy: Boolean(props.loading) }}
disabled={props.disabled}
onPress={props.onPress}
className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70"
>
{props.loading ? (
<AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
) : props.icon ? (
<SymbolView
name={props.icon}
size={16}
tintColorClassName={active ? "accent-prominent-action-foreground" : "accent-icon-muted"}
/>
) : null}
<AppText
className={`shrink text-center text-sm font-t3-semibold ${active ? "text-prominent-action-foreground" : "text-foreground-muted"}`}
>
{props.label}
</AppText>
</Pressable>
{props.actions.length > 0 ? (
<View pointerEvents={menuEnabled ? "auto" : "none"}>
<Menu
title={props.menuTitle}
actions={props.actions}
onPressAction={({ nativeEvent }) => props.onPressAction(nativeEvent.event)}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={props.menuTitle}
accessibilityState={{ disabled: !menuEnabled }}
disabled={!menuEnabled}
className="min-h-11 w-11 items-center justify-center border-l border-border-subtle"
>
<SymbolView
name="chevron.down"
size={13}
tintColorClassName={
active ? "accent-prominent-action-foreground" : "accent-icon-muted"
}
/>
</Pressable>
</Menu>
</View>
) : null}
</View>
);
}
14 changes: 14 additions & 0 deletions apps/mobile/src/components/ui/SplitButton.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { MenuAction } from "@react-native-menu/menu";
import type { AppSymbolName } from "./AppSymbol";

export interface SplitButtonProps {
readonly label: string;
readonly accessibilityLabel?: string;
readonly icon?: AppSymbolName;
readonly disabled?: boolean;
readonly loading?: boolean;
readonly onPress: () => void;
readonly menuTitle: string;
readonly actions: MenuAction[];
readonly onPressAction: (id: string) => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ function OpenDevicePreview({
access={access}
platform={session.platform}
deviceId={session.deviceId}
serveSimOnly={preview.serveSimOnly}
colors={{
background: themeVariables["--color-sheet-solid"],
foreground: themeVariables["--color-foreground"],
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/devices/DeviceStreamWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function DeviceStreamWebView({
access: props.access,
platform: props.platform,
deviceId: props.deviceId,
...(props.serveSimOnly ? { serveSimOnly: true } : {}),
colors: props.colors,
});
return (
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/devices/device-stream-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface DeviceStreamConfiguration {
readonly access: DeviceHubAccess;
readonly platform: DevicePlatform;
readonly deviceId: string;
/** See `DeviceStreamTarget.serveSimOnly`. */
readonly serveSimOnly?: boolean;
readonly colors: {
readonly background: string;
readonly foreground: string;
Expand Down
Loading
Loading