feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen

This commit is contained in:
2026-10-19 11:40:26 +02:00
parent bfba29b15e
commit da84e98b8a
14 changed files with 1107 additions and 289 deletions

View File

@@ -0,0 +1,4 @@
import { apiRequest } from '@/src/lib/api';
import { createChatApi } from '@/src/lib/chat';
export const chatApi = createChatApi(apiRequest);

View 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
View 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 } }),
};
}