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(); 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(); 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>(null); const [status, setStatus] = useState(null); const [matrixUserId, setMatrixUserId] = useState(''); const [rooms, setRooms] = useState([]); const [members, setMembers] = useState([]); const [users, setUsers] = useState([]); const [messages, setMessages] = useState([]); const [activeRoomKey, setActiveRoomKey] = useState('allgemein'); const [syncSince, setSyncSince] = useState(); const [draft, setDraft] = useState(''); const [search, setSearch] = useState(''); const [error, setError] = useState(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(null); const [editingMessage, setEditingMessage] = useState(null); const [dialogMode, setDialogMode] = useState(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 { 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 ( setActiveRoomKey(item.key)}> {item.group || 'Raum'} {item.name || item.key} {!item.exists ? bereitstellen : null} {item.unread ? {item.mentions ? `@${item.mentions}` : item.unread} : null} ); } 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 ( {own ? 'Du' : item.senderDisplayName || item.sender} {messageTime(item.timestamp)} {reply ? ( {reply.senderDisplayName || reply.sender}: {messagePreview(reply)} ) : null} {item.body ? {item.body} : null} {item.attachment ? ( {item.attachment.fileName || 'Anhang'} {item.attachment.mimeType || 'Datei'} {item.attachment.size ? `· ${Math.round(item.attachment.size / 1024)} KB` : ''} ) : null} {item.edited ? bearbeitet : null} {item.reactions?.length ? ( {item.reactions.map((reaction) => ( {reaction.key} {reaction.count || ''} ))} ) : null} setReplyTarget(item)}> Antworten {REACTION_PRESETS.map((reaction) => ( addReaction(item, reaction)}> {reaction} ))} {own && !item.attachment ? ( openEdit(item)}> Bearbeiten ) : null} {own ? ( confirmDelete(item)}> Löschen ) : null} ); } return ( Kommunikation {activeRoom?.name || 'Matrix Chat'} setDialogMode('create-room')}> + setMembersOpen((value) => !value)}> {error ? {error} : null} {status && status.enabled === false ? ( Matrix ist nicht aktiv Die Kommunikation ist serverseitig noch nicht aktiviert. ) : null} {!matrixUserId ? ( Matrix-Benutzer fehlt Lege deinen Matrix-Zugang an, um Räume nutzen zu können. {provisioning ? 'Wird erstellt...' : 'Benutzer erstellen'} ) : null} item.key} renderItem={renderRoom} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} /> {membersOpen ? ( Mitglieder · {members.length} Sync setDialogMode('invite-member')}> Einladen {members.map((member) => ( confirmRemoveMember(member)}> {member.displayName || member.matrixUserId} ))} ) : null} {!activeRoom?.exists ? ( Raum noch nicht bereitgestellt Beim Öffnen wird der Matrix-Raum inklusive Einladungen angelegt. {provisioning ? 'Wird bereitgestellt...' : 'Raum bereitstellen'} ) : null} {loading ? ( Nachrichten werden geladen... ) : ( item.id} renderItem={renderMessage} contentContainerStyle={styles.messageList} refreshControl={ { setRefreshing(true); void refreshAll(false); }} /> } ListEmptyComponent={Noch keine Nachrichten in diesem Raum.} onContentSizeChange={() => messageListRef.current?.scrollToEnd({ animated: true })} /> )} {(replyTarget || editingMessage) && !dialogMode ? ( {editingMessage ? 'Bearbeiten' : 'Antwort auf'} {messagePreview(editingMessage || replyTarget!)} { setReplyTarget(null); setEditingMessage(null); setDraft(''); }}> Abbrechen ) : null} + {sending ? '...' : 'Senden'} setDialogMode(null)}> {dialogMode === 'create-room' ? ( <> Raum erstellen setDialogMode(null)}> Abbrechen Erstellen ) : null} {dialogMode === 'edit-message' ? ( <> Nachricht bearbeiten setDialogMode(null)}> Abbrechen { setDraft(dialogText); setDialogMode(null); }}> Übernehmen ) : null} {dialogMode === 'invite-member' ? ( <> Mitglied einladen {inviteCandidates.map((user) => ( setSelectedUserId(user.userId)}> {user.displayName || user.email || user.userId} {user.matrixUserId} ))} {!inviteCandidates.length ? Keine weiteren Benutzer verfügbar. : null} setDialogMode(null)}> Abbrechen Einladen ) : null} ); } 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 }, });