Skip to content

Commit 22ada7e

Browse files
committed
feat(mobile): add composer attachment menu with video support (#8843)
Adopted from upstream `pingdotgg/t3code#8843` (`e3dcc1615`), stacked on the voice-input branch because #8843 lands after #8614 upstream. The + control now opens a native menu anchored beside it with Photo Library and Choose Files, replacing the Alert sheet, and videos are accepted against the server's file upload limit. An empty composer also shows the interrupt button while the agent works; adding text or an attachment swaps it for send. Twelve of the thirteen ThreadComposer conflicts were #8793's toolbar restructure arriving as context again, declined as before. The genuine content is ComposerAttachmentButton and the showStopAction change, both applied into Pylon's structure: the button sits inside Pylon's toolbar scroller rather than upstream's fixed left group. Two silent drops caught that a clean typecheck would not have: ComposerToolbarButton was removed from the import list through a non-conflicting hunk, because upstream's restructure replaced every call site - Pylon still has eleven. And upstream adds IconPhoto via a per-icon deep import while Pylon uses the barrel, so the icon arrived in the symbol map with no import behind it. docs/user/composer.md keeps Pylon's voice and branding; upstream's text says T3 Code.
1 parent b21cb8f commit 22ada7e

19 files changed

Lines changed: 818 additions & 195 deletions

‎apps/mobile/app.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
178178
supportsText: true,
179179
supportsWebUrlWithMaxCount: 1,
180180
supportsImageWithMaxCount: 8,
181+
supportsMovieWithMaxCount: 8,
181182
supportsFileWithMaxCount: 8,
182183
},
183184
},

‎apps/mobile/src/components/AppSymbol.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
IconMoon,
5555
IconNetwork,
5656
IconPalette,
57+
IconPhoto,
5758
IconPin,
5859
IconPinnedOff,
5960
IconPlayerPlay,
@@ -132,6 +133,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
132133
magnifyingglass: IconSearch,
133134
paintbrush: IconPalette,
134135
"person.crop.circle": IconUserCircle,
136+
photo: IconPhoto,
135137
pin: IconPin,
136138
"pin.slash": IconPinnedOff,
137139
play: IconPlayerPlay,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { MenuAction } from "@react-native-menu/menu";
2+
import { Pressable } from "react-native";
3+
4+
import { SymbolView } from "./AppSymbol";
5+
import { ControlPillMenu } from "./ControlPill";
6+
7+
const ATTACHMENT_MENU_ACTIONS: MenuAction[] = [
8+
{ id: "photos", title: "Photo Library", image: "photo" },
9+
{ id: "files", title: "Choose Files", image: "folder" },
10+
];
11+
12+
export function ComposerAttachmentButton(props: {
13+
readonly disabled?: boolean;
14+
readonly supportsFiles: boolean;
15+
readonly onPickMedia: () => Promise<void>;
16+
readonly onPickFiles: () => Promise<void>;
17+
}) {
18+
const button = (
19+
<Pressable
20+
accessibilityLabel="Add attachment"
21+
accessibilityRole="button"
22+
accessibilityState={{ disabled: props.disabled }}
23+
className="size-[44px] shrink-0 items-center justify-center rounded-full active:opacity-70 disabled:opacity-50"
24+
disabled={props.disabled}
25+
onPress={props.supportsFiles ? undefined : () => void props.onPickMedia()}
26+
>
27+
<SymbolView
28+
name="plus"
29+
size={20}
30+
weight="regular"
31+
tintColorClassName="accent-icon"
32+
type="monochrome"
33+
/>
34+
</Pressable>
35+
);
36+
37+
if (props.disabled || !props.supportsFiles) {
38+
return button;
39+
}
40+
41+
return (
42+
<ControlPillMenu
43+
actions={ATTACHMENT_MENU_ACTIONS}
44+
onPressAction={({ nativeEvent }) => {
45+
if (nativeEvent.event === "photos") {
46+
void props.onPickMedia();
47+
} else if (nativeEvent.event === "files") {
48+
void props.onPickFiles();
49+
}
50+
}}
51+
>
52+
{button}
53+
</ControlPillMenu>
54+
);
55+
}

‎apps/mobile/src/components/ComposerToolbar.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export function ComposerToolbarRow(props: {
113113

114114
export function ComposerToolbarScroller(props: {
115115
readonly children: ReactNode;
116+
readonly align?: "start" | "end";
116117
/** Only for non-Uniwind surfaces such as the native terminal palette. */
117118
readonly fadeOpaque?: string;
118119
/** Only for non-Uniwind surfaces such as the native terminal palette. */
@@ -167,6 +168,8 @@ export function ComposerToolbarScroller(props: {
167168
showsHorizontalScrollIndicator={false}
168169
contentContainerStyle={{
169170
alignItems: "center",
171+
flexGrow: props.align === "end" ? 1 : undefined,
172+
justifyContent: props.align === "end" ? "flex-end" : undefined,
170173
gap: COMPOSER_TOOLBAR_GAP,
171174
paddingLeft: 0,
172175
paddingRight: props.contentPaddingRight ?? 1,
@@ -214,6 +217,46 @@ export function ComposerToolbarScroller(props: {
214217
);
215218
}
216219

220+
export function ComposerActionButton(props: {
221+
readonly accessibilityLabel: string;
222+
readonly disabled?: boolean;
223+
readonly icon: ComponentProps<typeof SymbolView>["name"];
224+
readonly onPress: () => void;
225+
readonly variant?: "primary" | "danger";
226+
}) {
227+
return (
228+
<Pressable
229+
accessibilityLabel={props.accessibilityLabel}
230+
accessibilityRole="button"
231+
accessibilityState={{ disabled: props.disabled }}
232+
className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
233+
disabled={props.disabled}
234+
onPress={props.onPress}
235+
>
236+
<View
237+
className={cn(
238+
"size-[30px] items-center justify-center rounded-full",
239+
props.variant === "danger"
240+
? "bg-danger"
241+
: props.disabled
242+
? "bg-primary/15"
243+
: "bg-primary",
244+
)}
245+
>
246+
<SymbolView
247+
name={props.icon}
248+
size={16}
249+
weight="semibold"
250+
tintColorClassName={
251+
props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground"
252+
}
253+
type="monochrome"
254+
/>
255+
</View>
256+
</Pressable>
257+
);
258+
}
259+
217260
export function ComposerToolbarButton(props: {
218261
readonly icon?: ComponentProps<typeof SymbolView>["name"];
219262
readonly iconNode?: ReactNode;

‎apps/mobile/src/components/ControlPill.tsx‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,38 @@ import {
66
type ComponentProps,
77
type ReactElement,
88
type ReactNode,
9+
useMemo,
910
useRef,
1011
} from "react";
11-
import { Platform, Pressable, View } from "react-native";
12+
import { Platform, Pressable, View, type ColorValue } from "react-native";
13+
import { withUniwind } from "uniwind";
1214
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1315

1416
import { cn } from "../lib/cn";
17+
import { withMenuActionIconColors } from "../lib/menu-action-colors";
1518
import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
1619
import { SymbolView } from "./AppSymbol";
1720
import { AppText as Text } from "./AppText";
1821

22+
const ThemedMenuView = withUniwind(function NativeMenuView({
23+
iconColor,
24+
destructiveIconColor,
25+
...props
26+
}: ComponentProps<typeof MenuView> & {
27+
readonly iconColor?: ColorValue;
28+
readonly destructiveIconColor?: ColorValue;
29+
}) {
30+
const actions = useMemo(
31+
() =>
32+
withMenuActionIconColors(props.actions, {
33+
icon: iconColor,
34+
destructiveIcon: destructiveIconColor,
35+
}),
36+
[props.actions, iconColor, destructiveIconColor],
37+
);
38+
return <MenuView {...props} actions={actions} />;
39+
});
40+
1941
export function ControlPill(props: {
2042
readonly icon?: ComponentProps<typeof SymbolView>["name"];
2143
readonly iconNode?: ReactNode;
@@ -177,8 +199,13 @@ export function ControlPillMenu(
177199
});
178200
}
179201
return (
180-
<MenuView {...menuProps} themeVariant={isDarkMode ? "dark" : "light"}>
202+
<ThemedMenuView
203+
{...menuProps}
204+
iconColorClassName="accent-icon"
205+
destructiveIconColorClassName="accent-danger-foreground"
206+
themeVariant={isDarkMode ? "dark" : "light"}
207+
>
181208
{children}
182-
</MenuView>
209+
</ThemedMenuView>
183210
);
184211
}

‎apps/mobile/src/features/sharing/incoming-share-model.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,54 @@ describe("incoming native shares", () => {
181181
expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
182182
});
183183

184+
it.each([
185+
{ value: "file:///shared/clip.MOV", mimeType: "video/quicktime", originalName: "clip.MOV" },
186+
{ value: "content://media/videos/12", mimeType: "video/mp4", originalName: "clip.mp4" },
187+
])("imports a shared video from $value without reading it as an image", async (video) => {
188+
const sizeBytes = 20 * 1024 * 1024;
189+
const fileUri = `file:///documents/${video.originalName}`;
190+
const readBase64 = vi.fn(async () => "unused");
191+
const persistFile = vi.fn(async () => fileUri);
192+
const removeOwnedFile = vi.fn(async () => undefined);
193+
194+
const result = await buildIncomingShareDraft({
195+
id: "share-video",
196+
createdAt: "2026-08-30T10:00:00.000Z",
197+
payloads: [{ ...video, shareType: "video" }],
198+
resolvedPayloads: [],
199+
fileReader: { readBase64, persistFile, readSize: async () => sizeBytes, removeOwnedFile },
200+
});
201+
202+
expect(result.warnings).toEqual([]);
203+
expect(result.attachments).toEqual([
204+
{
205+
id: "share-video:file:0",
206+
type: "file",
207+
name: video.originalName,
208+
mimeType: video.mimeType,
209+
sizeBytes,
210+
fileUri,
211+
},
212+
]);
213+
expect(readBase64).not.toHaveBeenCalled();
214+
expect(removeOwnedFile).toHaveBeenCalledWith(video.value);
215+
expect(
216+
selectIncomingShareAttachments({
217+
attachments: result.attachments,
218+
maxFileAttachmentBytes: 50 * 1024 * 1024,
219+
}),
220+
).toEqual({ attachments: result.attachments, warnings: [] });
221+
expect(
222+
selectIncomingShareAttachments({
223+
attachments: result.attachments,
224+
maxFileAttachmentBytes: 10 * 1024 * 1024,
225+
}),
226+
).toEqual({
227+
attachments: [],
228+
warnings: [`'${video.originalName}' exceeds the 10 MB attachment limit.`],
229+
});
230+
});
231+
184232
it("reports an unreadable shared file without calling it oversized", async () => {
185233
const file: SharePayload = {
186234
shareType: "file",

0 commit comments

Comments
 (0)