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,