Skip to content

Commit 11d38f3

Browse files
committed
feature: improve attachment support on mobile
1 parent 9834723 commit 11d38f3

30 files changed

Lines changed: 740 additions & 751 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,7 @@ pnpm-lock.yaml
7474
.expo
7575
expo-env.d.ts
7676
/android
77-
/ios
77+
/ios
78+
79+
apps/mobile/android
80+
apps/mobile/ios
Lines changed: 48 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
"use client";
22

3-
import { type FC, useCallback, useMemo } from "react";
4-
import {
5-
Image as NativeImage,
6-
Pressable,
7-
ScrollView,
8-
View,
9-
} from "react-native";
10-
import * as ImagePicker from "expo-image-picker";
11-
import * as DocumentPicker from "expo-document-picker";
12-
import { Alert } from "react-native";
3+
import { type FC, useMemo } from "react";
4+
import { Image as NativeImage, ScrollView, View } from "react-native";
135
import { Icon } from "../ui/icon";
146
import { Text } from "../ui/text";
157
import { cn } from "~/utils/cn";
@@ -18,87 +10,7 @@ import * as AttachmentPrimitive from "@creatorem/ai-react-native/primitives/atta
1810
import { useAttachment } from "@creatorem/ai-react-native/primitives/attachment";
1911
import * as ComposerPrimitive from "@creatorem/ai-react-native/primitives/composer";
2012
import * as MessagePrimitive from "@creatorem/ai-react-native/primitives/message";
21-
import { useComposerStore } from "@creatorem/ai-chat/primitives/composer";
22-
23-
const uriToFile = async (
24-
uri: string,
25-
name: string,
26-
type: string,
27-
): Promise<File> => {
28-
const response = await fetch(uri);
29-
const blob = await response.blob();
30-
return new File([blob], name, { type });
31-
};
32-
33-
const isImageOnlyAccept = (accept: string): boolean => {
34-
if (accept === "*") return false;
35-
return accept.split(",").every((item) => item.trim().startsWith("image"));
36-
};
37-
38-
const useComposerAddAttachment = ({
39-
multiple = true,
40-
}: {
41-
multiple?: boolean;
42-
} = {}) => {
43-
const composerStore = useComposerStore();
44-
45-
return useCallback(async () => {
46-
const { attachmentAccept, addAttachment } = composerStore.getState();
47-
48-
try {
49-
if (isImageOnlyAccept(attachmentAccept)) {
50-
const { status } =
51-
await ImagePicker.requestMediaLibraryPermissionsAsync();
52-
if (status !== "granted") {
53-
Alert.alert(
54-
"Permission required",
55-
"Please grant media library access to add attachments.",
56-
);
57-
return;
58-
}
59-
60-
const result = await ImagePicker.launchImageLibraryAsync({
61-
mediaTypes: ["images"],
62-
allowsMultipleSelection: multiple,
63-
quality: 1,
64-
});
65-
66-
if (result.canceled || !result.assets?.length) return;
67-
68-
for (const asset of result.assets) {
69-
const file = await uriToFile(
70-
asset.uri,
71-
asset.fileName || `image-${Date.now()}.jpg`,
72-
asset.mimeType || "image/jpeg",
73-
);
74-
await addAttachment(file);
75-
}
76-
return;
77-
}
78-
79-
const result = await DocumentPicker.getDocumentAsync({
80-
multiple,
81-
type:
82-
attachmentAccept !== "*"
83-
? attachmentAccept.split(",").map((item) => item.trim())
84-
: undefined,
85-
});
86-
87-
if (result.canceled || !result.assets?.length) return;
88-
89-
for (const asset of result.assets) {
90-
const file = await uriToFile(
91-
asset.uri,
92-
asset.name,
93-
asset.mimeType || "application/octet-stream",
94-
);
95-
await addAttachment(file);
96-
}
97-
} catch {
98-
Alert.alert("Error", "Failed to add attachment. Please try again.");
99-
}
100-
}, [composerStore, multiple]);
101-
};
13+
import { useActionSheet } from "../ui/action-sheet";
10214

10315
const useAttachmentImageSrc = () => {
10416
const attachment = useAttachment();
@@ -133,132 +45,116 @@ const AttachmentThumb: FC = () => {
13345
);
13446
};
13547

136-
const AttachmentRemove: FC = () => {
137-
return (
138-
<View className="absolute top-1 right-1 z-10">
139-
<AttachmentPrimitive.Remove
140-
size="icon"
141-
variant="ghost"
142-
className="h-6 w-6 rounded-full bg-background/90 p-1"
143-
aria-label="Remove attachment"
144-
>
145-
<Icon name="X" size={12} />
146-
</AttachmentPrimitive.Remove>
147-
</View>
148-
);
149-
};
150-
151-
const AttachmentUI: FC = () => {
152-
const attachment = useAttachment();
153-
const isComposerAttachment = attachment.status.type !== "complete";
154-
const tileSizeClass =
155-
isComposerAttachment && attachment.type === "image"
156-
? "h-24 w-24"
157-
: "h-14 w-14";
158-
48+
const AttachmentUI: FC<{ noRemoveButton?: boolean }> = ({ noRemoveButton }) => {
15949
return (
16050
<AttachmentPrimitive.Root className="relative">
16151
<View
16252
className={cn(
163-
"overflow-hidden rounded-xl border border-border bg-secondary",
164-
tileSizeClass,
53+
"aspect-square flex-1 overflow-hidden rounded-xl border border-input",
16554
)}
16655
>
16756
<AttachmentThumb />
16857
</View>
16958

170-
{isComposerAttachment && <AttachmentRemove />}
171-
172-
<View className="mt-1 max-w-24">
173-
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
174-
<AttachmentPrimitive.Name />
175-
</Text>
176-
</View>
59+
{!noRemoveButton && (
60+
<View className="absolute top-1.5 right-1.5 z-20">
61+
<AttachmentPrimitive.Remove
62+
size="icon"
63+
variant="ghost"
64+
className="size-5 rounded-full bg-background p-0 text-foreground"
65+
aria-label="Remove attachment"
66+
>
67+
<Icon name="X" size={14} strokeWidth={3} />
68+
</AttachmentPrimitive.Remove>
69+
</View>
70+
)}
17771
</AttachmentPrimitive.Root>
17872
);
17973
};
18074

18175
export const UserMessageAttachments: FC = () => {
18276
return (
183-
<View className="col-span-full col-start-1 row-start-1 mb-2 flex w-full flex-row justify-end gap-2">
184-
<MessagePrimitive.Attachments components={{ Attachment: AttachmentUI }} />
77+
<View className="col-span-full col-start-1 row-start-1 mb-2 flex h-32 w-full flex-row justify-end gap-2">
78+
<MessagePrimitive.Attachments
79+
components={{ Attachment: AttachmentUI }}
80+
componentProps={{ noRemoveButton: true }}
81+
/>
18582
</View>
18683
);
18784
};
18885

18986
export const ComposerAttachments: FC = () => {
190-
return (
87+
const attachments = ComposerPrimitive.useComposer((s) => s.attachments);
88+
return attachments.length > 0 ? (
19189
<ScrollView
19290
horizontal
193-
showsHorizontalScrollIndicator={false}
19491
contentContainerStyle={{ gap: 8, paddingHorizontal: 6, paddingBottom: 4 }}
195-
className="mb-2 w-full"
92+
className="h-32 w-full p-2"
19693
>
19794
<ComposerPrimitive.Attachments
19895
components={{ Attachment: AttachmentUI }}
19996
/>
20097
</ScrollView>
201-
);
98+
) : null;
20299
};
203100

204-
// export const ComposerAddAttachment: FC<{ multiple?: boolean }> = ({
205-
// multiple = true,
206-
// }) => {
207-
// const onAddAttachment = useComposerAddAttachment({ multiple });
208-
209-
// return (
210-
// <Pressable
211-
// onPress={onAddAttachment}
212-
// className="size-8 items-center justify-center rounded-full"
213-
// accessibilityLabel="Add attachment"
214-
// >
215-
// <Icon name="Plus" size={18} />
216-
// </Pressable>
217-
// );
218-
// };
219-
220-
const ComposerAddAttachmentTakePhoto: FC = () => {
101+
const ComposerAddAttachmentTakePhoto: FC<{ onAddAttachment?: () => void }> = ({
102+
onAddAttachment,
103+
}) => {
221104
return (
222105
<ComposerPrimitive.AddAttachmentTakePhoto
223106
variant="secondary"
224107
className="flex h-20 flex-1 flex-col rounded-2xl p-2"
108+
onAddAttachment={onAddAttachment}
225109
>
226110
<Icon name="Camera" size={20} />
227111
<Text>Take photo</Text>
228112
</ComposerPrimitive.AddAttachmentTakePhoto>
229113
);
230114
};
231115

232-
const ComposerAddAttachmentImage: FC = () => {
116+
const ComposerAddAttachmentImage: FC<{ onAddAttachment?: () => void }> = ({
117+
onAddAttachment,
118+
}) => {
233119
return (
234120
<ComposerPrimitive.AddAttachmentImage
235121
variant="secondary"
236122
className="flex h-20 flex-1 flex-col rounded-2xl p-2"
123+
onAddAttachment={onAddAttachment}
237124
>
238125
<Icon name="Image" size={20} />
239126
<Text>Image</Text>
240127
</ComposerPrimitive.AddAttachmentImage>
241128
);
242129
};
243130

244-
const ComposerAddAttachmentFile: FC = () => {
131+
const ComposerAddAttachmentFile: FC<{ onAddAttachment?: () => void }> = ({
132+
onAddAttachment,
133+
}) => {
245134
return (
246135
<ComposerPrimitive.AddAttachmentFile
247136
variant="secondary"
248137
className="flex h-20 flex-1 flex-col rounded-2xl p-2"
138+
onAddAttachment={onAddAttachment}
249139
>
250140
<Icon name="Paperclip" size={20} />
251141
<Text>Files</Text>
252142
</ComposerPrimitive.AddAttachmentFile>
253143
);
254144
};
255145

256-
export const ComposerAddAttachment: FC = () => {
146+
export const ComposerAddAttachmentSheet: FC = () => {
147+
const { setOpen } = useActionSheet();
148+
149+
const handleAddAttachment = () => {
150+
setOpen(false);
151+
};
152+
257153
return (
258154
<View className="flex flex-row gap-4 p-6 pt-2">
259-
<ComposerAddAttachmentTakePhoto />
260-
<ComposerAddAttachmentImage />
261-
<ComposerAddAttachmentFile />
155+
<ComposerAddAttachmentTakePhoto onAddAttachment={handleAddAttachment} />
156+
<ComposerAddAttachmentImage onAddAttachment={handleAddAttachment} />
157+
<ComposerAddAttachmentFile onAddAttachment={handleAddAttachment} />
262158
</View>
263159
);
264160
};

apps/mobile/components/ai-chat/thread.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import { ScrollView } from "react-native-gesture-handler";
2626
import { ToolFallback } from "./tool-fallback";
2727
import { MarkdownText } from "./markdown-text";
2828
import { useCSSVariable } from "uniwind";
29-
import { ComposerAddAttachment } from "./attachment";
29+
import {
30+
ComposerAddAttachmentSheet,
31+
ComposerAttachments,
32+
UserMessageAttachments,
33+
} from "./attachment";
3034
import { WeatherToolRegistration } from "../tools/weather-tool-ui";
3135
import { Reasoning, ReasoningGroup } from "./reasoning";
3236

@@ -53,7 +57,7 @@ export const Thread: React.FC = () => {
5357
/>
5458
</ThreadPrimitive.ViewportScrollable>
5559

56-
<View className="mx-auto flex w-full flex-col gap-4 overflow-visible border-border border-t bg-transparent px-6 pt-2 pb-6">
60+
<View className="mx-auto flex w-full flex-col gap-4 overflow-visible bg-transparent px-6 pt-2 pb-6">
5761
<ThreadScrollToBottom />
5862
<Composer />
5963
</View>
@@ -172,14 +176,16 @@ const Composer: React.FC = () => {
172176
return (
173177
<ComposerPrimitive.Root>
174178
<View className="flex-row items-end gap-2">
175-
{/* <ComposerAttachments /> */}
176179
<ComposerAction />
177-
<ComposerPrimitive.Input
178-
placeholder="Send a message..."
179-
className="my-auto flex max-h-40 min-h-12 w-full flex-1 flex-row items-center rounded-3xl bg-secondary px-3 py-2 text-base outline-none transition-shadow placeholder:text-muted-foreground"
180-
autoFocus
181-
aria-label="Message input"
182-
/>
180+
<View className="flex flex-1 items-start rounded-3xl bg-secondary">
181+
<ComposerAttachments />
182+
<ComposerPrimitive.Input
183+
placeholder="Send a message..."
184+
className="my-auto flex max-h-40 min-h-12 w-full flex-1 flex-row items-center rounded-3xl bg-secondary px-3 py-2 text-base outline-none transition-shadow placeholder:text-muted-foreground"
185+
autoFocus
186+
aria-label="Message input"
187+
/>
188+
</View>
183189
<ComposerSubmit />
184190
</View>
185191
</ComposerPrimitive.Root>
@@ -205,7 +211,7 @@ const ComposerAction: FC = () => {
205211
backgroundColor: backgroundColor,
206212
}}
207213
>
208-
<ComposerAddAttachment />
214+
<ComposerAddAttachmentSheet />
209215
{/* <View className="h-40 px-4">
210216
<ComposerAddAttachmentFile />
211217
<ComposerAddAttachmentImage />
@@ -354,7 +360,7 @@ const UserMessage: FC = () => {
354360
className="fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
355361
data-role="user"
356362
>
357-
{/* <UserMessageAttachments /> */}
363+
<UserMessageAttachments />
358364

359365
<Animated.View
360366
entering={FadeIn.duration(320)}

apps/mobile/components/ui/action-sheet.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ export const ActionSheetClose: React.FC<
115115
interface ThemedActionSheetProps extends RNActionSheetProps {}
116116

117117
const defaultHeaderComponent = (
118-
<View className="w-full flex h-4">
119-
<View className="h-1 w-10 mx-auto mt-1 rounded-full bg-input" />
118+
<View className="flex h-4 w-full">
119+
<View className="mx-auto mt-1 h-1 w-10 rounded-full bg-input" />
120120
</View>
121121
);
122122

0 commit comments

Comments
 (0)