feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen

This commit is contained in:
2026-10-19 11:40:26 +02:00
parent bfba29b15e
commit da84e98b8a
14 changed files with 1107 additions and 289 deletions

View File

@@ -1,12 +1,13 @@
import * as Notifications from 'expo-notifications';
import { Redirect, Tabs } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { AppState, DeviceEventEmitter } from 'react-native';
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 { fetchChatUnreadCounts } from '@/src/lib/api';
import { chatApi } from '@/src/lib/chat-api';
import { useAuth } from '@/src/providers/auth-provider';
export default function TabLayout() {
@@ -14,31 +15,27 @@ export default function TabLayout() {
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 fetchChatUnreadCounts(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]);
let active = true;
let revision = 0;
setCommunicationUnread(0);
void Notifications.setBadgeCountAsync(0).catch(() => undefined);
async function refresh() {
if (!token || !activeTenantId || requiresTenantSelection || AppState.currentState !== 'active') return;
const request = ++revision;
try {
const total = await chatApi.unread(token);
if (!active || request !== revision) return;
setCommunicationUnread(total);
await Notifications.setBadgeCountAsync(total);
} catch { /* Badge loading must not block tab navigation. */ }
}
void refresh();
const timer = setInterval(() => void refresh(), 30000);
const foreground = AppState.addEventListener('change', state => { if (state === 'active') void refresh(); });
const changed = DeviceEventEmitter.addListener('fedeo:chat-changed', () => void refresh());
return () => { active = false; ++revision; clearInterval(timer); foreground.remove(); changed.remove(); };
}, [activeTenantId, requiresTenantSelection, token]);
const communicationBadge = useMemo(() => {
if (!communicationUnread) return undefined;

View File

@@ -1,294 +1,224 @@
import { Ionicons } from '@expo/vector-icons';
import { useIsFocused } from '@react-navigation/native';
import { randomUUID } from 'expo-crypto';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
ActivityIndicator, AppState, DeviceEventEmitter, FlatList, KeyboardAvoidingView, Modal, Platform,
Pressable, ScrollView, StyleSheet, Text, TextInput, View,
} from 'react-native';
import {
ChatMember,
ChatMessage,
ChatRoom,
createChatRoom,
fetchChatMembers,
fetchChatMessages,
fetchChatRooms,
markChatRoomRead,
provisionChatRoom,
sendChatMessage,
syncChatRoom,
} from '@/src/lib/api';
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 { getApiBaseUrlSync } from '@/src/lib/server-config';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function normalizeRoomKey(value: string): string {
return value.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
}
function messageTime(value: ChatMessage['timestamp']): string {
if (!value) return '';
return new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
const byId = new Map<number, ChatMessage>();
for (const message of current) byId.set(message.id, message);
for (const message of incoming) byId.set(message.id, message);
return Array.from(byId.values()).sort((a, b) => a.id - b.id);
}
export default function CommunicationScreen() {
const { token } = useAuth();
const listRef = useRef<FlatList<ChatMessage>>(null);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [members, setMembers] = useState<ChatMember[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
const [draft, setDraft] = useState('');
const { token, activeTenantId } = useAuth();
if (!token || !activeTenantId) return null;
return <ChatScreen key={`${getApiBaseUrlSync()}:${activeTenantId}:${token}`} token={token} />;
}
function ChatScreen({ token }: { token: string }) {
const focused = useIsFocused();
const [foreground, setForeground] = useState(AppState.currentState === 'active');
const enabled = focused && foreground;
const [directory, setDirectory] = useState<ChatDirectory | null>(null);
const [activeKey, setActiveKey] = useState('');
const [search, setSearch] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [membersOpen, setMembersOpen] = useState(false);
const [busy, setBusy] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [roomName, setRoomName] = useState('');
const [roomTopic, setRoomTopic] = useState('');
const [roomKey, setRoomKey] = useState('');
const afterIdRef = useRef(0);
const activeRoom = useMemo(
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
[activeRoomKey, rooms]
);
const visibleRooms = useMemo(() => {
const query = search.trim().toLowerCase();
return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber]
.filter(Boolean).some((value) => String(value).toLowerCase().includes(query)));
}, [rooms, search]);
const loadRooms = useCallback(async () => {
if (!token) return [];
const result = await fetchChatRooms(token);
setRooms(result);
return result;
}, [token]);
const loadRoom = useCallback(async (key: string) => {
if (!token) return;
const [nextMessages, nextMembers] = await Promise.all([
fetchChatMessages(token, key),
fetchChatMembers(token, key),
]);
setMessages(nextMessages);
setMembers(nextMembers);
afterIdRef.current = nextMessages[nextMessages.length - 1]?.id || 0;
await markChatRoomRead(token, key, afterIdRef.current);
}, [token]);
const [roomType, setRoomType] = useState<'internal' | 'collaboration'>('internal');
const [projectId, setProjectId] = useState<number | null>(null);
const [drafts, setDrafts] = useState<Record<string, ChatDraft>>({});
const alive = useRef(true);
const directoryRevision = useRef(0);
const selectionRevision = useRef(0);
const operation = useRef(false);
const loadingDirectory = useRef(false);
const directoryRef = useRef(directory);
directoryRef.current = directory;
const [emptyDraft] = useState<ChatDraft>(() => ({ text: '', image: null, clientId: randomUUID() }));
const rooms = useMemo(() => directory ? visibleChatRooms(directory) : [], [directory]);
const filteredRooms = useMemo(() => rooms.filter(room => matchesChatSearch(room, search)), [rooms, search]);
const hiddenRooms = useMemo(() => directory?.rooms.filter(room => directory.hiddenRoomKeys.includes(room.key)) || [], [directory]);
const activeRoom = rooms.find(room => room.key === activeKey && room.exists);
const refresh = useCallback(async () => {
if (!token) return;
if (loadingDirectory.current || operation.current) return;
const version = ++directoryRevision.current;
loadingDirectory.current = true;
try {
setError(null);
const nextRooms = await loadRooms();
const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0];
if (selected?.exists) {
setActiveRoomKey(selected.key);
await loadRoom(selected.key);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.');
const result = await chatApi.directory(token);
if (!alive.current || version !== directoryRevision.current) return;
directoryRef.current = result; setDirectory(result); setError('');
const available = visibleChatRooms(result);
setActiveKey(previous => available.some(room => room.key === previous && room.exists)
? previous : available.find(room => room.exists)?.key || '');
} catch (err) {
if (!alive.current || version !== directoryRevision.current) return;
setError(err instanceof Error ? err.message : 'Chats konnten nicht geladen werden.');
if (isChatAccessError(err)) { directoryRef.current = null; setDirectory(null); setActiveKey(''); setDrafts({}); }
} finally {
setLoading(false);
setRefreshing(false);
loadingDirectory.current = false;
if (alive.current && version === directoryRevision.current) setLoading(false);
}
}, [activeRoomKey, loadRoom, loadRooms, token]);
useEffect(() => { void refresh(); }, [refresh]);
}, [token]);
useEffect(() => {
if (!token || !activeRoom?.exists) return undefined;
const id = setInterval(async () => {
try {
const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current);
if (!result.messages?.length) return;
setMessages((current) => mergeMessages(current, result.messages || []));
afterIdRef.current = result.nextId || afterIdRef.current;
await markChatRoomRead(token, activeRoom.key, afterIdRef.current);
} catch {
// Beim nächsten Intervall erneut versuchen.
}
}, 3000);
return () => clearInterval(id);
}, [activeRoom, token]);
alive.current = true;
const listener = AppState.addEventListener('change', state => setForeground(state === 'active'));
return () => { alive.current = false; listener.remove(); };
}, []);
useEffect(() => {
if (!enabled) return;
void refresh();
const timer = setInterval(() => void refresh(), 15000);
return () => clearInterval(timer);
}, [enabled, refresh]);
async function selectRoom(room: ChatRoom) {
if (!token) return;
async function select(room: MobileChatRoom) {
if (directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
const version = ++selectionRevision.current;
setError('');
if (room.exists) { setActiveKey(room.key); return; }
try {
setLoading(true);
const readyRoom = room.exists ? room : await provisionChatRoom(token, room);
setActiveRoomKey(readyRoom.key);
await loadRooms();
await loadRoom(readyRoom.key);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.');
} finally {
setLoading(false);
}
setBusy(room.key);
const ready = await chatApi.provision(token, room);
if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) }));
setActiveKey(ready.key);
} catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); }
finally { if (alive.current && version === selectionRevision.current) setBusy(''); }
}
async function submitMessage() {
if (!token || !activeRoom?.exists || !draft.trim() || sending) return;
const text = draft.trim();
setDraft('');
setSending(true);
async function mutate(label: string, action: () => Promise<string | void>) {
if (operation.current) return;
operation.current = true; ++directoryRevision.current;
setBusy(label); setError('');
try {
const message = await sendChatMessage(token, activeRoom.key, text);
setMessages((current) => mergeMessages(current, [message]));
afterIdRef.current = Math.max(afterIdRef.current, message.id);
await markChatRoomRead(token, activeRoom.key, message.id);
} catch (nextError) {
setDraft(text);
setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.');
} finally {
setSending(false);
}
const selectKey = await action();
if (!alive.current) return;
DeviceEventEmitter.emit('fedeo:chat-changed');
const result = await chatApi.directory(token);
if (!alive.current) return;
directoryRef.current = result; setDirectory(result);
const available = visibleChatRooms(result);
setActiveKey(previous => available.find(room => room.key === (selectKey || previous) && room.exists)?.key
|| available.find(room => room.exists)?.key || '');
} catch (err) { if (alive.current) setError(err instanceof Error ? err.message : 'Änderung konnte nicht gespeichert werden.'); }
finally { operation.current = false; if (alive.current) { setBusy(''); setLoading(false); } }
}
async function submitRoom() {
if (!token || !roomName.trim()) return;
try {
const created = await createChatRoom(token, {
key: normalizeRoomKey(roomKey || roomName),
name: roomName.trim(),
topic: roomTopic.trim() || null,
async function hide(room: MobileChatRoom, hidden: boolean) {
await mutate(room.key, async () => {
await chatApi.hide(token, room.key, hidden);
if (!alive.current) return;
// Update immediately after success, even if refreshing the directory subsequently fails.
setDirectory(previous => {
if (!previous) return previous;
const next = { ...previous, hiddenRoomKeys: hidden ? [...new Set([...previous.hiddenRoomKeys, room.key])]
: previous.hiddenRoomKeys.filter(key => key !== room.key) };
directoryRef.current = next;
return next;
});
setCreateOpen(false);
setRoomName('');
setRoomKey('');
setRoomTopic('');
await loadRooms();
await selectRoom(created);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.');
}
if (hidden) setActiveKey(previous => previous === room.key ? '' : previous);
else { setShowHidden(false); return room.key; }
});
}
function renderRoom({ item }: { item: ChatRoom }) {
const active = item.key === activeRoom?.key;
return (
<Pressable style={[styles.roomChip, active && styles.roomChipActive]} onPress={() => void selectRoom(item)}>
<Text style={styles.roomGroup}>{item.group || 'Räume'}</Text>
<Text style={[styles.roomName, active && styles.roomTextActive]} numberOfLines={1}>{item.name}</Text>
{item.unread ? <Text style={styles.roomBadge}>{item.unread}</Text> : null}
</Pressable>
);
async function respond(id: string, accept: boolean) {
await mutate(id, async () => {
const result = await chatApi.respond(token, id, accept);
return accept && result.roomId ? collaborationKey(result.roomId) : undefined;
});
}
function renderMessage({ item }: { item: ChatMessage }) {
return (
<View style={[styles.messageRow, item.own && styles.messageRowOwn]}>
<View style={[styles.messageBubble, item.own && styles.messageBubbleOwn]}>
{!item.own ? <Text style={styles.messageAuthor}>{item.senderDisplayName || 'Benutzer'}</Text> : null}
<Text style={[styles.messageBody, item.own && styles.messageBodyOwn]}>{item.body}</Text>
<Text style={[styles.messageTime, item.own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
</View>
</View>
);
async function create() {
if (!roomName.trim()) return;
await mutate('create', async () => {
let key: string;
if (roomType === 'collaboration') {
const created = await apiRequest<{ id: string }>('/api/collaboration/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), projectId } });
key = collaborationKey(created.id);
} else {
const normalized = roomName.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '').slice(0, 48);
const created = await apiRequest<{ key: string }>('/api/communication/chat/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), key: normalized } });
key = created.key;
}
if (alive.current) { setCreateOpen(false); setRoomName(''); setRoomTopic(''); }
return key;
});
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
<View style={styles.header}>
<View style={styles.headerMain}>
<Text style={styles.title}>Chat</Text>
<Text style={styles.subtitle} numberOfLines={1}>{activeRoom?.name || 'FEDEO Kommunikation'}</Text>
</View>
<Pressable style={styles.headerButton} onPress={() => setCreateOpen(true)}><Text style={styles.headerButtonText}>+</Text></Pressable>
<Pressable style={styles.headerButton} onPress={() => setMembersOpen((value) => !value)}><Text style={styles.headerButtonText}>☰</Text></Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.roomPanel}>
<TextInput style={styles.searchInput} placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
<FlatList data={visibleRooms} horizontal keyExtractor={(item) => item.key} renderItem={renderRoom}
showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} />
</View>
{membersOpen ? (
<View style={styles.membersPanel}>
<Text style={styles.membersTitle}>Teilnehmer · {members.length}</Text>
<Text style={styles.membersText} numberOfLines={2}>{members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')}</Text>
</View>
) : null}
{loading ? (
<View style={styles.loading}><ActivityIndicator /><Text style={styles.loadingText}>Nachrichten werden geladen …</Text></View>
) : (
<FlatList ref={listRef} data={messages} keyExtractor={(item) => String(item.id)} renderItem={renderMessage}
contentContainerStyle={styles.messageList}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); void refresh(); }} />}
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} />
)}
<View style={styles.composer}>
<TextInput style={styles.composerInput} placeholder="Nachricht schreiben" value={draft} onChangeText={setDraft} multiline />
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={() => void submitMessage()}>
<Text style={styles.sendButtonText}>{sending ? '…' : 'Senden'}</Text>
</Pressable>
</View>
<Modal transparent visible={createOpen} animationType="fade" onRequestClose={() => setCreateOpen(false)}>
<View style={styles.modalBackdrop}><View style={styles.modalCard}>
<Text style={styles.modalTitle}>Chatraum erstellen</Text>
<TextInput style={styles.modalInput} placeholder="Name" value={roomName} onChangeText={setRoomName} />
<TextInput style={styles.modalInput} placeholder="Schlüssel, optional" value={roomKey} onChangeText={setRoomKey} autoCapitalize="none" />
<TextInput style={styles.modalInput} placeholder="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} />
<View style={styles.modalActions}>
<Pressable onPress={() => setCreateOpen(false)}><Text style={styles.linkText}>Abbrechen</Text></Pressable>
<Pressable style={styles.primaryButton} onPress={() => void submitRoom()}><Text style={styles.primaryButtonText}>Erstellen</Text></Pressable>
</View>
</View></View>
</Modal>
</KeyboardAvoidingView>
);
return <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 95 : 0} style={styles.screen}>
<View style={styles.toolbar}>
<TextInput style={styles.search} accessibilityLabel="Chats durchsuchen" placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
<Pressable accessibilityRole="button" accessibilityLabel="Chat erstellen" onPress={() => setCreateOpen(true)} disabled={!directory || !!busy} style={styles.icon}><Ionicons name="add" size={25} /></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel={`Ausgeblendete Chats (${hiddenRooms.length})`} onPress={() => setShowHidden(true)} style={styles.icon}><Ionicons name="eye-off-outline" size={23} /></Pressable>
</View>
{!!error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{error}</Text><Pressable onPress={() => void refresh()}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
{!!directory?.invitations.length && <ScrollView style={styles.invitations} contentContainerStyle={{ gap: 8 }}>
{directory.invitations.map(invitation => <View key={invitation.id} style={styles.invitation}>
<Text style={styles.bold}>Einladung: {invitation.name}</Text>
<Text style={styles.hint}>{invitation.ownerName}{invitation.remote ? ' · Extern' : ''} · {invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben'}</Text>
{invitation.overviewAllowed && <Text style={styles.hint}>Projektübersicht freigegeben</Text>}
<View style={styles.actions}><Pressable accessibilityRole="button" disabled={!!busy} onPress={() => void respond(invitation.id, true)} style={styles.button}><Text style={styles.white}>{busy === invitation.id ? 'Bitte warten …' : 'Annehmen'}</Text></Pressable>
<Pressable accessibilityRole="button" disabled={!!busy} onPress={() => void respond(invitation.id, false)} style={styles.secondary}><Text>Ablehnen</Text></Pressable></View>
</View>)}
</ScrollView>}
<View style={styles.roomPanel}><FlatList horizontal data={filteredRooms} keyExtractor={room => room.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList}
ListEmptyComponent={!loading ? <Text style={styles.hint}>{search ? 'Keine passenden Chats.' : 'Keine sichtbaren Chats.'}</Text> : null}
renderItem={({ item }) => <Pressable accessibilityRole="button" accessibilityState={{ selected: activeKey === item.key }} disabled={!!busy} onPress={() => void select(item)} style={[styles.room, activeKey === item.key && styles.selectedRoom]}>
<Text style={styles.group}>{item.group}{item.remote ? ' · EXTERN' : ''}</Text><Text style={styles.bold} numberOfLines={1}>{item.name}</Text>
{!!item.unread && <Text style={styles.badge}>{item.unread}</Text>}
</Pressable>} /></View>
{loading ? <View style={styles.empty}><ActivityIndicator /><Text>Chats werden geladen …</Text></View> : activeRoom ?
<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; })} />
: <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>
<FlatList data={hiddenRooms.filter(room => matchesChatSearch(room, search))} keyExtractor={room => room.key} style={{ maxHeight: 380 }}
ListEmptyComponent={<Text style={styles.hint}>Keine ausgeblendeten Chats gefunden.</Text>}
renderItem={({ item }) => <View style={styles.hiddenRow}><View style={{ flex: 1 }}><Text style={styles.bold}>{item.name}</Text><Text style={styles.hint}>{item.group}</Text></View>
<Pressable accessibilityRole="button" accessibilityLabel={`${item.name} einblenden`} disabled={!!busy} onPress={() => void hide(item, false)} style={styles.secondary}><Text style={styles.link}>Einblenden</Text></Pressable></View>} />
<Pressable onPress={() => setShowHidden(false)} style={styles.secondary}><Text>Schließen</Text></Pressable>
</View></View>
</Modal>
<Modal transparent visible={createOpen} animationType="slide" onRequestClose={() => setCreateOpen(false)}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.overlay}><View style={styles.modal}>
<Text style={styles.title}>Chat erstellen</Text>
{directory?.canManage && <View style={styles.actions}>{(['internal', 'collaboration'] as const).map(type => <Pressable key={type} onPress={() => setRoomType(type)} style={[styles.secondary, roomType === type && styles.selectedRoom]}><Text>{type === 'internal' ? 'Gruppenchat' : 'Unterraum'}</Text></Pressable>)}</View>}
<TextInput style={styles.input} accessibilityLabel="Chatname" placeholder="Name" value={roomName} onChangeText={setRoomName} maxLength={200} />
<TextInput style={styles.input} accessibilityLabel="Chatbeschreibung" placeholder="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} maxLength={1000} />
{roomType === 'collaboration' && <><Text style={styles.hint}>Projekt zuordnen (optional)</Text><ScrollView style={{ maxHeight: 150 }}>
<Pressable onPress={() => setProjectId(null)} style={styles.secondary}><Text>{projectId === null ? '✓ ' : ''}Ohne Projekt</Text></Pressable>
{directory?.projects.map(project => <Pressable key={project.id} onPress={() => setProjectId(project.id)} style={styles.secondary}><Text>{projectId === project.id ? '✓ ' : ''}{project.name}</Text></Pressable>)}
</ScrollView></>}
{!!error && <Text accessibilityRole="alert" style={styles.error}>{error}</Text>}
<View style={styles.actions}><Pressable disabled={!!busy} onPress={() => setCreateOpen(false)} style={styles.secondary}><Text>Abbrechen</Text></Pressable>
<Pressable disabled={!!busy || !roomName.trim()} onPress={() => void create()} style={styles.button}><Text style={styles.white}>{busy === 'create' ? 'Wird erstellt …' : 'Erstellen'}</Text></Pressable></View>
</View></KeyboardAvoidingView>
</Modal>
</KeyboardAvoidingView>;
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#f9fafb' },
header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 },
headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' },
headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' },
roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 },
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' },
roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' },
roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 },
membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, loadingText: { color: '#6b7280' },
messageList: { flexGrow: 1, padding: 14, gap: 10 }, emptyText: { marginTop: 40, textAlign: 'center', color: '#9ca3af' },
messageRow: { flexDirection: 'row' }, messageRowOwn: { justifyContent: 'flex-end' }, messageBubble: { maxWidth: '82%', padding: 11, borderRadius: 16, backgroundColor: '#f3f4f6' },
messageBubbleOwn: { backgroundColor: PRIMARY }, messageAuthor: { color: '#2f6f25', fontSize: 12, fontWeight: '700', marginBottom: 3 }, messageBody: { color: '#111827', fontSize: 15 }, messageBodyOwn: { color: '#fff' },
messageTime: { marginTop: 4, color: '#9ca3af', fontSize: 10, textAlign: 'right' }, messageTimeOwn: { color: '#e7f7e2' },
composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e5e7eb' },
composerInput: { flex: 1, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 18, paddingHorizontal: 13, paddingVertical: 9 },
sendButton: { backgroundColor: PRIMARY, borderRadius: 18, paddingHorizontal: 15, paddingVertical: 10 }, sendButtonDisabled: { opacity: 0.45 }, sendButtonText: { color: '#fff', fontWeight: '700' },
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.45)' }, modalCard: { padding: 18, gap: 12, borderRadius: 14, backgroundColor: '#fff' },
modalTitle: { fontSize: 18, fontWeight: '800', color: '#111827' }, modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, padding: 10 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 18 }, linkText: { color: '#2f6f25', fontWeight: '700' }, primaryButton: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 9, backgroundColor: PRIMARY }, primaryButtonText: { color: '#fff', fontWeight: '700' },
screen: { flex: 1, backgroundColor: '#f9fafb' }, toolbar: { flexDirection: 'row', alignItems: 'center', padding: 10, gap: 5, backgroundColor: '#fff' },
search: { flex: 1, minWidth: 0, minHeight: 42, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, fontSize: 13, color: '#111827' },
icon: { padding: 8, minWidth: 40, minHeight: 44, justifyContent: 'center', alignItems: 'center' }, roomPanel: { backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
roomList: { paddingHorizontal: 12, paddingBottom: 10, gap: 8 }, room: { width: 165, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
selectedRoom: { backgroundColor: '#eff9ea', borderColor: '#69c350', borderWidth: 1 }, group: { fontSize: 10, color: '#6b7280', textTransform: 'uppercase', marginBottom: 4 },
bold: { fontWeight: '700', color: '#111827', fontSize: 13 }, badge: { marginTop: 4, alignSelf: 'flex-start', paddingHorizontal: 6, borderRadius: 8, backgroundColor: '#dcfce7', color: '#166534', fontSize: 11 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 }, hint: { fontSize: 12, color: '#6b7280' }, error: { fontSize: 12, color: '#991b1b' }, errorBox: { backgroundColor: '#fef2f2', padding: 10, gap: 4 },
link: { color: '#2f6f25', fontWeight: '600' }, invitations: { maxHeight: 170, flexGrow: 0, padding: 10, backgroundColor: '#f0fdf4' }, invitation: { gap: 5, paddingBottom: 8 },
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, button: { paddingVertical: 11, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#438b30' },
white: { color: '#fff', fontWeight: '600' }, secondary: { padding: 11, borderRadius: 8 }, overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'center', padding: 20 },
modal: { maxHeight: '85%', backgroundColor: '#fff', borderRadius: 16, padding: 18, gap: 12 }, title: { fontSize: 18, fontWeight: '700', color: '#111827' },
hiddenRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 9, borderBottomWidth: 1, borderColor: '#e5e7eb' }, input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 12, color: '#111827' },
});