KI-AGENT: Ungelesene Chat Badges in Mobile App anzeigen

This commit is contained in:
2026-05-22 18:51:33 +02:00
parent 00da371dfb
commit f150cfd740
3 changed files with 69 additions and 5 deletions

View File

@@ -1,14 +1,49 @@
import * as Notifications from 'expo-notifications';
import { Redirect, Tabs } from 'expo-router'; import { Redirect, Tabs } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { HapticTab } from '@/components/haptic-tab'; import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol'; import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import { fetchMatrixUnreadCounts } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); 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) { if (isBootstrapping) {
return null; return null;
@@ -54,6 +89,13 @@ export default function TabLayout() {
name="communication" name="communication"
options={{ options={{
title: 'Kommunikation', title: 'Kommunikation',
tabBarBadge: communicationBadge,
tabBarBadgeStyle: {
backgroundColor: '#ef4444',
color: '#ffffff',
fontSize: 11,
fontWeight: '800',
},
tabBarIcon: ({ color }) => <IconSymbol size={24} name="message.fill" color={color} />, tabBarIcon: ({ color }) => <IconSymbol size={24} name="message.fill" color={color} />,
}} }}
/> />

View File

@@ -185,6 +185,12 @@ export default function CommunicationScreen() {
return users.filter((user) => !existing.has(user.matrixUserId)); return users.filter((user) => !existing.has(user.matrixUserId));
}, [members, users]); }, [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 () => { const loadRooms = useCallback(async () => {
if (!token) return; if (!token) return;
const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([ const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([
@@ -221,7 +227,10 @@ export default function CommunicationScreen() {
setMembers(sync.members || nextMembers); setMembers(sync.members || nextMembers);
setSyncSince(sync.nextBatch); setSyncSince(sync.nextBatch);
const last = merged.at(-1); 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 })); requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.'); setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.');
@@ -230,7 +239,7 @@ export default function CommunicationScreen() {
setRefreshing(false); setRefreshing(false);
} }
}, },
[token] [clearRoomUnread, token]
); );
const refreshAll = useCallback( const refreshAll = useCallback(
@@ -255,19 +264,25 @@ export default function CommunicationScreen() {
if (!token || !activeRoom?.exists || !syncSince) return; if (!token || !activeRoom?.exists || !syncSince) return;
try { try {
const sync = await syncMatrixRoom(token, activeRoom.key, syncSince); const sync = await syncMatrixRoom(token, activeRoom.key, syncSince);
const incomingMessages = sync.messages || [];
setSyncSince(sync.nextBatch || syncSince); setSyncSince(sync.nextBatch || syncSince);
setMembers((current) => sync.members || current); setMembers((current) => sync.members || current);
setMessages((current) => { setMessages((current) => {
let next = mergeMessages(current, sync.messages || []); let next = mergeMessages(current, incomingMessages);
next = applyReplacements(next, sync.replacements); next = applyReplacements(next, sync.replacements);
next = applyReactions(next, sync.reactions); next = applyReactions(next, sync.reactions);
next = applyRedactions(next, sync.redactions); next = applyRedactions(next, sync.redactions);
return next; return next;
}); });
const last = incomingMessages.at(-1);
if (last?.id) {
await markMatrixRoomRead(token, activeRoom.key, last.id);
clearRoomUnread(activeRoom.key);
}
} catch { } catch {
// Polling errors are surfaced by manual refresh to avoid noisy chat usage. // Polling errors are surfaced by manual refresh to avoid noisy chat usage.
} }
}, [activeRoom, syncSince, token]); }, [activeRoom, clearRoomUnread, syncSince, token]);
useEffect(() => { useEffect(() => {
void refreshAll(true); void refreshAll(true);

View File

@@ -292,6 +292,8 @@ export type MatrixSyncResponse = {
[key: string]: unknown; [key: string]: unknown;
}; };
export type MatrixUnreadCounts = Record<string, { count?: number; mentions?: number }>;
function buildUrl(path: string): string { function buildUrl(path: string): string {
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
return path; return path;
@@ -408,6 +410,11 @@ export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
]; ];
} }
export async function fetchMatrixUnreadCounts(token: string): Promise<MatrixUnreadCounts> {
const response = await apiRequest<{ rooms?: MatrixUnreadCounts }>('/api/communication/matrix/unread', { token });
return response.rooms || {};
}
export async function fetchMatrixUsers(token: string): Promise<MatrixUser[]> { export async function fetchMatrixUsers(token: string): Promise<MatrixUser[]> {
const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token }); const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token });
return response.users || []; return response.users || [];