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:
@@ -1,9 +1,16 @@
|
||||
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { isNative } from "@/constants/platform";
|
||||
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 {
|
||||
onOpen: () => void;
|
||||
@@ -24,7 +31,6 @@ export function AttachmentPill({
|
||||
testID,
|
||||
children,
|
||||
}: AttachmentPillProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isBodyHovered, setIsBodyHovered] = useState(false);
|
||||
const [isCloseHovered, setIsCloseHovered] = useState(false);
|
||||
@@ -48,7 +54,7 @@ export function AttachmentPill({
|
||||
onHoverOut={handleBodyHoverOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={openAccessibilityLabel}
|
||||
style={styles.body}
|
||||
style={styles.frame}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
@@ -62,22 +68,127 @@ export function AttachmentPill({
|
||||
accessibilityLabel={removeAccessibilityLabel}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={12} color={theme.colors.foregroundMuted} />
|
||||
<ThemedX size={12} uniProps={iconForegroundMutedMapping} />
|
||||
</Pressable>
|
||||
</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) => ({
|
||||
wrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
body: {
|
||||
frame: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
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: {
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
CircleDot,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@@ -41,13 +42,15 @@ import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Copy,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
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 Animated, {
|
||||
Easing,
|
||||
@@ -101,6 +104,13 @@ import {
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
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 { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
@@ -160,6 +170,10 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||
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 foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -363,34 +377,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
imagePreviewSpacing: {
|
||||
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: {
|
||||
alignSelf: "center",
|
||||
padding: theme.spacing[1],
|
||||
@@ -417,36 +403,82 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
||||
const uri = useAttachmentPreviewUrl(image);
|
||||
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||
if (!uri) {
|
||||
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
|
||||
}
|
||||
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
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,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
): UserMessageAttachmentContent {
|
||||
switch (attachment.type) {
|
||||
case "review": {
|
||||
const count = attachment.comments.length;
|
||||
return count === 1
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count });
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle:
|
||||
count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count }),
|
||||
};
|
||||
}
|
||||
case "github_pr":
|
||||
return `PR #${attachment.number}`;
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return `Issue #${attachment.number}`;
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
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":
|
||||
return attachment.fileName;
|
||||
default:
|
||||
return "";
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
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 { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
|
||||
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
@@ -534,24 +568,31 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<View key={image.id} style={userMessageStylesheet.imagePill}>
|
||||
<UserMessageAttachmentThumbnail image={image} />
|
||||
</View>
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<View
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
style={userMessageStylesheet.structuredAttachmentPill}
|
||||
>
|
||||
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
|
||||
{getUserMessageAttachmentLabel(attachment, t)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getUserMessageAttachmentContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
@@ -579,6 +620,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user