diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts index c51592ff..27c36511 100644 --- a/backend/src/modules/collaboration/federation.ts +++ b/backend/src/modules/collaboration/federation.ts @@ -242,9 +242,11 @@ export class FederationService { for (const b of bindings) { if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true }) if (b.status === "active") { - const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`) + const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count, + (SELECT payload->>'createdAt' FROM collaboration_remote_messages WHERE binding_id=${b.id} ORDER BY seq DESC LIMIT 1) AS "lastActivityAt" + FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`) result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic, - ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count }) + ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count, lastActivityAt: unread.lastActivityAt || null }) } } return result diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts index 3c763885..f5ef2118 100644 --- a/backend/src/modules/collaboration/service.ts +++ b/backend/src/modules/collaboration/service.ts @@ -144,6 +144,8 @@ export class CollaborationService { t.name AS "ownerName", m.overview_allowed AS "overviewAllowed", (SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread + ,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id + AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt" FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id LEFT JOIN projects p ON p.id=r.project_id WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index db58e290..12530b54 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -158,6 +158,16 @@ export default async function communicationRoutes(server: FastifyInstance) { entityUuid: room.entityUuid, exists: true, }) + // Rooms without messages fall back to their creation date so new rooms still sort by recency. + const withActivity = async (rows: T[]) => { + const ids = rows.map(({ room }) => room.id) + const latest = ids.length ? await server.db.select({ + roomId: communicationMessages.roomId, + at: sql`max(${communicationMessages.createdAt})`.mapWith(communicationMessages.createdAt), + }).from(communicationMessages).where(inArray(communicationMessages.roomId, ids)).groupBy(communicationMessages.roomId) : [] + const byRoom = new Map(latest.map((row) => [row.roomId, row.at])) + return (room: typeof communicationRooms.$inferSelect) => (byRoom.get(room.id) || room.createdAt).toISOString() + } const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => { const conditions = [eq(communicationMessages.roomId, roomId)] @@ -295,7 +305,8 @@ export default async function communicationRoutes(server: FastifyInstance) { eq(communicationRooms.archived, false), eq(communicationRoomMembers.userId, req.user.user_id) )).orderBy(asc(communicationRooms.name)) - return { rooms: rows.map(({ room }) => roomToApi(room)) } + const activity = await withActivity(rows) + return { rooms: rows.map(({ room }) => ({ ...roomToApi(room), lastActivityAt: activity(room) })) } }) server.post("/communication/chat/rooms", async (req: any, reply) => { @@ -335,9 +346,10 @@ export default async function communicationRoutes(server: FastifyInstance) { projectChatAccess(server.db, req), ]) const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room])) + const activity = await withActivity(roomRows) return { rooms: projectRows.map((project) => { const room = roomsByProject.get(project.id) - return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : { + return room ? { ...roomToApi(room), lastActivityAt: activity(room), projectId: project.id, projectNumber: project.projectNumber } : { key: `project_${project.id}`, name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, topic: `Projektkommunikation zu ${project.name}`, @@ -387,11 +399,13 @@ export default async function communicationRoutes(server: FastifyInstance) { .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id))) const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room])) + const activity = await withActivity(existingRows) return { rooms: users.map((user) => { const key = directRoomKey(req.user.user_id, user.userId) const room = roomsByKey.get(key) return room ? { ...roomToApi(room), + lastActivityAt: activity(room), name: displayName(user), topic: `Direktnachricht mit ${displayName(user)}`, entityUuid: user.userId, diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index 15aa34a8..8cce0684 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -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 }) { 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; }); + }} /> : Wähle einen Chat aus.} setShowHidden(false)}> Ausgeblendete ChatsDiese Einstellung gilt auch im Web. diff --git a/mobile/components/chat-conversation.tsx b/mobile/components/chat-conversation.tsx index 93287bbe..9162de4b 100644 --- a/mobile/components/chat-conversation.tsx +++ b/mobile/components/chat-conversation.tsx @@ -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>(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 @@ -110,7 +109,8 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, ; }; - 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 {chat.detail?.name || room.name} @@ -126,13 +126,11 @@ export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, {Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => {key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value})} } {chat.loading ? Nachrichten werden geladen … : - String(item.id)} renderItem={renderMessage} + !chat.messages.length ? Noch keine Nachrichten in diesem Chat. : + 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 ? {chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'} : null} - ListEmptyComponent={Noch keine Nachrichten in diesem Chat.} />} + 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}} diff --git a/mobile/src/lib/chat.ts b/mobile/src/lib/chat.ts index f5c15d8e..96984e58 100644 --- a/mobile/src/lib/chat.ts +++ b/mobile/src/lib/chat.ts @@ -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 || [], }; diff --git a/mobile/tests/chat.test.mjs b/mobile/tests/chat.test.mjs index ab137ed9..bf21c3f0 100644 --- a/mobile/tests/chat.test.mjs +++ b/mobile/tests/chat.test.mjs @@ -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']); +});