Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
supportsText: true,
supportsWebUrlWithMaxCount: 1,
supportsImageWithMaxCount: 8,
supportsMovieWithMaxCount: 8,
supportsFileWithMaxCount: 8,
},
},
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import IconMinus from "@tabler/icons-react-native/IconMinus";
import IconMoon from "@tabler/icons-react-native/IconMoon";
import IconNetwork from "@tabler/icons-react-native/IconNetwork";
import IconPalette from "@tabler/icons-react-native/IconPalette";
import IconPhoto from "@tabler/icons-react-native/IconPhoto";
import IconPin from "@tabler/icons-react-native/IconPin";
import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";
import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay";
Expand Down Expand Up @@ -133,6 +134,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
magnifyingglass: IconSearch,
paintbrush: IconPalette,
"person.crop.circle": IconUserCircle,
photo: IconPhoto,
pin: IconPin,
"pin.slash": IconPinnedOff,
play: IconPlayerPlay,
Expand Down
55 changes: 55 additions & 0 deletions apps/mobile/src/components/ComposerAttachmentButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { MenuAction } from "@react-native-menu/menu";
import { Pressable } from "react-native";

import { SymbolView } from "./AppSymbol";
import { ControlPillMenu } from "./ControlPill";

const ATTACHMENT_MENU_ACTIONS: MenuAction[] = [
{ id: "photos", title: "Photo Library", image: "photo" },
{ id: "files", title: "Choose Files", image: "folder" },
];

export function ComposerAttachmentButton(props: {
readonly disabled?: boolean;
readonly supportsFiles: boolean;
readonly onPickMedia: () => Promise<void>;
readonly onPickFiles: () => Promise<void>;
}) {
const button = (
<Pressable
accessibilityLabel="Add attachment"
accessibilityRole="button"
accessibilityState={{ disabled: props.disabled }}
className="size-[44px] shrink-0 items-center justify-center rounded-full active:opacity-70 disabled:opacity-50"
disabled={props.disabled}
onPress={props.supportsFiles ? undefined : () => void props.onPickMedia()}
>
<SymbolView
name="plus"
size={20}
weight="regular"
tintColorClassName="accent-icon"
type="monochrome"
/>
</Pressable>
);

if (props.disabled || !props.supportsFiles) {
return button;
}

return (
<ControlPillMenu
actions={ATTACHMENT_MENU_ACTIONS}
onPressAction={({ nativeEvent }) => {
if (nativeEvent.event === "photos") {
void props.onPickMedia();
} else if (nativeEvent.event === "files") {
void props.onPickFiles();
}
}}
>
{button}
</ControlPillMenu>
);
}
43 changes: 43 additions & 0 deletions apps/mobile/src/components/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export function ComposerToolbarRow(props: {

export function ComposerToolbarScroller(props: {
readonly children: ReactNode;
readonly align?: "start" | "end";
/** Only for non-Uniwind surfaces such as the native terminal palette. */
readonly fadeOpaque?: string;
/** Only for non-Uniwind surfaces such as the native terminal palette. */
Expand Down Expand Up @@ -167,6 +168,8 @@ export function ComposerToolbarScroller(props: {
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
alignItems: "center",
flexGrow: props.align === "end" ? 1 : undefined,
justifyContent: props.align === "end" ? "flex-end" : undefined,
gap: COMPOSER_TOOLBAR_GAP,
paddingLeft: 0,
paddingRight: props.contentPaddingRight ?? 1,
Expand Down Expand Up @@ -214,6 +217,46 @@ export function ComposerToolbarScroller(props: {
);
}

export function ComposerActionButton(props: {
readonly accessibilityLabel: string;
readonly disabled?: boolean;
readonly icon: ComponentProps<typeof SymbolView>["name"];
readonly onPress: () => void;
readonly variant?: "primary" | "danger";
}) {
return (
<Pressable
accessibilityLabel={props.accessibilityLabel}
accessibilityRole="button"
accessibilityState={{ disabled: props.disabled }}
className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
disabled={props.disabled}
onPress={props.onPress}
>
<View
className={cn(
"size-[30px] items-center justify-center rounded-full",
props.variant === "danger"
? "bg-danger"
: props.disabled
? "bg-primary/15"
: "bg-primary",
)}
>
<SymbolView
name={props.icon}
size={16}
weight="semibold"
tintColorClassName={
props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground"
}
type="monochrome"
/>
</View>
</Pressable>
);
}

export function ComposerToolbarButton(props: {
readonly icon?: ComponentProps<typeof SymbolView>["name"];
readonly iconNode?: ReactNode;
Expand Down
18 changes: 17 additions & 1 deletion apps/mobile/src/components/ControlPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
type ComponentProps,
type ReactElement,
type ReactNode,
useMemo,
useRef,
} from "react";
import { Platform, Pressable, View, type PressableProps } from "react-native";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";

import { cn } from "../lib/cn";
import { withMenuActionIconColors } from "../lib/menu-action-colors";
import { useUniwindTheme } from "../lib/useUniwindTheme";
import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
import { SymbolView } from "./AppSymbol";
import { AppText as Text } from "./AppText";
Expand Down Expand Up @@ -120,6 +123,19 @@ export function ControlPillMenu(
) {
const { themeAppearance } = useAppearancePreferences();
const isDarkMode = themeAppearance === "dark";
const theme = useUniwindTheme();
const iconColor = theme["--color-icon"];
const destructiveIconColor = theme["--color-danger-foreground"];
const actions = useMemo(
() =>
Platform.OS === "android"
? props.actions
: withMenuActionIconColors(props.actions, {
icon: iconColor,
destructiveIcon: destructiveIconColor,
}),
[props.actions, iconColor, destructiveIconColor],
);
const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false });
const pendingPress = useRef<(() => void) | null>(null);

Expand Down Expand Up @@ -217,7 +233,7 @@ export function ControlPillMenu(
};
}
return (
<MenuView {...menuProps} themeVariant={isDarkMode ? "dark" : "light"}>
<MenuView {...menuProps} actions={actions} themeVariant={isDarkMode ? "dark" : "light"}>
{children}
</MenuView>
);
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/features/sharing/incoming-share-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,54 @@ describe("incoming native shares", () => {
expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
});

it.each([
{ value: "file:///shared/clip.MOV", mimeType: "video/quicktime", originalName: "clip.MOV" },
{ value: "content://media/videos/12", mimeType: "video/mp4", originalName: "clip.mp4" },
])("imports a shared video from $value without reading it as an image", async (video) => {
const sizeBytes = 20 * 1024 * 1024;
const fileUri = `file:///documents/${video.originalName}`;
const readBase64 = vi.fn(async () => "unused");
const persistFile = vi.fn(async () => fileUri);
const removeOwnedFile = vi.fn(async () => undefined);

const result = await buildIncomingShareDraft({
id: "share-video",
createdAt: "2026-08-30T10:00:00.000Z",
payloads: [{ ...video, shareType: "video" }],
resolvedPayloads: [],
fileReader: { readBase64, persistFile, readSize: async () => sizeBytes, removeOwnedFile },
});

expect(result.warnings).toEqual([]);
expect(result.attachments).toEqual([
{
id: "share-video:file:0",
type: "file",
name: video.originalName,
mimeType: video.mimeType,
sizeBytes,
fileUri,
},
]);
expect(readBase64).not.toHaveBeenCalled();
expect(removeOwnedFile).toHaveBeenCalledWith(video.value);
expect(
selectIncomingShareAttachments({
attachments: result.attachments,
maxFileAttachmentBytes: 50 * 1024 * 1024,
}),
).toEqual({ attachments: result.attachments, warnings: [] });
expect(
selectIncomingShareAttachments({
attachments: result.attachments,
maxFileAttachmentBytes: 10 * 1024 * 1024,
}),
).toEqual({
attachments: [],
warnings: [`'${video.originalName}' exceeds the 10 MB attachment limit.`],
});
});

it("reports an unreadable shared file without calling it oversized", async () => {
const file: SharePayload = {
shareType: "file",
Expand Down
Loading
Loading