919 lines
37 KiB
TypeScript
919 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 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);
|
|
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.');
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
},
|
|
[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);
|
|
setSyncSince(sync.nextBatch || syncSince);
|
|
setMembers((current) => sync.members || current);
|
|
setMessages((current) => {
|
|
let next = mergeMessages(current, sync.messages || []);
|
|
next = applyReplacements(next, sync.replacements);
|
|
next = applyReactions(next, sync.reactions);
|
|
next = applyRedactions(next, sync.redactions);
|
|
return next;
|
|
});
|
|
} catch {
|
|
// Polling errors are surfaced by manual refresh to avoid noisy chat usage.
|
|
}
|
|
}, [activeRoom, 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 },
|
|
});
|