feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen
This commit is contained in:
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 } }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user