KI-AGENT: Mobile Matrix-Kommunikation vollständig integrieren
This commit is contained in:
@@ -50,6 +50,13 @@ export default function TabLayout() {
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="communication"
|
||||
options={{
|
||||
title: 'Kommunikation',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={24} name="message.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="time"
|
||||
options={{
|
||||
|
||||
918
mobile/app/(tabs)/communication.tsx
Normal file
918
mobile/app/(tabs)/communication.tsx
Normal file
@@ -0,0 +1,918 @@
|
||||
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 },
|
||||
});
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user