import { Ionicons } from '@expo/vector-icons'; import { Image } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { useChatConversation } from '@/hooks/use-chat-conversation'; import { chatApi } from '@/src/lib/chat-api'; import { prepareMobileChatImage } from '@/src/lib/chat-image'; import { reactionEmojis, type ChatAttachment, type MobileChatMessage, type MobileChatRoom } from '@/src/lib/chat'; export type ChatDraft = { text: string; image: ChatAttachment | null; clientId: string }; type Props = { token: string; room: MobileChatRoom; enabled: boolean; draft: ChatDraft; onDraft: (value: Pick) => void; onSent: (clientId: string) => void; onHide: () => void; hiding: boolean; }; function MessageImage({ token, room, message }: { token: string; room: MobileChatRoom; message: MobileChatMessage }) { const [uri, setUri] = useState(''); const [error, setError] = useState(false); const [expanded, setExpanded] = useState(false); const [retry, setRetry] = useState(0); const roomRef = useRef(room); roomRef.current = room; useEffect(() => { let active = true; setUri(''); setError(false); setExpanded(false); if (message.pending || message.failed) return; chatApi.image(token, roomRef.current, message.id).then(value => { if (active) setUri(value); }) .catch(() => { if (active) setError(true); }); return () => { active = false; }; }, [message.failed, message.id, message.pending, retry, room.key, token]); const image = message.image!; const width = Math.min(260, image.width); const height = Math.min(220, width * image.height / image.width); if (message.pending || message.failed) return 📷 {image.name}; return {uri ? setExpanded(true)}> : error ? setRetry(value => value + 1)}> Bild erneut laden : } setExpanded(false)}> setExpanded(false)} style={styles.closeImage}>Schließen ; } export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, onHide, hiding }: Props) { const chat = useChatConversation(token, room, enabled); const [membersOpen, setMembersOpen] = useState(false); const [reactionMessage, setReactionMessage] = useState(null); const [preparing, setPreparing] = useState(false); const [imageError, setImageError] = useState(''); const list = useRef>(null); const alive = useRef(true); const pickerGeneration = useRef(0); const allowed = enabled && !!chat.detail?.canSend && !chat.loading; useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { if (!allowed) setReactionMessage(null); }, [allowed]); const attach = async () => { if (!allowed || preparing || chat.sending) return; const version = ++pickerGeneration.current; setPreparing(true); setImageError(''); try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 }); if (result.canceled || !alive.current || version !== pickerGeneration.current) return; const image = await prepareMobileChatImage(result.assets[0]); // The system photo picker can temporarily background the app. Keep the local // attachment; sending still requires refreshed room permissions afterwards. if (alive.current && version === pickerGeneration.current) onDraft({ text: draft.text, image }); } catch (error) { if (alive.current && version === pickerGeneration.current) setImageError(error instanceof Error ? error.message : 'Bild konnte nicht geöffnet werden.'); } finally { if (alive.current && version === pickerGeneration.current) setPreparing(false); } }; const send = async () => { if (!allowed || preparing || (!draft.text.trim() && !draft.image)) return; const sent = await chat.send(draft.text.trim(), draft.image, draft.clientId); if (sent) { onSent(draft.clientId); list.current?.scrollToOffset({ offset: 0, animated: true }); } }; const time = (message: MobileChatMessage) => { const value = message.createdAt || message.timestamp; return value ? new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; }; const older = () => void chat.older(); const renderMessage = ({ item }: { item: MobileChatMessage }) => { const canReact = allowed && !chat.detail?.offline && !item.pending && !item.failed; return {item.own ? 'Du' : item.senderName || item.senderDisplayName || 'Benutzer'} {item.image && } {!!item.body && {item.body}} {time(item)}{item.pending ? ' · Wartet auf Zustellung' : item.failed ? ' · Zustellung fehlgeschlagen' : ''} {(chat.reactions[item.id] || []).map(reaction => void chat.react(item, reaction.emoji)} style={[styles.reaction, reaction.own && styles.ownReaction]}> {reaction.emoji} {reaction.count} )} {canReact && setReactionMessage(item)} style={styles.reaction}>☺ +} ; }; // The inverted list renders the newest message at the bottom and always opens there. const newestFirst = useMemo(() => [...chat.messages].reverse(), [chat.messages]); return {chat.detail?.name || room.name} {room.remote ? 'Extern · ' : ''}{room.ownerName || room.group}{chat.detail?.status === 'archived' ? ' · Archiviert' : ''} setMembersOpen(value => !value)} style={styles.iconButton}> {membersOpen && Teilnehmer · {chat.members.length} {chat.members.map(member => `${member.own ? 'Du' : member.name}${member.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') || 'Keine Teilnehmer verfügbar.'}} {!!chat.error && {chat.error}Erneut laden} {chat.detail?.offline && Die Partnerinstanz ist nicht erreichbar. Neue Nachrichten warten auf Zustellung.} {!!chat.detail?.overview && Freigegebene Projektinformationen {Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => {key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value})} } {chat.loading ? Nachrichten werden geladen … : !chat.messages.length ? Noch keine Nachrichten in diesem Chat. : String(item.id)} renderItem={renderMessage} style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled" maintainVisibleContentPosition={{ minIndexForVisible: 1, autoscrollToTopThreshold: 100 }} ListFooterComponent={chat.hasMore ? {chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'} : null} />} {chat.detail && {!chat.detail.canSend && Dieser Chat ist nur lesbar.} {!!imageError && {imageError}} {draft.image && {draft.image.name} onDraft({ text: draft.text, image: null })} style={styles.iconButton}>} void attach()} style={styles.iconButton}> {preparing ? : } onDraft({ text, image: draft.image })} /> void send()} style={[styles.send, (!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)) && styles.disabled]}> {chat.sending ? : } } setReactionMessage(null)}> setReactionMessage(null)}> Reaktion auswählen {reactionEmojis.map(emoji => { if (reactionMessage) void chat.react(reactionMessage, emoji); setReactionMessage(null); }}>{emoji})} ; } const styles = StyleSheet.create({ container: { flex: 1, minHeight: 0 }, grow: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 8, backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' }, title: { fontSize: 16, fontWeight: '700', color: '#111827' }, hint: { color: '#6b7280', fontSize: 12 }, iconButton: { padding: 10, minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' }, messages: { padding: 12, gap: 12 }, messageRow: { flexDirection: 'row' }, ownRow: { justifyContent: 'flex-end' }, bubble: { maxWidth: '90%', padding: 12, borderRadius: 14, backgroundColor: '#fff', borderWidth: 1, borderColor: '#e5e7eb' }, ownBubble: { backgroundColor: '#eff9ea', borderColor: '#c7e6bb' }, author: { fontWeight: '700', fontSize: 12, color: '#374151', marginBottom: 4 }, body: { color: '#111827', fontSize: 15, lineHeight: 21 }, time: { color: '#6b7280', fontSize: 10, marginTop: 6, textAlign: 'right' }, reactions: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }, reaction: { paddingHorizontal: 9, paddingVertical: 5, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 16, backgroundColor: '#fff' }, ownReaction: { backgroundColor: '#dcfce7', borderColor: '#69c350' }, info: { padding: 12, gap: 3, backgroundColor: '#f0fdf4' }, notice: { padding: 12, backgroundColor: '#fffbeb', color: '#92400e', fontSize: 12 }, errorBox: { padding: 10, backgroundColor: '#fef2f2', gap: 5 }, error: { color: '#991b1b', fontSize: 12 }, link: { color: '#2f6f25', fontWeight: '600', paddingVertical: 5 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 10 }, composer: { padding: 10, backgroundColor: '#fff', borderTopWidth: 1, borderColor: '#e5e7eb', gap: 8 }, composerRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 6 }, input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, padding: 10, fontSize: 15, color: '#111827' }, send: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#438b30', alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 }, attachment: { flexDirection: 'row', alignItems: 'center', gap: 8 }, thumbnail: { width: 52, height: 52 }, loadOlder: { alignItems: 'center', padding: 10 }, overlay: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.4)' }, reactionPicker: { backgroundColor: '#fff', borderRadius: 16, padding: 16 }, emoji: { padding: 9 }, imageModal: { flex: 1, backgroundColor: '#111827', paddingTop: 60, paddingBottom: 30 }, closeImage: { alignSelf: 'flex-end', padding: 20 }, white: { color: '#fff' }, fullImage: { flex: 1 }, });