Files
FEDEO/mobile/app/(tabs)/communication.tsx

227 lines
17 KiB
TypeScript

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, 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 <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 [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<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 (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<string | void>) {
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 <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' }, 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' },
});