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, AppState, DeviceEventEmitter, FlatList, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; 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, touchChatRoom, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat'; import { getApiBaseUrlSync } from '@/src/lib/server-config'; import { useAuth } from '@/src/providers/auth-provider'; export default function CommunicationScreen() { const { token, activeTenantId } = useAuth(); if (!token || !activeTenantId) return null; return ; } function ChatScreen({ token }: { token: string }) { const focused = useIsFocused(); const [foreground, setForeground] = useState(AppState.currentState === 'active'); const enabled = focused && foreground; const [directory, setDirectory] = useState(null); const [activeKey, setActiveKey] = useState(''); const [search, setSearch] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(''); const [showHidden, setShowHidden] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [roomName, setRoomName] = useState(''); const [roomTopic, setRoomTopic] = useState(''); const [roomType, setRoomType] = useState<'internal' | 'collaboration'>('internal'); const [projectId, setProjectId] = useState(null); const [drafts, setDrafts] = useState>({}); 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(() => ({ 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 (loadingDirectory.current || operation.current) return; const version = ++directoryRevision.current; loadingDirectory.current = true; try { 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 { loadingDirectory.current = false; if (alive.current && version === directoryRevision.current) setLoading(false); } }, [token]); useEffect(() => { 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 select(room: MobileChatRoom) { if (directoryRef.current?.hiddenRoomKeys.includes(room.key)) return; const version = ++selectionRevision.current; setError(''); if (room.exists) { setActiveKey(room.key); return; } try { setBusy(room.key); const ready = await chatApi.provision(token, room); if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return; // A list requested before provisioning must not replace the new room with its placeholder. ++directoryRevision.current; 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 mutate(label: string, action: () => Promise) { if (operation.current) return; operation.current = true; ++directoryRevision.current; setBusy(label); setError(''); try { 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 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; }); if (hidden) setActiveKey(previous => previous === room.key ? '' : previous); else { setShowHidden(false); return room.key; } }); } 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; }); } 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 setCreateOpen(true)} disabled={!directory || !!busy} style={styles.icon}> setShowHidden(true)} style={styles.icon}> {!!error && {error} void refresh()}>Erneut laden} {!!directory?.invitations.length && {directory.invitations.map(invitation => Einladung: {invitation.name} {invitation.ownerName}{invitation.remote ? ' · Extern' : ''} · {invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben'} {invitation.overviewAllowed && Projektübersicht freigegeben} void respond(invitation.id, true)} style={styles.button}>{busy === invitation.id ? 'Bitte warten …' : 'Annehmen'} void respond(invitation.id, false)} style={styles.secondary}>Ablehnen )} } room.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} ListEmptyComponent={!loading ? {search ? 'Keine passenden Chats.' : 'Keine sichtbaren Chats.'} : null} renderItem={({ item }) => void select(item)} style={[styles.room, activeKey === item.key && styles.selectedRoom]}> {item.group}{item.remote ? ' · EXTERN' : ''}{item.name} {!!item.unread && {item.unread}} } /> {loading ? Chats werden geladen … : activeRoom ? void hide(activeRoom, true)} onDraft={value => setDrafts(previous => ({ ...previous, [activeKey]: { ...value, clientId: randomUUID() } }))} 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. matchesChatSearch(room, search))} keyExtractor={room => room.key} style={{ maxHeight: 380 }} ListEmptyComponent={Keine ausgeblendeten Chats gefunden.} renderItem={({ item }) => {item.name}{item.group} void hide(item, false)} style={styles.secondary}>Einblenden} /> setShowHidden(false)} style={styles.secondary}>Schließen setCreateOpen(false)}> Chat erstellen {directory?.canManage && {(['internal', 'collaboration'] as const).map(type => setRoomType(type)} style={[styles.secondary, roomType === type && styles.selectedRoom]}>{type === 'internal' ? 'Gruppenchat' : 'Unterraum'})}} {roomType === 'collaboration' && <>Projekt zuordnen (optional) setProjectId(null)} style={styles.secondary}>{projectId === null ? '✓ ' : ''}Ohne Projekt {directory?.projects.map(project => setProjectId(project.id)} style={styles.secondary}>{projectId === project.id ? '✓ ' : ''}{project.name})} } {!!error && {error}} setCreateOpen(false)} style={styles.secondary}>Abbrechen void create()} style={styles.button}>{busy === 'create' ? 'Wird erstellt …' : 'Erstellen'} ; } const styles = StyleSheet.create({ 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' }, });