diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index ffeefd5..aaed6e7 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,14 +1,49 @@ +import * as Notifications from 'expo-notifications'; import { Redirect, Tabs } from 'expo-router'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; +import { fetchMatrixUnreadCounts } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; export default function TabLayout() { const colorScheme = useColorScheme(); - const { isBootstrapping, token, requiresTenantSelection } = useAuth(); + const { activeTenantId, isBootstrapping, token, requiresTenantSelection } = useAuth(); + const [communicationUnread, setCommunicationUnread] = useState(0); + + const refreshCommunicationUnread = useCallback(async () => { + if (!token || requiresTenantSelection) { + setCommunicationUnread(0); + await Notifications.setBadgeCountAsync(0); + return; + } + + try { + const unread = await fetchMatrixUnreadCounts(token); + const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0); + setCommunicationUnread(total); + await Notifications.setBadgeCountAsync(total); + } catch { + // Badge loading must not block tab navigation. + } + }, [requiresTenantSelection, token]); + + useEffect(() => { + void refreshCommunicationUnread(); + + if (!token || requiresTenantSelection) return undefined; + + const id = setInterval(() => void refreshCommunicationUnread(), 30000); + return () => clearInterval(id); + }, [activeTenantId, refreshCommunicationUnread, requiresTenantSelection, token]); + + const communicationBadge = useMemo(() => { + if (!communicationUnread) return undefined; + return communicationUnread > 99 ? '99+' : communicationUnread; + }, [communicationUnread]); if (isBootstrapping) { return null; @@ -54,6 +89,13 @@ export default function TabLayout() { name="communication" options={{ title: 'Kommunikation', + tabBarBadge: communicationBadge, + tabBarBadgeStyle: { + backgroundColor: '#ef4444', + color: '#ffffff', + fontSize: 11, + fontWeight: '800', + }, tabBarIcon: ({ color }) => , }} /> diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index f9f63b3..f9725fa 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -185,6 +185,12 @@ export default function CommunicationScreen() { return users.filter((user) => !existing.has(user.matrixUserId)); }, [members, users]); + const clearRoomUnread = useCallback((roomKey: string) => { + setRooms((current) => + current.map((room) => (room.key === roomKey ? { ...room, unread: 0, mentions: 0 } : room)) + ); + }, []); + const loadRooms = useCallback(async () => { if (!token) return; const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([ @@ -221,7 +227,10 @@ export default function CommunicationScreen() { setMembers(sync.members || nextMembers); setSyncSince(sync.nextBatch); const last = merged.at(-1); - if (last?.id) await markMatrixRoomRead(token, roomKeyToLoad, last.id); + if (last?.id) { + await markMatrixRoomRead(token, roomKeyToLoad, last.id); + clearRoomUnread(roomKeyToLoad); + } requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); } catch (err) { setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.'); @@ -230,7 +239,7 @@ export default function CommunicationScreen() { setRefreshing(false); } }, - [token] + [clearRoomUnread, token] ); const refreshAll = useCallback( @@ -255,19 +264,25 @@ export default function CommunicationScreen() { if (!token || !activeRoom?.exists || !syncSince) return; try { const sync = await syncMatrixRoom(token, activeRoom.key, syncSince); + const incomingMessages = sync.messages || []; setSyncSince(sync.nextBatch || syncSince); setMembers((current) => sync.members || current); setMessages((current) => { - let next = mergeMessages(current, sync.messages || []); + let next = mergeMessages(current, incomingMessages); next = applyReplacements(next, sync.replacements); next = applyReactions(next, sync.reactions); next = applyRedactions(next, sync.redactions); return next; }); + const last = incomingMessages.at(-1); + if (last?.id) { + await markMatrixRoomRead(token, activeRoom.key, last.id); + clearRoomUnread(activeRoom.key); + } } catch { // Polling errors are surfaced by manual refresh to avoid noisy chat usage. } - }, [activeRoom, syncSince, token]); + }, [activeRoom, clearRoomUnread, syncSince, token]); useEffect(() => { void refreshAll(true); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 1742f48..1ecfb88 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -292,6 +292,8 @@ export type MatrixSyncResponse = { [key: string]: unknown; }; +export type MatrixUnreadCounts = Record; + function buildUrl(path: string): string { if (path.startsWith('http://') || path.startsWith('https://')) { return path; @@ -408,6 +410,11 @@ export async function fetchMatrixRooms(token: string): Promise { ]; } +export async function fetchMatrixUnreadCounts(token: string): Promise { + const response = await apiRequest<{ rooms?: MatrixUnreadCounts }>('/api/communication/matrix/unread', { token }); + return response.rooms || {}; +} + export async function fetchMatrixUsers(token: string): Promise { const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token }); return response.users || [];