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
|
## TestFlight vorbereiten
|
||||||
|
|
||||||
|
Der Preflight prüft Lint, die mobilen Chat-Regressionstests und Expo Doctor. Für eine separate Typprüfung: `npx tsc --noEmit`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run preflight:testflight
|
npm run preflight:testflight
|
||||||
npm run build:ios: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.
|
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": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "software.federspiel.fedeo",
|
"bundleIdentifier": "software.federspiel.fedeo",
|
||||||
"buildNumber": "15",
|
"buildNumber": "16",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
"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.",
|
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { Redirect, Tabs } from 'expo-router';
|
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 { HapticTab } from '@/components/haptic-tab';
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from '@/constants/theme';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
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';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
@@ -14,31 +15,27 @@ export default function TabLayout() {
|
|||||||
const { activeTenantId, isBootstrapping, token, requiresTenantSelection } = useAuth();
|
const { activeTenantId, isBootstrapping, token, requiresTenantSelection } = useAuth();
|
||||||
const [communicationUnread, setCommunicationUnread] = useState(0);
|
const [communicationUnread, setCommunicationUnread] = useState(0);
|
||||||
|
|
||||||
const refreshCommunicationUnread = useCallback(async () => {
|
|
||||||
if (!token || requiresTenantSelection) {
|
|
||||||
setCommunicationUnread(0);
|
|
||||||
await Notifications.setBadgeCountAsync(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const unread = await fetchChatUnreadCounts(token);
|
|
||||||
const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0);
|
|
||||||
setCommunicationUnread(total);
|
|
||||||
await Notifications.setBadgeCountAsync(total);
|
|
||||||
} catch {
|
|
||||||
// Badge loading must not block tab navigation.
|
|
||||||
}
|
|
||||||
}, [requiresTenantSelection, token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshCommunicationUnread();
|
let active = true;
|
||||||
|
let revision = 0;
|
||||||
if (!token || requiresTenantSelection) return undefined;
|
setCommunicationUnread(0);
|
||||||
|
void Notifications.setBadgeCountAsync(0).catch(() => undefined);
|
||||||
const id = setInterval(() => void refreshCommunicationUnread(), 30000);
|
async function refresh() {
|
||||||
return () => clearInterval(id);
|
if (!token || !activeTenantId || requiresTenantSelection || AppState.currentState !== 'active') return;
|
||||||
}, [activeTenantId, refreshCommunicationUnread, requiresTenantSelection, token]);
|
const request = ++revision;
|
||||||
|
try {
|
||||||
|
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. */ }
|
||||||
|
}
|
||||||
|
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(() => {
|
const communicationBadge = useMemo(() => {
|
||||||
if (!communicationUnread) return undefined;
|
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator, AppState, DeviceEventEmitter, FlatList, KeyboardAvoidingView, Modal, Platform,
|
||||||
FlatList,
|
Pressable, ScrollView, StyleSheet, Text, TextInput, View,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Modal,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
RefreshControl,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import {
|
import { ChatConversation, type ChatDraft } from '@/components/chat-conversation';
|
||||||
ChatMember,
|
import { apiRequest } from '@/src/lib/api';
|
||||||
ChatMessage,
|
import { chatApi } from '@/src/lib/chat-api';
|
||||||
ChatRoom,
|
import { collaborationKey, isChatAccessError, matchesChatSearch, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat';
|
||||||
createChatRoom,
|
import { getApiBaseUrlSync } from '@/src/lib/server-config';
|
||||||
fetchChatMembers,
|
|
||||||
fetchChatMessages,
|
|
||||||
fetchChatRooms,
|
|
||||||
markChatRoomRead,
|
|
||||||
provisionChatRoom,
|
|
||||||
sendChatMessage,
|
|
||||||
syncChatRoom,
|
|
||||||
} from '@/src/lib/api';
|
|
||||||
import { useAuth } from '@/src/providers/auth-provider';
|
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() {
|
export default function CommunicationScreen() {
|
||||||
const { token } = useAuth();
|
const { token, activeTenantId } = useAuth();
|
||||||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
if (!token || !activeTenantId) return null;
|
||||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
return <ChatScreen key={`${getApiBaseUrlSync()}:${activeTenantId}:${token}`} token={token} />;
|
||||||
const [members, setMembers] = useState<ChatMember[]>([]);
|
}
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
|
function ChatScreen({ token }: { token: string }) {
|
||||||
const [draft, setDraft] = useState('');
|
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 [search, setSearch] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [busy, setBusy] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [showHidden, setShowHidden] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [membersOpen, setMembersOpen] = useState(false);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [roomName, setRoomName] = useState('');
|
const [roomName, setRoomName] = useState('');
|
||||||
const [roomTopic, setRoomTopic] = useState('');
|
const [roomTopic, setRoomTopic] = useState('');
|
||||||
const [roomKey, setRoomKey] = useState('');
|
const [roomType, setRoomType] = useState<'internal' | 'collaboration'>('internal');
|
||||||
const afterIdRef = useRef(0);
|
const [projectId, setProjectId] = useState<number | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, ChatDraft>>({});
|
||||||
const activeRoom = useMemo(
|
const alive = useRef(true);
|
||||||
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
|
const directoryRevision = useRef(0);
|
||||||
[activeRoomKey, rooms]
|
const selectionRevision = useRef(0);
|
||||||
);
|
const operation = useRef(false);
|
||||||
|
const loadingDirectory = useRef(false);
|
||||||
const visibleRooms = useMemo(() => {
|
const directoryRef = useRef(directory);
|
||||||
const query = search.trim().toLowerCase();
|
directoryRef.current = directory;
|
||||||
return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber]
|
const [emptyDraft] = useState<ChatDraft>(() => ({ text: '', image: null, clientId: randomUUID() }));
|
||||||
.filter(Boolean).some((value) => String(value).toLowerCase().includes(query)));
|
const rooms = useMemo(() => directory ? visibleChatRooms(directory) : [], [directory]);
|
||||||
}, [rooms, search]);
|
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 loadRooms = useCallback(async () => {
|
const activeRoom = rooms.find(room => room.key === activeKey && room.exists);
|
||||||
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 refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!token) return;
|
if (loadingDirectory.current || operation.current) return;
|
||||||
|
const version = ++directoryRevision.current;
|
||||||
|
loadingDirectory.current = true;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
const result = await chatApi.directory(token);
|
||||||
const nextRooms = await loadRooms();
|
if (!alive.current || version !== directoryRevision.current) return;
|
||||||
const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0];
|
directoryRef.current = result; setDirectory(result); setError('');
|
||||||
if (selected?.exists) {
|
const available = visibleChatRooms(result);
|
||||||
setActiveRoomKey(selected.key);
|
setActiveKey(previous => available.some(room => room.key === previous && room.exists)
|
||||||
await loadRoom(selected.key);
|
? previous : available.find(room => room.exists)?.key || '');
|
||||||
}
|
} catch (err) {
|
||||||
} catch (nextError) {
|
if (!alive.current || version !== directoryRevision.current) return;
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.');
|
setError(err instanceof Error ? err.message : 'Chats konnten nicht geladen werden.');
|
||||||
|
if (isChatAccessError(err)) { directoryRef.current = null; setDirectory(null); setActiveKey(''); setDrafts({}); }
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
loadingDirectory.current = false;
|
||||||
setRefreshing(false);
|
if (alive.current && version === directoryRevision.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeRoomKey, loadRoom, loadRooms, token]);
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => { void refresh(); }, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !activeRoom?.exists) return undefined;
|
alive.current = true;
|
||||||
const id = setInterval(async () => {
|
const listener = AppState.addEventListener('change', state => setForeground(state === 'active'));
|
||||||
try {
|
return () => { alive.current = false; listener.remove(); };
|
||||||
const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current);
|
}, []);
|
||||||
if (!result.messages?.length) return;
|
useEffect(() => {
|
||||||
setMessages((current) => mergeMessages(current, result.messages || []));
|
if (!enabled) return;
|
||||||
afterIdRef.current = result.nextId || afterIdRef.current;
|
void refresh();
|
||||||
await markChatRoomRead(token, activeRoom.key, afterIdRef.current);
|
const timer = setInterval(() => void refresh(), 15000);
|
||||||
} catch {
|
return () => clearInterval(timer);
|
||||||
// Beim nächsten Intervall erneut versuchen.
|
}, [enabled, refresh]);
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [activeRoom, token]);
|
|
||||||
|
|
||||||
async function selectRoom(room: ChatRoom) {
|
async function select(room: MobileChatRoom) {
|
||||||
if (!token) return;
|
if (directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
|
||||||
|
const version = ++selectionRevision.current;
|
||||||
|
setError('');
|
||||||
|
if (room.exists) { setActiveKey(room.key); return; }
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setBusy(room.key);
|
||||||
const readyRoom = room.exists ? room : await provisionChatRoom(token, room);
|
const ready = await chatApi.provision(token, room);
|
||||||
setActiveRoomKey(readyRoom.key);
|
if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
|
||||||
await loadRooms();
|
setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) }));
|
||||||
await loadRoom(readyRoom.key);
|
setActiveKey(ready.key);
|
||||||
} catch (nextError) {
|
} catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); }
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.');
|
finally { if (alive.current && version === selectionRevision.current) setBusy(''); }
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitMessage() {
|
async function mutate(label: string, action: () => Promise<string | void>) {
|
||||||
if (!token || !activeRoom?.exists || !draft.trim() || sending) return;
|
if (operation.current) return;
|
||||||
const text = draft.trim();
|
operation.current = true; ++directoryRevision.current;
|
||||||
setDraft('');
|
setBusy(label); setError('');
|
||||||
setSending(true);
|
|
||||||
try {
|
try {
|
||||||
const message = await sendChatMessage(token, activeRoom.key, text);
|
const selectKey = await action();
|
||||||
setMessages((current) => mergeMessages(current, [message]));
|
if (!alive.current) return;
|
||||||
afterIdRef.current = Math.max(afterIdRef.current, message.id);
|
DeviceEventEmitter.emit('fedeo:chat-changed');
|
||||||
await markChatRoomRead(token, activeRoom.key, message.id);
|
const result = await chatApi.directory(token);
|
||||||
} catch (nextError) {
|
if (!alive.current) return;
|
||||||
setDraft(text);
|
directoryRef.current = result; setDirectory(result);
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.');
|
const available = visibleChatRooms(result);
|
||||||
} finally {
|
setActiveKey(previous => available.find(room => room.key === (selectKey || previous) && room.exists)?.key
|
||||||
setSending(false);
|
|| 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 hide(room: MobileChatRoom, hidden: boolean) {
|
||||||
async function submitRoom() {
|
await mutate(room.key, async () => {
|
||||||
if (!token || !roomName.trim()) return;
|
await chatApi.hide(token, room.key, hidden);
|
||||||
try {
|
if (!alive.current) return;
|
||||||
const created = await createChatRoom(token, {
|
// Update immediately after success, even if refreshing the directory subsequently fails.
|
||||||
key: normalizeRoomKey(roomKey || roomName),
|
setDirectory(previous => {
|
||||||
name: roomName.trim(),
|
if (!previous) return previous;
|
||||||
topic: roomTopic.trim() || null,
|
const next = { ...previous, hiddenRoomKeys: hidden ? [...new Set([...previous.hiddenRoomKeys, room.key])]
|
||||||
|
: previous.hiddenRoomKeys.filter(key => key !== room.key) };
|
||||||
|
directoryRef.current = next;
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
setCreateOpen(false);
|
if (hidden) setActiveKey(previous => previous === room.key ? '' : previous);
|
||||||
setRoomName('');
|
else { setShowHidden(false); return room.key; }
|
||||||
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) {
|
||||||
function renderRoom({ item }: { item: ChatRoom }) {
|
await mutate(id, async () => {
|
||||||
const active = item.key === activeRoom?.key;
|
const result = await chatApi.respond(token, id, accept);
|
||||||
return (
|
return accept && result.roomId ? collaborationKey(result.roomId) : undefined;
|
||||||
<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() {
|
||||||
function renderMessage({ item }: { item: ChatMessage }) {
|
if (!roomName.trim()) return;
|
||||||
return (
|
await mutate('create', async () => {
|
||||||
<View style={[styles.messageRow, item.own && styles.messageRowOwn]}>
|
let key: string;
|
||||||
<View style={[styles.messageBubble, item.own && styles.messageBubbleOwn]}>
|
if (roomType === 'collaboration') {
|
||||||
{!item.own ? <Text style={styles.messageAuthor}>{item.senderDisplayName || 'Benutzer'}</Text> : null}
|
const created = await apiRequest<{ id: string }>('/api/collaboration/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), projectId } });
|
||||||
<Text style={[styles.messageBody, item.own && styles.messageBodyOwn]}>{item.body}</Text>
|
key = collaborationKey(created.id);
|
||||||
<Text style={[styles.messageTime, item.own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
|
} else {
|
||||||
</View>
|
const normalized = roomName.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '').slice(0, 48);
|
||||||
</View>
|
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;
|
||||||
|
}
|
||||||
|
if (alive.current) { setCreateOpen(false); setRoomName(''); setRoomTopic(''); }
|
||||||
|
return key;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 95 : 0} style={styles.screen}>
|
||||||
return (
|
<View style={styles.toolbar}>
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
|
<TextInput style={styles.search} accessibilityLabel="Chats durchsuchen" placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
|
||||||
<View style={styles.header}>
|
<Pressable accessibilityRole="button" accessibilityLabel="Chat erstellen" onPress={() => setCreateOpen(true)} disabled={!directory || !!busy} style={styles.icon}><Ionicons name="add" size={25} /></Pressable>
|
||||||
<View style={styles.headerMain}>
|
<Pressable accessibilityRole="button" accessibilityLabel={`Ausgeblendete Chats (${hiddenRooms.length})`} onPress={() => setShowHidden(true)} style={styles.icon}><Ionicons name="eye-off-outline" size={23} /></Pressable>
|
||||||
<Text style={styles.title}>Chat</Text>
|
</View>
|
||||||
<Text style={styles.subtitle} numberOfLines={1}>{activeRoom?.name || 'FEDEO Kommunikation'}</Text>
|
{!!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>}
|
||||||
</View>
|
{!!directory?.invitations.length && <ScrollView style={styles.invitations} contentContainerStyle={{ gap: 8 }}>
|
||||||
<Pressable style={styles.headerButton} onPress={() => setCreateOpen(true)}><Text style={styles.headerButtonText}>+</Text></Pressable>
|
{directory.invitations.map(invitation => <View key={invitation.id} style={styles.invitation}>
|
||||||
<Pressable style={styles.headerButton} onPress={() => setMembersOpen((value) => !value)}><Text style={styles.headerButtonText}>☰</Text></Pressable>
|
<Text style={styles.bold}>Einladung: {invitation.name}</Text>
|
||||||
</View>
|
<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>}
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
<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 style={styles.roomPanel}>
|
</View>)}
|
||||||
<TextInput style={styles.searchInput} placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
|
</ScrollView>}
|
||||||
<FlatList data={visibleRooms} horizontal keyExtractor={(item) => item.key} renderItem={renderRoom}
|
<View style={styles.roomPanel}><FlatList horizontal data={filteredRooms} keyExtractor={room => room.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList}
|
||||||
showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} />
|
ListEmptyComponent={!loading ? <Text style={styles.hint}>{search ? 'Keine passenden Chats.' : 'Keine sichtbaren Chats.'}</Text> : null}
|
||||||
</View>
|
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>
|
||||||
{membersOpen ? (
|
{!!item.unread && <Text style={styles.badge}>{item.unread}</Text>}
|
||||||
<View style={styles.membersPanel}>
|
</Pressable>} /></View>
|
||||||
<Text style={styles.membersTitle}>Teilnehmer · {members.length}</Text>
|
{loading ? <View style={styles.empty}><ActivityIndicator /><Text>Chats werden geladen …</Text></View> : activeRoom ?
|
||||||
<Text style={styles.membersText} numberOfLines={2}>{members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')}</Text>
|
<ChatConversation key={activeRoom.key} token={token} room={activeRoom} enabled={enabled} hiding={!!busy}
|
||||||
</View>
|
draft={drafts[activeKey] || emptyDraft} onHide={() => void hide(activeRoom, true)}
|
||||||
) : null}
|
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; })} />
|
||||||
{loading ? (
|
: <View style={styles.empty}><Ionicons name="chatbubbles-outline" size={36} color="#69c350" /><Text>Wähle einen Chat aus.</Text></View>}
|
||||||
<View style={styles.loading}><ActivityIndicator /><Text style={styles.loadingText}>Nachrichten werden geladen …</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 ref={listRef} data={messages} keyExtractor={(item) => String(item.id)} renderItem={renderMessage}
|
<FlatList data={hiddenRooms.filter(room => matchesChatSearch(room, search))} keyExtractor={room => room.key} style={{ maxHeight: 380 }}
|
||||||
contentContainerStyle={styles.messageList}
|
ListEmptyComponent={<Text style={styles.hint}>Keine ausgeblendeten Chats gefunden.</Text>}
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); void refresh(); }} />}
|
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>
|
||||||
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
|
<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>} />
|
||||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} />
|
<Pressable onPress={() => setShowHidden(false)} style={styles.secondary}><Text>Schließen</Text></Pressable>
|
||||||
)}
|
</View></View>
|
||||||
|
</Modal>
|
||||||
<View style={styles.composer}>
|
<Modal transparent visible={createOpen} animationType="slide" onRequestClose={() => setCreateOpen(false)}>
|
||||||
<TextInput style={styles.composerInput} placeholder="Nachricht schreiben" value={draft} onChangeText={setDraft} multiline />
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.overlay}><View style={styles.modal}>
|
||||||
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={() => void submitMessage()}>
|
<Text style={styles.title}>Chat erstellen</Text>
|
||||||
<Text style={styles.sendButtonText}>{sending ? '…' : 'Senden'}</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>}
|
||||||
</Pressable>
|
<TextInput style={styles.input} accessibilityLabel="Chatname" placeholder="Name" value={roomName} onChangeText={setRoomName} maxLength={200} />
|
||||||
</View>
|
<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 }}>
|
||||||
<Modal transparent visible={createOpen} animationType="fade" onRequestClose={() => setCreateOpen(false)}>
|
<Pressable onPress={() => setProjectId(null)} style={styles.secondary}><Text>{projectId === null ? '✓ ' : ''}Ohne Projekt</Text></Pressable>
|
||||||
<View style={styles.modalBackdrop}><View style={styles.modalCard}>
|
{directory?.projects.map(project => <Pressable key={project.id} onPress={() => setProjectId(project.id)} style={styles.secondary}><Text>{projectId === project.id ? '✓ ' : ''}{project.name}</Text></Pressable>)}
|
||||||
<Text style={styles.modalTitle}>Chatraum erstellen</Text>
|
</ScrollView></>}
|
||||||
<TextInput style={styles.modalInput} placeholder="Name" value={roomName} onChangeText={setRoomName} />
|
{!!error && <Text accessibilityRole="alert" style={styles.error}>{error}</Text>}
|
||||||
<TextInput style={styles.modalInput} placeholder="Schlüssel, optional" value={roomKey} onChangeText={setRoomKey} autoCapitalize="none" />
|
<View style={styles.actions}><Pressable disabled={!!busy} onPress={() => setCreateOpen(false)} style={styles.secondary}><Text>Abbrechen</Text></Pressable>
|
||||||
<TextInput style={styles.modalInput} placeholder="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} />
|
<Pressable disabled={!!busy || !roomName.trim()} onPress={() => void create()} style={styles.button}><Text style={styles.white}>{busy === 'create' ? 'Wird erstellt …' : 'Erstellen'}</Text></Pressable></View>
|
||||||
<View style={styles.modalActions}>
|
</View></KeyboardAvoidingView>
|
||||||
<Pressable onPress={() => setCreateOpen(false)}><Text style={styles.linkText}>Abbrechen</Text></Pressable>
|
</Modal>
|
||||||
<Pressable style={styles.primaryButton} onPress={() => void submitRoom()}><Text style={styles.primaryButtonText}>Erstellen</Text></Pressable>
|
</KeyboardAvoidingView>;
|
||||||
</View>
|
|
||||||
</View></View>
|
|
||||||
</Modal>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: '#f9fafb' },
|
screen: { flex: 1, backgroundColor: '#f9fafb' }, toolbar: { flexDirection: 'row', alignItems: 'center', padding: 10, gap: 5, backgroundColor: '#fff' },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
search: { flex: 1, minWidth: 0, minHeight: 42, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, fontSize: 13, color: '#111827' },
|
||||||
headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 },
|
icon: { padding: 8, minWidth: 40, minHeight: 44, justifyContent: 'center', alignItems: 'center' }, roomPanel: { backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
|
||||||
headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' },
|
roomList: { paddingHorizontal: 12, paddingBottom: 10, gap: 8 }, room: { width: 165, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
|
||||||
headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' },
|
selectedRoom: { backgroundColor: '#eff9ea', borderColor: '#69c350', borderWidth: 1 }, group: { fontSize: 10, color: '#6b7280', textTransform: 'uppercase', marginBottom: 4 },
|
||||||
roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
bold: { fontWeight: '700', color: '#111827', fontSize: 13 }, badge: { marginTop: 4, alignSelf: 'flex-start', paddingHorizontal: 6, borderRadius: 8, backgroundColor: '#dcfce7', color: '#166534', fontSize: 11 },
|
||||||
searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 },
|
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 },
|
||||||
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
|
link: { color: '#2f6f25', fontWeight: '600' }, invitations: { maxHeight: 170, flexGrow: 0, padding: 10, backgroundColor: '#f0fdf4' }, invitation: { gap: 5, paddingBottom: 8 },
|
||||||
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' },
|
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, button: { paddingVertical: 11, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#438b30' },
|
||||||
roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' },
|
white: { color: '#fff', fontWeight: '600' }, secondary: { padding: 11, borderRadius: 8 }, overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'center', padding: 20 },
|
||||||
roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 },
|
modal: { maxHeight: '85%', backgroundColor: '#fff', borderRadius: 16, padding: 18, gap: 12 }, title: { fontSize: 18, fontWeight: '700', color: '#111827' },
|
||||||
membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 },
|
hiddenRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 9, borderBottomWidth: 1, borderColor: '#e5e7eb' }, input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 12, color: '#111827' },
|
||||||
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' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
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": "~54.0.37",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.14",
|
"expo-constants": "~18.0.14",
|
||||||
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "^14.0.8",
|
"expo-document-picker": "^14.0.8",
|
||||||
"expo-file-system": "~19.0.24",
|
"expo-file-system": "~19.0.24",
|
||||||
"expo-font": "~14.0.12",
|
"expo-font": "~14.0.12",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-image-manipulator": "~14.0.8",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"react-test-renderer": "19.1.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6250,6 +6253,18 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-document-picker": {
|
||||||
"version": "14.0.8",
|
"version": "14.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||||
@@ -6318,6 +6333,18 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-image-picker": {
|
||||||
"version": "17.0.11",
|
"version": "17.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz",
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"doctor": "npx expo-doctor",
|
"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:dev": "eas build --profile development --platform ios",
|
||||||
"build:ios:preview": "eas build --profile preview --platform ios",
|
"build:ios:preview": "eas build --profile preview --platform ios",
|
||||||
"build:ios:testflight": "eas build --profile testflight --platform ios",
|
"build:ios:testflight": "eas build --profile testflight --platform ios",
|
||||||
@@ -29,11 +30,13 @@
|
|||||||
"expo": "~54.0.37",
|
"expo": "~54.0.37",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.14",
|
"expo-constants": "~18.0.14",
|
||||||
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "^14.0.8",
|
"expo-document-picker": "^14.0.8",
|
||||||
"expo-file-system": "~19.0.24",
|
"expo-file-system": "~19.0.24",
|
||||||
"expo-font": "~14.0.12",
|
"expo-font": "~14.0.12",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-image-manipulator": "~14.0.8",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
@@ -62,6 +65,7 @@
|
|||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"react-test-renderer": "19.1.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"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