1import type { ChatAttachment, EnvironmentId } from "@t3tools/contracts";
2import { Image } from "expo-image";
3import { Pressable, ScrollView, View } from "react-native";
4
5import { AppText as Text } from "../../components/AppText";
6import { SymbolView } from "../../components/AppSymbol";
7import { useAssetUrl } from "../../state/assets";
8import { assetImageSource } from "../../lib/assetImageSource";
9
10/**
11 * The composer is shared, so editing a queued message needs a visible way back
12 * out. This is that way out, and the only place the edit announces itself.
13 */
14export function ComposerQueuedEditBanner(props: {
15 readonly saving: boolean;
16 readonly onCancel: () => void;
17}) {
18 return (
19 <View className="flex-row items-center gap-2 px-4 pb-2">
20 <SymbolView name="pencil" size={12} tintColorClassName="accent-foreground-muted" />
21 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
22 Editing queued message
23 </Text>
24 <Pressable
25 accessibilityRole="button"
26 accessibilityLabel="Cancel editing queued message"
27 disabled={props.saving}
28 onPress={props.onCancel}
29 hitSlop={8}
30 className="min-h-8 justify-center px-1 active:opacity-70 disabled:opacity-40"
31 >
32 <Text className="font-t3-medium text-xs text-primary">Cancel</Text>
33 </Pressable>
34 </View>
35 );
36}
37
38/**
39 * Attachments already on the queued message. They live on the server rather
40 * than in the draft, so they get their own row: removing one here drops it
41 * from the replacement list the edit sends.
42 */
43export function ComposerQueuedEditAttachments(props: {
44 readonly environmentId: EnvironmentId;
45 readonly attachments: ReadonlyArray<ChatAttachment>;
46 readonly disabled: boolean;
47 readonly onRemove: (attachmentId: string) => void;
48}) {
49 if (props.attachments.length === 0) return null;
50 return (
51 <ScrollView
52 horizontal
53 showsHorizontalScrollIndicator={false}
54 keyboardShouldPersistTaps="always"
55 className="grow-0"
56 contentContainerClassName="flex-row items-center gap-2"
57 >
58 {props.attachments.map((attachment) => (
59 <QueuedEditAttachmentChip
60 key={attachment.id}
61 environmentId={props.environmentId}
62 attachment={attachment}
63 disabled={props.disabled}
64 onRemove={props.onRemove}
65 />
66 ))}
67 </ScrollView>
68 );
69}
70
71function QueuedEditAttachmentChip(props: {
72 readonly environmentId: EnvironmentId;
73 readonly attachment: ChatAttachment;
74 readonly disabled: boolean;
75 readonly onRemove: (attachmentId: string) => void;
76}) {
77 const { attachment } = props;
78 const isImage = attachment.mimeType.startsWith("image/");
79 const resource = {
80 _tag: "attachment",
81 attachmentId: attachment.id,
82 fileName: attachment.name,
83 mimeType: attachment.mimeType,
84 disposition: "inline",
85 } as const;
86 const url = useAssetUrl(props.environmentId, isImage ? resource : null);
87
88 return (
89 <View className="max-w-[180px] flex-row items-center gap-2 rounded-xl border border-border bg-card py-1 pl-1 pr-2">
90 {isImage && url !== null ? (
91 <Image
92 source={assetImageSource(props.environmentId, resource, url)}
93 cachePolicy="memory-disk"
94 contentFit="cover"
95 style={{ width: 24, height: 24, borderRadius: 6 }}
96 accessibilityIgnoresInvertColors
97 />
98 ) : (
99 <View className="h-6 w-6 items-center justify-center rounded-md bg-subtle">
100 <SymbolView
101 name={isImage ? "photo" : "doc"}
102 size={12}
103 tintColorClassName="accent-icon-subtle"
104 />
105 </View>
106 )}
107 <Text className="min-w-0 shrink text-xs text-foreground" numberOfLines={1}>
108 {attachment.name}
109 </Text>
110 <Pressable
111 accessibilityRole="button"
112 accessibilityLabel={`Remove ${attachment.name}`}
113 disabled={props.disabled}
114 hitSlop={8}
115 onPress={() => props.onRemove(attachment.id)}
116 className="active:opacity-70 disabled:opacity-40"
117 >
118 <SymbolView name="xmark.circle.fill" size={14} tintColorClassName="accent-icon-subtle" />
119 </Pressable>
120 </View>
121 );
122}