feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen
This commit is contained in:
@@ -23,6 +23,8 @@ xcrun simctl launch booted software.federspiel.fedeo
|
||||
|
||||
## TestFlight vorbereiten
|
||||
|
||||
Der Preflight prüft Lint, die mobilen Chat-Regressionstests und Expo Doctor. Für eine separate Typprüfung: `npx tsc --noEmit`.
|
||||
|
||||
```bash
|
||||
npm run preflight:testflight
|
||||
npm run build:ios:testflight
|
||||
@@ -35,3 +37,9 @@ npm run submit:ios:testflight
|
||||
```
|
||||
|
||||
Der TestFlight-Build nutzt das EAS-Profil `testflight` aus `eas.json` mit Store-Distribution und automatischer Buildnummer-Erhöhung.
|
||||
|
||||
## Mobile Chats
|
||||
|
||||
Die Kommunikation zeigt interne Gruppen-, Projekt- und Direktchats sowie lokale und föderierte Unterräume mit bestehender Mitgliedschaft. Einladungen können angenommen oder abgelehnt werden. Leserechte, freigegebene Projektinformationen und wartende beziehungsweise fehlgeschlagene Zustellungen werden berücksichtigt. Bilder werden vor dem Senden verkleinert; Reaktionen funktionieren in beiden Chatarten.
|
||||
|
||||
Ausgeblendete Chats verwenden dieselben benutzer- und mandantenbezogenen Einstellungen wie das Web. Die App lädt sie beim Öffnen, beim Zurückkehren in den Vordergrund und während der Nutzung regelmäßig neu. Ausgeblendete Räume zählen nicht zum Chat-Badge und können über das Augensymbol wieder eingeblendet werden. Nachrichtenentwürfe bleiben beim Raumwechsel innerhalb derselben Sitzung erhalten und werden bei einem Sitzungswechsel verworfen.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "software.federspiel.fedeo",
|
||||
"buildNumber": "15",
|
||||
"buildNumber": "16",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
||||
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Redirect, Tabs } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AppState, DeviceEventEmitter } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { fetchChatUnreadCounts } from '@/src/lib/api';
|
||||
import { chatApi } from '@/src/lib/chat-api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
export default function TabLayout() {
|
||||
@@ -14,31 +15,27 @@ export default function TabLayout() {
|
||||
const { activeTenantId, isBootstrapping, token, requiresTenantSelection } = useAuth();
|
||||
const [communicationUnread, setCommunicationUnread] = useState(0);
|
||||
|
||||
const refreshCommunicationUnread = useCallback(async () => {
|
||||
if (!token || requiresTenantSelection) {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let revision = 0;
|
||||
setCommunicationUnread(0);
|
||||
await Notifications.setBadgeCountAsync(0);
|
||||
return;
|
||||
}
|
||||
|
||||
void Notifications.setBadgeCountAsync(0).catch(() => undefined);
|
||||
async function refresh() {
|
||||
if (!token || !activeTenantId || requiresTenantSelection || AppState.currentState !== 'active') return;
|
||||
const request = ++revision;
|
||||
try {
|
||||
const unread = await fetchChatUnreadCounts(token);
|
||||
const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0);
|
||||
const total = await chatApi.unread(token);
|
||||
if (!active || request !== revision) return;
|
||||
setCommunicationUnread(total);
|
||||
await Notifications.setBadgeCountAsync(total);
|
||||
} catch {
|
||||
// Badge loading must not block tab navigation.
|
||||
} catch { /* Badge loading must not block tab navigation. */ }
|
||||
}
|
||||
}, [requiresTenantSelection, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCommunicationUnread();
|
||||
|
||||
if (!token || requiresTenantSelection) return undefined;
|
||||
|
||||
const id = setInterval(() => void refreshCommunicationUnread(), 30000);
|
||||
return () => clearInterval(id);
|
||||
}, [activeTenantId, refreshCommunicationUnread, requiresTenantSelection, token]);
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), 30000);
|
||||
const foreground = AppState.addEventListener('change', state => { if (state === 'active') void refresh(); });
|
||||
const changed = DeviceEventEmitter.addListener('fedeo:chat-changed', () => void refresh());
|
||||
return () => { active = false; ++revision; clearInterval(timer); foreground.remove(); changed.remove(); };
|
||||
}, [activeTenantId, requiresTenantSelection, token]);
|
||||
|
||||
const communicationBadge = useMemo(() => {
|
||||
if (!communicationUnread) return undefined;
|
||||
|
||||
@@ -1,294 +1,224 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
ActivityIndicator, AppState, DeviceEventEmitter, FlatList, KeyboardAvoidingView, Modal, Platform,
|
||||
Pressable, ScrollView, StyleSheet, Text, TextInput, View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
ChatMember,
|
||||
ChatMessage,
|
||||
ChatRoom,
|
||||
createChatRoom,
|
||||
fetchChatMembers,
|
||||
fetchChatMessages,
|
||||
fetchChatRooms,
|
||||
markChatRoomRead,
|
||||
provisionChatRoom,
|
||||
sendChatMessage,
|
||||
syncChatRoom,
|
||||
} from '@/src/lib/api';
|
||||
import { ChatConversation, type ChatDraft } from '@/components/chat-conversation';
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { chatApi } from '@/src/lib/chat-api';
|
||||
import { collaborationKey, isChatAccessError, matchesChatSearch, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat';
|
||||
import { getApiBaseUrlSync } from '@/src/lib/server-config';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
function normalizeRoomKey(value: string): string {
|
||||
return value.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
|
||||
}
|
||||
|
||||
function messageTime(value: ChatMessage['timestamp']): string {
|
||||
if (!value) return '';
|
||||
return new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
|
||||
const byId = new Map<number, ChatMessage>();
|
||||
for (const message of current) byId.set(message.id, message);
|
||||
for (const message of incoming) byId.set(message.id, message);
|
||||
return Array.from(byId.values()).sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
export default function CommunicationScreen() {
|
||||
const { token } = useAuth();
|
||||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
||||
const [members, setMembers] = useState<ChatMember[]>([]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
|
||||
const [draft, setDraft] = useState('');
|
||||
const { token, activeTenantId } = useAuth();
|
||||
if (!token || !activeTenantId) return null;
|
||||
return <ChatScreen key={`${getApiBaseUrlSync()}:${activeTenantId}:${token}`} token={token} />;
|
||||
}
|
||||
|
||||
function ChatScreen({ token }: { token: string }) {
|
||||
const focused = useIsFocused();
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
const enabled = focused && foreground;
|
||||
const [directory, setDirectory] = useState<ChatDirectory | null>(null);
|
||||
const [activeKey, setActiveKey] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [roomTopic, setRoomTopic] = useState('');
|
||||
const [roomKey, setRoomKey] = useState('');
|
||||
const afterIdRef = useRef(0);
|
||||
|
||||
const activeRoom = useMemo(
|
||||
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
|
||||
[activeRoomKey, rooms]
|
||||
);
|
||||
|
||||
const visibleRooms = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber]
|
||||
.filter(Boolean).some((value) => String(value).toLowerCase().includes(query)));
|
||||
}, [rooms, search]);
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
if (!token) return [];
|
||||
const result = await fetchChatRooms(token);
|
||||
setRooms(result);
|
||||
return result;
|
||||
}, [token]);
|
||||
|
||||
const loadRoom = useCallback(async (key: string) => {
|
||||
if (!token) return;
|
||||
const [nextMessages, nextMembers] = await Promise.all([
|
||||
fetchChatMessages(token, key),
|
||||
fetchChatMembers(token, key),
|
||||
]);
|
||||
setMessages(nextMessages);
|
||||
setMembers(nextMembers);
|
||||
afterIdRef.current = nextMessages[nextMessages.length - 1]?.id || 0;
|
||||
await markChatRoomRead(token, key, afterIdRef.current);
|
||||
}, [token]);
|
||||
const [roomType, setRoomType] = useState<'internal' | 'collaboration'>('internal');
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, ChatDraft>>({});
|
||||
const alive = useRef(true);
|
||||
const directoryRevision = useRef(0);
|
||||
const selectionRevision = useRef(0);
|
||||
const operation = useRef(false);
|
||||
const loadingDirectory = useRef(false);
|
||||
const directoryRef = useRef(directory);
|
||||
directoryRef.current = directory;
|
||||
const [emptyDraft] = useState<ChatDraft>(() => ({ text: '', image: null, clientId: randomUUID() }));
|
||||
const rooms = useMemo(() => directory ? visibleChatRooms(directory) : [], [directory]);
|
||||
const filteredRooms = useMemo(() => rooms.filter(room => matchesChatSearch(room, search)), [rooms, search]);
|
||||
const hiddenRooms = useMemo(() => directory?.rooms.filter(room => directory.hiddenRoomKeys.includes(room.key)) || [], [directory]);
|
||||
const activeRoom = rooms.find(room => room.key === activeKey && room.exists);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!token) return;
|
||||
if (loadingDirectory.current || operation.current) return;
|
||||
const version = ++directoryRevision.current;
|
||||
loadingDirectory.current = true;
|
||||
try {
|
||||
setError(null);
|
||||
const nextRooms = await loadRooms();
|
||||
const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0];
|
||||
if (selected?.exists) {
|
||||
setActiveRoomKey(selected.key);
|
||||
await loadRoom(selected.key);
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.');
|
||||
const result = await chatApi.directory(token);
|
||||
if (!alive.current || version !== directoryRevision.current) return;
|
||||
directoryRef.current = result; setDirectory(result); setError('');
|
||||
const available = visibleChatRooms(result);
|
||||
setActiveKey(previous => available.some(room => room.key === previous && room.exists)
|
||||
? previous : available.find(room => room.exists)?.key || '');
|
||||
} catch (err) {
|
||||
if (!alive.current || version !== directoryRevision.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Chats konnten nicht geladen werden.');
|
||||
if (isChatAccessError(err)) { directoryRef.current = null; setDirectory(null); setActiveKey(''); setDrafts({}); }
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
loadingDirectory.current = false;
|
||||
if (alive.current && version === directoryRevision.current) setLoading(false);
|
||||
}
|
||||
}, [activeRoomKey, loadRoom, loadRooms, token]);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !activeRoom?.exists) return undefined;
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current);
|
||||
if (!result.messages?.length) return;
|
||||
setMessages((current) => mergeMessages(current, result.messages || []));
|
||||
afterIdRef.current = result.nextId || afterIdRef.current;
|
||||
await markChatRoomRead(token, activeRoom.key, afterIdRef.current);
|
||||
} catch {
|
||||
// Beim nächsten Intervall erneut versuchen.
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [activeRoom, token]);
|
||||
alive.current = true;
|
||||
const listener = AppState.addEventListener('change', state => setForeground(state === 'active'));
|
||||
return () => { alive.current = false; listener.remove(); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled, refresh]);
|
||||
|
||||
async function selectRoom(room: ChatRoom) {
|
||||
if (!token) return;
|
||||
async function select(room: MobileChatRoom) {
|
||||
if (directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
|
||||
const version = ++selectionRevision.current;
|
||||
setError('');
|
||||
if (room.exists) { setActiveKey(room.key); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
const readyRoom = room.exists ? room : await provisionChatRoom(token, room);
|
||||
setActiveRoomKey(readyRoom.key);
|
||||
await loadRooms();
|
||||
await loadRoom(readyRoom.key);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setBusy(room.key);
|
||||
const ready = await chatApi.provision(token, room);
|
||||
if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
|
||||
setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) }));
|
||||
setActiveKey(ready.key);
|
||||
} catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); }
|
||||
finally { if (alive.current && version === selectionRevision.current) setBusy(''); }
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
if (!token || !activeRoom?.exists || !draft.trim() || sending) return;
|
||||
const text = draft.trim();
|
||||
setDraft('');
|
||||
setSending(true);
|
||||
async function mutate(label: string, action: () => Promise<string | void>) {
|
||||
if (operation.current) return;
|
||||
operation.current = true; ++directoryRevision.current;
|
||||
setBusy(label); setError('');
|
||||
try {
|
||||
const message = await sendChatMessage(token, activeRoom.key, text);
|
||||
setMessages((current) => mergeMessages(current, [message]));
|
||||
afterIdRef.current = Math.max(afterIdRef.current, message.id);
|
||||
await markChatRoomRead(token, activeRoom.key, message.id);
|
||||
} catch (nextError) {
|
||||
setDraft(text);
|
||||
setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
const selectKey = await action();
|
||||
if (!alive.current) return;
|
||||
DeviceEventEmitter.emit('fedeo:chat-changed');
|
||||
const result = await chatApi.directory(token);
|
||||
if (!alive.current) return;
|
||||
directoryRef.current = result; setDirectory(result);
|
||||
const available = visibleChatRooms(result);
|
||||
setActiveKey(previous => available.find(room => room.key === (selectKey || previous) && room.exists)?.key
|
||||
|| available.find(room => room.exists)?.key || '');
|
||||
} catch (err) { if (alive.current) setError(err instanceof Error ? err.message : 'Änderung konnte nicht gespeichert werden.'); }
|
||||
finally { operation.current = false; if (alive.current) { setBusy(''); setLoading(false); } }
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoom() {
|
||||
if (!token || !roomName.trim()) return;
|
||||
try {
|
||||
const created = await createChatRoom(token, {
|
||||
key: normalizeRoomKey(roomKey || roomName),
|
||||
name: roomName.trim(),
|
||||
topic: roomTopic.trim() || null,
|
||||
async function hide(room: MobileChatRoom, hidden: boolean) {
|
||||
await mutate(room.key, async () => {
|
||||
await chatApi.hide(token, room.key, hidden);
|
||||
if (!alive.current) return;
|
||||
// Update immediately after success, even if refreshing the directory subsequently fails.
|
||||
setDirectory(previous => {
|
||||
if (!previous) return previous;
|
||||
const next = { ...previous, hiddenRoomKeys: hidden ? [...new Set([...previous.hiddenRoomKeys, room.key])]
|
||||
: previous.hiddenRoomKeys.filter(key => key !== room.key) };
|
||||
directoryRef.current = next;
|
||||
return next;
|
||||
});
|
||||
if (hidden) setActiveKey(previous => previous === room.key ? '' : previous);
|
||||
else { setShowHidden(false); return room.key; }
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setRoomName('');
|
||||
setRoomKey('');
|
||||
setRoomTopic('');
|
||||
await loadRooms();
|
||||
await selectRoom(created);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.');
|
||||
}
|
||||
async function respond(id: string, accept: boolean) {
|
||||
await mutate(id, async () => {
|
||||
const result = await chatApi.respond(token, id, accept);
|
||||
return accept && result.roomId ? collaborationKey(result.roomId) : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoom({ item }: { item: ChatRoom }) {
|
||||
const active = item.key === activeRoom?.key;
|
||||
return (
|
||||
<Pressable style={[styles.roomChip, active && styles.roomChipActive]} onPress={() => void selectRoom(item)}>
|
||||
<Text style={styles.roomGroup}>{item.group || 'Räume'}</Text>
|
||||
<Text style={[styles.roomName, active && styles.roomTextActive]} numberOfLines={1}>{item.name}</Text>
|
||||
{item.unread ? <Text style={styles.roomBadge}>{item.unread}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
async function create() {
|
||||
if (!roomName.trim()) return;
|
||||
await mutate('create', async () => {
|
||||
let key: string;
|
||||
if (roomType === 'collaboration') {
|
||||
const created = await apiRequest<{ id: string }>('/api/collaboration/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), projectId } });
|
||||
key = collaborationKey(created.id);
|
||||
} else {
|
||||
const normalized = roomName.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '').slice(0, 48);
|
||||
const created = await apiRequest<{ key: string }>('/api/communication/chat/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), key: normalized } });
|
||||
key = created.key;
|
||||
}
|
||||
|
||||
function renderMessage({ item }: { item: ChatMessage }) {
|
||||
return (
|
||||
<View style={[styles.messageRow, item.own && styles.messageRowOwn]}>
|
||||
<View style={[styles.messageBubble, item.own && styles.messageBubbleOwn]}>
|
||||
{!item.own ? <Text style={styles.messageAuthor}>{item.senderDisplayName || 'Benutzer'}</Text> : null}
|
||||
<Text style={[styles.messageBody, item.own && styles.messageBodyOwn]}>{item.body}</Text>
|
||||
<Text style={[styles.messageTime, item.own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
if (alive.current) { setCreateOpen(false); setRoomName(''); setRoomTopic(''); }
|
||||
return key;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerMain}>
|
||||
<Text style={styles.title}>Chat</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>{activeRoom?.name || 'FEDEO Kommunikation'}</Text>
|
||||
</View>
|
||||
<Pressable style={styles.headerButton} onPress={() => setCreateOpen(true)}><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}
|
||||
|
||||
<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}>
|
||||
<Text style={styles.membersTitle}>Teilnehmer · {members.length}</Text>
|
||||
<Text style={styles.membersText} numberOfLines={2}>{members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loading}><ActivityIndicator /><Text style={styles.loadingText}>Nachrichten werden geladen …</Text></View>
|
||||
) : (
|
||||
<FlatList ref={listRef} data={messages} keyExtractor={(item) => String(item.id)} renderItem={renderMessage}
|
||||
contentContainerStyle={styles.messageList}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); void refresh(); }} />}
|
||||
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} />
|
||||
)}
|
||||
|
||||
<View style={styles.composer}>
|
||||
<TextInput style={styles.composerInput} placeholder="Nachricht schreiben" value={draft} onChangeText={setDraft} multiline />
|
||||
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={() => void submitMessage()}>
|
||||
<Text style={styles.sendButtonText}>{sending ? '…' : 'Senden'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Modal transparent visible={createOpen} animationType="fade" onRequestClose={() => setCreateOpen(false)}>
|
||||
<View style={styles.modalBackdrop}><View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>Chatraum 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="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} />
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={() => setCreateOpen(false)}><Text style={styles.linkText}>Abbrechen</Text></Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void submitRoom()}><Text style={styles.primaryButtonText}>Erstellen</Text></Pressable>
|
||||
return <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 95 : 0} style={styles.screen}>
|
||||
<View style={styles.toolbar}>
|
||||
<TextInput style={styles.search} accessibilityLabel="Chats durchsuchen" placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Chat erstellen" onPress={() => setCreateOpen(true)} disabled={!directory || !!busy} style={styles.icon}><Ionicons name="add" size={25} /></Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={`Ausgeblendete Chats (${hiddenRooms.length})`} onPress={() => setShowHidden(true)} style={styles.icon}><Ionicons name="eye-off-outline" size={23} /></Pressable>
|
||||
</View>
|
||||
{!!error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{error}</Text><Pressable onPress={() => void refresh()}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
|
||||
{!!directory?.invitations.length && <ScrollView style={styles.invitations} contentContainerStyle={{ gap: 8 }}>
|
||||
{directory.invitations.map(invitation => <View key={invitation.id} style={styles.invitation}>
|
||||
<Text style={styles.bold}>Einladung: {invitation.name}</Text>
|
||||
<Text style={styles.hint}>{invitation.ownerName}{invitation.remote ? ' · Extern' : ''} · {invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben'}</Text>
|
||||
{invitation.overviewAllowed && <Text style={styles.hint}>Projektübersicht freigegeben</Text>}
|
||||
<View style={styles.actions}><Pressable accessibilityRole="button" disabled={!!busy} onPress={() => void respond(invitation.id, true)} style={styles.button}><Text style={styles.white}>{busy === invitation.id ? 'Bitte warten …' : 'Annehmen'}</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" disabled={!!busy} onPress={() => void respond(invitation.id, false)} style={styles.secondary}><Text>Ablehnen</Text></Pressable></View>
|
||||
</View>)}
|
||||
</ScrollView>}
|
||||
<View style={styles.roomPanel}><FlatList horizontal data={filteredRooms} keyExtractor={room => room.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList}
|
||||
ListEmptyComponent={!loading ? <Text style={styles.hint}>{search ? 'Keine passenden Chats.' : 'Keine sichtbaren Chats.'}</Text> : null}
|
||||
renderItem={({ item }) => <Pressable accessibilityRole="button" accessibilityState={{ selected: activeKey === item.key }} disabled={!!busy} onPress={() => void select(item)} style={[styles.room, activeKey === item.key && styles.selectedRoom]}>
|
||||
<Text style={styles.group}>{item.group}{item.remote ? ' · EXTERN' : ''}</Text><Text style={styles.bold} numberOfLines={1}>{item.name}</Text>
|
||||
{!!item.unread && <Text style={styles.badge}>{item.unread}</Text>}
|
||||
</Pressable>} /></View>
|
||||
{loading ? <View style={styles.empty}><ActivityIndicator /><Text>Chats werden geladen …</Text></View> : activeRoom ?
|
||||
<ChatConversation key={activeRoom.key} token={token} room={activeRoom} enabled={enabled} hiding={!!busy}
|
||||
draft={drafts[activeKey] || emptyDraft} onHide={() => void hide(activeRoom, true)}
|
||||
onDraft={value => setDrafts(previous => ({ ...previous, [activeKey]: { ...value, clientId: randomUUID() } }))}
|
||||
onSent={clientId => setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; })} />
|
||||
: <View style={styles.empty}><Ionicons name="chatbubbles-outline" size={36} color="#69c350" /><Text>Wähle einen Chat aus.</Text></View>}
|
||||
<Modal transparent visible={showHidden} animationType="slide" onRequestClose={() => setShowHidden(false)}>
|
||||
<View style={styles.overlay}><View style={styles.modal}><Text style={styles.title}>Ausgeblendete Chats</Text><Text style={styles.hint}>Diese Einstellung gilt auch im Web.</Text>
|
||||
<FlatList data={hiddenRooms.filter(room => matchesChatSearch(room, search))} keyExtractor={room => room.key} style={{ maxHeight: 380 }}
|
||||
ListEmptyComponent={<Text style={styles.hint}>Keine ausgeblendeten Chats gefunden.</Text>}
|
||||
renderItem={({ item }) => <View style={styles.hiddenRow}><View style={{ flex: 1 }}><Text style={styles.bold}>{item.name}</Text><Text style={styles.hint}>{item.group}</Text></View>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={`${item.name} einblenden`} disabled={!!busy} onPress={() => void hide(item, false)} style={styles.secondary}><Text style={styles.link}>Einblenden</Text></Pressable></View>} />
|
||||
<Pressable onPress={() => setShowHidden(false)} style={styles.secondary}><Text>Schließen</Text></Pressable>
|
||||
</View></View>
|
||||
</Modal>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<Modal transparent visible={createOpen} animationType="slide" onRequestClose={() => setCreateOpen(false)}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.overlay}><View style={styles.modal}>
|
||||
<Text style={styles.title}>Chat erstellen</Text>
|
||||
{directory?.canManage && <View style={styles.actions}>{(['internal', 'collaboration'] as const).map(type => <Pressable key={type} onPress={() => setRoomType(type)} style={[styles.secondary, roomType === type && styles.selectedRoom]}><Text>{type === 'internal' ? 'Gruppenchat' : 'Unterraum'}</Text></Pressable>)}</View>}
|
||||
<TextInput style={styles.input} accessibilityLabel="Chatname" placeholder="Name" value={roomName} onChangeText={setRoomName} maxLength={200} />
|
||||
<TextInput style={styles.input} accessibilityLabel="Chatbeschreibung" placeholder="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} maxLength={1000} />
|
||||
{roomType === 'collaboration' && <><Text style={styles.hint}>Projekt zuordnen (optional)</Text><ScrollView style={{ maxHeight: 150 }}>
|
||||
<Pressable onPress={() => setProjectId(null)} style={styles.secondary}><Text>{projectId === null ? '✓ ' : ''}Ohne Projekt</Text></Pressable>
|
||||
{directory?.projects.map(project => <Pressable key={project.id} onPress={() => setProjectId(project.id)} style={styles.secondary}><Text>{projectId === project.id ? '✓ ' : ''}{project.name}</Text></Pressable>)}
|
||||
</ScrollView></>}
|
||||
{!!error && <Text accessibilityRole="alert" style={styles.error}>{error}</Text>}
|
||||
<View style={styles.actions}><Pressable disabled={!!busy} onPress={() => setCreateOpen(false)} style={styles.secondary}><Text>Abbrechen</Text></Pressable>
|
||||
<Pressable disabled={!!busy || !roomName.trim()} onPress={() => void create()} style={styles.button}><Text style={styles.white}>{busy === 'create' ? 'Wird erstellt …' : 'Erstellen'}</Text></Pressable></View>
|
||||
</View></KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</KeyboardAvoidingView>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#f9fafb' },
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
||||
headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 },
|
||||
headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' },
|
||||
headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' },
|
||||
roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
||||
searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 },
|
||||
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
|
||||
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' },
|
||||
roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' },
|
||||
roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 },
|
||||
membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, loadingText: { color: '#6b7280' },
|
||||
messageList: { flexGrow: 1, padding: 14, gap: 10 }, emptyText: { marginTop: 40, textAlign: 'center', color: '#9ca3af' },
|
||||
messageRow: { flexDirection: 'row' }, messageRowOwn: { justifyContent: 'flex-end' }, messageBubble: { maxWidth: '82%', padding: 11, borderRadius: 16, backgroundColor: '#f3f4f6' },
|
||||
messageBubbleOwn: { backgroundColor: PRIMARY }, messageAuthor: { color: '#2f6f25', fontSize: 12, fontWeight: '700', marginBottom: 3 }, messageBody: { color: '#111827', fontSize: 15 }, messageBodyOwn: { color: '#fff' },
|
||||
messageTime: { marginTop: 4, color: '#9ca3af', fontSize: 10, textAlign: 'right' }, messageTimeOwn: { color: '#e7f7e2' },
|
||||
composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e5e7eb' },
|
||||
composerInput: { flex: 1, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 18, paddingHorizontal: 13, paddingVertical: 9 },
|
||||
sendButton: { backgroundColor: PRIMARY, borderRadius: 18, paddingHorizontal: 15, paddingVertical: 10 }, sendButtonDisabled: { opacity: 0.45 }, sendButtonText: { color: '#fff', fontWeight: '700' },
|
||||
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.45)' }, modalCard: { padding: 18, gap: 12, borderRadius: 14, backgroundColor: '#fff' },
|
||||
modalTitle: { fontSize: 18, fontWeight: '800', color: '#111827' }, modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, padding: 10 },
|
||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 18 }, linkText: { color: '#2f6f25', fontWeight: '700' }, primaryButton: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 9, backgroundColor: PRIMARY }, primaryButtonText: { color: '#fff', fontWeight: '700' },
|
||||
screen: { flex: 1, backgroundColor: '#f9fafb' }, toolbar: { flexDirection: 'row', alignItems: 'center', padding: 10, gap: 5, backgroundColor: '#fff' },
|
||||
search: { flex: 1, minWidth: 0, minHeight: 42, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, fontSize: 13, color: '#111827' },
|
||||
icon: { padding: 8, minWidth: 40, minHeight: 44, justifyContent: 'center', alignItems: 'center' }, roomPanel: { backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
|
||||
roomList: { paddingHorizontal: 12, paddingBottom: 10, gap: 8 }, room: { width: 165, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
|
||||
selectedRoom: { backgroundColor: '#eff9ea', borderColor: '#69c350', borderWidth: 1 }, group: { fontSize: 10, color: '#6b7280', textTransform: 'uppercase', marginBottom: 4 },
|
||||
bold: { fontWeight: '700', color: '#111827', fontSize: 13 }, badge: { marginTop: 4, alignSelf: 'flex-start', paddingHorizontal: 6, borderRadius: 8, backgroundColor: '#dcfce7', color: '#166534', fontSize: 11 },
|
||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 }, hint: { fontSize: 12, color: '#6b7280' }, error: { fontSize: 12, color: '#991b1b' }, errorBox: { backgroundColor: '#fef2f2', padding: 10, gap: 4 },
|
||||
link: { color: '#2f6f25', fontWeight: '600' }, invitations: { maxHeight: 170, flexGrow: 0, padding: 10, backgroundColor: '#f0fdf4' }, invitation: { gap: 5, paddingBottom: 8 },
|
||||
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, button: { paddingVertical: 11, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#438b30' },
|
||||
white: { color: '#fff', fontWeight: '600' }, secondary: { padding: 11, borderRadius: 8 }, overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'center', padding: 20 },
|
||||
modal: { maxHeight: '85%', backgroundColor: '#fff', borderRadius: 16, padding: 18, gap: 12 }, title: { fontSize: 18, fontWeight: '700', color: '#111827' },
|
||||
hiddenRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 9, borderBottomWidth: 1, borderColor: '#e5e7eb' }, input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 12, color: '#111827' },
|
||||
});
|
||||
|
||||
181
mobile/components/chat-conversation.tsx
Normal file
181
mobile/components/chat-conversation.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
|
||||
import { useChatConversation } from '@/hooks/use-chat-conversation';
|
||||
import { chatApi } from '@/src/lib/chat-api';
|
||||
import { prepareMobileChatImage } from '@/src/lib/chat-image';
|
||||
import { reactionEmojis, type ChatAttachment, type MobileChatMessage, type MobileChatRoom } from '@/src/lib/chat';
|
||||
|
||||
export type ChatDraft = { text: string; image: ChatAttachment | null; clientId: string };
|
||||
type Props = {
|
||||
token: string; room: MobileChatRoom; enabled: boolean; draft: ChatDraft;
|
||||
onDraft: (value: Pick<ChatDraft, 'text' | 'image'>) => void;
|
||||
onSent: (clientId: string) => void; onHide: () => void; hiding: boolean;
|
||||
};
|
||||
|
||||
function MessageImage({ token, room, message }: { token: string; room: MobileChatRoom; message: MobileChatMessage }) {
|
||||
const [uri, setUri] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const roomRef = useRef(room);
|
||||
roomRef.current = room;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setUri(''); setError(false); setExpanded(false);
|
||||
if (message.pending || message.failed) return;
|
||||
chatApi.image(token, roomRef.current, message.id).then(value => { if (active) setUri(value); })
|
||||
.catch(() => { if (active) setError(true); });
|
||||
return () => { active = false; };
|
||||
}, [message.failed, message.id, message.pending, retry, room.key, token]);
|
||||
const image = message.image!;
|
||||
const width = Math.min(260, image.width);
|
||||
const height = Math.min(220, width * image.height / image.width);
|
||||
if (message.pending || message.failed) return <Text style={styles.hint}>📷 {image.name}</Text>;
|
||||
return <View style={{ marginVertical: 8 }}>
|
||||
{uri ? <Pressable accessibilityRole="button" accessibilityLabel={`Bild öffnen: ${image.name}`} onPress={() => setExpanded(true)}>
|
||||
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={{ width, height }} accessibilityLabel={image.name} />
|
||||
</Pressable> : error ? <Pressable accessibilityRole="button" onPress={() => setRetry(value => value + 1)}>
|
||||
<Text style={styles.link}>Bild erneut laden</Text>
|
||||
</Pressable> : <ActivityIndicator accessibilityLabel="Bild wird geladen" />}
|
||||
<Modal visible={expanded} animationType="fade" onRequestClose={() => setExpanded(false)}>
|
||||
<View style={styles.imageModal}>
|
||||
<Pressable accessibilityRole="button" onPress={() => setExpanded(false)} style={styles.closeImage}><Text style={styles.white}>Schließen</Text></Pressable>
|
||||
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={styles.fullImage} accessibilityLabel={image.name} />
|
||||
</View>
|
||||
</Modal>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, onHide, hiding }: Props) {
|
||||
const chat = useChatConversation(token, room, enabled);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [reactionMessage, setReactionMessage] = useState<MobileChatMessage | null>(null);
|
||||
const [preparing, setPreparing] = useState(false);
|
||||
const [imageError, setImageError] = useState('');
|
||||
const list = useRef<FlatList<MobileChatMessage>>(null);
|
||||
const alive = useRef(true);
|
||||
const nearBottom = useRef(true);
|
||||
const pickerGeneration = useRef(0);
|
||||
const allowed = enabled && !!chat.detail?.canSend && !chat.loading;
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => { if (!allowed) setReactionMessage(null); }, [allowed]);
|
||||
|
||||
const attach = async () => {
|
||||
if (!allowed || preparing || chat.sending) return;
|
||||
const version = ++pickerGeneration.current;
|
||||
setPreparing(true); setImageError('');
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
|
||||
if (result.canceled || !alive.current || version !== pickerGeneration.current) return;
|
||||
const image = await prepareMobileChatImage(result.assets[0]);
|
||||
// The system photo picker can temporarily background the app. Keep the local
|
||||
// attachment; sending still requires refreshed room permissions afterwards.
|
||||
if (alive.current && version === pickerGeneration.current) onDraft({ text: draft.text, image });
|
||||
} catch (error) {
|
||||
if (alive.current && version === pickerGeneration.current) setImageError(error instanceof Error ? error.message : 'Bild konnte nicht geöffnet werden.');
|
||||
} finally { if (alive.current && version === pickerGeneration.current) setPreparing(false); }
|
||||
};
|
||||
const send = async () => {
|
||||
if (!allowed || preparing || (!draft.text.trim() && !draft.image)) return;
|
||||
const sent = await chat.send(draft.text.trim(), draft.image, draft.clientId);
|
||||
if (sent) { onSent(draft.clientId); nearBottom.current = true; list.current?.scrollToEnd({ animated: true }); }
|
||||
};
|
||||
const time = (message: MobileChatMessage) => {
|
||||
const value = message.createdAt || message.timestamp;
|
||||
return value ? new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||||
};
|
||||
const older = () => { nearBottom.current = false; void chat.older(); };
|
||||
const renderMessage = ({ item }: { item: MobileChatMessage }) => {
|
||||
const canReact = allowed && !chat.detail?.offline && !item.pending && !item.failed;
|
||||
return <View style={[styles.messageRow, item.own && styles.ownRow]}>
|
||||
<View style={[styles.bubble, item.own && styles.ownBubble]}>
|
||||
<Text style={styles.author}>{item.own ? 'Du' : item.senderName || item.senderDisplayName || 'Benutzer'}</Text>
|
||||
{item.image && <MessageImage key={`${chat.detail?.revision || 0}:${item.id}`} token={token} room={room} message={item} />}
|
||||
{!!item.body && <Text selectable style={styles.body}>{item.body}</Text>}
|
||||
<Text style={styles.time}>{time(item)}{item.pending ? ' · Wartet auf Zustellung' : item.failed ? ' · Zustellung fehlgeschlagen' : ''}</Text>
|
||||
<View style={styles.reactions}>
|
||||
{(chat.reactions[item.id] || []).map(reaction => <Pressable key={reaction.emoji} accessibilityRole="button"
|
||||
accessibilityLabel={`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`}
|
||||
accessibilityState={{ selected: reaction.own, disabled: !canReact || chat.reacting }} disabled={!canReact || chat.reacting}
|
||||
onPress={() => void chat.react(item, reaction.emoji)} style={[styles.reaction, reaction.own && styles.ownReaction]}>
|
||||
<Text>{reaction.emoji} {reaction.count}</Text>
|
||||
</Pressable>)}
|
||||
{canReact && <Pressable accessibilityRole="button" accessibilityLabel="Reaktion hinzufügen" disabled={chat.reacting}
|
||||
onPress={() => setReactionMessage(item)} style={styles.reaction}><Text>☺ +</Text></Pressable>}
|
||||
</View>
|
||||
</View>
|
||||
</View>;
|
||||
};
|
||||
const scrollToEnd = useCallback(() => { if (nearBottom.current) list.current?.scrollToEnd({ animated: false }); }, []);
|
||||
return <View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.grow}><Text style={styles.title} numberOfLines={1}>{chat.detail?.name || room.name}</Text>
|
||||
<Text style={styles.hint} numberOfLines={1}>{room.remote ? 'Extern · ' : ''}{room.ownerName || room.group}{chat.detail?.status === 'archived' ? ' · Archiviert' : ''}</Text></View>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Teilnehmer anzeigen" onPress={() => setMembersOpen(value => !value)} style={styles.iconButton}><Ionicons name="people-outline" size={23} /></Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Chat ausblenden" disabled={hiding} onPress={onHide} style={styles.iconButton}><Ionicons name="eye-off-outline" size={23} /></Pressable>
|
||||
</View>
|
||||
{membersOpen && <View style={styles.info}><Text style={styles.author}>Teilnehmer · {chat.members.length}</Text>
|
||||
<Text>{chat.members.map(member => `${member.own ? 'Du' : member.name}${member.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') || 'Keine Teilnehmer verfügbar.'}</Text></View>}
|
||||
{!!chat.error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{chat.error}</Text><Pressable onPress={chat.retry}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
|
||||
{chat.detail?.offline && <Text style={styles.notice}>Die Partnerinstanz ist nicht erreichbar. Neue Nachrichten warten auf Zustellung.</Text>}
|
||||
{!!chat.detail?.overview && <View style={styles.info}><Text style={styles.author}>Freigegebene Projektinformationen</Text>
|
||||
{Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => <Text key={key}>{key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value}</Text>)}
|
||||
</View>}
|
||||
{chat.loading ? <View style={styles.empty}><ActivityIndicator /><Text>Nachrichten werden geladen …</Text></View> :
|
||||
<FlatList ref={list} data={chat.messages} keyExtractor={item => String(item.id)} renderItem={renderMessage}
|
||||
style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
onScroll={event => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearBottom.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; }}
|
||||
scrollEventThrottle={100} onContentSizeChange={scrollToEnd}
|
||||
ListHeaderComponent={chat.hasMore ? <Pressable disabled={chat.olderLoading} onPress={older} style={styles.loadOlder}><Text style={styles.link}>{chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'}</Text></Pressable> : null}
|
||||
ListEmptyComponent={<Text style={styles.hint}>Noch keine Nachrichten in diesem Chat.</Text>} />}
|
||||
{chat.detail && <View style={styles.composer}>
|
||||
{!chat.detail.canSend && <Text style={styles.hint}>Dieser Chat ist nur lesbar.</Text>}
|
||||
{!!imageError && <Text accessibilityRole="alert" style={styles.error}>{imageError}</Text>}
|
||||
{draft.image && <View style={styles.attachment}><Image source={{ uri: draft.image.dataUrl }} cachePolicy="none" style={styles.thumbnail} contentFit="contain" />
|
||||
<Text numberOfLines={1} style={styles.grow}>{draft.image.name}</Text>
|
||||
<Pressable accessibilityLabel="Bild entfernen" disabled={chat.sending || preparing} onPress={() => onDraft({ text: draft.text, image: null })} style={styles.iconButton}><Ionicons name="close" size={23} /></Pressable></View>}
|
||||
<View style={styles.composerRow}>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Bild anhängen" disabled={!allowed || preparing || chat.sending} onPress={() => void attach()} style={styles.iconButton}>
|
||||
{preparing ? <ActivityIndicator /> : <Ionicons name="image-outline" size={25} color={allowed ? '#2f6f25' : '#9ca3af'} />}</Pressable>
|
||||
<TextInput accessibilityLabel="Nachricht" placeholder={allowed ? 'Nachricht schreiben …' : 'Nur lesbar'} style={styles.input} multiline maxLength={10000}
|
||||
editable={allowed && !preparing && !chat.sending} value={draft.text} onChangeText={text => onDraft({ text, image: draft.image })} />
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Nachricht senden" disabled={!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)} onPress={() => void send()}
|
||||
style={[styles.send, (!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)) && styles.disabled]}>
|
||||
{chat.sending ? <ActivityIndicator color="#fff" /> : <Ionicons name="send" size={21} color="#fff" />}</Pressable>
|
||||
</View>
|
||||
</View>}
|
||||
<Modal transparent visible={!!reactionMessage && allowed} animationType="fade" onRequestClose={() => setReactionMessage(null)}>
|
||||
<Pressable style={styles.overlay} onPress={() => setReactionMessage(null)}><View style={styles.reactionPicker}>
|
||||
<Text style={styles.title}>Reaktion auswählen</Text><ScrollView horizontal contentContainerStyle={styles.reactions}>
|
||||
{reactionEmojis.map(emoji => <Pressable key={emoji} accessibilityRole="button" accessibilityLabel={`Mit ${emoji} reagieren`} style={styles.emoji}
|
||||
onPress={() => { if (reactionMessage) void chat.react(reactionMessage, emoji); setReactionMessage(null); }}><Text style={{ fontSize: 28 }}>{emoji}</Text></Pressable>)}
|
||||
</ScrollView></View></Pressable>
|
||||
</Modal>
|
||||
</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, minHeight: 0 }, grow: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 8, backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
|
||||
title: { fontSize: 16, fontWeight: '700', color: '#111827' }, hint: { color: '#6b7280', fontSize: 12 },
|
||||
iconButton: { padding: 10, minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' },
|
||||
messages: { padding: 12, gap: 12 }, messageRow: { flexDirection: 'row' }, ownRow: { justifyContent: 'flex-end' },
|
||||
bubble: { maxWidth: '90%', padding: 12, borderRadius: 14, backgroundColor: '#fff', borderWidth: 1, borderColor: '#e5e7eb' },
|
||||
ownBubble: { backgroundColor: '#eff9ea', borderColor: '#c7e6bb' }, author: { fontWeight: '700', fontSize: 12, color: '#374151', marginBottom: 4 },
|
||||
body: { color: '#111827', fontSize: 15, lineHeight: 21 }, time: { color: '#6b7280', fontSize: 10, marginTop: 6, textAlign: 'right' },
|
||||
reactions: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }, reaction: { paddingHorizontal: 9, paddingVertical: 5, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 16, backgroundColor: '#fff' },
|
||||
ownReaction: { backgroundColor: '#dcfce7', borderColor: '#69c350' }, info: { padding: 12, gap: 3, backgroundColor: '#f0fdf4' },
|
||||
notice: { padding: 12, backgroundColor: '#fffbeb', color: '#92400e', fontSize: 12 }, errorBox: { padding: 10, backgroundColor: '#fef2f2', gap: 5 }, error: { color: '#991b1b', fontSize: 12 },
|
||||
link: { color: '#2f6f25', fontWeight: '600', paddingVertical: 5 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 10 },
|
||||
composer: { padding: 10, backgroundColor: '#fff', borderTopWidth: 1, borderColor: '#e5e7eb', gap: 8 }, composerRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 6 },
|
||||
input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, padding: 10, fontSize: 15, color: '#111827' },
|
||||
send: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#438b30', alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 },
|
||||
attachment: { flexDirection: 'row', alignItems: 'center', gap: 8 }, thumbnail: { width: 52, height: 52 }, loadOlder: { alignItems: 'center', padding: 10 },
|
||||
overlay: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.4)' }, reactionPicker: { backgroundColor: '#fff', borderRadius: 16, padding: 16 }, emoji: { padding: 9 },
|
||||
imageModal: { flex: 1, backgroundColor: '#111827', paddingTop: 60, paddingBottom: 30 }, closeImage: { alignSelf: 'flex-end', padding: 20 }, white: { color: '#fff' }, fullImage: { flex: 1 },
|
||||
});
|
||||
149
mobile/hooks/use-chat-conversation.ts
Normal file
149
mobile/hooks/use-chat-conversation.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { chatApi } from '@/src/lib/chat-api';
|
||||
import {
|
||||
applyChatAccess, chatCursor, isChatAccessError, mergeChatMessages,
|
||||
type ChatAttachment, type ChatDetail, type ChatPerson, type ChatReactions,
|
||||
type MobileChatMessage, type MobileChatRoom,
|
||||
} from '@/src/lib/chat';
|
||||
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : 'Chat konnte nicht geladen werden.';
|
||||
|
||||
// Each mounted conversation belongs to one session and one room. Late responses cannot
|
||||
// repopulate an unmounted conversation or a conversation whose access was revoked.
|
||||
export function useChatConversation(token: string, room: MobileChatRoom, enabled: boolean) {
|
||||
const [detail, setDetail] = useState<ChatDetail | null>(null);
|
||||
const [messages, setMessages] = useState<MobileChatMessage[]>([]);
|
||||
const [members, setMembers] = useState<ChatPerson[]>([]);
|
||||
const [reactions, setReactions] = useState<ChatReactions>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [olderLoading, setOlderLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [reacting, setReacting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reload, setReload] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const state = useRef<{ detail: ChatDetail | null; messages: MobileChatMessage[] }>({ detail: null, messages: [] });
|
||||
const busy = useRef({ send: false, reaction: false, older: false });
|
||||
const reactionRevision = useRef(0);
|
||||
const roomRef = useRef(room);
|
||||
roomRef.current = room;
|
||||
const current = useCallback((version: number) => generation.current === version, []);
|
||||
const update = useCallback((next: typeof state.current) => {
|
||||
state.current = next; setDetail(next.detail); setMessages(next.messages);
|
||||
}, []);
|
||||
const fail = useCallback((err: unknown, version: number) => {
|
||||
if (!current(version)) return;
|
||||
setError(errorText(err));
|
||||
if (isChatAccessError(err)) {
|
||||
++generation.current; ++reactionRevision.current;
|
||||
update({ detail: null, messages: [] }); setMembers([]); setReactions({});
|
||||
setLoading(false); setSending(false); setReacting(false); setOlderLoading(false); setHasMore(false);
|
||||
}
|
||||
}, [current, update]);
|
||||
|
||||
useEffect(() => {
|
||||
const version = ++generation.current;
|
||||
const target = roomRef.current;
|
||||
busy.current = { send: false, reaction: false, older: false };
|
||||
setSending(false); setReacting(false); setOlderLoading(false);
|
||||
if (!enabled) return;
|
||||
let polling = false;
|
||||
async function read(nextMessages: MobileChatMessage[]) {
|
||||
const cursor = chatCursor(target, nextMessages);
|
||||
if (current(version) && cursor) {
|
||||
try { await chatApi.read(token, target, cursor); } catch { /* Read receipts do not block the chat. */ }
|
||||
}
|
||||
}
|
||||
async function refreshReactions(nextMessages: MobileChatMessage[]) {
|
||||
if (busy.current.reaction) return;
|
||||
const revision = ++reactionRevision.current;
|
||||
try {
|
||||
const result = await chatApi.reactions(token, target, nextMessages);
|
||||
if (current(version) && reactionRevision.current === revision) setReactions(result);
|
||||
} catch { /* A temporary reaction failure must not hide the conversation. */ }
|
||||
}
|
||||
async function load() {
|
||||
setLoading(true); setError(''); setHasMore(false);
|
||||
update({ detail: null, messages: [] }); setMembers([]); setReactions({});
|
||||
try {
|
||||
const [nextDetail, page, people] = await Promise.all([
|
||||
chatApi.detail(token, target), chatApi.messages(token, target), chatApi.members(token, target),
|
||||
]);
|
||||
if (!current(version)) return;
|
||||
const nextMessages = target.source === 'collaboration' ? applyChatAccess(page.messages, nextDetail) : page.messages;
|
||||
update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setHasMore(!!page.hasMore);
|
||||
await Promise.all([read(nextMessages), refreshReactions(nextMessages)]);
|
||||
} catch (err) { fail(err, version); }
|
||||
finally { if (current(version)) setLoading(false); }
|
||||
}
|
||||
async function poll() {
|
||||
if (!current(version) || polling || !state.current.detail || busy.current.older) return;
|
||||
polling = true;
|
||||
try {
|
||||
const [nextDetail, page, people] = await Promise.all([
|
||||
chatApi.detail(token, target), chatApi.messages(token, target, { after: chatCursor(target, state.current.messages) }),
|
||||
chatApi.members(token, target),
|
||||
]);
|
||||
if (!current(version)) return;
|
||||
const collaboration = target.source === 'collaboration';
|
||||
const retained = collaboration ? applyChatAccess(state.current.messages, nextDetail, state.current.detail) : state.current.messages;
|
||||
let nextMessages = mergeChatMessages(retained, page.messages, collaboration);
|
||||
if (collaboration) nextMessages = applyChatAccess(nextMessages, nextDetail);
|
||||
update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setError('');
|
||||
await Promise.all([read(nextMessages), refreshReactions(nextMessages)]);
|
||||
} catch (err) { fail(err, version); }
|
||||
finally { polling = false; }
|
||||
}
|
||||
void load();
|
||||
const timer = setInterval(() => void poll(), 3000);
|
||||
return () => { ++generation.current; ++reactionRevision.current; clearInterval(timer); };
|
||||
}, [current, enabled, fail, reload, room.key, token, update]);
|
||||
|
||||
async function send(text: string, image: ChatAttachment | null, clientId: string) {
|
||||
if (!enabled || !state.current.detail?.canSend || busy.current.send) return false;
|
||||
const version = generation.current;
|
||||
const membershipRevision = state.current.detail.revision;
|
||||
busy.current.send = true; setSending(true); setError('');
|
||||
try {
|
||||
const message = await chatApi.send(token, room, text, image, clientId);
|
||||
// Clear the matching draft after a successful delivery, even if its room is no
|
||||
// longer open. Never append a late message after access/history has changed.
|
||||
if (!current(version) || state.current.detail?.revision !== membershipRevision) return true;
|
||||
const next = mergeChatMessages(state.current.messages, [message], room.source === 'collaboration');
|
||||
update({ ...state.current, messages: next });
|
||||
// The send is complete even if the read receipt cannot be delivered.
|
||||
const cursor = chatCursor(room, next);
|
||||
if (cursor) void chatApi.read(token, room, cursor).catch(() => undefined);
|
||||
return true;
|
||||
} catch (err) { fail(err, version); return false; }
|
||||
finally { if (current(version)) { busy.current.send = false; setSending(false); } }
|
||||
}
|
||||
async function older() {
|
||||
const before = state.current.messages.find(message => message.seq)?.seq;
|
||||
if (!enabled || !before || !hasMore || busy.current.older) return;
|
||||
const version = generation.current;
|
||||
busy.current.older = true; setOlderLoading(true);
|
||||
try {
|
||||
const page = await chatApi.messages(token, room, { before });
|
||||
if (!current(version) || !state.current.detail) return;
|
||||
const next = applyChatAccess(mergeChatMessages(state.current.messages, page.messages, true), state.current.detail);
|
||||
update({ ...state.current, messages: next }); setHasMore(!!page.hasMore);
|
||||
} catch (err) { fail(err, version); }
|
||||
finally { if (current(version)) { busy.current.older = false; setOlderLoading(false); } }
|
||||
}
|
||||
async function react(message: MobileChatMessage, emoji: string) {
|
||||
if (!enabled || !state.current.detail?.canSend || state.current.detail.offline || message.pending || message.failed || busy.current.reaction) return;
|
||||
const version = generation.current;
|
||||
++reactionRevision.current; busy.current.reaction = true; setReacting(true);
|
||||
try {
|
||||
const active = !(reactions[message.id] || []).some(item => item.emoji === emoji && item.own);
|
||||
const result = await chatApi.react(token, room, message.id, emoji, active);
|
||||
if (current(version)) setReactions(previous => ({ ...previous, [message.id]: result.reactions[message.id] || [] }));
|
||||
} catch (err) { if (current(version)) setError(errorText(err)); }
|
||||
finally { if (current(version)) { busy.current.reaction = false; setReacting(false); } }
|
||||
}
|
||||
return { detail, messages, members, reactions, loading, olderLoading, hasMore, sending, reacting, error,
|
||||
send, older, react, retry: () => setReload(value => value + 1) };
|
||||
}
|
||||
41
mobile/package-lock.json
generated
41
mobile/package-lock.json
generated
@@ -16,11 +16,13 @@
|
||||
"expo": "~54.0.37",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-file-system": "~19.0.24",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-notifications": "~0.32.17",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6250,6 +6253,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "15.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
|
||||
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
@@ -6318,6 +6333,18 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-manipulator": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
|
||||
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-picker": {
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz",
|
||||
@@ -11188,6 +11215,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-is": "^19.1.0",
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"doctor": "npx expo-doctor",
|
||||
"preflight:testflight": "npm run lint && npm run doctor",
|
||||
"test": "node --experimental-strip-types --test tests/*.test.mjs",
|
||||
"preflight:testflight": "npm run lint && npm test && npm run doctor",
|
||||
"build:ios:dev": "eas build --profile development --platform ios",
|
||||
"build:ios:preview": "eas build --profile preview --platform ios",
|
||||
"build:ios:testflight": "eas build --profile testflight --platform ios",
|
||||
@@ -29,11 +30,13 @@
|
||||
"expo": "~54.0.37",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-file-system": "~19.0.24",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-notifications": "~0.32.17",
|
||||
@@ -62,6 +65,7 @@
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
4
mobile/src/lib/chat-api.ts
Normal file
4
mobile/src/lib/chat-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { createChatApi } from '@/src/lib/chat';
|
||||
|
||||
export const chatApi = createChatApi(apiRequest);
|
||||
30
mobile/src/lib/chat-image.ts
Normal file
30
mobile/src/lib/chat-image.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { deleteAsync } from 'expo-file-system/legacy';
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
|
||||
import type { ImagePickerAsset } from 'expo-image-picker';
|
||||
|
||||
import type { ChatAttachment } from '@/src/lib/chat';
|
||||
|
||||
export async function prepareMobileChatImage(asset: ImagePickerAsset): Promise<ChatAttachment> {
|
||||
if ((asset.fileSize || 0) > 10 * 1024 * 1024) throw new Error('Das Originalbild darf höchstens 10 MB groß sein.');
|
||||
if (!asset.width || !asset.height) throw new Error('Das Bild konnte nicht gelesen werden.');
|
||||
// Convert camera formats such as HEIC to a bounded raster before sending to FEDEO.
|
||||
for (const limit of [1600, 1000]) {
|
||||
const context = ImageManipulator.manipulate(asset.uri);
|
||||
const scale = Math.min(1, limit / Math.max(asset.width, asset.height));
|
||||
context.resize({ width: Math.max(1, Math.round(asset.width * scale)), height: Math.max(1, Math.round(asset.height * scale)) });
|
||||
try {
|
||||
const image = await context.renderAsync();
|
||||
try {
|
||||
for (const compress of [0.8, 0.55, 0.35]) {
|
||||
const result = await image.saveAsync({ format: SaveFormat.JPEG, compress, base64: true });
|
||||
await deleteAsync(result.uri, { idempotent: true }).catch(() => undefined);
|
||||
if (result.base64 && result.base64.length <= 480_000) return {
|
||||
dataUrl: `data:image/jpeg;base64,${result.base64}`,
|
||||
name: `${(asset.fileName || 'Bild').replace(/\.[^.]+$/, '').slice(0, 195)}.jpg`,
|
||||
};
|
||||
}
|
||||
} finally { image.release(); }
|
||||
} finally { context.release(); }
|
||||
}
|
||||
throw new Error('Dieses Bild ist zu groß. Bitte einen kleineren Ausschnitt auswählen.');
|
||||
}
|
||||
164
mobile/src/lib/chat.ts
Normal file
164
mobile/src/lib/chat.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
export type ChatImage = { name: string; width: number; height: number };
|
||||
export type ChatAttachment = { name: string; dataUrl: string };
|
||||
export type ChatReaction = { emoji: string; count: number; own: boolean };
|
||||
export type ChatReactions = Record<string, ChatReaction[]>;
|
||||
export type MobileChatRoom = {
|
||||
key: string; id?: string; name: string; topic?: string | null;
|
||||
source: 'internal' | 'collaboration'; group: string; exists: boolean;
|
||||
type?: string; projectId?: number; projectNumber?: string | null; userId?: string; email?: string | null;
|
||||
ownerName?: string; remote?: boolean; status?: string; unread?: number;
|
||||
};
|
||||
export type ChatInvitation = {
|
||||
id: string; name: string; ownerName?: string; role: string;
|
||||
overviewAllowed?: boolean; remote?: boolean; expiresAt?: string;
|
||||
};
|
||||
export type ChatDetail = {
|
||||
name: string; topic?: string | null; canSend: boolean; status?: string;
|
||||
offline?: boolean; historyFromSeq?: number; revision?: number;
|
||||
overview?: { title?: string; status?: string; date?: string; description?: string } | null;
|
||||
};
|
||||
export type MobileChatMessage = {
|
||||
id: string | number; clientId?: string; seq?: number | null; body?: string | null;
|
||||
own?: boolean; senderDisplayName?: string | null; senderName?: string;
|
||||
timestamp?: string | number | null; createdAt?: string;
|
||||
image?: ChatImage | null; pending?: boolean; failed?: boolean;
|
||||
};
|
||||
export type ChatPerson = { name: string; own?: boolean; kind?: string };
|
||||
export type ChatPage = { messages: MobileChatMessage[]; hasMore?: boolean };
|
||||
export type ChatDirectory = {
|
||||
rooms: MobileChatRoom[]; hiddenRoomKeys: string[]; invitations: ChatInvitation[];
|
||||
canManage: boolean; projects: { id: number; name: string }[];
|
||||
};
|
||||
type NativeRoom = Omit<MobileChatRoom, 'source' | 'group'>;
|
||||
type CollaborationRoom = Omit<MobileChatRoom, 'key' | 'source' | 'group' | 'exists'> & { id: string };
|
||||
type CollaborationList = {
|
||||
rooms: CollaborationRoom[]; invitations: ChatInvitation[];
|
||||
canManage?: boolean; projects?: ChatDirectory['projects'];
|
||||
};
|
||||
type Request = <T>(path: string, options: {
|
||||
token: string; method?: 'GET' | 'POST' | 'PUT'; body?: unknown;
|
||||
}) => Promise<T>;
|
||||
|
||||
export const reactionEmojis = ['👍', '❤️', '😂', '🎉', '😮', '😢', '🙏', '🔥', '✅', '👎'];
|
||||
export const collaborationKey = (id: string) => `collaboration:${id}`;
|
||||
export const chatEndpoint = (room: MobileChatRoom, suffix = '') => room.source === 'collaboration'
|
||||
? `/api/collaboration/rooms/${encodeURIComponent(room.id!)}${suffix}`
|
||||
: `/api/communication/chat/rooms/${encodeURIComponent(room.key)}${suffix}`;
|
||||
export const visibleChatRooms = (directory: ChatDirectory) => directory.rooms.filter(room => !directory.hiddenRoomKeys.includes(room.key));
|
||||
export const chatCursor = (room: MobileChatRoom, messages: MobileChatMessage[]) => Math.max(0,
|
||||
...messages.map(message => room.source === 'collaboration' ? message.seq || 0 : Number(message.id)));
|
||||
export function mergeChatMessages(current: MobileChatMessage[], incoming: MobileChatMessage[], collaboration: boolean) {
|
||||
const byId = new Map(current.map(message => [message.id, message]));
|
||||
for (const message of incoming) {
|
||||
// A federation acknowledgement replaces its queued copy even when the server ID changes.
|
||||
if (collaboration && message.own && message.clientId) {
|
||||
if (!message.seq && [...byId.values()].some(old => old.own && old.seq && old.clientId === message.clientId)) continue;
|
||||
if (message.seq) for (const [id, old] of byId) {
|
||||
if (old.own && !old.seq && old.clientId === message.clientId) byId.delete(id);
|
||||
}
|
||||
}
|
||||
byId.set(message.id, message);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => collaboration
|
||||
? (a.seq || Number.MAX_SAFE_INTEGER) - (b.seq || Number.MAX_SAFE_INTEGER)
|
||||
: Number(a.id) - Number(b.id));
|
||||
}
|
||||
export function applyChatAccess(messages: MobileChatMessage[], detail: ChatDetail, previous?: ChatDetail | null) {
|
||||
return messages.filter(message => message.seq
|
||||
? message.seq >= (detail.historyFromSeq || 0)
|
||||
: !previous || previous.revision === detail.revision);
|
||||
}
|
||||
export function isChatAccessError(error: unknown) {
|
||||
return typeof error === 'object' && error !== null && 'status' in error && [401, 403, 404, 410].includes(Number(error.status));
|
||||
}
|
||||
export function validChatImage(dataUrl: unknown): dataUrl is string {
|
||||
return typeof dataUrl === 'string' && dataUrl.length <= 700_000
|
||||
&& /^data:image\/(webp|png|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/.test(dataUrl);
|
||||
}
|
||||
export function matchesChatSearch(room: MobileChatRoom, search: string) {
|
||||
const normalize = (text: string) => text.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss');
|
||||
const text = normalize([room.name, room.topic, room.projectNumber, room.email, room.ownerName].filter(Boolean).join(' '));
|
||||
return normalize(search).trim().split(/\s+/).every(term => text.includes(term));
|
||||
}
|
||||
|
||||
export function createChatApi(request: Request) {
|
||||
const preferences = '/api/communication/chat/room-preferences';
|
||||
return {
|
||||
async directory(token: string): Promise<ChatDirectory> {
|
||||
// Do not expose any rooms before the server's visibility preferences are available.
|
||||
const [base, projects, direct, unread, hidden, collaboration] = await Promise.all([
|
||||
request<{ rooms: NativeRoom[] }>('/api/communication/chat/rooms', { token }),
|
||||
request<{ rooms: NativeRoom[] }>('/api/communication/chat/project-rooms', { token }),
|
||||
request<{ rooms: NativeRoom[] }>('/api/communication/chat/direct-rooms', { token }),
|
||||
request<{ rooms: Record<string, { count?: number }> }>('/api/communication/chat/unread', { token }),
|
||||
request<{ hiddenRoomKeys: string[] }>(preferences, { token }),
|
||||
request<CollaborationList>('/api/collaboration', { token }),
|
||||
]);
|
||||
const decorate = (rooms: NativeRoom[], group: string): MobileChatRoom[] => rooms.map(room => ({
|
||||
...room, source: 'internal', group, unread: unread.rooms?.[room.key]?.count || 0,
|
||||
}));
|
||||
return {
|
||||
rooms: [...decorate(base.rooms, 'Räume'), ...decorate(projects.rooms, 'Projekte'), ...decorate(direct.rooms, 'Direkt'),
|
||||
...collaboration.rooms.map(room => ({ ...room, key: collaborationKey(room.id),
|
||||
source: 'collaboration' as const, group: 'Zusammenarbeit', exists: true }))],
|
||||
hiddenRoomKeys: hidden.hiddenRoomKeys, invitations: collaboration.invitations,
|
||||
canManage: collaboration.canManage === true, projects: collaboration.projects || [],
|
||||
};
|
||||
},
|
||||
async unread(token: string) {
|
||||
const [native, hidden, collaboration] = await Promise.all([
|
||||
request<{ rooms: Record<string, { count?: number }> }>('/api/communication/chat/unread', { token }),
|
||||
request<{ hiddenRoomKeys: string[] }>(preferences, { token }),
|
||||
request<CollaborationList>('/api/collaboration', { token }),
|
||||
]);
|
||||
const counts = [...Object.entries(native.rooms).map(([key, value]) => ({ key, count: value.count || 0 })),
|
||||
...collaboration.rooms.map(room => ({ key: collaborationKey(room.id), count: room.unread || 0 }))];
|
||||
return counts.filter(room => !hidden.hiddenRoomKeys.includes(room.key)).reduce((sum, room) => sum + room.count, 0);
|
||||
},
|
||||
hide: (token: string, roomKey: string, hidden: boolean) => request(preferences, { token, method: 'PUT', body: { roomKey, hidden } }),
|
||||
respond: (token: string, id: string, accept: boolean) => request<{ roomId?: string }>(
|
||||
`/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { token, method: 'POST' }),
|
||||
async provision(token: string, room: MobileChatRoom): Promise<MobileChatRoom> {
|
||||
if (room.exists || room.source === 'collaboration') return room;
|
||||
const path = room.type === 'project' ? `/api/communication/chat/project-rooms/${room.projectId}/provision`
|
||||
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId!)}/provision`;
|
||||
return { ...room, ...await request<NativeRoom>(path, { token, method: 'POST' }), source: 'internal' };
|
||||
},
|
||||
detail: (token: string, room: MobileChatRoom) => room.source === 'collaboration'
|
||||
? request<ChatDetail>(chatEndpoint(room), { token })
|
||||
: Promise.resolve<ChatDetail>({ name: room.name, topic: room.topic, canSend: true }),
|
||||
async members(token: string, room: MobileChatRoom): Promise<ChatPerson[]> {
|
||||
const result = await request<{ members: (ChatPerson & { displayName?: string })[] }>(chatEndpoint(room, '/members'), { token });
|
||||
return result.members.map(member => ({ ...member, name: member.name || member.displayName || 'Benutzer' }));
|
||||
},
|
||||
messages(token: string, room: MobileChatRoom, cursor?: { after?: number; before?: number }) {
|
||||
const suffix = room.source === 'collaboration'
|
||||
? `/messages${cursor?.before ? `?before=${cursor.before}` : cursor?.after ? `?after=${cursor.after}` : ''}`
|
||||
: cursor?.after ? `/sync?afterId=${cursor.after}` : '/messages';
|
||||
return request<ChatPage>(chatEndpoint(room, suffix), { token });
|
||||
},
|
||||
send: (token: string, room: MobileChatRoom, text: string, image: ChatAttachment | null, clientId: string) => request<MobileChatMessage>(
|
||||
chatEndpoint(room, '/messages'), { token, method: 'POST', body: { text, ...(image ? { image } : {}),
|
||||
...(room.source === 'collaboration' ? { clientId } : {}) } }),
|
||||
read: (token: string, room: MobileChatRoom, cursor: number) => request(chatEndpoint(room, '/read'), {
|
||||
token, method: 'POST', body: room.source === 'collaboration' ? { seq: cursor } : { messageId: cursor },
|
||||
}),
|
||||
async image(token: string, room: MobileChatRoom, id: MobileChatMessage['id']) {
|
||||
const result = await request<{ dataUrl: string }>(chatEndpoint(room, `/image?messageId=${encodeURIComponent(id)}`), { token });
|
||||
if (!validChatImage(result.dataUrl)) throw new Error('Ungültiges Chatbild.');
|
||||
return result.dataUrl;
|
||||
},
|
||||
async reactions(token: string, room: MobileChatRoom, messages: MobileChatMessage[]): Promise<ChatReactions> {
|
||||
const ids = messages.filter(message => !message.pending && !message.failed).map(message => message.id);
|
||||
const reactions: ChatReactions = {};
|
||||
for (let index = 0; index < ids.length; index += 100) {
|
||||
const result = await request<{ reactions: ChatReactions }>(chatEndpoint(room,
|
||||
`/reactions?ids=${encodeURIComponent(ids.slice(index, index + 100).join(','))}`), { token });
|
||||
Object.assign(reactions, result.reactions);
|
||||
}
|
||||
return reactions;
|
||||
},
|
||||
react: (token: string, room: MobileChatRoom, messageId: MobileChatMessage['id'], emoji: string, active: boolean) => request<{ reactions: ChatReactions }>(
|
||||
chatEndpoint(room, '/reactions'), { token, method: 'PUT', body: { messageId, emoji, active } }),
|
||||
};
|
||||
}
|
||||
121
mobile/tests/chat-conversation.test.mjs
Normal file
121
mobile/tests/chat-conversation.test.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { test } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import * as chatModel from '../src/lib/chat.ts';
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const require = createRequire(import.meta.url);
|
||||
const remote = { key: 'collaboration:a', id: 'a', source: 'collaboration', name: 'A', exists: true };
|
||||
const detail = { name: 'A', canSend: true, historyFromSeq: 1, revision: 1 };
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
|
||||
function harness(overrides = {}) {
|
||||
const reads = [], sends = [], timers = new Map();
|
||||
const api = {
|
||||
detail: async () => detail, messages: async () => ({ messages: [{ id: 'first', seq: 1, body: 'Start' }] }),
|
||||
members: async () => [{ name: 'Partner' }], reactions: async () => ({}),
|
||||
read: async (...args) => { reads.push(args); },
|
||||
send: async (...args) => { sends.push(args); return { id: 'sent', seq: 2, own: true }; },
|
||||
react: async () => ({ reactions: {} }), ...overrides,
|
||||
};
|
||||
const output = ts.transpileModule(readFileSync(new URL('../hooks/use-chat-conversation.ts', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
new Function('require', 'exports', 'setInterval', 'clearInterval', output)(name => {
|
||||
if (name === '@/src/lib/chat-api') return { chatApi: api };
|
||||
if (name === '@/src/lib/chat') return chatModel;
|
||||
return require(name);
|
||||
}, exports, callback => { const id = Symbol(); timers.set(id, callback); return id; }, id => timers.delete(id));
|
||||
let latest, root;
|
||||
function Probe(props) { latest = exports.useChatConversation(props.token || 'token', props.room, props.enabled ?? true); return null; }
|
||||
return {
|
||||
api, reads, sends, get state() { return latest; },
|
||||
async render(room = remote, enabled = true, token = 'token') {
|
||||
await act(async () => {
|
||||
const element = React.createElement(Probe, { key: `${token}:${room.key}`, room, enabled, token });
|
||||
if (root) root.update(element); else root = create(element);
|
||||
});
|
||||
},
|
||||
async poll() { await act(async () => { for (const callback of timers.values()) callback(); }); },
|
||||
async dispose() { await act(async () => root?.unmount()); },
|
||||
};
|
||||
}
|
||||
|
||||
test('Verspätete Antworten des vorherigen Raums werden nach Raumwechsel verworfen', async () => {
|
||||
const pending = deferred();
|
||||
const h = harness({ detail: async (_token, room) => room.id === 'a' ? pending.promise : { ...detail, name: 'B' },
|
||||
messages: async (_token, room) => ({ messages: [{ id: room.id, seq: 1, body: room.name }] }) });
|
||||
try {
|
||||
await h.render();
|
||||
await h.render({ ...remote, key: 'collaboration:b', id: 'b', name: 'B' });
|
||||
await act(async () => pending.resolve(detail));
|
||||
assert.equal(h.state.detail.name, 'B');
|
||||
assert.equal(h.state.messages[0].body, 'B');
|
||||
assert.ok(h.reads.every(([, room]) => room.id === 'b'));
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
test('Mandantenwechsel verwirft laufende Antworten der alten Sitzung', async () => {
|
||||
const pending = deferred();
|
||||
const h = harness({ messages: async token => token === 'old' ? pending.promise : { messages: [{ id: 'new', seq: 3 }] } });
|
||||
try {
|
||||
await h.render(remote, true, 'old');
|
||||
await h.render(remote, true, 'new');
|
||||
await act(async () => pending.resolve({ messages: [{ id: 'private-old', seq: 9 }] }));
|
||||
assert.deepEqual(h.state.messages.map(message => message.id), ['new']);
|
||||
assert.ok(h.reads.every(([token]) => token === 'new'));
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
test('Entzogener Zugriff entfernt Inhalt und ignoriert danach eintreffende Sendeantworten', async () => {
|
||||
const pending = deferred(); let forbidden = false;
|
||||
const h = harness({ detail: async () => { if (forbidden) throw Object.assign(new Error('Zugriff entzogen'), { status: 403 }); return detail; }, send: () => pending.promise });
|
||||
try {
|
||||
await h.render();
|
||||
let send;
|
||||
await act(async () => { send = h.state.send('Hallo', null, 'stable'); });
|
||||
forbidden = true; await h.poll();
|
||||
assert.equal(h.state.detail, null);
|
||||
assert.deepEqual(h.state.messages, []); assert.deepEqual(h.state.members, []);
|
||||
await act(async () => pending.resolve({ id: 'late', seq: 7 }));
|
||||
assert.equal(await send, true); // The old draft can be cleared, but no content reappears.
|
||||
assert.deepEqual(h.state.messages, []);
|
||||
assert.equal(await h.state.send('Nicht erlaubt', null, 'blocked'), false);
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
test('Neue Leserechte und Historiengrenzen werden während des Pollings übernommen', async () => {
|
||||
let revision = 1;
|
||||
const h = harness({ detail: async () => ({ ...detail, revision, canSend: revision === 1, historyFromSeq: revision === 1 ? 1 : 5 }),
|
||||
messages: async () => ({ messages: revision === 1 ? [{ id: 'old', seq: 1 }, { id: 'allowed', seq: 5 }, { id: 'queued', seq: null, pending: true }] : [] }) });
|
||||
try {
|
||||
await h.render(); revision = 2; await h.poll();
|
||||
assert.deepEqual(h.state.messages.map(message => message.id), ['allowed']);
|
||||
assert.equal(h.state.detail.canSend, false);
|
||||
assert.equal(await h.state.send('Gesperrt', null, 'no-send'), false);
|
||||
assert.equal(h.sends.length, 0);
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
test('Doppelklick sendet nur einmal; fehlgeschlagene Nachrichten können mit derselben ID wiederholt werden', async () => {
|
||||
const pending = deferred(), ids = []; let attempt = 0;
|
||||
const h = harness({ send: (_token, _room, _text, _image, clientId) => { ids.push(clientId); return ++attempt === 1 ? pending.promise : Promise.resolve({ id: 'sent', seq: 2 }); } });
|
||||
try {
|
||||
await h.render(); let first, second;
|
||||
await act(async () => { first = h.state.send('Hallo', null, 'same-id'); second = h.state.send('Hallo', null, 'same-id'); });
|
||||
assert.equal(await second, false); assert.equal(ids.length, 1);
|
||||
await act(async () => pending.reject(new Error('Netzwerkfehler')));
|
||||
assert.equal(await first, false);
|
||||
await act(async () => assert.equal(await h.state.send('Hallo', null, 'same-id'), true));
|
||||
assert.deepEqual(ids, ['same-id', 'same-id']);
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
test('Im Hintergrund werden keine Nachrichten abgefragt oder als gelesen markiert', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.render(remote, false); await h.poll(); assert.equal(h.reads.length, 0);
|
||||
await h.render(remote, true); assert.equal(h.reads.length, 1);
|
||||
await h.render(remote, false); await h.poll(); assert.equal(h.reads.length, 1);
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
56
mobile/tests/chat-picker.test.mjs
Normal file
56
mobile/tests/chat-picker.test.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { test } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import * as chatModel from '../src/lib/chat.ts';
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const require = createRequire(import.meta.url);
|
||||
function harness() {
|
||||
let resolvePicker;
|
||||
const picker = new Promise(resolve => { resolvePicker = resolve; });
|
||||
const drafts = [];
|
||||
const chat = { detail: { name: 'Test', canSend: true }, messages: [], members: [], reactions: {}, loading: false };
|
||||
const output = ts.transpileModule(readFileSync(new URL('../components/chat-conversation.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
new Function('require', 'exports', output)(name => {
|
||||
if (name === 'react-native') return { ...Object.fromEntries(['ActivityIndicator', 'FlatList', 'Modal', 'Pressable', 'ScrollView', 'Text', 'TextInput', 'View'].map(key => [key, key])), StyleSheet: { create: value => value } };
|
||||
if (name === '@expo/vector-icons') return { Ionicons: 'Icon' };
|
||||
if (name === 'expo-image') return { Image: 'Image' };
|
||||
if (name === 'expo-image-picker') return { launchImageLibraryAsync: () => picker };
|
||||
if (name === '@/hooks/use-chat-conversation') return { useChatConversation: () => chat };
|
||||
if (name === '@/src/lib/chat-api') return { chatApi: {} };
|
||||
if (name === '@/src/lib/chat-image') return { prepareMobileChatImage: async () => ({ name: 'Bild.jpg', dataUrl: 'data:image/jpeg;base64,YQ==' }) };
|
||||
if (name === '@/src/lib/chat') return chatModel;
|
||||
return require(name);
|
||||
}, exports);
|
||||
const props = { token: 't', room: { key: 'test', name: 'Test' }, enabled: true,
|
||||
draft: { text: 'Bild dazu', image: null, clientId: 'draft' }, onDraft: draft => drafts.push(draft) };
|
||||
let root;
|
||||
return {
|
||||
drafts,
|
||||
async render(enabled) { await act(async () => { const element = React.createElement(exports.ChatConversation, { ...props, enabled }); if (root) root.update(element); else root = create(element); }); },
|
||||
async pick() { await act(async () => root.root.findByProps({ accessibilityLabel: 'Bild anhängen' }).props.onPress()); },
|
||||
async finish() { await act(async () => resolvePicker({ canceled: false, assets: [{ uri: 'file:///test.jpg' }] })); },
|
||||
async dispose() { await act(async () => root.unmount()); },
|
||||
};
|
||||
}
|
||||
test('System-Fotoauswahl darf die App vorübergehend in den Hintergrund versetzen', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.render(true); await h.pick(); await h.render(false); await h.finish();
|
||||
assert.equal(h.drafts.length, 1);
|
||||
assert.equal(h.drafts[0].text, 'Bild dazu');
|
||||
assert.equal(h.drafts[0].image.name, 'Bild.jpg');
|
||||
} finally { await h.dispose(); }
|
||||
});
|
||||
test('Eine verspätete Fotoauswahl verändert nach Raumwechsel keinen anderen Entwurf', async () => {
|
||||
const h = harness();
|
||||
await h.render(true); await h.pick(); await h.dispose(); await h.finish();
|
||||
assert.equal(h.drafts.length, 0);
|
||||
});
|
||||
133
mobile/tests/chat.test.mjs
Normal file
133
mobile/tests/chat.test.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
applyChatAccess, chatCursor, chatEndpoint, collaborationKey, createChatApi, matchesChatSearch,
|
||||
mergeChatMessages, validChatImage, visibleChatRooms,
|
||||
} from '../src/lib/chat.ts';
|
||||
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
const internal = { key: 'allgemein', name: 'Allgemein', source: 'internal', exists: true };
|
||||
const remote = { key: collaborationKey(id), id, name: 'Partner', source: 'collaboration', exists: true };
|
||||
function fixture(overrides = {}) {
|
||||
const responses = {
|
||||
'/api/communication/chat/rooms': { rooms: [internal] },
|
||||
'/api/communication/chat/project-rooms': { rooms: [{ key: 'project_42', name: 'Projekt', exists: true }] },
|
||||
'/api/communication/chat/direct-rooms': { rooms: [] },
|
||||
'/api/communication/chat/unread': { rooms: { allgemein: { count: 7 }, project_42: { count: 2 } } },
|
||||
'/api/communication/chat/room-preferences': { hiddenRoomKeys: ['allgemein', collaborationKey(id)] },
|
||||
'/api/collaboration': { rooms: [{ id, name: 'Partner', remote: true, unread: 11 }], invitations: [{ id: 'invite', name: 'Team' }],
|
||||
managedRooms: [{ id: 'not-a-member', name: 'Verwalteter Raum ohne Teilnahme' }], canManage: true, projects: [{ id: 42, name: 'Projekt' }] },
|
||||
...overrides,
|
||||
};
|
||||
const calls = [];
|
||||
return { calls, api: createChatApi(async (path, options) => {
|
||||
calls.push({ path, ...options });
|
||||
if (!(path in responses)) throw new Error(`Unexpected request: ${path}`);
|
||||
if (responses[path] instanceof Error) throw responses[path];
|
||||
return structuredClone(responses[path]);
|
||||
}) };
|
||||
}
|
||||
|
||||
test('Web-Sichtbarkeit gilt auch für föderierte Räume und den Ungelesen-Zähler', async () => {
|
||||
const { api, calls } = fixture();
|
||||
const directory = await api.directory('tenant-token');
|
||||
assert.deepEqual(visibleChatRooms(directory).map(room => room.key), ['project_42']);
|
||||
assert.equal(directory.rooms.length, 3); // No implicit access via managedRooms.
|
||||
assert.equal(directory.rooms[2].source, 'collaboration');
|
||||
assert.equal(directory.rooms[2].remote, true);
|
||||
assert.equal(directory.invitations[0].id, 'invite');
|
||||
assert.equal(await api.unread('tenant-token'), 2);
|
||||
assert.ok(calls.every(call => call.token === 'tenant-token'));
|
||||
});
|
||||
test('Fehlende Sichtbarkeitseinstellungen zeigen keine ungefilterte Chatliste', async () => {
|
||||
const { api } = fixture({ '/api/communication/chat/room-preferences': new Error('offline') });
|
||||
await assert.rejects(api.directory('token'), /offline/);
|
||||
await assert.rejects(api.unread('token'), /offline/);
|
||||
});
|
||||
test('Föderierte Endpunkte, Cursor und Lesebestätigungen verwenden Raum-ID und Sequenz', async () => {
|
||||
const calls = [];
|
||||
const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { messages: [] }; });
|
||||
await api.messages('t', remote, { after: 50 });
|
||||
await api.messages('t', remote, { before: 20 });
|
||||
await api.read('t', remote, 60);
|
||||
await api.messages('t', internal, { after: 12 });
|
||||
await api.read('t', internal, 14);
|
||||
assert.equal(calls[0].path, `/api/collaboration/rooms/${id}/messages?after=50`);
|
||||
assert.equal(calls[1].path, `/api/collaboration/rooms/${id}/messages?before=20`);
|
||||
assert.deepEqual(calls[2].body, { seq: 60 });
|
||||
assert.equal(calls[3].path, '/api/communication/chat/rooms/allgemein/sync?afterId=12');
|
||||
assert.deepEqual(calls[4].body, { messageId: 14 });
|
||||
assert.equal(chatCursor(remote, [{ id: 'pending', seq: null }, { id: 'uuid', seq: 7 }]), 7);
|
||||
});
|
||||
test('Bestätigungen ersetzen wartende Nachrichten ohne doppelte oder verspätete Kopien', () => {
|
||||
const pending = { id: 'client-1', clientId: 'client-1', own: true, pending: true, seq: null };
|
||||
const delivered = { id: 'server-1', clientId: 'client-1', own: true, seq: 9 };
|
||||
const first = mergeChatMessages([pending], [delivered], true);
|
||||
assert.deepEqual(first, [delivered]);
|
||||
assert.deepEqual(mergeChatMessages(first, [pending], true), [delivered]);
|
||||
assert.deepEqual(mergeChatMessages([], [delivered, pending], true), [delivered]);
|
||||
assert.deepEqual(mergeChatMessages([{ id: 2 }], [{ id: 1 }, { id: 2, body: 'aktualisiert' }], false), [{ id: 1 }, { id: 2, body: 'aktualisiert' }]);
|
||||
});
|
||||
test('Geänderte Teilnahmerechte entfernen frühere Historie und alte ausstehende Nachrichten', () => {
|
||||
const messages = [{ id: 'old', seq: 1 }, { id: 'new', seq: 8 }, { id: 'pending', seq: null }];
|
||||
assert.deepEqual(applyChatAccess(messages, { historyFromSeq: 5, revision: 2 }, { revision: 1 }), [{ id: 'new', seq: 8 }]);
|
||||
assert.equal(applyChatAccess(messages, { historyFromSeq: 5, revision: 1 }, { revision: 1 }).length, 2);
|
||||
});
|
||||
test('Senden behält die Idempotenz-ID und überträgt Bilder an die passende Schnittstelle', async () => {
|
||||
const calls = [];
|
||||
const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return {}; });
|
||||
const image = { name: 'Foto.jpg', dataUrl: 'data:image/jpeg;base64,YQ==' };
|
||||
await api.send('t', remote, '', image, 'same-client-id');
|
||||
await api.send('t', remote, '', image, 'same-client-id');
|
||||
await api.send('t', internal, 'Hallo', null, 'unused');
|
||||
assert.deepEqual(calls[0], calls[1]);
|
||||
assert.deepEqual(calls[0].body, { text: '', image, clientId: 'same-client-id' });
|
||||
assert.deepEqual(calls[2].body, { text: 'Hallo' });
|
||||
});
|
||||
test('Einladungen und ausgeblendete Räume verwenden dieselben Schlüssel wie das Web', async () => {
|
||||
const calls = [];
|
||||
const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return {}; });
|
||||
await api.respond('t', id, true); await api.respond('t', id, false);
|
||||
await api.hide('t', remote.key, true); await api.hide('t', remote.key, false);
|
||||
assert.equal(calls[0].path, `/api/collaboration/invitations/${id}/accept`);
|
||||
assert.equal(calls[1].path, `/api/collaboration/invitations/${id}/decline`);
|
||||
assert.deepEqual(calls[2].body, { roomKey: collaborationKey(id), hidden: true });
|
||||
assert.equal(calls[3].body.hidden, false);
|
||||
});
|
||||
test('Reaktionen werden begrenzt geladen; wartende Nachrichten bleiben ausgeschlossen', async () => {
|
||||
const calls = [];
|
||||
const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { reactions: {} }; });
|
||||
const messages = Array.from({ length: 205 }, (_, index) => ({ id: index + 1 }));
|
||||
messages.push({ id: 'pending', pending: true }, { id: 'failed', failed: true });
|
||||
await api.reactions('t', remote, messages);
|
||||
assert.equal(calls.length, 3);
|
||||
for (const call of calls) {
|
||||
const ids = new URL(call.path, 'https://example.invalid').searchParams.get('ids').split(',');
|
||||
assert.ok(ids.length <= 100); assert.ok(ids.every(value => /^\d+$/.test(value)));
|
||||
}
|
||||
await api.react('t', remote, id, '👍', false);
|
||||
assert.deepEqual(calls[3].body, { messageId: id, emoji: '👍', active: false });
|
||||
});
|
||||
test('Chatbilder akzeptieren keine externen URLs, SVGs oder übergroßen Inhalte', async () => {
|
||||
for (const value of ['https://tracker.invalid/image.png', 'data:image/svg+xml;base64,YQ==', `data:image/png;base64,${'a'.repeat(700_000)}`]) {
|
||||
assert.equal(validChatImage(value), false);
|
||||
const api = createChatApi(async () => ({ dataUrl: value }));
|
||||
await assert.rejects(api.image('t', remote, id), /Ungültiges Chatbild/);
|
||||
}
|
||||
assert.equal(validChatImage('data:image/webp;base64,YQ=='), true);
|
||||
});
|
||||
test('Suche berücksichtigt mehrere Begriffe, Umlaute und Partnernamen', () => {
|
||||
assert.equal(matchesChatSearch({ name: 'Büro Süd', ownerName: 'Straße GmbH' }, 'buro strasse'), true);
|
||||
assert.equal(matchesChatSearch({ name: 'Büro Süd', ownerName: 'Straße GmbH' }, 'buro nord'), false);
|
||||
});
|
||||
test('Noch nicht angelegte Direkt- und Projektchats werden gezielt provisioniert', async () => {
|
||||
const calls = [];
|
||||
const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { exists: true }; });
|
||||
await api.provision('t', { ...internal, exists: false, type: 'project', projectId: 42 });
|
||||
await api.provision('t', { ...internal, exists: false, type: 'direct', userId: 'user/id' });
|
||||
await api.provision('t', remote);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].path, '/api/communication/chat/project-rooms/42/provision');
|
||||
assert.equal(calls[1].path, '/api/communication/chat/direct-rooms/user%2Fid/provision');
|
||||
assert.equal(chatEndpoint(remote), `/api/collaboration/rooms/${id}`);
|
||||
});
|
||||
Reference in New Issue
Block a user