feat: Multiple attachments + paste support (App + Diagnostic)

App:
- Multiple pending attachments (horizontal scroll preview)
- Individual remove (X) or clear all
- Send button shows when any attachment pending
- All files sent before text message

Diagnostic:
- Clip icon for file selection (multiple)
- Paste images/files from clipboard (Ctrl+V)
- Pending preview with thumbnails
- Files sent via RVS before text message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 11:34:33 +02:00
parent 07ed2cdcf6
commit 6363da97b1
3 changed files with 198 additions and 82 deletions
+109 -75
View File
@@ -16,6 +16,7 @@ import {
Platform,
StyleSheet,
Image,
ScrollView,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -94,7 +95,7 @@ const ChatScreen: React.FC = () => {
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [pendingAttachment, setPendingAttachment] = useState<{file: any, isPhoto: boolean} | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
@@ -402,9 +403,9 @@ const ChatScreen: React.FC = () => {
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhang vorhanden → Anhang + Text zusammen senden
if (pendingAttachment) {
sendPendingAttachment(text);
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
@@ -427,7 +428,7 @@ const ChatScreen: React.FC = () => {
text,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachment, sendPendingAttachment]);
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
@@ -449,69 +450,81 @@ const ChatScreen: React.FC = () => {
});
}, [getCurrentLocation]);
// Datei auswaehlen → als pending speichern (nicht sofort senden)
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
setShowFileUpload(false);
setPendingAttachment({ file, isPhoto: false });
setInputText(''); // Focus auf Textfeld
setPendingAttachments(prev => [...prev, { file, isPhoto: false }]);
}, []);
// Foto auswaehlen → als pending speichern (nicht sofort senden)
// Foto auswaehlen → zur Pending-Liste hinzufuegen
const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
setShowCameraUpload(false);
setPendingAttachment({ file: photo, isPhoto: true });
setInputText(''); // Focus auf Textfeld
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
}, []);
// Pending Anhang + Text/Sprache senden
const sendPendingAttachment = useCallback(async (messageText: string) => {
if (!pendingAttachment) return;
const { file, isPhoto } = pendingAttachment;
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
const location = await getCurrentLocation();
const msgId = nextId();
// Chat-Nachricht erstellen
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri;
// Alle Attachments fuer die Chat-Nachricht sammeln
const attachments: Attachment[] = [];
for (const { file, isPhoto } of pendingAttachments) {
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || 'Anhang',
timestamp: Date.now(),
attachments: [{
attachments.push({
type: isImage ? 'image' : 'file',
name,
size: file.size,
uri: imageUri,
mimeType,
}],
});
}
// Chat-Nachricht mit allen Anhaengen
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments,
};
setMessages(prev => [...prev, userMsg]);
// Auf Disk speichern
if (base64) {
persistAttachment(base64, msgId, name).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
// Alle Dateien an RVS senden + auf Disk speichern
for (const { file, isPhoto } of pendingAttachments) {
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
// Auf Disk speichern
if (base64) {
persistAttachment(base64, msgId + '_' + name, name).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a =>
a.name === name && !a.uri?.startsWith('file://') ? { ...a, uri: filePath } : a
)} : m
));
}).catch(() => {});
}
// An RVS senden
rvs.send('file', {
name,
type: mimeType,
size: file.size,
base64,
...(isPhoto && file.width && { width: file.width, height: file.height }),
...(location && { location }),
});
}
// Datei an RVS senden
rvs.send('file', {
name,
type: mimeType,
size: file.size,
base64,
...(isPhoto && file.width && { width: file.width, height: file.height }),
...(location && { location }),
});
// Wenn Text dabei ist, als separate Chat-Nachricht senden (damit ARIA weiss was zu tun ist)
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
if (messageText) {
rvs.send('chat', {
text: messageText,
@@ -519,9 +532,9 @@ const ChatScreen: React.FC = () => {
});
}
setPendingAttachment(null);
setPendingAttachments([]);
setInputText('');
}, [pendingAttachment, getCurrentLocation]);
}, [pendingAttachments, getCurrentLocation]);
}, [getCurrentLocation]);
// --- Rendering ---
@@ -670,24 +683,36 @@ const ChatScreen: React.FC = () => {
}
/>
{/* Pending Anhang Vorschau */}
{pendingAttachment && (
{/* Pending Anhaenge Vorschau */}
{pendingAttachments.length > 0 && (
<View style={styles.pendingBar}>
{pendingAttachment.file.type?.startsWith('image/') || pendingAttachment.isPhoto ? (
<Image
source={{ uri: pendingAttachment.file.base64
? `data:${pendingAttachment.file.type};base64,${pendingAttachment.file.base64}`
: pendingAttachment.file.uri }}
style={styles.pendingThumb}
/>
) : (
<Text style={{fontSize: 24, marginRight: 8}}>{'\uD83D\uDCC4'}</Text>
)}
<Text style={styles.pendingName} numberOfLines={1}>
{pendingAttachment.isPhoto ? pendingAttachment.file.fileName : pendingAttachment.file.name}
</Text>
<TouchableOpacity onPress={() => setPendingAttachment(null)}>
<Text style={{color: '#FF3B30', fontSize: 18, paddingHorizontal: 8}}>X</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{flex: 1}}>
{pendingAttachments.map((att, idx) => (
<View key={idx} style={styles.pendingItem}>
{att.file.type?.startsWith('image/') || att.isPhoto ? (
<Image
source={{ uri: att.file.base64
? `data:${att.file.type};base64,${att.file.base64}`
: att.file.uri }}
style={styles.pendingThumb}
/>
) : (
<View style={[styles.pendingThumb, {justifyContent: 'center', alignItems: 'center'}]}>
<Text style={{fontSize: 20}}>{'\uD83D\uDCC4'}</Text>
</View>
)}
<TouchableOpacity
style={styles.pendingRemove}
onPress={() => setPendingAttachments(prev => prev.filter((_, i) => i !== idx))}
>
<Text style={{color: '#fff', fontSize: 10, fontWeight: 'bold'}}>X</Text>
</TouchableOpacity>
</View>
))}
</ScrollView>
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 8}}>{pendingAttachments.length}</Text>
<TouchableOpacity onPress={() => setPendingAttachments([])}>
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>Alle X</Text>
</TouchableOpacity>
</View>
)}
@@ -714,7 +739,7 @@ const ChatScreen: React.FC = () => {
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder={pendingAttachment ? "Text zum Anhang (optional)..." : "Nachricht an ARIA..."}
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
placeholderTextColor="#555570"
multiline
maxLength={4000}
@@ -723,7 +748,7 @@ const ChatScreen: React.FC = () => {
/>
{/* Senden oder Sprache */}
{inputText.trim() || pendingAttachment ? (
{inputText.trim() || pendingAttachments.length > 0 ? (
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
@@ -963,17 +988,26 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
borderTopColor: '#2A2A3E',
},
pendingThumb: {
width: 40,
height: 40,
borderRadius: 6,
pendingItem: {
position: 'relative',
marginRight: 8,
},
pendingThumb: {
width: 50,
height: 50,
borderRadius: 6,
backgroundColor: '#0D0D1A',
},
pendingName: {
flex: 1,
color: '#E0E0F0',
fontSize: 13,
pendingRemove: {
position: 'absolute',
top: -4,
right: -4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#FF3B30',
justifyContent: 'center',
alignItems: 'center',
},
searchBar: {
flexDirection: 'row',