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