diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
index 6c067d0..ffeefd5 100644
--- a/mobile/app/(tabs)/_layout.tsx
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -50,6 +50,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => ,
}}
/>
+ ,
+ }}
+ />
{
+ 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 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 {
+ 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 },
+});
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 55eadf1..7e88a18 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -18,7 +18,6 @@ export default function RootLayout() {
title: 'Projekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -28,7 +27,6 @@ export default function RootLayout() {
title: 'Konto',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -38,7 +36,6 @@ export default function RootLayout() {
title: 'Einstellungen',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -48,7 +45,6 @@ export default function RootLayout() {
title: 'Wiki',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -58,7 +54,6 @@ export default function RootLayout() {
title: 'Kunden',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -68,7 +63,6 @@ export default function RootLayout() {
title: 'Kunde',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -78,7 +72,6 @@ export default function RootLayout() {
title: 'Objekte',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -88,7 +81,6 @@ export default function RootLayout() {
title: 'Objekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -98,7 +90,6 @@ export default function RootLayout() {
title: 'Kundeninventar',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
@@ -108,7 +99,6 @@ export default function RootLayout() {
title: 'Nimbot M2',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
- headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
diff --git a/mobile/components/ui/icon-symbol.tsx b/mobile/components/ui/icon-symbol.tsx
index b7ece6b..8acd510 100644
--- a/mobile/components/ui/icon-symbol.tsx
+++ b/mobile/components/ui/icon-symbol.tsx
@@ -16,6 +16,11 @@ type IconSymbolName = keyof typeof MAPPING;
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
+ 'message.fill': 'chat',
+ 'folder.fill': 'folder',
+ checklist: 'checklist',
+ 'clock.fill': 'schedule',
+ 'ellipsis.circle.fill': 'more-horiz',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts
index 8eb7747..7efd3bb 100644
--- a/mobile/src/lib/api.ts
+++ b/mobile/src/lib/api.ts
@@ -183,6 +183,100 @@ type RequestOptions = {
body?: unknown;
};
+export type MatrixStatus = {
+ enabled?: boolean;
+ ready?: boolean;
+ configured?: boolean;
+ homeserverUrl?: string | null;
+ [key: string]: unknown;
+};
+
+export type MatrixIdentity = {
+ matrixUserId: string;
+ displayName?: string | null;
+};
+
+export type MatrixRoom = {
+ key: string;
+ name: string;
+ topic?: string | null;
+ type?: 'room' | 'project' | 'direct' | string;
+ group?: string;
+ roomId?: string | null;
+ alias?: string | null;
+ exists?: boolean;
+ projectId?: number;
+ projectNumber?: string | null;
+ userId?: string;
+ email?: string | null;
+ entityType?: string | null;
+ entityId?: number | null;
+ entityUuid?: string | null;
+ unread?: number;
+ mentions?: number;
+ provisionEndpoint?: string;
+ [key: string]: unknown;
+};
+
+export type MatrixAttachment = {
+ fileName?: string | null;
+ mimeType?: string | null;
+ size?: number | null;
+ mxcUri?: string | null;
+ previewUrl?: string | null;
+ downloadUrl?: string | null;
+};
+
+export type MatrixReaction = {
+ key: string;
+ count?: number;
+ own?: boolean;
+ senders?: string[];
+ [key: string]: unknown;
+};
+
+export type MatrixMessage = {
+ id: string;
+ sender: string;
+ senderDisplayName?: string | null;
+ body?: string | null;
+ timestamp?: string | number | null;
+ own?: boolean;
+ edited?: boolean;
+ redacted?: boolean;
+ msgtype?: string;
+ attachment?: MatrixAttachment | null;
+ replyToEventId?: string | null;
+ reactions?: MatrixReaction[];
+ [key: string]: unknown;
+};
+
+export type MatrixMember = {
+ matrixUserId: string;
+ displayName?: string | null;
+ avatarUrl?: string | null;
+ membership?: string;
+ [key: string]: unknown;
+};
+
+export type MatrixUser = {
+ userId: string;
+ matrixUserId: string;
+ displayName?: string | null;
+ email?: string | null;
+ [key: string]: unknown;
+};
+
+export type MatrixSyncResponse = {
+ nextBatch?: string;
+ messages?: MatrixMessage[];
+ replacements?: MatrixMessage[];
+ reactions?: (MatrixReaction & { targetEventId?: string })[];
+ redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[];
+ members?: MatrixMember[];
+ [key: string]: unknown;
+};
+
function buildUrl(path: string): string {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
@@ -227,10 +321,231 @@ export async function apiRequest(path: string, options: RequestOptions = {}):
return payload as T;
}
+async function apiFormRequest(path: string, token: string, formData: FormData): Promise {
+ const response = await fetch(buildUrl(path), {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: formData,
+ });
+
+ const payload = await parseJson(response);
+
+ if (!response.ok) {
+ const message =
+ (payload as { message?: string; error?: string } | null)?.message ||
+ (payload as { message?: string; error?: string } | null)?.error ||
+ `Request failed (${response.status}) for ${path}`;
+ throw new Error(message);
+ }
+
+ return payload as T;
+}
+
+function matrixRoomPath(roomKey: string, suffix = ''): string {
+ return `/api/communication/matrix/rooms/${encodeURIComponent(roomKey)}${suffix}`;
+}
+
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
}
+export async function fetchMatrixStatus(token: string): Promise {
+ return apiRequest('/api/communication/matrix/status', { token });
+}
+
+export async function fetchMatrixIdentity(token: string): Promise {
+ return apiRequest('/api/communication/matrix/me', { token });
+}
+
+export async function provisionMatrixUser(token: string): Promise {
+ return apiRequest('/api/communication/matrix/me/provision', {
+ method: 'POST',
+ token,
+ });
+}
+
+export async function fetchMatrixRooms(token: string): Promise {
+ const [rooms, projectRooms, directRooms, unread] = await Promise.all([
+ apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }),
+ apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }),
+ apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }),
+ apiRequest<{ rooms?: Record }>('/api/communication/matrix/unread', {
+ token,
+ }),
+ ]);
+
+ const unreadByRoom = unread.rooms || {};
+ const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({
+ ...room,
+ group,
+ unread: unreadByRoom[room.key]?.count || 0,
+ mentions: unreadByRoom[room.key]?.mentions || 0,
+ });
+
+ return [
+ ...(rooms.rooms || []).map((room) => decorate(room, 'Räume')),
+ ...(projectRooms.rooms || []).map((room) => decorate(room, 'Projekte')),
+ ...(directRooms.rooms || []).map((room) => decorate(room, 'Direkt')),
+ ];
+}
+
+export async function fetchMatrixUsers(token: string): Promise {
+ const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token });
+ return response.users || [];
+}
+
+export async function createMatrixRoom(
+ token: string,
+ payload: { key: string; name: string; topic?: string | null; type?: string }
+): Promise {
+ return apiRequest('/api/communication/matrix/rooms', {
+ method: 'POST',
+ token,
+ body: payload,
+ });
+}
+
+export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise {
+ if (room.provisionEndpoint) {
+ return apiRequest(room.provisionEndpoint, { method: 'POST', token });
+ }
+
+ if (room.type === 'project' && room.projectId) {
+ return apiRequest(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, {
+ method: 'POST',
+ token,
+ });
+ }
+
+ if (room.type === 'direct' && room.userId) {
+ return apiRequest(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, {
+ method: 'POST',
+ token,
+ });
+ }
+
+ return apiRequest(matrixRoomPath(room.key, '/provision'), {
+ method: 'POST',
+ token,
+ body: {
+ key: room.key,
+ name: room.name,
+ topic: room.topic,
+ type: room.type || 'room',
+ entityType: room.entityType,
+ entityId: room.entityId,
+ entityUuid: room.entityUuid,
+ },
+ });
+}
+
+export async function fetchMatrixMessages(token: string, roomKey: string): Promise {
+ const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token });
+ return response.messages || [];
+}
+
+export async function syncMatrixRoom(
+ token: string,
+ roomKey: string,
+ since?: string,
+ initial = false
+): Promise {
+ const query = new URLSearchParams();
+ if (since) query.set('since', since);
+ if (initial) query.set('initial', '1');
+ const suffix = query.toString() ? `/sync?${query.toString()}` : '/sync';
+ return apiRequest(matrixRoomPath(roomKey, suffix), { token });
+}
+
+export async function fetchMatrixMembers(token: string, roomKey: string): Promise {
+ const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token });
+ return response.members || [];
+}
+
+export async function sendMatrixMessage(
+ token: string,
+ roomKey: string,
+ text: string,
+ replyToEventId?: string | null
+): Promise {
+ return apiRequest(matrixRoomPath(roomKey, '/messages'), {
+ method: 'POST',
+ token,
+ body: { text, replyToEventId },
+ });
+}
+
+export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise {
+ return apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
+ method: 'PUT',
+ token,
+ body: { text },
+ });
+}
+
+export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise {
+ await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
+ method: 'DELETE',
+ token,
+ });
+}
+
+export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise {
+ await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), {
+ method: 'POST',
+ token,
+ body: { key },
+ });
+}
+
+export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise {
+ await apiRequest(matrixRoomPath(roomKey, '/read'), {
+ method: 'POST',
+ token,
+ body: { eventId },
+ });
+}
+
+export async function syncMatrixMembers(token: string, roomKey: string): Promise {
+ await apiRequest(matrixRoomPath(roomKey, '/members/sync'), {
+ method: 'POST',
+ token,
+ });
+}
+
+export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise {
+ await apiRequest(matrixRoomPath(roomKey, '/members/invite'), {
+ method: 'POST',
+ token,
+ body: { userId },
+ });
+}
+
+export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise {
+ await apiRequest(matrixRoomPath(roomKey, `/members/${encodeURIComponent(matrixUserId)}`), {
+ method: 'DELETE',
+ token,
+ });
+}
+
+export async function uploadMatrixAttachment(
+ token: string,
+ roomKey: string,
+ file: { uri: string; name: string; mimeType?: string | null }
+): Promise {
+ const formData = new FormData();
+ formData.append('file', {
+ uri: file.uri,
+ name: file.name,
+ type: file.mimeType || 'application/octet-stream',
+ } as unknown as Blob);
+
+ return apiFormRequest(matrixRoomPath(roomKey, '/attachments'), token, formData);
+}
+
export async function renderPrintLabel(
token: string,
context: Record,