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
+117 -6
View File
@@ -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,