Files
FEDEO/mobile/components/chat-conversation.tsx
florianfederspiel f0775ad52a fix(mobile): Chat startet bei neuester Nachricht und Chats nach letzter Aktivität sortieren
Der Verlauf nutzt eine invertierte Liste und öffnet dadurch immer ganz unten.
Raumlisten liefern lastActivityAt; die App sortiert alle Chats danach und
zieht einen Chat nach dem Senden sofort nach oben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:40:09 +02:00

180 lines
15 KiB
TypeScript

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<ChatDraft, 'text' | 'image'>) => 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 <Text style={styles.hint}>📷 {image.name}</Text>;
return <View style={{ marginVertical: 8 }}>
{uri ? <Pressable accessibilityRole="button" accessibilityLabel={`Bild öffnen: ${image.name}`} onPress={() => setExpanded(true)}>
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={{ width, height }} accessibilityLabel={image.name} />
</Pressable> : error ? <Pressable accessibilityRole="button" onPress={() => setRetry(value => value + 1)}>
<Text style={styles.link}>Bild erneut laden</Text>
</Pressable> : <ActivityIndicator accessibilityLabel="Bild wird geladen" />}
<Modal visible={expanded} animationType="fade" onRequestClose={() => setExpanded(false)}>
<View style={styles.imageModal}>
<Pressable accessibilityRole="button" onPress={() => setExpanded(false)} style={styles.closeImage}><Text style={styles.white}>Schließen</Text></Pressable>
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={styles.fullImage} accessibilityLabel={image.name} />
</View>
</Modal>
</View>;
}
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<MobileChatMessage | null>(null);
const [preparing, setPreparing] = useState(false);
const [imageError, setImageError] = useState('');
const list = useRef<FlatList<MobileChatMessage>>(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 <View style={[styles.messageRow, item.own && styles.ownRow]}>
<View style={[styles.bubble, item.own && styles.ownBubble]}>
<Text style={styles.author}>{item.own ? 'Du' : item.senderName || item.senderDisplayName || 'Benutzer'}</Text>
{item.image && <MessageImage key={`${chat.detail?.revision || 0}:${item.id}`} token={token} room={room} message={item} />}
{!!item.body && <Text selectable style={styles.body}>{item.body}</Text>}
<Text style={styles.time}>{time(item)}{item.pending ? ' · Wartet auf Zustellung' : item.failed ? ' · Zustellung fehlgeschlagen' : ''}</Text>
<View style={styles.reactions}>
{(chat.reactions[item.id] || []).map(reaction => <Pressable key={reaction.emoji} accessibilityRole="button"
accessibilityLabel={`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`}
accessibilityState={{ selected: reaction.own, disabled: !canReact || chat.reacting }} disabled={!canReact || chat.reacting}
onPress={() => void chat.react(item, reaction.emoji)} style={[styles.reaction, reaction.own && styles.ownReaction]}>
<Text>{reaction.emoji} {reaction.count}</Text>
</Pressable>)}
{canReact && <Pressable accessibilityRole="button" accessibilityLabel="Reaktion hinzufügen" disabled={chat.reacting}
onPress={() => setReactionMessage(item)} style={styles.reaction}><Text>☺ +</Text></Pressable>}
</View>
</View>
</View>;
};
// The inverted list renders the newest message at the bottom and always opens there.
const newestFirst = useMemo(() => [...chat.messages].reverse(), [chat.messages]);
return <View style={styles.container}>
<View style={styles.header}>
<View style={styles.grow}><Text style={styles.title} numberOfLines={1}>{chat.detail?.name || room.name}</Text>
<Text style={styles.hint} numberOfLines={1}>{room.remote ? 'Extern · ' : ''}{room.ownerName || room.group}{chat.detail?.status === 'archived' ? ' · Archiviert' : ''}</Text></View>
<Pressable accessibilityRole="button" accessibilityLabel="Teilnehmer anzeigen" onPress={() => setMembersOpen(value => !value)} style={styles.iconButton}><Ionicons name="people-outline" size={23} /></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Chat ausblenden" disabled={hiding} onPress={onHide} style={styles.iconButton}><Ionicons name="eye-off-outline" size={23} /></Pressable>
</View>
{membersOpen && <View style={styles.info}><Text style={styles.author}>Teilnehmer · {chat.members.length}</Text>
<Text>{chat.members.map(member => `${member.own ? 'Du' : member.name}${member.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') || 'Keine Teilnehmer verfügbar.'}</Text></View>}
{!!chat.error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{chat.error}</Text><Pressable onPress={chat.retry}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
{chat.detail?.offline && <Text style={styles.notice}>Die Partnerinstanz ist nicht erreichbar. Neue Nachrichten warten auf Zustellung.</Text>}
{!!chat.detail?.overview && <View style={styles.info}><Text style={styles.author}>Freigegebene Projektinformationen</Text>
{Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => <Text key={key}>{key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value}</Text>)}
</View>}
{chat.loading ? <View style={styles.empty}><ActivityIndicator /><Text>Nachrichten werden geladen …</Text></View> :
!chat.messages.length ? <View style={styles.grow}><Text style={[styles.hint, styles.messages]}>Noch keine Nachrichten in diesem Chat.</Text></View> :
<FlatList ref={list} inverted data={newestFirst} keyExtractor={item => String(item.id)} renderItem={renderMessage}
style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{ minIndexForVisible: 1, autoscrollToTopThreshold: 100 }}
ListFooterComponent={chat.hasMore ? <Pressable disabled={chat.olderLoading} onPress={older} style={styles.loadOlder}><Text style={styles.link}>{chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'}</Text></Pressable> : null} />}
{chat.detail && <View style={styles.composer}>
{!chat.detail.canSend && <Text style={styles.hint}>Dieser Chat ist nur lesbar.</Text>}
{!!imageError && <Text accessibilityRole="alert" style={styles.error}>{imageError}</Text>}
{draft.image && <View style={styles.attachment}><Image source={{ uri: draft.image.dataUrl }} cachePolicy="none" style={styles.thumbnail} contentFit="contain" />
<Text numberOfLines={1} style={styles.grow}>{draft.image.name}</Text>
<Pressable accessibilityLabel="Bild entfernen" disabled={chat.sending || preparing} onPress={() => onDraft({ text: draft.text, image: null })} style={styles.iconButton}><Ionicons name="close" size={23} /></Pressable></View>}
<View style={styles.composerRow}>
<Pressable accessibilityRole="button" accessibilityLabel="Bild anhängen" disabled={!allowed || preparing || chat.sending} onPress={() => void attach()} style={styles.iconButton}>
{preparing ? <ActivityIndicator /> : <Ionicons name="image-outline" size={25} color={allowed ? '#2f6f25' : '#9ca3af'} />}</Pressable>
<TextInput accessibilityLabel="Nachricht" placeholder={allowed ? 'Nachricht schreiben …' : 'Nur lesbar'} style={styles.input} multiline maxLength={10000}
editable={allowed && !preparing && !chat.sending} value={draft.text} onChangeText={text => onDraft({ text, image: draft.image })} />
<Pressable accessibilityRole="button" accessibilityLabel="Nachricht senden" disabled={!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)} onPress={() => void send()}
style={[styles.send, (!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)) && styles.disabled]}>
{chat.sending ? <ActivityIndicator color="#fff" /> : <Ionicons name="send" size={21} color="#fff" />}</Pressable>
</View>
</View>}
<Modal transparent visible={!!reactionMessage && allowed} animationType="fade" onRequestClose={() => setReactionMessage(null)}>
<Pressable style={styles.overlay} onPress={() => setReactionMessage(null)}><View style={styles.reactionPicker}>
<Text style={styles.title}>Reaktion auswählen</Text><ScrollView horizontal contentContainerStyle={styles.reactions}>
{reactionEmojis.map(emoji => <Pressable key={emoji} accessibilityRole="button" accessibilityLabel={`Mit ${emoji} reagieren`} style={styles.emoji}
onPress={() => { if (reactionMessage) void chat.react(reactionMessage, emoji); setReactionMessage(null); }}><Text style={{ fontSize: 28 }}>{emoji}</Text></Pressable>)}
</ScrollView></View></Pressable>
</Modal>
</View>;
}
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 },
});