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

934 lines
37 KiB
TypeScript

import * as DocumentPicker from 'expo-document-picker';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {
createMatrixRoom,
deleteMatrixMessage,
editMatrixMessage,
fetchMatrixIdentity,
fetchMatrixMembers,
fetchMatrixMessages,
fetchMatrixRooms,
fetchMatrixStatus,
fetchMatrixUsers,
inviteMatrixMember,
markMatrixRoomRead,
MatrixMember,
MatrixMessage,
MatrixRoom,
MatrixStatus,
MatrixUser,
provisionMatrixRoom,
provisionMatrixUser,
reactToMatrixMessage,
removeMatrixMember,
sendMatrixMessage,
syncMatrixMembers,
syncMatrixRoom,
uploadMatrixAttachment,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
const REACTION_PRESETS = ['👍', '✅', '👀', '🙏'];
type DialogMode = 'create-room' | 'edit-message' | 'invite-member' | null;
function normalizeRoomKey(value: string): string {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
function messageTime(value: MatrixMessage['timestamp']): string {
if (!value) return '';
const date = typeof value === 'number' ? new Date(value) : new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function messagePreview(message: MatrixMessage): string {
return message.body || message.attachment?.fileName || 'Nachricht';
}
function sortMessages(messages: MatrixMessage[]): MatrixMessage[] {
return [...messages].sort((a, b) => {
const left = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const right = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return left - right;
});
}
function mergeMessages(current: MatrixMessage[], incoming: MatrixMessage[]): MatrixMessage[] {
const byId = new Map<string, MatrixMessage>();
for (const message of current) byId.set(message.id, message);
for (const message of incoming || []) byId.set(message.id, { ...byId.get(message.id), ...message });
return sortMessages(Array.from(byId.values()));
}
function applyReplacements(current: MatrixMessage[], replacements: MatrixMessage[] = []): MatrixMessage[] {
if (!replacements.length) return current;
const byTarget = new Map<string, MatrixMessage>();
for (const replacement of replacements) {
const targetId = String(replacement.targetEventId || replacement.replyToEventId || replacement.id || '');
if (targetId) byTarget.set(targetId, replacement);
}
return current.map((message) => {
const replacement = byTarget.get(message.id);
if (!replacement) return message;
return {
...message,
body: replacement.body ?? message.body,
edited: true,
timestamp: replacement.timestamp || message.timestamp,
};
});
}
function applyRedactions(
current: MatrixMessage[],
redactions: { redacts?: string; eventId?: string; targetEventId?: string }[] = []
): MatrixMessage[] {
if (!redactions.length) return current;
const ids = new Set(redactions.map((item) => item.redacts || item.eventId || item.targetEventId).filter(Boolean));
return current.filter((message) => !ids.has(message.id));
}
function applyReactions(
current: MatrixMessage[],
reactions: { targetEventId?: string; key: string; count?: number; own?: boolean; senders?: string[] }[] = []
): MatrixMessage[] {
if (!reactions.length) return current;
return current.map((message) => {
const next = reactions.filter((reaction) => reaction.targetEventId === message.id);
if (!next.length) return message;
return { ...message, reactions: next };
});
}
export default function CommunicationScreen() {
const { token } = useAuth();
const messageListRef = useRef<FlatList<MatrixMessage>>(null);
const [status, setStatus] = useState<MatrixStatus | null>(null);
const [matrixUserId, setMatrixUserId] = useState('');
const [rooms, setRooms] = useState<MatrixRoom[]>([]);
const [members, setMembers] = useState<MatrixMember[]>([]);
const [users, setUsers] = useState<MatrixUser[]>([]);
const [messages, setMessages] = useState<MatrixMessage[]>([]);
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
const [syncSince, setSyncSince] = useState<string | undefined>();
const [draft, setDraft] = useState('');
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sending, setSending] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [replyTarget, setReplyTarget] = useState<MatrixMessage | null>(null);
const [editingMessage, setEditingMessage] = useState<MatrixMessage | null>(null);
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [roomName, setRoomName] = useState('');
const [roomTopic, setRoomTopic] = useState('');
const [roomKey, setRoomKey] = useState('');
const [dialogText, setDialogText] = useState('');
const [selectedUserId, setSelectedUserId] = useState('');
const activeRoom = useMemo(
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
[activeRoomKey, rooms]
);
const visibleRooms = useMemo(() => {
const query = search.trim().toLowerCase();
const base = [...rooms].sort((a, b) => {
const groupCompare = String(a.group || '').localeCompare(String(b.group || ''));
if (groupCompare !== 0) return groupCompare;
return String(a.name || a.key).localeCompare(String(b.name || b.key));
});
if (!query) return base;
return base.filter((room) =>
[room.name, room.key, room.topic, room.email, room.projectNumber]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query))
);
}, [rooms, search]);
const inviteCandidates = useMemo(() => {
const existing = new Set(members.map((member) => member.matrixUserId));
return users.filter((user) => !existing.has(user.matrixUserId));
}, [members, users]);
const clearRoomUnread = useCallback((roomKey: string) => {
setRooms((current) =>
current.map((room) => (room.key === roomKey ? { ...room, unread: 0, mentions: 0 } : room))
);
}, []);
const loadRooms = useCallback(async () => {
if (!token) return;
const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([
fetchMatrixStatus(token),
fetchMatrixIdentity(token),
fetchMatrixRooms(token),
fetchMatrixUsers(token),
]);
setStatus(nextStatus);
setMatrixUserId(identity.matrixUserId || '');
setRooms(nextRooms);
if (!nextRooms.some((room) => room.key === activeRoomKey) && nextRooms[0]) {
setActiveRoomKey(nextRooms[0].key);
}
setUsers(nextUsers);
}, [activeRoomKey, token]);
const loadRoomContent = useCallback(
async (roomKeyToLoad: string, showSpinner = true) => {
if (!token || !roomKeyToLoad) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [nextMessages, nextMembers, sync] = await Promise.all([
fetchMatrixMessages(token, roomKeyToLoad),
fetchMatrixMembers(token, roomKeyToLoad),
syncMatrixRoom(token, roomKeyToLoad, undefined, true),
]);
const merged = mergeMessages(nextMessages, sync.messages || []);
setMessages(sortMessages(merged));
setMembers(sync.members || nextMembers);
setSyncSince(sync.nextBatch);
const last = merged.at(-1);
if (last?.id) {
await markMatrixRoomRead(token, roomKeyToLoad, last.id);
clearRoomUnread(roomKeyToLoad);
}
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[clearRoomUnread, token]
);
const refreshAll = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
await loadRooms();
await loadRoomContent(activeRoomKey, false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Matrix-Kommunikation konnte nicht geladen werden.');
setLoading(false);
setRefreshing(false);
}
},
[activeRoomKey, loadRoomContent, loadRooms, token]
);
const pollSync = useCallback(async () => {
if (!token || !activeRoom?.exists || !syncSince) return;
try {
const sync = await syncMatrixRoom(token, activeRoom.key, syncSince);
const incomingMessages = sync.messages || [];
setSyncSince(sync.nextBatch || syncSince);
setMembers((current) => sync.members || current);
setMessages((current) => {
let next = mergeMessages(current, incomingMessages);
next = applyReplacements(next, sync.replacements);
next = applyReactions(next, sync.reactions);
next = applyRedactions(next, sync.redactions);
return next;
});
const last = incomingMessages.at(-1);
if (last?.id) {
await markMatrixRoomRead(token, activeRoom.key, last.id);
clearRoomUnread(activeRoom.key);
}
} catch {
// Polling errors are surfaced by manual refresh to avoid noisy chat usage.
}
}, [activeRoom, clearRoomUnread, syncSince, token]);
useEffect(() => {
void refreshAll(true);
}, [refreshAll]);
useEffect(() => {
if (!activeRoomKey) return;
setReplyTarget(null);
setEditingMessage(null);
setMessages([]);
setMembers([]);
setSyncSince(undefined);
void loadRoomContent(activeRoomKey, true);
}, [activeRoomKey, loadRoomContent]);
useEffect(() => {
const id = setInterval(() => void pollSync(), 5000);
return () => clearInterval(id);
}, [pollSync]);
async function ensureActiveRoom(): Promise<MatrixRoom | null> {
if (!token || !activeRoom) return null;
if (activeRoom.exists) return activeRoom;
setProvisioning(true);
try {
const room = await provisionMatrixRoom(token, activeRoom);
setRooms((current) => current.map((item) => (item.key === activeRoom.key ? { ...item, ...room, exists: true } : item)));
await loadRoomContent(activeRoom.key, false);
return { ...activeRoom, ...room, exists: true };
} catch (err) {
setError(err instanceof Error ? err.message : 'Raum konnte nicht bereitgestellt werden.');
return null;
} finally {
setProvisioning(false);
}
}
async function sendMessage() {
if (!token || !draft.trim()) return;
const room = await ensureActiveRoom();
if (!room) return;
setSending(true);
try {
const message = editingMessage
? await editMatrixMessage(token, room.key, editingMessage.id, draft.trim())
: await sendMatrixMessage(token, room.key, draft.trim(), replyTarget?.id);
setMessages((current) =>
editingMessage
? current.map((item) => (item.id === editingMessage.id ? { ...item, ...message, edited: true } : item))
: mergeMessages(current, [message])
);
setDraft('');
setReplyTarget(null);
setEditingMessage(null);
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gesendet werden.');
} finally {
setSending(false);
}
}
async function pickAttachment() {
if (!token) return;
const room = await ensureActiveRoom();
if (!room) return;
const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
if (result.canceled || !result.assets[0]) return;
setSending(true);
try {
const asset = result.assets[0];
const message = await uploadMatrixAttachment(token, room.key, {
uri: asset.uri,
name: asset.name || 'Anhang',
mimeType: asset.mimeType,
});
setMessages((current) => mergeMessages(current, [message]));
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Anhang konnte nicht hochgeladen werden.');
} finally {
setSending(false);
}
}
async function provisionUser() {
if (!token) return;
setProvisioning(true);
try {
const identity = await provisionMatrixUser(token);
setMatrixUserId(identity.matrixUserId || '');
await refreshAll(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Matrix-Benutzer konnte nicht erstellt werden.');
} finally {
setProvisioning(false);
}
}
async function createRoomFromDialog() {
if (!token || !roomName.trim()) return;
const key = roomKey.trim() || normalizeRoomKey(roomName);
if (!key) return;
setProvisioning(true);
try {
const room = await createMatrixRoom(token, {
key,
name: roomName.trim(),
topic: roomTopic.trim() || null,
type: 'room',
});
setRooms((current) => [{ ...room, group: 'Räume', exists: true }, ...current.filter((item) => item.key !== key)]);
setActiveRoomKey(key);
setDialogMode(null);
setRoomName('');
setRoomTopic('');
setRoomKey('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Raum konnte nicht erstellt werden.');
} finally {
setProvisioning(false);
}
}
async function inviteSelectedUser() {
if (!token || !activeRoom || !selectedUserId) return;
const room = await ensureActiveRoom();
if (!room) return;
setProvisioning(true);
try {
await inviteMatrixMember(token, room.key, selectedUserId);
setMembers(await fetchMatrixMembers(token, room.key));
setDialogMode(null);
setSelectedUserId('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Mitglied konnte nicht eingeladen werden.');
} finally {
setProvisioning(false);
}
}
function openEdit(message: MatrixMessage) {
setEditingMessage(message);
setDialogText(message.body || '');
setDialogMode('edit-message');
}
function confirmDelete(message: MatrixMessage) {
Alert.alert('Nachricht löschen', 'Diese Nachricht wirklich entfernen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => {
if (!token || !activeRoom) return;
void deleteMatrixMessage(token, activeRoom.key, message.id)
.then(() => setMessages((current) => current.filter((item) => item.id !== message.id)))
.catch((err) => setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gelöscht werden.'));
},
},
]);
}
function confirmRemoveMember(member: MatrixMember) {
Alert.alert('Mitglied entfernen', `${member.displayName || member.matrixUserId} aus dem Raum entfernen?`, [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Entfernen',
style: 'destructive',
onPress: () => {
if (!token || !activeRoom) return;
void removeMatrixMember(token, activeRoom.key, member.matrixUserId)
.then(() => setMembers((current) => current.filter((item) => item.matrixUserId !== member.matrixUserId)))
.catch((err) => setError(err instanceof Error ? err.message : 'Mitglied konnte nicht entfernt werden.'));
},
},
]);
}
async function addReaction(message: MatrixMessage, key: string) {
if (!token || !activeRoom) return;
try {
await reactToMatrixMessage(token, activeRoom.key, message.id, key);
await pollSync();
} catch (err) {
setError(err instanceof Error ? err.message : 'Reaktion konnte nicht gesendet werden.');
}
}
async function syncMembersNow() {
if (!token || !activeRoom) return;
setProvisioning(true);
try {
await syncMatrixMembers(token, activeRoom.key);
setMembers(await fetchMatrixMembers(token, activeRoom.key));
} catch (err) {
setError(err instanceof Error ? err.message : 'Mitglieder konnten nicht synchronisiert werden.');
} finally {
setProvisioning(false);
}
}
function renderRoom({ item }: { item: MatrixRoom }) {
const active = item.key === activeRoomKey;
return (
<Pressable style={[styles.roomChip, active && styles.roomChipActive]} onPress={() => setActiveRoomKey(item.key)}>
<Text style={[styles.roomGroup, active && styles.roomTextActive]}>{item.group || 'Raum'}</Text>
<Text style={[styles.roomName, active && styles.roomTextActive]} numberOfLines={1}>
{item.name || item.key}
</Text>
<View style={styles.roomMetaRow}>
{!item.exists ? <Text style={styles.roomBadgeMuted}>bereitstellen</Text> : null}
{item.unread ? <Text style={styles.roomBadge}>{item.mentions ? `@${item.mentions}` : item.unread}</Text> : null}
</View>
</Pressable>
);
}
function renderMessage({ item }: { item: MatrixMessage }) {
const own = Boolean(item.own || item.sender === matrixUserId);
const reply = item.replyToEventId ? messages.find((message) => message.id === item.replyToEventId) : null;
return (
<View style={[styles.messageRow, own && styles.messageRowOwn]}>
<View style={[styles.messageBubble, own && styles.messageBubbleOwn]}>
<View style={styles.messageHeader}>
<Text style={[styles.messageSender, own && styles.messageSenderOwn]} numberOfLines={1}>
{own ? 'Du' : item.senderDisplayName || item.sender}
</Text>
<Text style={[styles.messageTime, own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
</View>
{reply ? (
<View style={[styles.replyBox, own && styles.replyBoxOwn]}>
<Text style={[styles.replyText, own && styles.replyTextOwn]} numberOfLines={2}>
{reply.senderDisplayName || reply.sender}: {messagePreview(reply)}
</Text>
</View>
) : null}
{item.body ? <Text style={[styles.messageBody, own && styles.messageBodyOwn]}>{item.body}</Text> : null}
{item.attachment ? (
<View style={[styles.attachmentBox, own && styles.attachmentBoxOwn]}>
<Text style={[styles.attachmentTitle, own && styles.messageBodyOwn]} numberOfLines={1}>
{item.attachment.fileName || 'Anhang'}
</Text>
<Text style={[styles.attachmentMeta, own && styles.messageTimeOwn]}>
{item.attachment.mimeType || 'Datei'} {item.attachment.size ? `· ${Math.round(item.attachment.size / 1024)} KB` : ''}
</Text>
</View>
) : null}
{item.edited ? <Text style={[styles.editedText, own && styles.messageTimeOwn]}>bearbeitet</Text> : null}
{item.reactions?.length ? (
<View style={styles.reactionRow}>
{item.reactions.map((reaction) => (
<Text key={reaction.key} style={[styles.reactionPill, reaction.own && styles.reactionPillOwn]}>
{reaction.key} {reaction.count || ''}
</Text>
))}
</View>
) : null}
<View style={styles.messageActions}>
<Pressable onPress={() => setReplyTarget(item)}>
<Text style={[styles.actionText, own && styles.actionTextOwn]}>Antworten</Text>
</Pressable>
{REACTION_PRESETS.map((reaction) => (
<Pressable key={reaction} onPress={() => addReaction(item, reaction)}>
<Text style={[styles.actionText, own && styles.actionTextOwn]}>{reaction}</Text>
</Pressable>
))}
{own && !item.attachment ? (
<Pressable onPress={() => openEdit(item)}>
<Text style={[styles.actionText, styles.actionTextOwn]}>Bearbeiten</Text>
</Pressable>
) : null}
{own ? (
<Pressable onPress={() => confirmDelete(item)}>
<Text style={[styles.actionText, styles.actionTextDestructive]}>Löschen</Text>
</Pressable>
) : null}
</View>
</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}>Kommunikation</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{activeRoom?.name || 'Matrix Chat'}
</Text>
</View>
<Pressable style={styles.headerButton} onPress={() => setDialogMode('create-room')}>
<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}
{status && status.enabled === false ? (
<View style={styles.notice}>
<Text style={styles.noticeTitle}>Matrix ist nicht aktiv</Text>
<Text style={styles.noticeText}>Die Kommunikation ist serverseitig noch nicht aktiviert.</Text>
</View>
) : null}
{!matrixUserId ? (
<View style={styles.notice}>
<Text style={styles.noticeTitle}>Matrix-Benutzer fehlt</Text>
<Text style={styles.noticeText}>Lege deinen Matrix-Zugang an, um Räume nutzen zu können.</Text>
<Pressable style={styles.primaryButton} onPress={provisionUser} disabled={provisioning}>
<Text style={styles.primaryButtonText}>{provisioning ? 'Wird erstellt...' : 'Benutzer erstellen'}</Text>
</Pressable>
</View>
) : 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}>
<View style={styles.membersHeader}>
<Text style={styles.membersTitle}>Mitglieder · {members.length}</Text>
<View style={styles.membersActions}>
<Pressable onPress={syncMembersNow}>
<Text style={styles.linkText}>Sync</Text>
</Pressable>
<Pressable onPress={() => setDialogMode('invite-member')}>
<Text style={styles.linkText}>Einladen</Text>
</Pressable>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.memberList}>
{members.map((member) => (
<Pressable key={member.matrixUserId} style={styles.memberPill} onLongPress={() => confirmRemoveMember(member)}>
<Text style={styles.memberName}>{member.displayName || member.matrixUserId}</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{!activeRoom?.exists ? (
<View style={styles.provisionPanel}>
<Text style={styles.noticeTitle}>Raum noch nicht bereitgestellt</Text>
<Text style={styles.noticeText}>Beim Öffnen wird der Matrix-Raum inklusive Einladungen angelegt.</Text>
<Pressable style={styles.primaryButton} onPress={ensureActiveRoom} disabled={provisioning}>
<Text style={styles.primaryButtonText}>{provisioning ? 'Wird bereitgestellt...' : 'Raum bereitstellen'}</Text>
</Pressable>
</View>
) : null}
{loading ? (
<View style={styles.loading}>
<ActivityIndicator />
<Text style={styles.loadingText}>Nachrichten werden geladen...</Text>
</View>
) : (
<FlatList
ref={messageListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
void refreshAll(false);
}}
/>
}
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
onContentSizeChange={() => messageListRef.current?.scrollToEnd({ animated: true })}
/>
)}
{(replyTarget || editingMessage) && !dialogMode ? (
<View style={styles.composerContext}>
<View style={styles.composerContextMain}>
<Text style={styles.composerContextLabel}>{editingMessage ? 'Bearbeiten' : 'Antwort auf'}</Text>
<Text style={styles.composerContextText} numberOfLines={1}>
{messagePreview(editingMessage || replyTarget!)}
</Text>
</View>
<Pressable
onPress={() => {
setReplyTarget(null);
setEditingMessage(null);
setDraft('');
}}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
</View>
) : null}
<View style={styles.composer}>
<Pressable style={styles.attachButton} onPress={pickAttachment} disabled={sending || !activeRoom}>
<Text style={styles.attachButtonText}>+</Text>
</Pressable>
<TextInput
style={styles.composerInput}
placeholder="Nachricht schreiben"
value={draft}
onChangeText={setDraft}
multiline
/>
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={sendMessage}>
<Text style={styles.sendButtonText}>{sending ? '...' : 'Senden'}</Text>
</Pressable>
</View>
<Modal transparent visible={Boolean(dialogMode)} animationType="fade" onRequestClose={() => setDialogMode(null)}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
{dialogMode === 'create-room' ? (
<>
<Text style={styles.modalTitle}>Raum 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="Thema, optional" value={roomTopic} onChangeText={setRoomTopic} />
<View style={styles.modalActions}>
<Pressable onPress={() => setDialogMode(null)}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={createRoomFromDialog}>
<Text style={styles.primaryButtonText}>Erstellen</Text>
</Pressable>
</View>
</>
) : null}
{dialogMode === 'edit-message' ? (
<>
<Text style={styles.modalTitle}>Nachricht bearbeiten</Text>
<TextInput style={[styles.modalInput, styles.modalTextarea]} value={dialogText} onChangeText={setDialogText} multiline />
<View style={styles.modalActions}>
<Pressable onPress={() => setDialogMode(null)}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
<Pressable
style={styles.primaryButton}
onPress={() => {
setDraft(dialogText);
setDialogMode(null);
}}>
<Text style={styles.primaryButtonText}>Übernehmen</Text>
</Pressable>
</View>
</>
) : null}
{dialogMode === 'invite-member' ? (
<>
<Text style={styles.modalTitle}>Mitglied einladen</Text>
<ScrollView style={styles.inviteList}>
{inviteCandidates.map((user) => (
<Pressable
key={user.userId}
style={[styles.inviteRow, selectedUserId === user.userId && styles.inviteRowActive]}
onPress={() => setSelectedUserId(user.userId)}>
<Text style={styles.inviteName}>{user.displayName || user.email || user.userId}</Text>
<Text style={styles.inviteMeta}>{user.matrixUserId}</Text>
</Pressable>
))}
{!inviteCandidates.length ? <Text style={styles.emptyText}>Keine weiteren Benutzer verfügbar.</Text> : null}
</ScrollView>
<View style={styles.modalActions}>
<Pressable onPress={() => setDialogMode(null)}>
<Text style={styles.linkText}>Abbrechen</Text>
</Pressable>
<Pressable style={[styles.primaryButton, !selectedUserId && styles.sendButtonDisabled]} onPress={inviteSelectedUser}>
<Text style={styles.primaryButtonText}>Einladen</Text>
</Pressable>
</View>
</>
) : null}
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#f9fafb' },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#ffffff',
borderBottomColor: '#e5e7eb',
borderBottomWidth: 1,
},
headerMain: { flex: 1, minWidth: 0 },
title: { color: '#111827', fontSize: 20, fontWeight: '800' },
subtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
headerButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
},
headerButtonText: { color: '#111827', fontSize: 20, fontWeight: '700' },
error: { margin: 12, color: '#991b1b', backgroundColor: '#fee2e2', borderRadius: 8, padding: 10 },
notice: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fed7aa', gap: 8 },
noticeTitle: { color: '#111827', fontWeight: '700', fontSize: 15 },
noticeText: { color: '#6b7280', fontSize: 13 },
roomPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 10 },
searchInput: {
marginHorizontal: 16,
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 9,
fontSize: 14,
backgroundColor: '#ffffff',
},
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 },
roomChip: { width: 150, padding: 10, borderRadius: 10, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: '#ffffff', gap: 3 },
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY },
roomGroup: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' },
roomName: { color: '#111827', fontSize: 14, fontWeight: '700' },
roomTextActive: { color: '#2f6f25' },
roomMetaRow: { flexDirection: 'row', gap: 5, minHeight: 18 },
roomBadge: { color: '#ffffff', backgroundColor: PRIMARY, borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 },
roomBadgeMuted: { color: '#6b7280', backgroundColor: '#f3f4f6', borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 },
membersPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', padding: 12, gap: 8 },
membersHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
membersTitle: { color: '#111827', fontWeight: '700' },
membersActions: { flexDirection: 'row', gap: 16 },
memberList: { gap: 8 },
memberPill: { backgroundColor: '#f3f4f6', paddingHorizontal: 10, paddingVertical: 7, borderRadius: 16 },
memberName: { color: '#374151', fontSize: 12 },
provisionPanel: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#e5e7eb', gap: 8 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
loadingText: { color: '#6b7280' },
messageList: { padding: 12, gap: 10, flexGrow: 1 },
emptyText: { color: '#6b7280', textAlign: 'center', padding: 18 },
messageRow: { alignItems: 'flex-start' },
messageRowOwn: { alignItems: 'flex-end' },
messageBubble: {
maxWidth: '88%',
minWidth: 120,
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 10,
gap: 6,
},
messageBubbleOwn: { backgroundColor: '#3f8f32', borderColor: '#3f8f32' },
messageHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
messageSender: { flex: 1, color: '#111827', fontSize: 12, fontWeight: '700' },
messageSenderOwn: { color: '#ffffff' },
messageTime: { color: '#6b7280', fontSize: 11 },
messageTimeOwn: { color: '#dff4d9' },
messageBody: { color: '#111827', fontSize: 15, lineHeight: 21 },
messageBodyOwn: { color: '#ffffff' },
replyBox: { backgroundColor: '#f3f4f6', borderRadius: 8, borderLeftWidth: 3, borderLeftColor: PRIMARY, padding: 7 },
replyBoxOwn: { backgroundColor: '#327628', borderLeftColor: '#dff4d9' },
replyText: { color: '#4b5563', fontSize: 12 },
replyTextOwn: { color: '#dff4d9' },
attachmentBox: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 8, gap: 2 },
attachmentBoxOwn: { borderColor: '#dff4d9' },
attachmentTitle: { color: '#111827', fontWeight: '700' },
attachmentMeta: { color: '#6b7280', fontSize: 12 },
editedText: { color: '#6b7280', fontSize: 11 },
reactionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 5 },
reactionPill: { backgroundColor: '#f3f4f6', borderRadius: 12, overflow: 'hidden', paddingHorizontal: 7, paddingVertical: 2, fontSize: 12 },
reactionPillOwn: { backgroundColor: '#dff4d9' },
messageActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingTop: 2 },
actionText: { color: '#3f8f32', fontSize: 12, fontWeight: '700' },
actionTextOwn: { color: '#ffffff' },
actionTextDestructive: { color: '#fee2e2' },
composerContext: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
composerContextMain: { flex: 1, minWidth: 0 },
composerContextLabel: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' },
composerContextText: { color: '#111827', fontSize: 13, fontWeight: '600' },
composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 10, backgroundColor: '#ffffff', borderTopWidth: 1, borderTopColor: '#e5e7eb' },
attachButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#f3f4f6' },
attachButtonText: { color: '#111827', fontSize: 22, fontWeight: '700' },
composerInput: { flex: 1, maxHeight: 110, borderRadius: 18, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
sendButton: { minWidth: 70, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: PRIMARY, paddingHorizontal: 12 },
sendButtonDisabled: { opacity: 0.45 },
sendButtonText: { color: '#ffffff', fontWeight: '800' },
primaryButton: { alignSelf: 'flex-start', backgroundColor: PRIMARY, borderRadius: 9, paddingHorizontal: 12, paddingVertical: 9 },
primaryButtonText: { color: '#ffffff', fontWeight: '800' },
linkText: { color: '#3f8f32', fontWeight: '800' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', alignItems: 'center', justifyContent: 'center', padding: 18 },
modalCard: { width: '100%', maxWidth: 420, borderRadius: 12, backgroundColor: '#ffffff', padding: 16, gap: 12 },
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '800' },
modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
modalTextarea: { minHeight: 110, textAlignVertical: 'top' },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 14 },
inviteList: { maxHeight: 280 },
inviteRow: { padding: 10, borderRadius: 9, borderWidth: 1, borderColor: '#e5e7eb', marginBottom: 8 },
inviteRowActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
inviteName: { color: '#111827', fontWeight: '700' },
inviteMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
});