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 || [];