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, Platform,
StyleSheet, StyleSheet,
Image, Image,
ScrollView,
Modal, Modal,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; 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 [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false); 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 flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -402,9 +403,9 @@ const ChatScreen: React.FC = () => {
const sendTextMessage = useCallback(async () => { const sendTextMessage = useCallback(async () => {
const text = inputText.trim(); const text = inputText.trim();
// Wenn pending Anhang vorhanden → Anhang + Text zusammen senden // Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachment) { if (pendingAttachments.length > 0) {
sendPendingAttachment(text); sendPendingAttachments(text);
return; return;
} }
@@ -427,7 +428,7 @@ const ChatScreen: React.FC = () => {
text, text,
...(location && { location }), ...(location && { location }),
}); });
}, [inputText, getCurrentLocation, pendingAttachment, sendPendingAttachment]); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
// Sprachaufnahme abgeschlossen // Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => { const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
@@ -449,69 +450,81 @@ const ChatScreen: React.FC = () => {
}); });
}, [getCurrentLocation]); }, [getCurrentLocation]);
// Datei auswaehlen → als pending speichern (nicht sofort senden) // Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => { const handleFileSelected = useCallback(async (file: FileData) => {
setShowFileUpload(false); setShowFileUpload(false);
setPendingAttachment({ file, isPhoto: false }); setPendingAttachments(prev => [...prev, { file, isPhoto: false }]);
setInputText(''); // Focus auf Textfeld
}, []); }, []);
// Foto auswaehlen → als pending speichern (nicht sofort senden) // Foto auswaehlen → zur Pending-Liste hinzufuegen
const handlePhotoSelected = useCallback(async (photo: PhotoData) => { const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
setShowCameraUpload(false); setShowCameraUpload(false);
setPendingAttachment({ file: photo, isPhoto: true }); setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
setInputText(''); // Focus auf Textfeld
}, []); }, []);
// Pending Anhang + Text/Sprache senden // Alle Pending Anhaenge + Text senden
const sendPendingAttachment = useCallback(async (messageText: string) => { const sendPendingAttachments = useCallback(async (messageText: string) => {
if (!pendingAttachment) return; if (pendingAttachments.length === 0) return;
const { file, isPhoto } = pendingAttachment;
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const msgId = nextId(); const msgId = nextId();
// Chat-Nachricht erstellen // Alle Attachments fuer die Chat-Nachricht sammeln
const isImage = isPhoto || (file.type && file.type.startsWith('image/')); const attachments: Attachment[] = [];
const name = isPhoto ? file.fileName : file.name; for (const { file, isPhoto } of pendingAttachments) {
const base64 = file.base64 || ''; const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const mimeType = file.type || ''; const name = isPhoto ? file.fileName : file.name;
const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri; const base64 = file.base64 || '';
const mimeType = file.type || '';
const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri;
const userMsg: ChatMessage = { attachments.push({
id: msgId,
sender: 'user',
text: messageText || 'Anhang',
timestamp: Date.now(),
attachments: [{
type: isImage ? 'image' : 'file', type: isImage ? 'image' : 'file',
name, name,
size: file.size, size: file.size,
uri: imageUri, uri: imageUri,
mimeType, 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]); setMessages(prev => [...prev, userMsg]);
// Auf Disk speichern // Alle Dateien an RVS senden + auf Disk speichern
if (base64) { for (const { file, isPhoto } of pendingAttachments) {
persistAttachment(base64, msgId, name).then(filePath => { const name = isPhoto ? file.fileName : file.name;
setMessages(prev => prev.map(m => const base64 = file.base64 || '';
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m const mimeType = file.type || '';
));
}).catch(() => {}); // 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 // Text als separate Nachricht (damit ARIA weiss was zu tun ist)
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)
if (messageText) { if (messageText) {
rvs.send('chat', { rvs.send('chat', {
text: messageText, text: messageText,
@@ -519,9 +532,9 @@ const ChatScreen: React.FC = () => {
}); });
} }
setPendingAttachment(null); setPendingAttachments([]);
setInputText(''); setInputText('');
}, [pendingAttachment, getCurrentLocation]); }, [pendingAttachments, getCurrentLocation]);
}, [getCurrentLocation]); }, [getCurrentLocation]);
// --- Rendering --- // --- Rendering ---
@@ -670,24 +683,36 @@ const ChatScreen: React.FC = () => {
} }
/> />
{/* Pending Anhang Vorschau */} {/* Pending Anhaenge Vorschau */}
{pendingAttachment && ( {pendingAttachments.length > 0 && (
<View style={styles.pendingBar}> <View style={styles.pendingBar}>
{pendingAttachment.file.type?.startsWith('image/') || pendingAttachment.isPhoto ? ( <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{flex: 1}}>
<Image {pendingAttachments.map((att, idx) => (
source={{ uri: pendingAttachment.file.base64 <View key={idx} style={styles.pendingItem}>
? `data:${pendingAttachment.file.type};base64,${pendingAttachment.file.base64}` {att.file.type?.startsWith('image/') || att.isPhoto ? (
: pendingAttachment.file.uri }} <Image
style={styles.pendingThumb} source={{ uri: att.file.base64
/> ? `data:${att.file.type};base64,${att.file.base64}`
) : ( : att.file.uri }}
<Text style={{fontSize: 24, marginRight: 8}}>{'\uD83D\uDCC4'}</Text> style={styles.pendingThumb}
)} />
<Text style={styles.pendingName} numberOfLines={1}> ) : (
{pendingAttachment.isPhoto ? pendingAttachment.file.fileName : pendingAttachment.file.name} <View style={[styles.pendingThumb, {justifyContent: 'center', alignItems: 'center'}]}>
</Text> <Text style={{fontSize: 20}}>{'\uD83D\uDCC4'}</Text>
<TouchableOpacity onPress={() => setPendingAttachment(null)}> </View>
<Text style={{color: '#FF3B30', fontSize: 18, paddingHorizontal: 8}}>X</Text> )}
<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> </TouchableOpacity>
</View> </View>
)} )}
@@ -714,7 +739,7 @@ const ChatScreen: React.FC = () => {
style={styles.textInput} style={styles.textInput}
value={inputText} value={inputText}
onChangeText={setInputText} 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" placeholderTextColor="#555570"
multiline multiline
maxLength={4000} maxLength={4000}
@@ -723,7 +748,7 @@ const ChatScreen: React.FC = () => {
/> />
{/* Senden oder Sprache */} {/* Senden oder Sprache */}
{inputText.trim() || pendingAttachment ? ( {inputText.trim() || pendingAttachments.length > 0 ? (
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}> <TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text> <Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -963,17 +988,26 @@ const styles = StyleSheet.create({
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#2A2A3E', borderTopColor: '#2A2A3E',
}, },
pendingThumb: { pendingItem: {
width: 40, position: 'relative',
height: 40,
borderRadius: 6,
marginRight: 8, marginRight: 8,
},
pendingThumb: {
width: 50,
height: 50,
borderRadius: 6,
backgroundColor: '#0D0D1A', backgroundColor: '#0D0D1A',
}, },
pendingName: { pendingRemove: {
flex: 1, position: 'absolute',
color: '#E0E0F0', top: -4,
fontSize: 13, right: -4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#FF3B30',
justifyContent: 'center',
alignItems: 'center',
}, },
searchBar: { searchBar: {
flexDirection: 'row', flexDirection: 'row',
+81 -7
View File
@@ -205,8 +205,14 @@
<span><span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text">ARIA denkt...</span></span> <span><span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text">ARIA denkt...</span></span>
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button> <button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
</div> </div>
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
</div>
<div class="input-row"> <div class="input-row">
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..."> <label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
&#x1F4CE;
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
</label>
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..." onpaste="handleDiagPaste(event)">
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button> <button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button> <button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
</div> </div>
@@ -939,21 +945,39 @@
} }
} }
function sendDiagAttachments() {
// Alle pending Dateien an RVS senden
for (const f of diagPendingFiles) {
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64 });
}
if (diagPendingFiles.length > 0) {
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
}
diagPendingFiles = [];
renderDiagPending();
}
function testGateway() { function testGateway() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text && diagPendingFiles.length === 0) return;
addChat('sent', text, 'Gateway direkt'); if (diagPendingFiles.length > 0) sendDiagAttachments();
send({ action: 'test_gateway', text }); if (text) {
addChat('sent', text, 'Gateway direkt');
send({ action: 'test_gateway', text });
}
input.value = ''; input.value = '';
} }
function testRVS() { function testRVS() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text && diagPendingFiles.length === 0) return;
addChat('sent', text, 'via RVS'); if (diagPendingFiles.length > 0) sendDiagAttachments();
send({ action: 'test_rvs', text }); if (text) {
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
}
input.value = ''; input.value = '';
} }
@@ -1302,6 +1326,56 @@
} }
} }
// ── Diagnostic Anhang-Handling ─────────────
let diagPendingFiles = [];
function handleDiagFileSelect(files) {
for (const file of files) {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
diagPendingFiles.push({ name: file.name, type: file.type, size: file.size, base64 });
renderDiagPending();
};
reader.readAsDataURL(file);
}
}
function handleDiagPaste(event) {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file') {
event.preventDefault();
const file = item.getAsFile();
if (file) handleDiagFileSelect([file]);
}
}
}
function renderDiagPending() {
const container = document.getElementById('diag-pending-attachments');
if (diagPendingFiles.length === 0) {
container.style.display = 'none';
return;
}
container.style.display = 'flex';
container.innerHTML = diagPendingFiles.map((f, i) => {
const isImage = f.type.startsWith('image/');
const preview = isImage ? `<img src="data:${f.type};base64,${f.base64}" style="width:40px;height:40px;border-radius:4px;object-fit:cover;">` : `<span style="font-size:20px;">&#x1F4C4;</span>`;
return `<div style="position:relative;display:inline-block;">
${preview}
<span onclick="removeDiagPending(${i})" style="position:absolute;top:-4px;right:-4px;width:16px;height:16px;border-radius:8px;background:#FF3B30;color:#fff;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;">X</span>
</div>`;
}).join('') + `<span style="color:#8888AA;font-size:11px;margin-left:4px;">${diagPendingFiles.length} Datei(en)</span>
<span onclick="diagPendingFiles=[];renderDiagPending();" style="color:#FF3B30;font-size:11px;cursor:pointer;margin-left:8px;">Alle X</span>`;
}
function removeDiagPending(idx) {
diagPendingFiles.splice(idx, 1);
renderDiagPending();
}
// ── Abbrechen ────────────────────────────── // ── Abbrechen ──────────────────────────────
function cancelRequest() { function cancelRequest() {
send({ action: 'cancel_request' }); send({ action: 'cancel_request' });
+8
View File
@@ -1181,6 +1181,14 @@ wss.on("connection", (ws) => {
if (ws._sshSock) ws._sshSock.write(msg.data); if (ws._sshSock) ws._sshSock.write(msg.data);
} else if (msg.action === "live_ssh_close") { } else if (msg.action === "live_ssh_close") {
if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; } if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; }
} else if (msg.action === "send_file") {
// Datei von Diagnostic an Bridge via RVS senden
sendToRVS_raw({
type: "file",
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 },
timestamp: Date.now(),
});
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
} else if (msg.action === "cancel_request") { } else if (msg.action === "cancel_request") {
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs // Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus"); log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");