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>
This commit is contained in:
2026-10-21 13:40:09 +02:00
parent e0a94f5e29
commit f0775ad52a
7 changed files with 64 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { useCallback, useEffect, useRef, useState } from 'react';
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';
@@ -58,7 +58,6 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent,
const [imageError, setImageError] = useState('');
const list = useRef<FlatList<MobileChatMessage>>(null);
const alive = useRef(true);
const nearBottom = useRef(true);
const pickerGeneration = useRef(0);
const allowed = enabled && !!chat.detail?.canSend && !chat.loading;
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
@@ -82,13 +81,13 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent,
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); nearBottom.current = true; list.current?.scrollToEnd({ animated: true }); }
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 = () => { nearBottom.current = false; void chat.older(); };
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]}>
@@ -110,7 +109,8 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent,
</View>
</View>;
};
const scrollToEnd = useCallback(() => { if (nearBottom.current) list.current?.scrollToEnd({ animated: false }); }, []);
// 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>
@@ -126,13 +126,11 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent,
{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> :
<FlatList ref={list} data={chat.messages} keyExtractor={item => String(item.id)} renderItem={renderMessage}
!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: 0 }}
onScroll={event => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearBottom.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; }}
scrollEventThrottle={100} onContentSizeChange={scrollToEnd}
ListHeaderComponent={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}
ListEmptyComponent={<Text style={styles.hint}>Noch keine Nachrichten in diesem Chat.</Text>} />}
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>}