@comergehq/studio 0.1.35 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +823 -386
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +832 -387
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/components/chat/ChatMessageAttachments.tsx +243 -0
- package/src/components/chat/ChatMessageBubble.tsx +74 -24
- package/src/components/chat/ChatMessageList.tsx +4 -1
- package/src/components/chat/ChatPage.tsx +3 -0
- package/src/components/draw/DrawModeOverlay.tsx +7 -1
- package/src/components/models/types.ts +11 -0
- package/src/components/overlays/EdgeGlowFrame.tsx +7 -2
- package/src/core/services/supabase/client.ts +2 -0
- package/src/data/attachment/types.ts +2 -0
- package/src/studio/ComergeStudio.tsx +3 -0
- package/src/studio/hooks/useAttachmentUpload.ts +37 -6
- package/src/studio/hooks/useOptimisticChatMessages.ts +47 -3
- package/src/studio/hooks/useThreadMessages.ts +79 -5
- package/src/studio/ui/ChatPanel.tsx +3 -0
- package/src/studio/ui/StudioOverlay.tsx +3 -0
- package/src/studio/ui/preview-panel/PreviewRelatedAppsSection.tsx +28 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comergehq/studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "Comerge studio",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"typescript": "^5.7.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
+
"@react-native-async-storage/async-storage": "*",
|
|
55
56
|
"@callstack/liquid-glass": "*",
|
|
56
57
|
"@supabase/supabase-js": "*",
|
|
57
58
|
"@gorhom/bottom-sheet": "*",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"expo": "*",
|
|
60
61
|
"expo-asset": "*",
|
|
61
62
|
"expo-file-system": "*",
|
|
63
|
+
"expo-image": "*",
|
|
62
64
|
"expo-haptics": "*",
|
|
63
65
|
"expo-linear-gradient": "*",
|
|
64
66
|
"lottie-react-native": "*",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Dimensions,
|
|
5
|
+
FlatList,
|
|
6
|
+
Modal,
|
|
7
|
+
Pressable,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
12
|
+
|
|
13
|
+
import type { ChatAttachment } from '../models/types';
|
|
14
|
+
import { useTheme } from '../../theme';
|
|
15
|
+
import { Text } from '../primitives/Text';
|
|
16
|
+
import { IconClose } from '../icons/StudioIcons';
|
|
17
|
+
|
|
18
|
+
export type ChatMessageAttachmentsProps = {
|
|
19
|
+
messageId: string;
|
|
20
|
+
attachments: ChatAttachment[];
|
|
21
|
+
align?: 'left' | 'right';
|
|
22
|
+
onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function ChatMessageAttachments({
|
|
26
|
+
messageId,
|
|
27
|
+
attachments,
|
|
28
|
+
align = 'left',
|
|
29
|
+
onAttachmentLoadError,
|
|
30
|
+
}: ChatMessageAttachmentsProps) {
|
|
31
|
+
const theme = useTheme();
|
|
32
|
+
const [viewerVisible, setViewerVisible] = React.useState(false);
|
|
33
|
+
const [viewerIndex, setViewerIndex] = React.useState(0);
|
|
34
|
+
const failedKeysRef = React.useRef<Set<string>>(new Set());
|
|
35
|
+
const [loadingById, setLoadingById] = React.useState<Record<string, boolean>>({});
|
|
36
|
+
const [modalLoadingById, setModalLoadingById] = React.useState<Record<string, boolean>>({});
|
|
37
|
+
const pulse = React.useRef(new Animated.Value(0.45)).current;
|
|
38
|
+
|
|
39
|
+
const imageAttachments = React.useMemo(
|
|
40
|
+
() =>
|
|
41
|
+
attachments.filter(
|
|
42
|
+
(att) =>
|
|
43
|
+
att.mimeType.startsWith('image/') &&
|
|
44
|
+
typeof att.uri === 'string' &&
|
|
45
|
+
att.uri.length > 0
|
|
46
|
+
),
|
|
47
|
+
[attachments]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const itemHeight = imageAttachments.length === 1 ? 180 : 124;
|
|
51
|
+
const maxItemWidth = imageAttachments.length === 1 ? 280 : 180;
|
|
52
|
+
|
|
53
|
+
const getAspectRatio = (att: ChatAttachment) => {
|
|
54
|
+
const width = typeof att.width === 'number' ? att.width : 0;
|
|
55
|
+
const height = typeof att.height === 'number' ? att.height : 0;
|
|
56
|
+
if (width > 0 && height > 0) {
|
|
57
|
+
return Math.max(0.35, Math.min(2.4, width / height));
|
|
58
|
+
}
|
|
59
|
+
return 0.8;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
Animated.loop(
|
|
64
|
+
Animated.sequence([
|
|
65
|
+
Animated.timing(pulse, { toValue: 0.85, duration: 650, useNativeDriver: true }),
|
|
66
|
+
Animated.timing(pulse, { toValue: 0.45, duration: 650, useNativeDriver: true }),
|
|
67
|
+
])
|
|
68
|
+
).start();
|
|
69
|
+
}, [pulse]);
|
|
70
|
+
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
if (imageAttachments.length === 0) {
|
|
73
|
+
setLoadingById({});
|
|
74
|
+
setModalLoadingById({});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setLoadingById((prev) => {
|
|
78
|
+
const next: Record<string, boolean> = {};
|
|
79
|
+
for (const att of imageAttachments) {
|
|
80
|
+
next[att.id] = prev[att.id] ?? true;
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
});
|
|
84
|
+
}, [imageAttachments]);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (!viewerVisible) return;
|
|
88
|
+
if (imageAttachments.length === 0) {
|
|
89
|
+
setModalLoadingById({});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setModalLoadingById(() => {
|
|
93
|
+
const next: Record<string, boolean> = {};
|
|
94
|
+
for (const att of imageAttachments) {
|
|
95
|
+
next[att.id] = true;
|
|
96
|
+
}
|
|
97
|
+
return next;
|
|
98
|
+
});
|
|
99
|
+
}, [viewerVisible, imageAttachments]);
|
|
100
|
+
|
|
101
|
+
if (imageAttachments.length === 0) return null;
|
|
102
|
+
|
|
103
|
+
const handleError = (attachmentId: string) => {
|
|
104
|
+
const key = `${messageId}:${attachmentId}`;
|
|
105
|
+
if (failedKeysRef.current.has(key)) return;
|
|
106
|
+
failedKeysRef.current.add(key);
|
|
107
|
+
onAttachmentLoadError?.(messageId, attachmentId);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<View
|
|
113
|
+
style={{
|
|
114
|
+
flexDirection: 'row',
|
|
115
|
+
flexWrap: 'wrap',
|
|
116
|
+
justifyContent: align === 'right' ? 'flex-end' : 'flex-start',
|
|
117
|
+
alignSelf: align === 'right' ? 'flex-end' : 'flex-start',
|
|
118
|
+
gap: theme.spacing.sm,
|
|
119
|
+
marginBottom: theme.spacing.sm,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{imageAttachments.map((att, index) => (
|
|
123
|
+
<Pressable
|
|
124
|
+
key={att.id}
|
|
125
|
+
onPress={() => {
|
|
126
|
+
setViewerIndex(index);
|
|
127
|
+
setViewerVisible(true);
|
|
128
|
+
}}
|
|
129
|
+
accessibilityRole="button"
|
|
130
|
+
accessibilityLabel={`Attachment ${index + 1} of ${imageAttachments.length}`}
|
|
131
|
+
style={{
|
|
132
|
+
height: itemHeight,
|
|
133
|
+
aspectRatio: getAspectRatio(att),
|
|
134
|
+
maxWidth: maxItemWidth,
|
|
135
|
+
borderRadius: theme.radii.lg,
|
|
136
|
+
overflow: 'hidden',
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{loadingById[att.id] ? (
|
|
140
|
+
<Animated.View
|
|
141
|
+
style={{
|
|
142
|
+
...StyleSheet.absoluteFillObject,
|
|
143
|
+
opacity: pulse,
|
|
144
|
+
backgroundColor: theme.colors.border,
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
<ExpoImage
|
|
149
|
+
source={{ uri: att.uri }}
|
|
150
|
+
style={{ width: '100%', height: '100%' }}
|
|
151
|
+
contentFit="contain"
|
|
152
|
+
transition={140}
|
|
153
|
+
cachePolicy="memory-disk"
|
|
154
|
+
onLoadStart={() => {
|
|
155
|
+
setLoadingById((prev) => ({ ...prev, [att.id]: true }));
|
|
156
|
+
}}
|
|
157
|
+
onLoadEnd={() => {
|
|
158
|
+
setLoadingById((prev) => ({ ...prev, [att.id]: false }));
|
|
159
|
+
}}
|
|
160
|
+
onError={() => handleError(att.id)}
|
|
161
|
+
/>
|
|
162
|
+
</Pressable>
|
|
163
|
+
))}
|
|
164
|
+
</View>
|
|
165
|
+
|
|
166
|
+
<Modal visible={viewerVisible} transparent animationType="fade" onRequestClose={() => setViewerVisible(false)}>
|
|
167
|
+
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.95)' }}>
|
|
168
|
+
<View
|
|
169
|
+
style={{
|
|
170
|
+
position: 'absolute',
|
|
171
|
+
top: 56,
|
|
172
|
+
right: 16,
|
|
173
|
+
zIndex: 2,
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<Pressable
|
|
177
|
+
accessibilityRole="button"
|
|
178
|
+
accessibilityLabel="Close attachment viewer"
|
|
179
|
+
onPress={() => setViewerVisible(false)}
|
|
180
|
+
style={{
|
|
181
|
+
width: 44,
|
|
182
|
+
height: 44,
|
|
183
|
+
borderRadius: 22,
|
|
184
|
+
alignItems: 'center',
|
|
185
|
+
justifyContent: 'center',
|
|
186
|
+
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<IconClose size={18} colorToken="onPrimary" />
|
|
190
|
+
</Pressable>
|
|
191
|
+
</View>
|
|
192
|
+
<FlatList
|
|
193
|
+
data={imageAttachments}
|
|
194
|
+
horizontal
|
|
195
|
+
pagingEnabled
|
|
196
|
+
initialScrollIndex={viewerIndex}
|
|
197
|
+
keyExtractor={(item) => item.id}
|
|
198
|
+
showsHorizontalScrollIndicator={false}
|
|
199
|
+
getItemLayout={(_, index) => {
|
|
200
|
+
const width = Dimensions.get('window').width;
|
|
201
|
+
return { length: width, offset: width * index, index };
|
|
202
|
+
}}
|
|
203
|
+
renderItem={({ item, index }) => (
|
|
204
|
+
<View style={{ width: Dimensions.get('window').width, height: '100%', justifyContent: 'center' }}>
|
|
205
|
+
{modalLoadingById[item.id] ? (
|
|
206
|
+
<Animated.View
|
|
207
|
+
style={{
|
|
208
|
+
...StyleSheet.absoluteFillObject,
|
|
209
|
+
opacity: pulse,
|
|
210
|
+
backgroundColor: theme.colors.border,
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
) : null}
|
|
214
|
+
<ExpoImage
|
|
215
|
+
source={{ uri: item.uri }}
|
|
216
|
+
style={{ width: '100%', height: '78%' }}
|
|
217
|
+
contentFit="contain"
|
|
218
|
+
transition={140}
|
|
219
|
+
cachePolicy="memory-disk"
|
|
220
|
+
onLoadStart={() => {
|
|
221
|
+
setModalLoadingById((prev) => ({ ...prev, [item.id]: true }));
|
|
222
|
+
}}
|
|
223
|
+
onLoadEnd={() => {
|
|
224
|
+
setModalLoadingById((prev) => ({ ...prev, [item.id]: false }));
|
|
225
|
+
}}
|
|
226
|
+
onError={() => handleError(item.id)}
|
|
227
|
+
/>
|
|
228
|
+
<Text
|
|
229
|
+
variant="caption"
|
|
230
|
+
color="#FFFFFF"
|
|
231
|
+
style={{ textAlign: 'center', marginTop: theme.spacing.sm }}
|
|
232
|
+
>
|
|
233
|
+
{index + 1} / {imageAttachments.length}
|
|
234
|
+
</Text>
|
|
235
|
+
</View>
|
|
236
|
+
)}
|
|
237
|
+
/>
|
|
238
|
+
</View>
|
|
239
|
+
</Modal>
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
@@ -8,6 +8,7 @@ import { Button } from '../primitives/Button';
|
|
|
8
8
|
import { MarkdownText } from '../primitives/MarkdownText';
|
|
9
9
|
import { Surface } from '../primitives/Surface';
|
|
10
10
|
import { Text } from '../primitives/Text';
|
|
11
|
+
import { ChatMessageAttachments } from './ChatMessageAttachments';
|
|
11
12
|
|
|
12
13
|
export type ChatMessageBubbleProps = {
|
|
13
14
|
message: ChatMessage;
|
|
@@ -18,6 +19,7 @@ export type ChatMessageBubbleProps = {
|
|
|
18
19
|
isLast?: boolean;
|
|
19
20
|
retrying?: boolean;
|
|
20
21
|
onRetryMessage?: (messageId: string) => void;
|
|
22
|
+
onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
|
|
21
23
|
style?: ViewStyle;
|
|
22
24
|
};
|
|
23
25
|
|
|
@@ -36,12 +38,34 @@ function areMessageMetaEqual(a: ChatMessage['meta'], b: ChatMessage['meta']): bo
|
|
|
36
38
|
);
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
function areMessageAttachmentsEqual(a: ChatMessage['attachments'], b: ChatMessage['attachments']): boolean {
|
|
42
|
+
if (a === b) return true;
|
|
43
|
+
const left = a ?? [];
|
|
44
|
+
const right = b ?? [];
|
|
45
|
+
if (left.length !== right.length) return false;
|
|
46
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
47
|
+
if (
|
|
48
|
+
left[i].id !== right[i].id ||
|
|
49
|
+
left[i].name !== right[i].name ||
|
|
50
|
+
left[i].mimeType !== right[i].mimeType ||
|
|
51
|
+
left[i].size !== right[i].size ||
|
|
52
|
+
left[i].uri !== right[i].uri ||
|
|
53
|
+
left[i].width !== right[i].width ||
|
|
54
|
+
left[i].height !== right[i].height
|
|
55
|
+
) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
function ChatMessageBubbleInner({
|
|
40
63
|
message,
|
|
41
64
|
renderContent,
|
|
42
65
|
isLast,
|
|
43
66
|
retrying,
|
|
44
67
|
onRetryMessage,
|
|
68
|
+
onAttachmentLoadError,
|
|
45
69
|
style,
|
|
46
70
|
}: ChatMessageBubbleProps) {
|
|
47
71
|
const theme = useTheme();
|
|
@@ -65,38 +89,62 @@ function ChatMessageBubbleInner({
|
|
|
65
89
|
metaStatus === 'success' ? theme.colors.success : metaStatus === 'error' ? theme.colors.danger : undefined;
|
|
66
90
|
const showRetry = Boolean(onRetryMessage) && isLast && metaStatus === 'error' && message.author === 'human';
|
|
67
91
|
const retryLabel = retrying ? 'Retrying...' : 'Retry';
|
|
92
|
+
const hasText = message.content.trim().length > 0;
|
|
93
|
+
const attachments = message.attachments ?? [];
|
|
94
|
+
const hasAttachments = attachments.length > 0;
|
|
95
|
+
const hasStatusIcon = isMergeCompleted || isSyncCompleted || isMergeApproved || isSyncStarted;
|
|
96
|
+
const shouldRenderBubble = hasText || hasStatusIcon;
|
|
68
97
|
const handleRetryPress = React.useCallback(() => {
|
|
69
98
|
onRetryMessage?.(message.id);
|
|
70
99
|
}, [message.id, onRetryMessage]);
|
|
71
100
|
|
|
72
101
|
return (
|
|
73
102
|
<View style={[align, style]}>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{
|
|
103
|
+
{hasAttachments ? (
|
|
104
|
+
<View
|
|
105
|
+
style={{
|
|
78
106
|
maxWidth: '85%',
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<CheckCheck size={16} color={theme.colors.success} style={{ marginRight: theme.spacing.sm }} />
|
|
91
|
-
) : null}
|
|
92
|
-
{isMergeApproved || isSyncStarted ? (
|
|
93
|
-
<GitMerge size={16} color={theme.colors.text} style={{ marginRight: theme.spacing.sm }} />
|
|
94
|
-
) : null}
|
|
95
|
-
<View style={{ flexShrink: 1, minWidth: 0 }}>
|
|
96
|
-
{renderContent ? renderContent(message) : <MarkdownText markdown={message.content} variant="chat" bodyColor={bodyColor} />}
|
|
97
|
-
</View>
|
|
107
|
+
marginBottom: shouldRenderBubble ? theme.spacing.xs : 0,
|
|
108
|
+
alignSelf: isHuman ? 'flex-end' : 'flex-start',
|
|
109
|
+
alignItems: isHuman ? 'flex-end' : 'flex-start',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<ChatMessageAttachments
|
|
113
|
+
messageId={message.id}
|
|
114
|
+
attachments={attachments}
|
|
115
|
+
align={isHuman ? 'right' : 'left'}
|
|
116
|
+
onAttachmentLoadError={onAttachmentLoadError}
|
|
117
|
+
/>
|
|
98
118
|
</View>
|
|
99
|
-
|
|
119
|
+
) : null}
|
|
120
|
+
{shouldRenderBubble ? (
|
|
121
|
+
<Surface
|
|
122
|
+
variant={bubbleVariant}
|
|
123
|
+
style={[
|
|
124
|
+
{
|
|
125
|
+
maxWidth: '85%',
|
|
126
|
+
borderRadius: theme.radii.lg,
|
|
127
|
+
paddingHorizontal: theme.spacing.lg,
|
|
128
|
+
paddingVertical: theme.spacing.md,
|
|
129
|
+
borderWidth: 1,
|
|
130
|
+
borderColor: theme.colors.border,
|
|
131
|
+
},
|
|
132
|
+
cornerStyle,
|
|
133
|
+
]}
|
|
134
|
+
>
|
|
135
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
136
|
+
{isMergeCompleted || isSyncCompleted ? (
|
|
137
|
+
<CheckCheck size={16} color={theme.colors.success} style={{ marginRight: theme.spacing.sm }} />
|
|
138
|
+
) : null}
|
|
139
|
+
{isMergeApproved || isSyncStarted ? (
|
|
140
|
+
<GitMerge size={16} color={theme.colors.text} style={{ marginRight: theme.spacing.sm }} />
|
|
141
|
+
) : null}
|
|
142
|
+
<View style={{ flexShrink: 1, minWidth: 0 }}>
|
|
143
|
+
{renderContent ? renderContent(message) : <MarkdownText markdown={message.content} variant="chat" bodyColor={bodyColor} />}
|
|
144
|
+
</View>
|
|
145
|
+
</View>
|
|
146
|
+
</Surface>
|
|
147
|
+
) : null}
|
|
100
148
|
{showRetry ? (
|
|
101
149
|
<View style={{ marginTop: theme.spacing.xs, alignSelf: align.alignSelf }}>
|
|
102
150
|
<Button
|
|
@@ -130,6 +178,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps): b
|
|
|
130
178
|
prev.message.id === next.message.id &&
|
|
131
179
|
prev.message.author === next.message.author &&
|
|
132
180
|
prev.message.content === next.message.content &&
|
|
181
|
+
areMessageAttachmentsEqual(prev.message.attachments, next.message.attachments) &&
|
|
133
182
|
prev.message.kind === next.message.kind &&
|
|
134
183
|
String(prev.message.createdAt) === String(next.message.createdAt) &&
|
|
135
184
|
areMessageMetaEqual(prev.message.meta, next.message.meta) &&
|
|
@@ -137,6 +186,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps): b
|
|
|
137
186
|
prev.isLast === next.isLast &&
|
|
138
187
|
prev.retrying === next.retrying &&
|
|
139
188
|
prev.onRetryMessage === next.onRetryMessage &&
|
|
189
|
+
prev.onAttachmentLoadError === next.onAttachmentLoadError &&
|
|
140
190
|
prev.style === next.style
|
|
141
191
|
);
|
|
142
192
|
}
|
|
@@ -17,6 +17,7 @@ export type ChatMessageListProps = {
|
|
|
17
17
|
renderMessageContent?: ChatMessageBubbleProps['renderContent'];
|
|
18
18
|
onRetryMessage?: (messageId: string) => void;
|
|
19
19
|
isRetryingMessage?: (messageId: string) => boolean;
|
|
20
|
+
onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
|
|
20
21
|
contentStyle?: ViewStyle;
|
|
21
22
|
bottomInset?: number;
|
|
22
23
|
/**
|
|
@@ -37,6 +38,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
37
38
|
renderMessageContent,
|
|
38
39
|
onRetryMessage,
|
|
39
40
|
isRetryingMessage,
|
|
41
|
+
onAttachmentLoadError,
|
|
40
42
|
contentStyle,
|
|
41
43
|
bottomInset = 0,
|
|
42
44
|
onNearBottomChange,
|
|
@@ -144,9 +146,10 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
144
146
|
isLast={Boolean(lastMessageId && item.id === lastMessageId)}
|
|
145
147
|
retrying={isRetryingMessage?.(item.id) ?? false}
|
|
146
148
|
onRetryMessage={onRetryMessage}
|
|
149
|
+
onAttachmentLoadError={onAttachmentLoadError}
|
|
147
150
|
/>
|
|
148
151
|
),
|
|
149
|
-
[isRetryingMessage, lastMessageId, onRetryMessage, renderMessageContent]
|
|
152
|
+
[isRetryingMessage, lastMessageId, onAttachmentLoadError, onRetryMessage, renderMessageContent]
|
|
150
153
|
);
|
|
151
154
|
|
|
152
155
|
return (
|
|
@@ -14,6 +14,7 @@ export type ChatPageProps = {
|
|
|
14
14
|
renderMessageContent?: ChatMessageListProps['renderMessageContent'];
|
|
15
15
|
onRetryMessage?: ChatMessageListProps['onRetryMessage'];
|
|
16
16
|
isRetryingMessage?: ChatMessageListProps['isRetryingMessage'];
|
|
17
|
+
onAttachmentLoadError?: ChatMessageListProps['onAttachmentLoadError'];
|
|
17
18
|
topBanner?: React.ReactNode;
|
|
18
19
|
composerTop?: React.ReactNode;
|
|
19
20
|
composer: Omit<ChatComposerProps, 'attachments'> & {
|
|
@@ -36,6 +37,7 @@ export function ChatPage({
|
|
|
36
37
|
renderMessageContent,
|
|
37
38
|
onRetryMessage,
|
|
38
39
|
isRetryingMessage,
|
|
40
|
+
onAttachmentLoadError,
|
|
39
41
|
topBanner,
|
|
40
42
|
composerTop,
|
|
41
43
|
composer,
|
|
@@ -86,6 +88,7 @@ export function ChatPage({
|
|
|
86
88
|
renderMessageContent={renderMessageContent}
|
|
87
89
|
onRetryMessage={onRetryMessage}
|
|
88
90
|
isRetryingMessage={isRetryingMessage}
|
|
91
|
+
onAttachmentLoadError={onAttachmentLoadError}
|
|
89
92
|
onNearBottomChange={onNearBottomChange}
|
|
90
93
|
bottomInset={bottomInset}
|
|
91
94
|
/>
|
|
@@ -107,7 +107,13 @@ export function DrawModeOverlay({
|
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
109
|
<View style={[StyleSheet.absoluteFill, styles.root, style]} pointerEvents="box-none">
|
|
110
|
-
<EdgeGlowFrame
|
|
110
|
+
<EdgeGlowFrame
|
|
111
|
+
visible={!hideUi}
|
|
112
|
+
role="danger"
|
|
113
|
+
thickness={50}
|
|
114
|
+
intensity={1}
|
|
115
|
+
animationDurationMs={hideUi ? 0 : 300}
|
|
116
|
+
/>
|
|
111
117
|
|
|
112
118
|
<DrawSurface
|
|
113
119
|
color={selectedColor}
|
|
@@ -31,6 +31,16 @@ export type ChatMessageMeta = {
|
|
|
31
31
|
threadId?: string;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export type ChatAttachment = {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
mimeType: string;
|
|
38
|
+
size: number;
|
|
39
|
+
uri?: string;
|
|
40
|
+
width?: number;
|
|
41
|
+
height?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
34
44
|
export type ChatMessage = {
|
|
35
45
|
id: string;
|
|
36
46
|
author: ChatAuthor;
|
|
@@ -38,6 +48,7 @@ export type ChatMessage = {
|
|
|
38
48
|
createdAt?: string | number | Date | null;
|
|
39
49
|
kind?: string | null;
|
|
40
50
|
meta?: ChatMessageMeta | null;
|
|
51
|
+
attachments?: ChatAttachment[];
|
|
41
52
|
};
|
|
42
53
|
|
|
43
54
|
|
|
@@ -19,6 +19,10 @@ export type EdgeGlowFrameProps = {
|
|
|
19
19
|
* Optional intensity multiplier for alpha (0..1).
|
|
20
20
|
*/
|
|
21
21
|
intensity?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Opacity animation duration in ms.
|
|
24
|
+
*/
|
|
25
|
+
animationDurationMs?: number;
|
|
22
26
|
style?: ViewStyle;
|
|
23
27
|
};
|
|
24
28
|
|
|
@@ -41,6 +45,7 @@ export function EdgeGlowFrame({
|
|
|
41
45
|
role = 'accent',
|
|
42
46
|
thickness = 40,
|
|
43
47
|
intensity = 1,
|
|
48
|
+
animationDurationMs = 300,
|
|
44
49
|
style,
|
|
45
50
|
}: EdgeGlowFrameProps) {
|
|
46
51
|
const theme = useTheme();
|
|
@@ -51,10 +56,10 @@ export function EdgeGlowFrame({
|
|
|
51
56
|
React.useEffect(() => {
|
|
52
57
|
Animated.timing(anim, {
|
|
53
58
|
toValue: visible ? 1 : 0,
|
|
54
|
-
duration:
|
|
59
|
+
duration: animationDurationMs,
|
|
55
60
|
useNativeDriver: true,
|
|
56
61
|
}).start();
|
|
57
|
-
}, [anim, visible]);
|
|
62
|
+
}, [anim, visible, animationDurationMs]);
|
|
58
63
|
|
|
59
64
|
const c = baseColor(role, theme);
|
|
60
65
|
const strong = withAlpha(c, 0.6 * alpha);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
3
|
|
|
3
4
|
let clientSingleton: SupabaseClient | null = null;
|
|
4
5
|
let injectedClient: SupabaseClient | null = null;
|
|
@@ -32,6 +33,7 @@ export function getSupabaseClient(): SupabaseClient {
|
|
|
32
33
|
|
|
33
34
|
clientSingleton = createClient(runtimeConfig.url, runtimeConfig.anonKey, {
|
|
34
35
|
auth: {
|
|
36
|
+
storage: AsyncStorage,
|
|
35
37
|
autoRefreshToken: true,
|
|
36
38
|
persistSession: true,
|
|
37
39
|
detectSessionInUrl: false,
|
|
@@ -550,6 +550,9 @@ function ComergeStudioInner({
|
|
|
550
550
|
chatSending={actions.sending}
|
|
551
551
|
chatShowTypingIndicator={chatShowTypingIndicator}
|
|
552
552
|
onSendChat={(text, attachments) => actions.sendEdit({ prompt: text, attachments })}
|
|
553
|
+
onChatAttachmentLoadError={() => {
|
|
554
|
+
thread.recoverAttachmentUrls();
|
|
555
|
+
}}
|
|
553
556
|
chatQueueItems={chatQueueItems}
|
|
554
557
|
onRemoveQueueItem={(id) => editQueueActions.cancel(id)}
|
|
555
558
|
chatProgress={showChatProgress ? agentProgress.view : null}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { Platform } from 'react-native';
|
|
2
|
+
import { Image, Platform } from 'react-native';
|
|
3
3
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
4
4
|
|
|
5
5
|
import { attachmentRepository } from '../../data/attachment/repository';
|
|
@@ -59,6 +59,23 @@ function getMimeTypeFromDataUrl(dataUrl: string): string {
|
|
|
59
59
|
return mimeMatch?.[1] ?? 'image/png';
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
async function getImageDimensionsFromDataUrl(dataUrl: string): Promise<{ width?: number; height?: number }> {
|
|
63
|
+
try {
|
|
64
|
+
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
65
|
+
const dims = await new Promise<{ width: number; height: number }>((resolve, reject) => {
|
|
66
|
+
Image.getSize(
|
|
67
|
+
normalized,
|
|
68
|
+
(width, height) => resolve({ width, height }),
|
|
69
|
+
(err) => reject(err)
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
if (dims.width > 0 && dims.height > 0) {
|
|
73
|
+
return { width: Math.round(dims.width), height: Math.round(dims.height) };
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
63
80
|
const [uploading, setUploading] = React.useState(false);
|
|
64
81
|
const [error, setError] = React.useState<Error | null>(null);
|
|
@@ -78,19 +95,28 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
|
78
95
|
? await dataUrlToBlobAndroid(normalized)
|
|
79
96
|
: await (await fetch(normalized)).blob();
|
|
80
97
|
const mimeType = getMimeTypeFromDataUrl(normalized);
|
|
81
|
-
|
|
98
|
+
const dimensions = mimeType.startsWith('image/')
|
|
99
|
+
? await getImageDimensionsFromDataUrl(normalized)
|
|
100
|
+
: {};
|
|
101
|
+
return { blob, idx, mimeType, ...dimensions };
|
|
82
102
|
})
|
|
83
103
|
);
|
|
84
104
|
|
|
85
|
-
const files = blobs.map(({ blob, mimeType }, idx) => ({
|
|
105
|
+
const files = blobs.map(({ blob, mimeType, width, height }, idx) => ({
|
|
86
106
|
name: `attachment-${Date.now()}-${idx}.png`,
|
|
87
107
|
size: blob.size,
|
|
88
108
|
mimeType,
|
|
109
|
+
width,
|
|
110
|
+
height,
|
|
89
111
|
}));
|
|
90
112
|
|
|
91
113
|
const presign = await attachmentRepository.presign({ threadId, appId, files });
|
|
92
114
|
await Promise.all(presign.uploads.map((u, index) => attachmentRepository.upload(u, blobs[index].blob)));
|
|
93
|
-
return presign.uploads.map((u) =>
|
|
115
|
+
return presign.uploads.map((u, index) => ({
|
|
116
|
+
...u.attachment,
|
|
117
|
+
width: blobs[index].width,
|
|
118
|
+
height: blobs[index].height,
|
|
119
|
+
}));
|
|
94
120
|
} catch (e) {
|
|
95
121
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
96
122
|
setError(err);
|
|
@@ -114,14 +140,19 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
|
114
140
|
? await dataUrlToBlobAndroid(normalized)
|
|
115
141
|
: await (await fetch(normalized)).blob();
|
|
116
142
|
const mimeType = getMimeTypeFromDataUrl(normalized);
|
|
117
|
-
|
|
143
|
+
const dimensions = mimeType.startsWith('image/')
|
|
144
|
+
? await getImageDimensionsFromDataUrl(normalized)
|
|
145
|
+
: {};
|
|
146
|
+
return { blob, mimeType, ...dimensions };
|
|
118
147
|
})
|
|
119
148
|
);
|
|
120
149
|
|
|
121
|
-
const files = blobs.map(({ blob, mimeType }, idx) => ({
|
|
150
|
+
const files = blobs.map(({ blob, mimeType, width, height }, idx) => ({
|
|
122
151
|
name: `attachment-${Date.now()}-${idx}.png`,
|
|
123
152
|
size: blob.size,
|
|
124
153
|
mimeType,
|
|
154
|
+
width,
|
|
155
|
+
height,
|
|
125
156
|
}));
|
|
126
157
|
|
|
127
158
|
const presign = await attachmentRepository.stagePresign({ files });
|