227 lines
17 KiB
TypeScript
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' },
|
|
});
|