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:
@@ -10,7 +10,7 @@ import {
|
||||
import { ChatConversation, type ChatDraft } from '@/components/chat-conversation';
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { chatApi } from '@/src/lib/chat-api';
|
||||
import { collaborationKey, isChatAccessError, matchesChatSearch, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat';
|
||||
import { collaborationKey, isChatAccessError, matchesChatSearch, touchChatRoom, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat';
|
||||
import { getApiBaseUrlSync } from '@/src/lib/server-config';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
@@ -181,7 +181,10 @@ function ChatScreen({ token }: { token: string }) {
|
||||
<ChatConversation key={activeRoom.key} token={token} room={activeRoom} enabled={enabled} hiding={!!busy}
|
||||
draft={drafts[activeKey] || emptyDraft} onHide={() => void hide(activeRoom, true)}
|
||||
onDraft={value => setDrafts(previous => ({ ...previous, [activeKey]: { ...value, clientId: randomUUID() } }))}
|
||||
onSent={clientId => setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; })} />
|
||||
onSent={clientId => {
|
||||
setDirectory(previous => { const next = previous && { ...previous, rooms: touchChatRoom(previous.rooms, activeRoom.key) }; directoryRef.current = next; return next; });
|
||||
setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; });
|
||||
}} />
|
||||
: <View style={styles.empty}><Ionicons name="chatbubbles-outline" size={36} color="#69c350" /><Text>Wähle einen Chat aus.</Text></View>}
|
||||
<Modal transparent visible={showHidden} animationType="slide" onRequestClose={() => setShowHidden(false)}>
|
||||
<View style={styles.overlay}><View style={styles.modal}><Text style={styles.title}>Ausgeblendete Chats</Text><Text style={styles.hint}>Diese Einstellung gilt auch im Web.</Text>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -6,7 +6,7 @@ export type MobileChatRoom = {
|
||||
key: string; id?: string; name: string; topic?: string | null;
|
||||
source: 'internal' | 'collaboration'; group: string; exists: boolean;
|
||||
type?: string; projectId?: number; projectNumber?: string | null; userId?: string; email?: string | null;
|
||||
ownerName?: string; remote?: boolean; status?: string; unread?: number;
|
||||
ownerName?: string; remote?: boolean; status?: string; unread?: number; lastActivityAt?: string | null;
|
||||
};
|
||||
export type ChatInvitation = {
|
||||
id: string; name: string; ownerName?: string; role: string;
|
||||
@@ -45,6 +45,11 @@ export const chatEndpoint = (room: MobileChatRoom, suffix = '') => room.source =
|
||||
? `/api/collaboration/rooms/${encodeURIComponent(room.id!)}${suffix}`
|
||||
: `/api/communication/chat/rooms/${encodeURIComponent(room.key)}${suffix}`;
|
||||
export const visibleChatRooms = (directory: ChatDirectory) => directory.rooms.filter(room => !directory.hiddenRoomKeys.includes(room.key));
|
||||
const activity = (room: MobileChatRoom) => (room.lastActivityAt && Date.parse(room.lastActivityAt)) || 0;
|
||||
// Most recently used chats first; rooms without activity keep their server order at the end.
|
||||
export const sortChatRooms = (rooms: MobileChatRoom[]) => [...rooms].sort((a, b) => activity(b) - activity(a));
|
||||
export const touchChatRoom = (rooms: MobileChatRoom[], key: string, at = new Date().toISOString()) =>
|
||||
sortChatRooms(rooms.map(room => room.key === key ? { ...room, lastActivityAt: at } : room));
|
||||
export const chatCursor = (room: MobileChatRoom, messages: MobileChatMessage[]) => Math.max(0,
|
||||
...messages.map(message => room.source === 'collaboration' ? message.seq || 0 : Number(message.id)));
|
||||
export function mergeChatMessages(current: MobileChatMessage[], incoming: MobileChatMessage[], collaboration: boolean) {
|
||||
@@ -98,9 +103,9 @@ export function createChatApi(request: Request) {
|
||||
...room, source: 'internal', group, unread: unread.rooms?.[room.key]?.count || 0,
|
||||
}));
|
||||
return {
|
||||
rooms: [...decorate(base.rooms, 'Räume'), ...decorate(projects.rooms, 'Projekte'), ...decorate(direct.rooms, 'Direkt'),
|
||||
rooms: sortChatRooms([...decorate(base.rooms, 'Räume'), ...decorate(projects.rooms, 'Projekte'), ...decorate(direct.rooms, 'Direkt'),
|
||||
...collaboration.rooms.map(room => ({ ...room, key: collaborationKey(room.id),
|
||||
source: 'collaboration' as const, group: 'Zusammenarbeit', exists: true }))],
|
||||
source: 'collaboration' as const, group: 'Zusammenarbeit', exists: true }))]),
|
||||
hiddenRoomKeys: hidden.hiddenRoomKeys, invitations: collaboration.invitations,
|
||||
canManage: collaboration.canManage === true, projects: collaboration.projects || [],
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
applyChatAccess, chatCursor, chatEndpoint, collaborationKey, createChatApi, matchesChatSearch,
|
||||
mergeChatMessages, validChatImage, visibleChatRooms,
|
||||
mergeChatMessages, sortChatRooms, touchChatRoom, validChatImage, visibleChatRooms,
|
||||
} from '../src/lib/chat.ts';
|
||||
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
@@ -131,3 +131,22 @@ test('Noch nicht angelegte Direkt- und Projektchats werden gezielt provisioniert
|
||||
assert.equal(calls[1].path, '/api/communication/chat/direct-rooms/user%2Fid/provision');
|
||||
assert.equal(chatEndpoint(remote), `/api/collaboration/rooms/${id}`);
|
||||
});
|
||||
|
||||
test('Chats werden nach letzter Aktivität sortiert, neue Nachrichten rücken nach oben', () => {
|
||||
const rooms = [
|
||||
{ key: 'ohne', name: 'Ohne Aktivität', source: 'internal', exists: false },
|
||||
{ key: 'alt', name: 'Alt', source: 'internal', exists: true, lastActivityAt: '2026-01-01T10:00:00.000Z' },
|
||||
{ key: 'neu', name: 'Neu', source: 'collaboration', exists: true, lastActivityAt: '2026-03-01T10:00:00.000Z' },
|
||||
];
|
||||
assert.deepEqual(sortChatRooms(rooms).map(room => room.key), ['neu', 'alt', 'ohne']);
|
||||
assert.deepEqual(touchChatRoom(rooms, 'alt', '2026-04-01T10:00:00.000Z').map(room => room.key), ['alt', 'neu', 'ohne']);
|
||||
assert.equal(rooms[1].lastActivityAt, '2026-01-01T10:00:00.000Z');
|
||||
});
|
||||
test('Verzeichnis sortiert Räume aller Gruppen gemeinsam nach Aktivität', async () => {
|
||||
const { api } = fixture({
|
||||
'/api/communication/chat/rooms': { rooms: [{ ...internal, lastActivityAt: '2026-01-01T00:00:00.000Z' }] },
|
||||
'/api/collaboration': { rooms: [{ id, name: 'Partner', lastActivityAt: '2026-02-01T00:00:00.000Z' }], invitations: [] },
|
||||
});
|
||||
const result = await api.directory('token');
|
||||
assert.deepEqual(result.rooms.map(room => room.key), [collaborationKey(id), 'allgemein', 'project_42']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user