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

295 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {
ChatMember,
ChatMessage,
ChatRoom,
createChatRoom,
fetchChatMembers,
fetchChatMessages,
fetchChatRooms,
markChatRoomRead,
provisionChatRoom,
sendChatMessage,
syncChatRoom,
} from '@/src/lib/api';
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 [search, setSearch] = 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 [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 refresh = useCallback(async () => {
if (!token) return;
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.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [activeRoomKey, loadRoom, loadRooms, token]);
useEffect(() => { void refresh(); }, [refresh]);
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]);
async function selectRoom(room: ChatRoom) {
if (!token) 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);
}
}
async function submitMessage() {
if (!token || !activeRoom?.exists || !draft.trim() || sending) return;
const text = draft.trim();
setDraft('');
setSending(true);
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);
}
}
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,
});
setCreateOpen(false);
setRoomName('');
setRoomKey('');
setRoomTopic('');
await loadRooms();
await selectRoom(created);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.');
}
}
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>
);
}
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>
);
}
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>
);
}
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' },
});