Unify attachment display and enable file uploads on all platforms (#1501)

Attachment pills were drawn by four separate style sets, so files,
images, GitHub items, and reviews rendered at different heights in the
same tray. Move the frame, the two-line label, and the image thumbnail
into shared components so every attachment lines up at one height,
across both the composer and sent messages.

Sent-message images now open the lightbox, and file uploads work on
iOS and Android via expo-document-picker instead of being gated to
desktop.
This commit is contained in:
Mohamed Boudra
2026-06-13 10:18:39 +08:00
committed by GitHub
parent a5c4ba4d89
commit ef750e0a85
15 changed files with 358 additions and 219 deletions
+10
View File
@@ -20410,6 +20410,15 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-eas-client": { "node_modules/expo-eas-client": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
@@ -37111,6 +37120,7 @@
"expo-constants": "~18.0.9", "expo-constants": "~18.0.9",
"expo-crypto": "^15.0.8", "expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.15", "expo-dev-client": "^6.0.15",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
+1
View File
@@ -66,6 +66,7 @@
"expo-constants": "~18.0.9", "expo-constants": "~18.0.9",
"expo-crypto": "^15.0.8", "expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.15", "expo-dev-client": "^6.0.15",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
@@ -69,6 +69,11 @@ export function getFileExtension(path: string): string {
return normalizedPath.slice(extensionIndex).toLowerCase(); return normalizedPath.slice(extensionIndex).toLowerCase();
} }
export function getFileTypeLabel(path: string): string | null {
const extension = getFileExtension(path).slice(1);
return extension ? extension.toUpperCase() : null;
}
export function getMimeTypeFromPath(path: string): string { export function getMimeTypeFromPath(path: string): string {
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream"; return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
} }
+117 -6
View File
@@ -1,9 +1,16 @@
import { type ReactNode, useCallback, useMemo, useState } from "react"; import { type ReactNode, useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native"; import { Image, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { Theme } from "@/styles/theme";
// Every attachment pill body — image thumbnail or labelled — renders at this
// height so mixed attachment trays line up.
const ATTACHMENT_CONTENT_HEIGHT = 48;
interface AttachmentPillProps { interface AttachmentPillProps {
onOpen: () => void; onOpen: () => void;
@@ -24,7 +31,6 @@ export function AttachmentPill({
testID, testID,
children, children,
}: AttachmentPillProps) { }: AttachmentPillProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const [isBodyHovered, setIsBodyHovered] = useState(false); const [isBodyHovered, setIsBodyHovered] = useState(false);
const [isCloseHovered, setIsCloseHovered] = useState(false); const [isCloseHovered, setIsCloseHovered] = useState(false);
@@ -48,7 +54,7 @@ export function AttachmentPill({
onHoverOut={handleBodyHoverOut} onHoverOut={handleBodyHoverOut}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel} accessibilityLabel={openAccessibilityLabel}
style={styles.body} style={styles.frame}
> >
{children} {children}
</Pressable> </Pressable>
@@ -62,22 +68,127 @@ export function AttachmentPill({
accessibilityLabel={removeAccessibilityLabel} accessibilityLabel={removeAccessibilityLabel}
style={closeButtonStyle} style={closeButtonStyle}
> >
<X size={12} color={theme.colors.foregroundMuted} /> <ThemedX size={12} uniProps={iconForegroundMutedMapping} />
</Pressable> </Pressable>
</View> </View>
); );
} }
interface AttachmentFrameProps {
onPress?: () => void;
accessibilityLabel?: string;
testID?: string;
children: ReactNode;
}
/** Bare attachment frame for read-only surfaces (sent messages) — no remove button. */
export function AttachmentFrame({
onPress,
accessibilityLabel,
testID,
children,
}: AttachmentFrameProps) {
if (!onPress) {
return (
<View testID={testID} style={styles.frame}>
{children}
</View>
);
}
return (
<Pressable
testID={testID}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.frame}
>
{children}
</Pressable>
);
}
interface AttachmentLabelProps {
icon?: ReactNode;
title: string;
subtitle: string;
}
/** Two-line labelled pill body: attachment name over its type. */
export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps) {
return (
<View style={styles.labelBody}>
{icon ? <View style={styles.labelIcon}>{icon}</View> : null}
<View style={styles.labelTextColumn}>
<Text style={styles.labelTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.labelSubtitle} numberOfLines={1}>
{subtitle}
</Text>
</View>
</View>
);
}
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
}
const ThemedX = withUnistyles(X);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
wrapper: { wrapper: {
position: "relative", position: "relative",
}, },
body: { frame: {
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
overflow: "hidden", overflow: "hidden",
}, },
labelBody: {
height: ATTACHMENT_CONTENT_HEIGHT,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
backgroundColor: theme.colors.surface1,
},
labelIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
labelTextColumn: {
minWidth: 0,
flexShrink: 1,
},
labelTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
labelSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
thumbnail: {
width: ATTACHMENT_CONTENT_HEIGHT,
height: ATTACHMENT_CONTENT_HEIGHT,
},
thumbnailPlaceholder: {
width: ATTACHMENT_CONTENT_HEIGHT,
height: ATTACHMENT_CONTENT_HEIGHT,
backgroundColor: theme.colors.surface1,
},
closeButton: { closeButton: {
position: "absolute", position: "absolute",
top: -8, top: -8,
+102 -60
View File
@@ -32,6 +32,7 @@ import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view"; import MaskedView from "@react-native-masked-view/masked-view";
import { import {
Circle, Circle,
CircleDot,
Info, Info,
CheckCircle, CheckCircle,
XCircle, XCircle,
@@ -41,13 +42,15 @@ import {
Check, Check,
CheckSquare, CheckSquare,
Copy, Copy,
GitPullRequest,
MessageSquareCode,
TriangleAlertIcon, TriangleAlertIcon,
Scissors, Scissors,
MicVocal, MicVocal,
FileSymlink, FileSymlink,
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, { import Animated, {
Easing, Easing,
@@ -101,6 +104,13 @@ import {
import { getCompactionMarkerLabel } from "./message-compaction-label"; import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import { getFileTypeLabel } from "@/attachments/file-types";
import {
AttachmentFrame,
AttachmentLabel,
AttachmentThumbnail,
} from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
@@ -160,6 +170,10 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedAttachmentFileText = withUnistyles(FileText);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -363,34 +377,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
imagePreviewSpacing: { imagePreviewSpacing: {
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
}, },
imagePill: {
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface1,
},
structuredAttachmentPill: {
maxWidth: 220,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
structuredAttachmentText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
copyButton: { copyButton: {
alignSelf: "center", alignSelf: "center",
padding: theme.spacing[1], padding: theme.spacing[1],
@@ -417,36 +403,82 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
}, },
})); }));
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) { const attachmentReviewIcon = (
const uri = useAttachmentPreviewUrl(image); <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]); );
if (!uri) { const attachmentGithubPrIcon = (
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />; <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
} );
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />; const attachmentGithubIssueIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentFileIcon = (
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
interface UserMessageImagePillProps {
image: UserMessageImageAttachment;
onOpen: (image: UserMessageImageAttachment) => void;
accessibilityLabel: string;
} }
function getUserMessageAttachmentLabel( function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessageImagePillProps) {
const handlePress = useCallback(() => {
onOpen(image);
}, [onOpen, image]);
return (
<AttachmentFrame onPress={handlePress} accessibilityLabel={accessibilityLabel}>
<AttachmentThumbnail metadata={image} />
</AttachmentFrame>
);
}
interface UserMessageAttachmentContent {
icon: ReactNode;
title: string;
subtitle: string;
}
function getUserMessageAttachmentContent(
attachment: AgentAttachment, attachment: AgentAttachment,
t: ReturnType<typeof useTranslation>["t"], t: ReturnType<typeof useTranslation>["t"],
): string { ): UserMessageAttachmentContent {
switch (attachment.type) { switch (attachment.type) {
case "review": { case "review": {
const count = attachment.comments.length; const count = attachment.comments.length;
return count === 1 return {
? t("message.attachments.reviewOne") icon: attachmentReviewIcon,
: t("message.attachments.reviewMany", { count }); title: t("message.attachments.review"),
subtitle:
count === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count }),
};
} }
case "github_pr": case "github_pr":
return `PR #${attachment.number}`; return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "github_issue": case "github_issue":
return `Issue #${attachment.number}`; return {
icon: attachmentGithubIssueIcon,
title: attachment.title,
subtitle: `Issue #${attachment.number}`,
};
case "text": case "text":
return attachment.title ?? t("message.attachments.textAttachment"); return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
subtitle: t("message.attachments.text"),
};
case "uploaded_file": case "uploaded_file":
return attachment.fileName; return {
default: icon: attachmentFileIcon,
return ""; title: attachment.fileName,
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
};
} }
} }
@@ -467,6 +499,8 @@ export const UserMessage = memo(function UserMessage({
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { t } = useTranslation(); const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0; const hasText = message.trim().length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
@@ -534,24 +568,31 @@ export const UserMessage = memo(function UserMessage({
{hasImages ? ( {hasImages ? (
<View style={imagePreviewContainerStyle}> <View style={imagePreviewContainerStyle}>
{images.map((image) => ( {images.map((image) => (
<View key={image.id} style={userMessageStylesheet.imagePill}> <UserMessageImagePill
<UserMessageAttachmentThumbnail image={image} /> key={image.id}
</View> image={image}
onOpen={setLightboxMetadata}
accessibilityLabel={t("composer.attachments.openImage")}
/>
))} ))}
</View> </View>
) : null} ) : null}
{hasAttachments ? ( {hasAttachments ? (
<View style={attachmentPreviewContainerStyle}> <View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => {
<View const content = getUserMessageAttachmentContent(attachment, t);
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`} return (
style={userMessageStylesheet.structuredAttachmentPill} <AttachmentFrame
> key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}> >
{getUserMessageAttachmentLabel(attachment, t)} <AttachmentLabel
</Text> icon={content.icon}
</View> title={content.title}
))} subtitle={content.subtitle}
/>
</AttachmentFrame>
);
})}
</View> </View>
) : null} ) : null}
{hasText ? ( {hasText ? (
@@ -579,6 +620,7 @@ export const UserMessage = memo(function UserMessage({
</View> </View>
) : null} ) : null}
</View> </View>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
</View> </View>
); );
}); });
@@ -1,14 +1,13 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native"; import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type { import type {
ComposerAttachment, ComposerAttachment,
UserComposerAttachment, UserComposerAttachment,
WorkspaceComposerAttachment, WorkspaceComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { import {
isWorkspaceAttachment, isWorkspaceAttachment,
@@ -112,16 +111,31 @@ function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string
return "Review"; return "Review";
} }
function getPillLabel(attachment: WorkspaceComposerAttachment, t: TranslationFn): string { interface PillContent {
title: string;
subtitle: string;
}
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
if (attachment.kind === "browser_element") { if (attachment.kind === "browser_element") {
return t("composer.attachments.browserElement", { tag: attachment.attachment.tag }); return {
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
};
} }
if (isPullRequestContextAttachment(attachment)) { if (isPullRequestContextAttachment(attachment)) {
return `${getContextSourceLabel(attachment)} · ${attachment.title}`; return {
title: attachment.title,
subtitle: getContextSourceLabel(attachment),
};
} }
return attachment.commentCount === 1 return {
? t("message.attachments.reviewOne") title: t("message.attachments.review"),
: t("message.attachments.reviewMany", { count: attachment.commentCount }); subtitle:
attachment.commentCount === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
};
} }
function getOpenAccessibilityLabel( function getOpenAccessibilityLabel(
@@ -309,7 +323,7 @@ function WorkspaceAttachmentPill({
onRemove, onRemove,
}: WorkspaceAttachmentPillProps) { }: WorkspaceAttachmentPillProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const label = getPillLabel(attachment, t); const content = getPillContent(attachment, t);
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
onOpen(attachment); onOpen(attachment);
}, [onOpen, attachment]); }, [onOpen, attachment]);
@@ -325,12 +339,11 @@ function WorkspaceAttachmentPill({
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)} removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
disabled={disabled} disabled={disabled}
> >
<View style={styles.pillBody}> <AttachmentLabel
<View style={styles.pillIcon}>{renderPillIcon(attachment)}</View> icon={renderPillIcon(attachment)}
<Text style={styles.pillText} numberOfLines={1}> title={content.title}
{label} subtitle={content.subtitle}
</Text> />
</View>
</AttachmentPill> </AttachmentPill>
); );
} }
@@ -343,30 +356,6 @@ export const composerWorkspaceAttachment = {
useBinding: useWorkspaceAttachmentBinding, useBinding: useWorkspaceAttachmentBinding,
}; };
const styles = StyleSheet.create((theme: Theme) => ({
pillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
pillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
pillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
})) as unknown as Record<string, object>;
const ThemedMousePointer2 = withUnistyles(MousePointer2); const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedFileText = withUnistyles(FileText); const ThemedFileText = withUnistyles(FileText);
+32 -90
View File
@@ -3,7 +3,6 @@ import {
Pressable, Pressable,
Text, Text,
ActivityIndicator, ActivityIndicator,
Image,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
@@ -100,9 +99,9 @@ import type {
WorkspaceComposerAttachment, WorkspaceComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
@@ -560,15 +559,6 @@ function QueuedMessageRow({
); );
} }
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={source} style={styles.imageThumbnail} />;
}
interface ImageAttachmentPillProps { interface ImageAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "image" }>; attachment: Extract<ComposerAttachment, { kind: "image" }>;
index: number; index: number;
@@ -603,7 +593,7 @@ function ImageAttachmentPill({
removeAccessibilityLabel={removeLabel} removeAccessibilityLabel={removeLabel}
disabled={disabled} disabled={disabled}
> >
<ImageAttachmentThumbnail image={attachment.metadata} /> <AttachmentThumbnail metadata={attachment.metadata} />
</AttachmentPill> </AttachmentPill>
); );
} }
@@ -644,18 +634,11 @@ function GithubAttachmentPill({
removeAccessibilityLabel={removeLabel(kindLabel, item.number)} removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
disabled={disabled} disabled={disabled}
> >
<View style={styles.githubPillBody}> <AttachmentLabel
<View style={styles.githubPillIcon}> icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
{item.kind === "pr" ? ( title={item.title}
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} /> subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
) : ( />
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
#{item.number} {item.title}
</Text>
</View>
</AttachmentPill> </AttachmentPill>
); );
} }
@@ -675,26 +658,25 @@ function FileAttachmentPill({
onRemove, onRemove,
removeLabel, removeLabel,
}: FileAttachmentPillProps) { }: FileAttachmentPillProps) {
const { t } = useTranslation();
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
onRemove(index); onRemove(index);
}, [onRemove, index]); }, [onRemove, index]);
const fileName = attachment.attachment.fileName;
return ( return (
<AttachmentPill <AttachmentPill
testID="composer-file-attachment-pill" testID="composer-file-attachment-pill"
onOpen={noopCallback} onOpen={noopCallback}
onRemove={handleRemove} onRemove={handleRemove}
openAccessibilityLabel={attachment.attachment.fileName} openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel} removeAccessibilityLabel={removeLabel}
disabled={disabled} disabled={disabled}
> >
<View style={styles.filePillBody}> <AttachmentLabel
<View style={styles.filePillIcon}> icon={filePillIcon}
<ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} /> title={fileName}
</View> subtitle={getFileTypeLabel(fileName) ?? t("message.attachments.file")}
<Text style={styles.filePillText} numberOfLines={1}> />
{attachment.attachment.fileName}
</Text>
</View>
</AttachmentPill> </AttachmentPill>
); );
} }
@@ -1705,18 +1687,14 @@ export function Composer({
setIsGithubPickerOpen(true); setIsGithubPickerOpen(true);
}, },
}, },
...(!isNative {
? [ id: "file",
{ label: t("composer.attachments.addFile"),
id: "file", icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
label: t("composer.attachments.addFile"), onSelect: () => {
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />, void handlePickFile();
onSelect: () => { },
void handlePickFile(); },
},
},
]
: []),
], ],
[handlePickImage, handlePickFile, t], [handlePickImage, handlePickFile, t],
); );
@@ -2059,50 +2037,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
flexWrap: "wrap", flexWrap: "wrap",
}, },
imageThumbnail: {
width: 32,
height: 32,
},
imageThumbnailPlaceholder: {
width: 32,
height: 32,
backgroundColor: theme.colors.surface2,
},
githubPillBody: {
minHeight: 32,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
githubPillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
filePillBody: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
filePillIcon: {
justifyContent: "center",
alignItems: "center",
},
filePillText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
githubPillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
tooltipRow: { tooltipRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -2173,3 +2107,11 @@ const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const githubPrPillIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const githubIssuePillIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const filePillIcon = <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
+22 -2
View File
@@ -1,4 +1,6 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands"; import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url"; import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
@@ -113,6 +115,25 @@ function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
}); });
} }
async function pickFilesWithDocumentPicker(): Promise<PickedFile[] | null> {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: true,
});
if (result.canceled || result.assets.length === 0) {
return null;
}
return await Promise.all(
result.assets.map(async (asset) => ({
fileName: asset.name,
mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name),
bytes: await new File(asset.uri).bytes(),
})),
);
}
export function useFilePicker() { export function useFilePicker() {
const isPickingRef = useRef(false); const isPickingRef = useRef(false);
@@ -131,8 +152,7 @@ export function useFilePicker() {
return await pickFilesWithWebInput(); return await pickFilesWithWebInput();
} }
// Native not supported yet return await pickFilesWithDocumentPicker();
return null;
} catch (error) { } catch (error) {
console.error("[FilePicker] Failed to pick files:", error); console.error("[FilePicker] Failed to pick files:", error);
throw error; throw error;
+4 -3
View File
@@ -575,10 +575,11 @@ describe("translation resources", () => {
}); });
it("includes remaining utility chrome keys for the Batch 4T migration", () => { it("includes remaining utility chrome keys for the Batch 4T migration", () => {
expect(en.message.attachments.reviewOne).toBe("Review · 1 comment"); expect(en.message.attachments.review).toBe("Review");
expect(en.message.attachments.reviewMany).toBe("Review · {{count}} comments"); expect(en.message.attachments.commentsOne).toBe("1 comment");
expect(en.message.attachments.commentsMany).toBe("{{count}} comments");
expect(en.message.attachments.textAttachment).toBe("Text attachment"); expect(en.message.attachments.textAttachment).toBe("Text attachment");
expect(en.composer.attachments.browserElement).toBe("Element · {{tag}}"); expect(en.composer.attachments.element).toBe("Element");
expect(en.workspace.hoverCard.scriptsAccessibility).toBe("Workspace scripts"); expect(en.workspace.hoverCard.scriptsAccessibility).toBe("Workspace scripts");
expect(en.branchSwitcher.restoreStashTitle).toBe("Restore stashed changes?"); expect(en.branchSwitcher.restoreStashTitle).toBe("Restore stashed changes?");
expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored"); expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored");
+6 -3
View File
@@ -110,7 +110,7 @@ export const ar: TranslationResources = {
removeFile: "Remove file attachment", removeFile: "Remove file attachment",
openGithub: "افتح{{kind}}#{{number}}", openGithub: "افتح{{kind}}#{{number}}",
removeGithub: "إزالة{{kind}}#{{number}}", removeGithub: "إزالة{{kind}}#{{number}}",
browserElement: "العنصر ·{{tag}}", element: "عنصر",
openBrowserElement: "افتح مرفق عنصر المتصفح", openBrowserElement: "افتح مرفق عنصر المتصفح",
removeBrowserElement: "إزالة مرفق عنصر المتصفح", removeBrowserElement: "إزالة مرفق عنصر المتصفح",
openReview: "فتح مرفق المراجعة", openReview: "فتح مرفق المراجعة",
@@ -255,9 +255,12 @@ export const ar: TranslationResources = {
imageUnavailable: "الصورة غير متاحة", imageUnavailable: "الصورة غير متاحة",
imagePreviewUnavailable: "معاينة الصورة غير متاحة.", imagePreviewUnavailable: "معاينة الصورة غير متاحة.",
imagePreviewLoadFailed: "غير قادر على تحميل معاينة الصورة.", imagePreviewLoadFailed: "غير قادر على تحميل معاينة الصورة.",
reviewOne: "مراجعة · تعليق واحد", review: "مراجعة",
reviewMany: "مراجعة · تعليقات{{count}}", commentsOne: "تعليق واحد",
commentsMany: "{{count}} تعليقات",
textAttachment: "مرفق النص", textAttachment: "مرفق النص",
text: "نص",
file: "ملف",
}, },
speak: { speak: {
header: "تكلم", header: "تكلم",
+6 -3
View File
@@ -108,7 +108,7 @@ export const en = {
removeFile: "Remove file attachment", removeFile: "Remove file attachment",
openGithub: "Open {{kind}} #{{number}}", openGithub: "Open {{kind}} #{{number}}",
removeGithub: "Remove {{kind}} #{{number}}", removeGithub: "Remove {{kind}} #{{number}}",
browserElement: "Element · {{tag}}", element: "Element",
openBrowserElement: "Open browser element attachment", openBrowserElement: "Open browser element attachment",
removeBrowserElement: "Remove browser element attachment", removeBrowserElement: "Remove browser element attachment",
openReview: "Open review attachment", openReview: "Open review attachment",
@@ -254,9 +254,12 @@ export const en = {
imageUnavailable: "Image unavailable", imageUnavailable: "Image unavailable",
imagePreviewUnavailable: "Image preview unavailable.", imagePreviewUnavailable: "Image preview unavailable.",
imagePreviewLoadFailed: "Unable to load image preview.", imagePreviewLoadFailed: "Unable to load image preview.",
reviewOne: "Review · 1 comment", review: "Review",
reviewMany: "Review · {{count}} comments", commentsOne: "1 comment",
commentsMany: "{{count}} comments",
textAttachment: "Text attachment", textAttachment: "Text attachment",
text: "Text",
file: "File",
}, },
speak: { speak: {
header: "Spoke", header: "Spoke",
+6 -3
View File
@@ -110,7 +110,7 @@ export const es: TranslationResources = {
removeFile: "Remove file attachment", removeFile: "Remove file attachment",
openGithub: "Abrir{{kind}}#{{number}}", openGithub: "Abrir{{kind}}#{{number}}",
removeGithub: "Quitar{{kind}}#{{number}}", removeGithub: "Quitar{{kind}}#{{number}}",
browserElement: "Elemento ·{{tag}}", element: "Elemento",
openBrowserElement: "Abrir archivo adjunto de elemento del navegador", openBrowserElement: "Abrir archivo adjunto de elemento del navegador",
removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador", removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador",
openReview: "Abrir archivo adjunto de reseña", openReview: "Abrir archivo adjunto de reseña",
@@ -258,9 +258,12 @@ export const es: TranslationResources = {
imageUnavailable: "Imagen no disponible", imageUnavailable: "Imagen no disponible",
imagePreviewUnavailable: "Vista previa de la imagen no disponible.", imagePreviewUnavailable: "Vista previa de la imagen no disponible.",
imagePreviewLoadFailed: "No se puede cargar la vista previa de la imagen.", imagePreviewLoadFailed: "No se puede cargar la vista previa de la imagen.",
reviewOne: "Reseña · 1 comentario", review: "Revisión",
reviewMany: "Revisión · Comentarios{{count}}", commentsOne: "1 comentario",
commentsMany: "{{count}} comentarios",
textAttachment: "Adjunto de texto", textAttachment: "Adjunto de texto",
text: "Texto",
file: "Archivo",
}, },
speak: { speak: {
header: "Habló", header: "Habló",
+6 -3
View File
@@ -111,7 +111,7 @@ export const fr: TranslationResources = {
removeFile: "Remove file attachment", removeFile: "Remove file attachment",
openGithub: "Ouvrir{{kind}}#{{number}}", openGithub: "Ouvrir{{kind}}#{{number}}",
removeGithub: "Supprimer{{kind}}#{{number}}", removeGithub: "Supprimer{{kind}}#{{number}}",
browserElement: "Élément ·{{tag}}", element: "Élément",
openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur", openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur",
removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur", removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur",
openReview: "Ouvrir la pièce jointe de l'avis", openReview: "Ouvrir la pièce jointe de l'avis",
@@ -258,9 +258,12 @@ export const fr: TranslationResources = {
imageUnavailable: "Image indisponible", imageUnavailable: "Image indisponible",
imagePreviewUnavailable: "Aperçu de l'image indisponible.", imagePreviewUnavailable: "Aperçu de l'image indisponible.",
imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.", imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.",
reviewOne: "Avis · 1 commentaire", review: "Revue",
reviewMany: "Révision · Commentaires{{count}}", commentsOne: "1 commentaire",
commentsMany: "{{count}} commentaires",
textAttachment: "Texte en pièce jointe", textAttachment: "Texte en pièce jointe",
text: "Texte",
file: "Fichier",
}, },
speak: { speak: {
header: "Rayon", header: "Rayon",
+6 -3
View File
@@ -110,7 +110,7 @@ export const ru: TranslationResources = {
removeFile: "Remove file attachment", removeFile: "Remove file attachment",
openGithub: "Открыть{{kind}}#{{number}}", openGithub: "Открыть{{kind}}#{{number}}",
removeGithub: "Удалить{{kind}}#{{number}}", removeGithub: "Удалить{{kind}}#{{number}}",
browserElement: "Элемент ·{{tag}}", element: "Элемент",
openBrowserElement: "Открыть вложение элемента браузера", openBrowserElement: "Открыть вложение элемента браузера",
removeBrowserElement: "Удалить вложение элемента браузера", removeBrowserElement: "Удалить вложение элемента браузера",
openReview: "Открыть прикрепленный файл с отзывом", openReview: "Открыть прикрепленный файл с отзывом",
@@ -257,9 +257,12 @@ export const ru: TranslationResources = {
imageUnavailable: "Изображение недоступно", imageUnavailable: "Изображение недоступно",
imagePreviewUnavailable: "Предварительный просмотр изображения недоступен.", imagePreviewUnavailable: "Предварительный просмотр изображения недоступен.",
imagePreviewLoadFailed: "Невозможно загрузить предварительный просмотр изображения.", imagePreviewLoadFailed: "Невозможно загрузить предварительный просмотр изображения.",
reviewOne: "Отзыв · 1 комментарий", review: "Ревью",
reviewMany: "Обзор · Комментарии{{count}}", commentsOne: "1 комментарий",
commentsMany: "Комментарии: {{count}}",
textAttachment: "Текстовое вложение", textAttachment: "Текстовое вложение",
text: "Текст",
file: "Файл",
}, },
speak: { speak: {
header: "Говорил", header: "Говорил",
+6 -3
View File
@@ -110,7 +110,7 @@ export const zhCN: TranslationResources = {
removeFile: "Remove file attachment", removeFile: "Remove file attachment",
openGithub: "打开 {{kind}} #{{number}}", openGithub: "打开 {{kind}} #{{number}}",
removeGithub: "移除 {{kind}} #{{number}}", removeGithub: "移除 {{kind}} #{{number}}",
browserElement: "元素 · {{tag}}", element: "元素",
openBrowserElement: "打开浏览器元素附件", openBrowserElement: "打开浏览器元素附件",
removeBrowserElement: "移除浏览器元素附件", removeBrowserElement: "移除浏览器元素附件",
openReview: "打开 review 附件", openReview: "打开 review 附件",
@@ -255,9 +255,12 @@ export const zhCN: TranslationResources = {
imageUnavailable: "图片不可用", imageUnavailable: "图片不可用",
imagePreviewUnavailable: "图片预览不可用。", imagePreviewUnavailable: "图片预览不可用。",
imagePreviewLoadFailed: "无法加载图片预览。", imagePreviewLoadFailed: "无法加载图片预览。",
reviewOne: "Review · 1 条评论", review: "审查",
reviewMany: "Review · {{count}} 条评论", commentsOne: "1 条评论",
commentsMany: "{{count}} 条评论",
textAttachment: "文本附件", textAttachment: "文本附件",
text: "文本",
file: "文件",
}, },
speak: { speak: {
header: "已朗读", header: "已朗读",