feat: Unified Messaging System — kommunikation plugin, AI/Proactive/System participants, MessageSidebar, Rich Content Renderer
Phase 1: Backend plugin kommunikation (13 files, 10 tables, REST API, WebSocket, RBAC, DMS Bridge, Participant Registry, Mini-App Registry, Search Provider) Phase 2: AI plugins as participants (ai_assistant + ai_proactive dock as participants, heartbeat job) Phase 3: system_notif plugin (system events → chat messages, pinned System room) Phase 4: Frontend MessageSidebar (replaces AISidebar, same design, comm API client, WebSocket hook, commStore) Phase 5: Rich Content Block Renderer (11 components: Markdown, HTML, Image, Audio, Video, File, ActionCard, ContactCard, MiniApp, BlockRenderer) BasePlugin: added services property + _container in on_activate
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Communication plugin API client.
|
||||
*
|
||||
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
|
||||
* kommunikation plugin routes under `/comm/...`.
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from './client';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
participant_id: string | null;
|
||||
participant_type: string;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
is_locked: boolean;
|
||||
locked_by: string | null;
|
||||
is_direct: boolean;
|
||||
is_archived: boolean;
|
||||
is_pinned: boolean;
|
||||
created_by: string | null;
|
||||
created_by_type: string;
|
||||
last_msg_at: string | null;
|
||||
last_msg_preview: string | null;
|
||||
last_msg_sender_type: string | null;
|
||||
participants: Participant[];
|
||||
unread_count: number;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MessageBlock {
|
||||
id: string;
|
||||
block_type: string;
|
||||
block_data: Record<string, any>;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: string;
|
||||
file_id: string | null;
|
||||
file_source: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_size: number | null;
|
||||
thumbnail_path: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string | null;
|
||||
sender_type: string;
|
||||
content: string;
|
||||
content_format: string;
|
||||
metadata: Record<string, any>;
|
||||
reply_to_id: string | null;
|
||||
is_pinned: boolean;
|
||||
created_at: string | null;
|
||||
edited_at: string | null;
|
||||
blocks: MessageBlock[];
|
||||
attachments: MessageAttachment[];
|
||||
reactions: any[];
|
||||
}
|
||||
|
||||
export interface ConversationListResponse {
|
||||
items: Conversation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
items: Message[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface BlockType {
|
||||
type: string;
|
||||
label: string;
|
||||
schema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MiniApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, any>;
|
||||
}
|
||||
|
||||
// ─── Conversations ───
|
||||
|
||||
export const listConversations = (archived?: boolean) =>
|
||||
apiGet<ConversationListResponse>('/comm/conversations', {
|
||||
params: archived !== undefined ? { archived } : {},
|
||||
});
|
||||
|
||||
export const createConversation = (data: {
|
||||
title?: string;
|
||||
participant_ids?: string[];
|
||||
is_direct?: boolean;
|
||||
initial_message?: string;
|
||||
}) => apiPost<Conversation>('/comm/conversations', data);
|
||||
|
||||
export const getConversation = (id: string) =>
|
||||
apiGet<Conversation>(`/comm/conversations/${id}`);
|
||||
|
||||
export const updateConversation = (id: string, data: { title?: string; is_archived?: boolean }) =>
|
||||
apiPatch<Conversation>(`/comm/conversations/${id}`, data);
|
||||
|
||||
export const leaveConversation = (id: string) =>
|
||||
apiDelete<{ success: boolean }>(`/comm/conversations/${id}`);
|
||||
|
||||
export const pinConversation = (id: string) =>
|
||||
apiPost<{ success: boolean }>(`/comm/conversations/${id}/pin`);
|
||||
|
||||
export const unpinConversation = (id: string) =>
|
||||
apiDelete<{ success: boolean }>(`/comm/conversations/${id}/pin`);
|
||||
|
||||
export const muteConversation = (id: string) =>
|
||||
apiPost<{ success: boolean }>(`/comm/conversations/${id}/mute`);
|
||||
|
||||
export const unmuteConversation = (id: string) =>
|
||||
apiDelete<{ success: boolean }>(`/comm/conversations/${id}/mute`);
|
||||
|
||||
// ─── Participants ───
|
||||
|
||||
export const addParticipant = (
|
||||
convId: string,
|
||||
data: { participant_id: string; participant_type: string; role?: string },
|
||||
) => apiPost<Participant>(`/comm/conversations/${convId}/participants`, data);
|
||||
|
||||
export const removeParticipant = (convId: string, pid: string) =>
|
||||
apiDelete<{ success: boolean }>(`/comm/conversations/${convId}/participants/${pid}`);
|
||||
|
||||
export const changeParticipantRole = (convId: string, pid: string, role: string) =>
|
||||
apiPatch<Participant>(`/comm/conversations/${convId}/participants/${pid}`, { role });
|
||||
|
||||
// ─── Messages ───
|
||||
|
||||
export const getMessages = (convId: string, page = 1, pageSize = 50, before?: string) =>
|
||||
apiGet<MessageListResponse>(`/comm/conversations/${convId}/messages`, {
|
||||
params: { page, page_size: pageSize, ...(before ? { before } : {}) },
|
||||
});
|
||||
|
||||
export const sendMessage = (
|
||||
convId: string,
|
||||
data: {
|
||||
content: string;
|
||||
content_format?: string;
|
||||
blocks?: Array<{ block_type: string; block_data: Record<string, any> }>;
|
||||
reply_to_id?: string;
|
||||
attachments?: Array<Record<string, any>>;
|
||||
},
|
||||
) => apiPost<Message>(`/comm/conversations/${convId}/messages`, data);
|
||||
|
||||
export const editMessage = (id: string, data: { content?: string }) =>
|
||||
apiPatch<Message>(`/comm/messages/${id}`, data);
|
||||
|
||||
export const deleteMessage = (id: string) =>
|
||||
apiDelete<{ success: boolean }>(`/comm/messages/${id}`);
|
||||
|
||||
// ─── Reactions ───
|
||||
|
||||
export const addReaction = (msgId: string, emoji: string) =>
|
||||
apiPost<any>(`/comm/messages/${msgId}/reactions`, { emoji });
|
||||
|
||||
export const removeReaction = (msgId: string, emoji: string) =>
|
||||
apiDelete<{ success: boolean }>(`/comm/messages/${msgId}/reactions/${encodeURIComponent(emoji)}`);
|
||||
|
||||
// ─── Read State ───
|
||||
|
||||
export const markRead = (convId: string, lastReadMsgId?: string) =>
|
||||
apiPost<{ success: boolean }>(`/comm/conversations/${convId}/read`, {
|
||||
last_read_msg_id: lastReadMsgId,
|
||||
});
|
||||
|
||||
// ─── Mini-Apps ───
|
||||
|
||||
export const listMiniApps = () =>
|
||||
apiGet<{ items: MiniApp[] }>('/comm/miniapps');
|
||||
|
||||
export const startMiniApp = (convId: string, appId: string, config?: Record<string, any>) =>
|
||||
apiPost<Message>(`/comm/conversations/${convId}/miniapps`, {
|
||||
app_id: appId,
|
||||
config: config || {},
|
||||
});
|
||||
|
||||
// ─── Block Types ───
|
||||
|
||||
export const getBlockTypes = () =>
|
||||
apiGet<{ items: BlockType[] }>('/comm/block-types');
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface ActionCardBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
interface ActionItem {
|
||||
label: string;
|
||||
action: string;
|
||||
type?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
const ActionCardBlock: React.FC<ActionCardBlockProps> = ({ block }) => {
|
||||
const { title, body, actions } = block.block_data;
|
||||
|
||||
const cardTitle: string = title || '';
|
||||
const cardBody: string = body || '';
|
||||
const actionList: ActionItem[] = Array.isArray(actions) ? actions : [];
|
||||
|
||||
const handleActionClick = (action: ActionItem) => {
|
||||
if (action.action === 'dismiss') {
|
||||
// Frontend handles dismiss — no-op here, parent component can wire up
|
||||
return;
|
||||
}
|
||||
// Treat as URL
|
||||
window.open(action.action, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 shadow-sm bg-white space-y-2">
|
||||
{cardTitle && (
|
||||
<h4 className="text-sm font-semibold text-secondary-800">{cardTitle}</h4>
|
||||
)}
|
||||
{cardBody && (
|
||||
<p className="text-sm text-secondary-600">{cardBody}</p>
|
||||
)}
|
||||
{actionList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{actionList.map((action, idx) => {
|
||||
const isPrimary = action.type !== 'secondary';
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleActionClick(action)}
|
||||
className={
|
||||
isPrimary
|
||||
? 'px-3 py-1.5 text-sm rounded-lg bg-primary-600 text-white hover:bg-primary-700 transition-colors'
|
||||
: 'px-3 py-1.5 text-sm rounded-lg bg-secondary-100 text-secondary-700 hover:bg-secondary-200 transition-colors'
|
||||
}
|
||||
>
|
||||
{action.label || 'Aktion'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionCardBlock;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface AudioBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | undefined): string | null {
|
||||
if (typeof seconds !== 'number' || seconds <= 0) return null;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const AudioBlock: React.FC<AudioBlockProps> = ({ block }) => {
|
||||
const { url, duration } = block.block_data;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationLabel = formatDuration(duration);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<audio
|
||||
src={url}
|
||||
controls
|
||||
className="w-full rounded-lg"
|
||||
preload="metadata"
|
||||
/>
|
||||
{durationLabel && (
|
||||
<p className="text-xs text-secondary-500">
|
||||
Dauer: {durationLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioBlock;
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
import MarkdownBlock from './MarkdownBlock';
|
||||
import HtmlBlock from './HtmlBlock';
|
||||
import ImageBlock from './ImageBlock';
|
||||
import AudioBlock from './AudioBlock';
|
||||
import VideoBlock from './VideoBlock';
|
||||
import FileBlock from './FileBlock';
|
||||
import ActionCardBlock from './ActionCardBlock';
|
||||
import ContactCardBlock from './ContactCardBlock';
|
||||
import MiniAppBlock from './MiniAppBlock';
|
||||
|
||||
interface BlockRendererProps {
|
||||
blocks: MessageBlock[];
|
||||
}
|
||||
|
||||
const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort blocks by sort_order to ensure correct rendering sequence
|
||||
const sortedBlocks = [...blocks].sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sortedBlocks.map((block) => {
|
||||
const renderBlock = (): React.ReactNode => {
|
||||
switch (block.block_type) {
|
||||
case 'text':
|
||||
// Text blocks: render as plain text in a styled div
|
||||
return (
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{block.block_data.text || block.block_data.content || ''}
|
||||
</div>
|
||||
);
|
||||
case 'markdown':
|
||||
return <MarkdownBlock block={block} />;
|
||||
case 'html':
|
||||
return <HtmlBlock block={block} />;
|
||||
case 'image':
|
||||
return <ImageBlock block={block} />;
|
||||
case 'audio':
|
||||
return <AudioBlock block={block} />;
|
||||
case 'video':
|
||||
return <VideoBlock block={block} />;
|
||||
case 'file':
|
||||
return <FileBlock block={block} />;
|
||||
case 'action_card':
|
||||
return <ActionCardBlock block={block} />;
|
||||
case 'contact_card':
|
||||
return <ContactCardBlock block={block} />;
|
||||
case 'miniapp':
|
||||
return <MiniAppBlock block={block} />;
|
||||
default:
|
||||
// Fallback for unknown block types
|
||||
return (
|
||||
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
||||
Unbekannter Block-Typ: {block.block_type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={block.id} className="block-wrapper">
|
||||
{renderBlock()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockRenderer;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface ContactCardBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
|
||||
const { contact_id, name } = block.block_data;
|
||||
|
||||
const contactName: string = name || 'Unbekannter Kontakt';
|
||||
const contactId: string = contact_id || '';
|
||||
const linkHref = contactId ? `/contacts/${contactId}` : '#';
|
||||
|
||||
// Generate initials for avatar placeholder
|
||||
const initials = contactName
|
||||
.split(' ')
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<a
|
||||
href={linkHref}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-secondary-200 bg-white shadow-sm hover:shadow-md hover:border-primary-300 transition-all"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-sm font-semibold">
|
||||
{initials || '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
|
||||
<p className="text-xs text-secondary-400">Kontakt anzeigen</p>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-secondary-400 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactCardBlock;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface FileBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (typeof bytes !== 'number' || bytes <= 0) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
const fileIcon = (
|
||||
<svg
|
||||
className="w-8 h-8 text-secondary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z M14 2v6h6 M8 13h8 M8 17h5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const downloadIcon = (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FileBlock: React.FC<FileBlockProps> = ({ block }) => {
|
||||
const { url, name, size } = block.block_data;
|
||||
|
||||
if (!url && !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName: string = name || 'Datei';
|
||||
const fileSizeLabel = formatFileSize(size);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-secondary-200 bg-white shadow-sm">
|
||||
<div className="flex-shrink-0">{fileIcon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{fileName}</p>
|
||||
{fileSizeLabel && (
|
||||
<p className="text-xs text-secondary-400">{fileSizeLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
download={fileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 transition-colors"
|
||||
aria-label={`Datei herunterladen: ${fileName}`}
|
||||
>
|
||||
{downloadIcon}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileBlock;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface HtmlBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic HTML sanitization: removes <script> tags and event handler attributes.
|
||||
* This is a minimal sanitizer — for production use DOMPurify or similar.
|
||||
*/
|
||||
function sanitizeHtml(html: string): string {
|
||||
let sanitized = html;
|
||||
// Remove <script>...</script> blocks (including content)
|
||||
sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
// Remove <script ...> self-referencing or incomplete tags
|
||||
sanitized = sanitized.replace(/<script\b[^>]*>/gi, '');
|
||||
// Remove on* event handler attributes (onclick, onload, onerror, etc.)
|
||||
sanitized = sanitized.replace(/\son\w+\s*=\s*"[^"]*"/gi, '');
|
||||
sanitized = sanitized.replace(/\son\w+\s*=\s*'[^']*'/gi, '');
|
||||
sanitized = sanitized.replace(/\son\w+\s*=\s*[^\s>]+/gi, '');
|
||||
// Remove javascript: URLs in href/src
|
||||
sanitized = sanitized.replace(/(href|src)\s*=\s*"javascript:[^"]*"/gi, '$1="#"');
|
||||
sanitized = sanitized.replace(/(href|src)\s*=\s*'javascript:[^']*'/gi, '$1="#"');
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const HtmlBlock: React.FC<HtmlBlockProps> = ({ block }) => {
|
||||
const rawHtml: string = block.block_data.html || '';
|
||||
|
||||
if (!rawHtml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeHtml(rawHtml);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-inherit"
|
||||
dangerouslySetInnerHTML={{ __html: sanitized }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HtmlBlock;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface ImageBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
const ImageBlock: React.FC<ImageBlockProps> = ({ block }) => {
|
||||
const { url, alt, width } = block.block_data;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAlt: string = alt || '';
|
||||
const imageWidth: number | undefined = typeof width === 'number' ? width : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={url}
|
||||
alt={imageAlt}
|
||||
width={imageWidth}
|
||||
className="rounded-lg max-w-full shadow-sm cursor-pointer hover:shadow-md transition-shadow"
|
||||
/>
|
||||
</a>
|
||||
{imageAlt && (
|
||||
<p className="text-xs text-secondary-500 italic">{imageAlt}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageBlock;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface MarkdownBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
const MarkdownBlock: React.FC<MarkdownBlockProps> = ({ block }) => {
|
||||
const markdown: string = block.block_data.markdown || block.block_data.content || '';
|
||||
|
||||
if (!markdown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-inherit">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc pl-4 mb-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal pl-4 mb-1">{children}</ol>,
|
||||
code: ({ children, className }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded bg-black/10 text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="p-2 rounded bg-black/10 overflow-x-auto">
|
||||
<code className="text-xs font-mono">{children}</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => <h1 className="text-base font-bold mb-1">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-bold mb-1">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1">{children}</h3>,
|
||||
a: ({ children, href }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-current/30 pl-2 italic opacity-80">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownBlock;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface MiniAppBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||
const { app_id, config } = block.block_data;
|
||||
|
||||
const appId: string = app_id || 'Unbekannt';
|
||||
const hasConfig = config && typeof config === 'object' && Object.keys(config).length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-2 border-dashed border-secondary-300 rounded-lg p-4 text-center bg-secondary-50">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
className="w-8 h-8 text-secondary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 6a2 2 0 012-2h12a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z M9 9h6v6H9z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-secondary-600">
|
||||
Mini-App: {appId}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400">
|
||||
Mini-Apps werden in Zukunft vollständig gerendert.
|
||||
</p>
|
||||
{hasConfig && (
|
||||
<details className="text-xs text-secondary-400 mt-1">
|
||||
<summary className="cursor-pointer hover:text-secondary-600">Konfiguration</summary>
|
||||
<pre className="mt-1 p-2 rounded bg-white text-left overflow-x-auto">
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniAppBlock;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
interface VideoBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
const VideoBlock: React.FC<VideoBlockProps> = ({ block }) => {
|
||||
const { url, thumbnail } = block.block_data;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const poster: string | undefined = typeof thumbnail === 'string' ? thumbnail : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
poster={poster}
|
||||
className="w-full rounded-lg"
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoBlock;
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as BlockRenderer } from './BlockRenderer';
|
||||
export { default as MarkdownBlock } from './MarkdownBlock';
|
||||
export { default as HtmlBlock } from './HtmlBlock';
|
||||
export { default as ImageBlock } from './ImageBlock';
|
||||
export { default as AudioBlock } from './AudioBlock';
|
||||
export { default as VideoBlock } from './VideoBlock';
|
||||
export { default as FileBlock } from './FileBlock';
|
||||
export { default as ActionCardBlock } from './ActionCardBlock';
|
||||
export { default as ContactCardBlock } from './ContactCardBlock';
|
||||
export { default as MiniAppBlock } from './MiniAppBlock';
|
||||
@@ -0,0 +1,66 @@
|
||||
# Test Report: Phase 5 — Rich Content Block-Renderer
|
||||
|
||||
## Task
|
||||
Implement Frontend Rich Content Block-Renderer Components for Unified Messaging System.
|
||||
|
||||
## Files Created
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `BlockRenderer.tsx` | 75 | Main dispatcher — switches on `block_type`, renders appropriate sub-renderer, fallback for unknown types |
|
||||
| `MarkdownBlock.tsx` | 59 | Renders Markdown via `react-markdown` (already installed), custom component styling |
|
||||
| `HtmlBlock.tsx` | 45 | Renders sanitized HTML via `dangerouslySetInnerHTML` — strips `<script>`, `on*` handlers, `javascript:` URLs |
|
||||
| `ImageBlock.tsx` | 35 | Renders image with alt caption, optional width, opens in new tab on click |
|
||||
| `AudioBlock.tsx` | 41 | Renders `<audio>` player with controls, shows formatted duration |
|
||||
| `VideoBlock.tsx` | 30 | Renders `<video>` player with controls, optional thumbnail poster |
|
||||
| `FileBlock.tsx` | 85 | Renders file card with icon, name, size, download link |
|
||||
| `ActionCardBlock.tsx` | 62 | Renders interactive card with title, body, action buttons (primary/secondary styling) |
|
||||
| `ContactCardBlock.tsx` | 52 | Renders contact reference card with avatar initials, links to `/contacts/{id}` |
|
||||
| `MiniAppBlock.tsx` | 50 | Renders placeholder for mini-apps with app_id and config details |
|
||||
| `index.ts` | 10 | Barrel export for all components |
|
||||
|
||||
**Total: 544 lines across 11 files**
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `MessageSidebar.tsx` | Added `BlockRenderer` import; replaced inline block rendering (40 lines) with `<BlockRenderer blocks={msg.blocks} />` (6 lines) |
|
||||
|
||||
## TypeScript Compilation Check
|
||||
|
||||
```
|
||||
cd /a0/usr/workdir/leocrm-fix/frontend && npx tsc --noEmit 2>&1 | grep -E 'comm/blocks|MessageSidebar'
|
||||
→ No errors in block components or MessageSidebar
|
||||
```
|
||||
|
||||
### Pre-existing errors (NOT introduced by this task):
|
||||
- `src/pages/Dms.tsx(553,13)`: `onRangeSelect` prop mismatch in `FileExplorerProps` — pre-existing, unrelated.
|
||||
|
||||
## Backend Compatibility
|
||||
|
||||
All block types match `content_types.py` definitions:
|
||||
- `text` → `block_data.text`
|
||||
- `markdown` → `block_data.markdown` (also falls back to `block_data.content`)
|
||||
- `html` → `block_data.html`
|
||||
- `image` → `block_data.url`, `block_data.alt`, `block_data.width`
|
||||
- `audio` → `block_data.url`, `block_data.duration`
|
||||
- `video` → `block_data.url`, `block_data.thumbnail`
|
||||
- `file` → `block_data.url`, `block_data.name`, `block_data.size`
|
||||
- `action_card` → `block_data.title`, `block_data.body`, `block_data.actions`
|
||||
- `contact_card` → `block_data.contact_id`, `block_data.name`
|
||||
- `miniapp` → `block_data.app_id`, `block_data.config`
|
||||
|
||||
## Smoke Test
|
||||
|
||||
- **TypeScript**: `npx tsc --noEmit` passes with zero new errors from block components.
|
||||
- **Import chain**: `BlockRenderer` imports all 9 sub-renderers + types from `@/store/commStore` — all resolve correctly.
|
||||
- **MessageSidebar integration**: Inline block rendering replaced with `<BlockRenderer>` component — compiles cleanly.
|
||||
- **Barrel export**: `index.ts` exports all 10 components for clean imports.
|
||||
|
||||
## Notes
|
||||
|
||||
- `react-markdown` was already in `package.json` (`^10.1.0`) — used directly.
|
||||
- HTML sanitization is minimal (regex-based). For production, consider `DOMPurify`.
|
||||
- Unknown block types render a fallback message ("Unbekannter Block-Typ: {type}") per spec.
|
||||
- Blocks are sorted by `sort_order` before rendering.
|
||||
@@ -3,7 +3,7 @@ import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TopBar } from './TopBar';
|
||||
import { PluginToolbar } from './PluginToolbar';
|
||||
import { AISidebar } from './AISidebar';
|
||||
import { MessageSidebar } from './MessageSidebar';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import { useAIContext } from '@/hooks/useAIContext';
|
||||
|
||||
@@ -13,8 +13,8 @@ export function AppShell() {
|
||||
// Track context for AI Proactive suggestions
|
||||
useAIContext();
|
||||
|
||||
// Hide AI sidebar on the AI Assistant page itself
|
||||
const showAISidebar = !location.pathname.startsWith('/ai-assistant');
|
||||
// Hide message sidebar on the AI Assistant page itself
|
||||
const showMessageSidebar = !location.pathname.startsWith('/ai-assistant');
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
||||
@@ -34,8 +34,8 @@ export function AppShell() {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/* AI Sidebar — full height, right of TopBar and Toolbar */}
|
||||
{showAISidebar && <AISidebar />}
|
||||
{/* Message Sidebar — full height, right of TopBar and Toolbar */}
|
||||
{showMessageSidebar && <MessageSidebar />}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useCommStore } from '@/store/commStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUsers, useGroups } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import {
|
||||
listConversations,
|
||||
getMessages,
|
||||
sendMessage,
|
||||
markRead,
|
||||
createConversation,
|
||||
} from '@/api/comm';
|
||||
import { useCommWebSocket } from '@/hooks/useCommWebSocket';
|
||||
import type { Conversation, Message } from '@/store/commStore';
|
||||
import { BlockRenderer } from '@/components/comm/blocks';
|
||||
|
||||
// ─── Icons (identical to AISidebar) ───
|
||||
|
||||
const robotIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2a4 4 0 014 4v1h1a3 3 0 013 3v6a3 3 0 01-3 3h-1v1a4 4 0 01-4 4H8a4 4 0 01-4-4v-1H3a3 3 0 01-3-3V10a3 3 0 013-3h1V6a4 4 0 014-4z M9 10h.01M15 10h.01M9 15h6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const bellIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const bulbIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const teamIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const chatBubbleIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const chevronRightIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const sendIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const pinIcon = (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
type SidebarView = 'conversations' | 'team';
|
||||
|
||||
interface QuickAccessDef {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: (cls: string) => React.ReactNode;
|
||||
testId: string;
|
||||
view: SidebarView;
|
||||
filterPinned?: string;
|
||||
}
|
||||
|
||||
// ─── Team Panel (reused from AISidebar) ───
|
||||
|
||||
function TeamPanel({ onStartDirectChat }: { onStartDirectChat: (userId: string, userName: string) => void }) {
|
||||
const { data: usersData, isLoading: usersLoading } = useUsers();
|
||||
const { data: groupsData, isLoading: groupsLoading } = useGroups();
|
||||
const users: any[] = usersData?.items || [];
|
||||
const groups: any[] = groupsData?.items || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto p-3 gap-3" data-testid="team-panel">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">Mitarbeiter</h3>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm text-secondary-400">Laden...</p>
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">Keine Mitarbeiter</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{users.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => onStartDirectChat(u.id, u.name)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors w-full text-left"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar name={u.name} size="sm" />
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label="offline" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
||||
<p className="text-xs text-secondary-400 truncate">{u.email}</p>
|
||||
</div>
|
||||
<span className="text-xs text-secondary-400">{u.role}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">Gruppen</h3>
|
||||
{groupsLoading ? (
|
||||
<p className="text-sm text-secondary-400">Laden...</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">Keine Gruppen</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-200 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p>
|
||||
{g.description && <p className="text-xs text-secondary-400 truncate">{g.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Conversation List Item ───
|
||||
|
||||
function ConversationListItem({
|
||||
conv,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
conv: Conversation;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const senderIcon =
|
||||
conv.last_msg_sender_type === 'ai' ? '🤖' : conv.last_msg_sender_type === 'system' ? '🔔' : '👤';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded-lg transition-colors w-full text-left ${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'hover:bg-secondary-50 text-secondary-700'
|
||||
}`}
|
||||
data-testid={`conv-item-${conv.id}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{conv.is_pinned && <span className="text-secondary-400 flex-shrink-0">📌</span>}
|
||||
<p className="text-sm font-medium truncate flex-1">
|
||||
{conv.title || 'Ohne Titel'}
|
||||
</p>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-danger-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_msg_preview && (
|
||||
<p className="text-xs text-secondary-400 truncate mt-0.5">
|
||||
<span className="mr-1">{senderIcon}</span>
|
||||
{conv.last_msg_preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message Feed ───
|
||||
|
||||
function MessageFeed({
|
||||
messages,
|
||||
loading,
|
||||
}: {
|
||||
messages: Message[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
|
||||
Laden...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
|
||||
Keine Nachrichten
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{messages.map((msg) => {
|
||||
const isAI = msg.sender_type === 'ai';
|
||||
const isSystem = msg.sender_type === 'system';
|
||||
const senderIcon = isAI ? '🤖' : isSystem ? '🔔' : '👤';
|
||||
const senderName = isAI ? 'KI' : isSystem ? 'System' : 'Benutzer';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col ${msg.sender_type === 'user' ? 'items-end' : 'items-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 ${
|
||||
msg.sender_type === 'user'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-secondary-100 text-secondary-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<span className="text-xs">{senderIcon}</span>
|
||||
<span className="text-xs font-medium opacity-70">{senderName}</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap break-words">{msg.content}</div>
|
||||
{/* Render rich content blocks via BlockRenderer */}
|
||||
{msg.blocks && msg.blocks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<BlockRenderer blocks={msg.blocks} />
|
||||
</div>
|
||||
)}
|
||||
{msg.created_at && (
|
||||
<span className="text-[10px] opacity-50 mt-0.5 block">
|
||||
{new Date(msg.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message Input ───
|
||||
|
||||
function MessageInput({
|
||||
onSend,
|
||||
disabled,
|
||||
}: {
|
||||
onSend: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
if (!text.trim() || disabled) return;
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border-t border-secondary-200">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? 'Schreibgeschützt' : 'Nachricht eingeben...'}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-lg border border-secondary-200 bg-white text-secondary-800 placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-secondary-50 disabled:text-secondary-400"
|
||||
data-testid="message-input"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:bg-secondary-200 disabled:text-secondary-400 transition-colors min-h-touch min-w-touch flex items-center justify-center"
|
||||
aria-label="Senden"
|
||||
data-testid="message-send-btn"
|
||||
>
|
||||
{sendIcon}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main MessageSidebar Component ───
|
||||
|
||||
export function MessageSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
messageSidebarCollapsed,
|
||||
toggleMessageSidebar,
|
||||
} = useUIStore();
|
||||
|
||||
const {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
messages,
|
||||
loading,
|
||||
setConversations,
|
||||
setActiveConversation,
|
||||
setMessages,
|
||||
setLoading,
|
||||
setUnread,
|
||||
} = useCommStore();
|
||||
|
||||
const [currentView, setCurrentView] = useState<SidebarView>('conversations');
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Connect WebSocket
|
||||
useCommWebSocket();
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listConversations();
|
||||
const convs = res.items || [];
|
||||
setConversations(convs);
|
||||
// Auto-select first pinned conversation or first conversation
|
||||
if (convs.length > 0 && !activeConversationId) {
|
||||
const pinned = convs.find((c) => c.is_pinned);
|
||||
const first = pinned || convs[0];
|
||||
setActiveConversation(first.id);
|
||||
loadMessages(first.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load conversations:', e);
|
||||
setError('Konversationen konnten nicht geladen werden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadMessages = useCallback(async (convId: string) => {
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const res = await getMessages(convId);
|
||||
setMessages(convId, res.items || []);
|
||||
// Mark as read
|
||||
await markRead(convId);
|
||||
setUnread(convId, 0);
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e);
|
||||
setMessages(convId, []);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
(convId: string) => {
|
||||
setActiveConversation(convId);
|
||||
loadMessages(convId);
|
||||
},
|
||||
[loadMessages],
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
if (!activeConversationId) return;
|
||||
try {
|
||||
await sendMessage(activeConversationId, {
|
||||
content: text,
|
||||
content_format: 'text',
|
||||
});
|
||||
// The message will arrive via WebSocket, but also reload to be safe
|
||||
loadMessages(activeConversationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to send message:', e);
|
||||
setError('Nachricht konnte nicht gesendet werden');
|
||||
}
|
||||
},
|
||||
[activeConversationId, loadMessages],
|
||||
);
|
||||
|
||||
const handleStartDirectChat = useCallback(
|
||||
async (userId: string, userName: string) => {
|
||||
try {
|
||||
const conv = await createConversation({
|
||||
title: userName,
|
||||
participant_ids: [userId],
|
||||
is_direct: true,
|
||||
});
|
||||
// Reload conversations to include the new one
|
||||
await loadConversations();
|
||||
if (conv && conv.id) {
|
||||
handleSelectConversation(conv.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start direct chat:', e);
|
||||
setError('Direkt-Chat konnte nicht gestartet werden');
|
||||
}
|
||||
},
|
||||
[loadConversations, handleSelectConversation],
|
||||
);
|
||||
|
||||
// Quick access buttons (same icons as AISidebar tabs)
|
||||
const quickAccess: QuickAccessDef[] = [
|
||||
{ key: 'assistant', label: 'Assistent', icon: robotIcon, testId: 'msg-sidebar-qa-assistant', view: 'conversations', filterPinned: 'assistant' },
|
||||
{ key: 'liveai', label: 'Live KI', icon: bulbIcon, testId: 'msg-sidebar-qa-liveai', view: 'conversations', filterPinned: 'liveai' },
|
||||
{ key: 'system', label: t('topbar.notifications'), icon: bellIcon, testId: 'msg-sidebar-qa-system', view: 'conversations', filterPinned: 'system' },
|
||||
{ key: 'team', label: 'Team', icon: teamIcon, testId: 'msg-sidebar-qa-team', view: 'team' },
|
||||
{ key: 'all', label: 'Chat', icon: chatBubbleIcon, testId: 'msg-sidebar-qa-all', view: 'conversations' },
|
||||
];
|
||||
|
||||
// Pinned conversations (system rooms)
|
||||
const pinnedConversations = conversations.filter((c) => c.is_pinned);
|
||||
const normalConversations = conversations.filter((c) => !c.is_pinned);
|
||||
|
||||
// Active conversation object
|
||||
const activeConv = conversations.find((c) => c.id === activeConversationId) || null;
|
||||
const activeMessages = activeConversationId ? messages[activeConversationId] || [] : [];
|
||||
const isSystemLocked =
|
||||
activeConv?.is_locked && activeConv?.locked_by === 'system_notif';
|
||||
|
||||
// Determine which pinned conv to select when a quick-access button is clicked
|
||||
const handleQuickAccess = (qa: QuickAccessDef) => {
|
||||
setCurrentView(qa.view);
|
||||
if (qa.view === 'conversations' && qa.filterPinned) {
|
||||
// Find pinned conversation matching the filter
|
||||
const filter = qa.filterPinned;
|
||||
const match = pinnedConversations.find((c) =>
|
||||
c.metadata?.room_type === filter ||
|
||||
(filter && c.title?.toLowerCase().includes(filter)),
|
||||
);
|
||||
if (match) {
|
||||
handleSelectConversation(match.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (qa.view === 'conversations' && !qa.filterPinned) {
|
||||
// 'all' — just show the list, select first if none active
|
||||
if (!activeConversationId && conversations.length > 0) {
|
||||
handleSelectConversation(conversations[0].id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Collapsed: narrow icon strip (desktop only) ───
|
||||
if (messageSidebarCollapsed) {
|
||||
return (
|
||||
<div
|
||||
className="hidden md:flex flex-shrink-0 w-12 flex-col items-center border-l border-secondary-200 bg-white py-3 gap-2"
|
||||
data-testid="message-sidebar-collapsed"
|
||||
>
|
||||
{quickAccess.map((qa) => (
|
||||
<button
|
||||
key={qa.key}
|
||||
onClick={() => {
|
||||
handleQuickAccess(qa);
|
||||
toggleMessageSidebar();
|
||||
}}
|
||||
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 relative"
|
||||
aria-label={qa.label}
|
||||
title={qa.label}
|
||||
data-testid={qa.testId}
|
||||
>
|
||||
{qa.icon('w-5 h-5 text-secondary-600')}
|
||||
{qa.key === 'system' &&
|
||||
pinnedConversations.some((c) => c.unread_count > 0) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-danger-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{pinnedConversations.reduce((sum, c) => sum + c.unread_count, 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Icon Strip (expanded) ───
|
||||
const iconStrip = (
|
||||
<div className="flex items-center gap-1 px-2 h-[58px] border-b border-secondary-200" role="tablist">
|
||||
{quickAccess.map((qa) => {
|
||||
const isActive =
|
||||
(qa.view === 'conversations' && currentView === 'conversations') ||
|
||||
(qa.view === 'team' && currentView === 'team');
|
||||
return (
|
||||
<button
|
||||
key={qa.key}
|
||||
onClick={() => handleQuickAccess(qa)}
|
||||
className={`p-2 rounded-md min-h-touch min-w-touch flex items-center justify-center transition-colors relative ${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-600'
|
||||
: 'text-secondary-500 hover:bg-secondary-100 hover:text-secondary-700'
|
||||
}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={qa.label}
|
||||
data-testid={qa.testId}
|
||||
>
|
||||
{qa.icon('w-5 h-5')}
|
||||
{qa.key === 'system' &&
|
||||
pinnedConversations.some((c) => c.unread_count > 0) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-danger-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{pinnedConversations.reduce((sum, c) => sum + c.unread_count, 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleMessageSidebar}
|
||||
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
||||
title="Einklappen"
|
||||
aria-label="Messaging einklappen"
|
||||
>
|
||||
{chevronRightIcon}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Conversation List Panel ───
|
||||
const renderConversationList = () => (
|
||||
<div className="flex flex-col h-full overflow-y-auto p-2 gap-1" data-testid="conversation-list">
|
||||
{loading && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Laden...</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center py-2">{error}</p>
|
||||
)}
|
||||
{!loading && conversations.length === 0 && !error && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Konversationen</p>
|
||||
)}
|
||||
{/* Pinned conversations */}
|
||||
{pinnedConversations.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide px-1 py-1 flex items-center gap-1">
|
||||
{pinIcon} Angespinnt
|
||||
</h3>
|
||||
{pinnedConversations.map((conv) => (
|
||||
<ConversationListItem
|
||||
key={conv.id}
|
||||
conv={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onClick={() => handleSelectConversation(conv.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Normal conversations */}
|
||||
{normalConversations.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide px-1 py-1">
|
||||
Konversationen
|
||||
</h3>
|
||||
{normalConversations.map((conv) => (
|
||||
<ConversationListItem
|
||||
key={conv.id}
|
||||
conv={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onClick={() => handleSelectConversation(conv.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Main Content Area ───
|
||||
const renderContent = () => {
|
||||
if (currentView === 'team') {
|
||||
return <TeamPanel onStartDirectChat={handleStartDirectChat} />;
|
||||
}
|
||||
// conversations view: show conversation list + message feed + input
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Conversation list (narrow, top section within panel) */}
|
||||
<div className="border-b border-secondary-200 max-h-[40%] overflow-y-auto">
|
||||
{renderConversationList()}
|
||||
</div>
|
||||
{/* Message feed */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{activeConv && (
|
||||
<div className="px-3 py-2 border-b border-secondary-200 flex items-center gap-2">
|
||||
{activeConv.is_pinned && <span className="text-secondary-400">📌</span>}
|
||||
<span className="text-sm font-medium text-secondary-700 truncate">
|
||||
{activeConv.title || 'Ohne Titel'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageFeed messages={activeMessages} loading={messagesLoading} />
|
||||
{/* Message input (hidden for system-locked conversations) */}
|
||||
{activeConv && !isSystemLocked && (
|
||||
<MessageInput onSend={handleSendMessage} disabled={false} />
|
||||
)}
|
||||
{isSystemLocked && (
|
||||
<div className="p-3 border-t border-secondary-200 text-center">
|
||||
<span className="text-xs text-secondary-400">System-Konversation (schreibgeschützt)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Expanded: Mobile = full width overlay, Desktop = resizable panel ───
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: full width overlay with back button */}
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-50 bg-white flex flex-col"
|
||||
data-testid="message-sidebar-mobile"
|
||||
>
|
||||
<div className="h-14 flex items-center gap-2 px-3 border-b border-secondary-200">
|
||||
<button
|
||||
onClick={toggleMessageSidebar}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||
aria-label="Zurück"
|
||||
data-testid="message-sidebar-back"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Zurück</span>
|
||||
</button>
|
||||
</div>
|
||||
{iconStrip}
|
||||
<div className="flex-1 overflow-hidden">{renderContent()}</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: resizable panel */}
|
||||
<ResizablePanel
|
||||
initialWidth={320}
|
||||
minWidth={240}
|
||||
maxWidth={600}
|
||||
handleSide="left"
|
||||
className="hidden md:flex border-l border-secondary-200 bg-white"
|
||||
data-testid="message-sidebar-pane"
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{iconStrip}
|
||||
<div className="flex-1 overflow-hidden">{renderContent()}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function TopBar() {
|
||||
const navigate = useNavigate();
|
||||
const { user, currentTenant } = useAuthStore();
|
||||
const tenants = user?.tenants || [];
|
||||
const { toggleSidebar, openAISidebarProactive } = useUIStore();
|
||||
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCommStore } from '@/store/commStore';
|
||||
import type { Conversation, Message } from '@/store/commStore';
|
||||
|
||||
/**
|
||||
* useCommWebSocket — manages a WebSocket connection to /api/v1/comm/ws.
|
||||
*
|
||||
* Handles real-time events: new messages, conversation updates, typing
|
||||
* indicators, and streaming tokens. Auto-reconnects with exponential backoff.
|
||||
*/
|
||||
export function useCommWebSocket() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<number | undefined>(undefined);
|
||||
const addMessage = useCommStore((s) => s.addMessage);
|
||||
const updateConversation = useCommStore((s) => s.updateConversation);
|
||||
const setTyping = useCommStore((s) => s.setTyping);
|
||||
const activeConversationId = useCommStore((s) => s.activeConversationId);
|
||||
const setMessages = useCommStore((s) => s.setMessages);
|
||||
|
||||
useEffect(() => {
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectDelay = 30000;
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/v1/comm/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
console.log('Comm WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case 'message.new': {
|
||||
if (data.message && data.conversation_id) {
|
||||
addMessage(data.conversation_id, data.message as Message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'conversation.updated': {
|
||||
if (data.conversation) {
|
||||
updateConversation(data.conversation as Conversation);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message.streaming': {
|
||||
// Update last message content with streaming token
|
||||
if (data.conversation_id && data.token) {
|
||||
const msgs = useCommStore.getState().messages[data.conversation_id] || [];
|
||||
if (msgs.length > 0) {
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
const updatedMsg = {
|
||||
...lastMsg,
|
||||
content: lastMsg.content + data.token,
|
||||
};
|
||||
setMessages(data.conversation_id, [...msgs.slice(0, -1), updatedMsg]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message.streaming.done': {
|
||||
// Replace streaming content with final message
|
||||
if (data.conversation_id && data.message) {
|
||||
const msgs = useCommStore.getState().messages[data.conversation_id] || [];
|
||||
if (msgs.length > 0) {
|
||||
setMessages(data.conversation_id, [...msgs.slice(0, -1), data.message as Message]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'typing': {
|
||||
if (data.conversation_id) {
|
||||
const currentTyping = useCommStore.getState().typingUsers[data.conversation_id] || [];
|
||||
if (data.is_typing) {
|
||||
if (!currentTyping.includes(data.user_id)) {
|
||||
setTyping(data.conversation_id, [...currentTyping, data.user_id]);
|
||||
}
|
||||
} else {
|
||||
setTyping(
|
||||
data.conversation_id,
|
||||
currentTyping.filter((uid) => uid !== data.user_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pong':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Comm WebSocket message parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Comm WebSocket disconnected');
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
||||
reconnectAttempts++;
|
||||
reconnectTimeout.current = window.setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Comm WebSocket error:', error);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// Ping interval to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval);
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Subscribe to active conversation when it changes
|
||||
useEffect(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN && activeConversationId) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: 'subscribe', conversation_id: activeConversationId }),
|
||||
);
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
return wsRef;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
participant_id: string | null;
|
||||
participant_type: string;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
is_locked: boolean;
|
||||
locked_by: string | null;
|
||||
is_direct: boolean;
|
||||
is_archived: boolean;
|
||||
is_pinned: boolean;
|
||||
created_by: string | null;
|
||||
created_by_type: string;
|
||||
last_msg_at: string | null;
|
||||
last_msg_preview: string | null;
|
||||
last_msg_sender_type: string | null;
|
||||
participants: Participant[];
|
||||
unread_count: number;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MessageBlock {
|
||||
id: string;
|
||||
block_type: string;
|
||||
block_data: Record<string, any>;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: string;
|
||||
file_id: string | null;
|
||||
file_source: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_size: number | null;
|
||||
thumbnail_path: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string | null;
|
||||
sender_type: string;
|
||||
content: string;
|
||||
content_format: string;
|
||||
metadata: Record<string, any>;
|
||||
reply_to_id: string | null;
|
||||
is_pinned: boolean;
|
||||
created_at: string | null;
|
||||
edited_at: string | null;
|
||||
blocks: MessageBlock[];
|
||||
attachments: MessageAttachment[];
|
||||
reactions: any[];
|
||||
}
|
||||
|
||||
export interface CommState {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
messages: Record<string, Message[]>;
|
||||
typingUsers: Record<string, string[]>;
|
||||
unreadCounts: Record<string, number>;
|
||||
loading: boolean;
|
||||
|
||||
setConversations: (convs: Conversation[]) => void;
|
||||
setActiveConversation: (id: string | null) => void;
|
||||
addMessage: (convId: string, msg: Message) => void;
|
||||
setMessages: (convId: string, msgs: Message[]) => void;
|
||||
updateConversation: (conv: Conversation) => void;
|
||||
setTyping: (convId: string, userIds: string[]) => void;
|
||||
setUnread: (convId: string, count: number) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useCommStore = create<CommState>((set) => ({
|
||||
conversations: [],
|
||||
activeConversationId: null,
|
||||
messages: {},
|
||||
typingUsers: {},
|
||||
unreadCounts: {},
|
||||
loading: false,
|
||||
setConversations: (conversations) => set({ conversations }),
|
||||
setActiveConversation: (activeConversationId) => set({ activeConversationId }),
|
||||
addMessage: (convId, msg) => set((s) => ({
|
||||
messages: { ...s.messages, [convId]: [...(s.messages[convId] || []), msg] },
|
||||
})),
|
||||
setMessages: (convId, msgs) => set((s) => ({
|
||||
messages: { ...s.messages, [convId]: msgs },
|
||||
})),
|
||||
updateConversation: (conv) => set((s) => ({
|
||||
conversations: s.conversations.map((c) => (c.id === conv.id ? conv : c)),
|
||||
})),
|
||||
setTyping: (convId, userIds) => set((s) => ({
|
||||
typingUsers: { ...s.typingUsers, [convId]: userIds },
|
||||
})),
|
||||
setUnread: (convId, count) => set((s) => ({
|
||||
unreadCounts: { ...s.unreadCounts, [convId]: count },
|
||||
})),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
}));
|
||||
@@ -18,6 +18,7 @@ export interface UIState {
|
||||
suggestionSidebarOpen: boolean;
|
||||
aiSidebarCollapsed: boolean;
|
||||
aiSidebarTab: AISidebarTab;
|
||||
messageSidebarCollapsed: boolean;
|
||||
toasts: Toast[];
|
||||
notifications: string[];
|
||||
setTheme: (theme: Theme) => void;
|
||||
@@ -29,6 +30,8 @@ export interface UIState {
|
||||
setAISidebarCollapsed: (collapsed: boolean) => void;
|
||||
setAISidebarTab: (tab: AISidebarTab) => void;
|
||||
openAISidebarProactive: () => void;
|
||||
toggleMessageSidebar: () => void;
|
||||
setMessageSidebarCollapsed: (collapsed: boolean) => void;
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
@@ -45,12 +48,9 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
suggestionSidebarOpen: false,
|
||||
aiSidebarCollapsed: true,
|
||||
aiSidebarTab: 'chat',
|
||||
messageSidebarCollapsed: true,
|
||||
toasts: [],
|
||||
notifications: [
|
||||
'Neuer Kontakt hinzugefügt: Max Mustermann',
|
||||
'Firma aktualisiert: TechCorp GmbH',
|
||||
'Meeting um 14:00 Uhr mit Müller AG',
|
||||
],
|
||||
notifications: [],
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem('leocrm_theme', theme);
|
||||
set({ theme });
|
||||
@@ -66,6 +66,8 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
setAISidebarCollapsed: (collapsed) => set({ aiSidebarCollapsed: collapsed }),
|
||||
setAISidebarTab: (tab) => set({ aiSidebarTab: tab }),
|
||||
openAISidebarProactive: () => set({ aiSidebarCollapsed: false, aiSidebarTab: 'proactive' }),
|
||||
toggleMessageSidebar: () => set((s) => ({ messageSidebarCollapsed: !s.messageSidebarCollapsed })),
|
||||
setMessageSidebarCollapsed: (collapsed) => set({ messageSidebarCollapsed: collapsed }),
|
||||
addToast: (toast) => {
|
||||
const id = `toast-${++toastIdCounter}`;
|
||||
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
|
||||
|
||||
+34
-100
@@ -1,110 +1,44 @@
|
||||
# Test Report — T08c: Frontend Mail UI + Global Search UI
|
||||
# Test Report — Phase 4: Unified Messaging Frontend
|
||||
|
||||
## Date: 2026-07-01
|
||||
## Task
|
||||
Implement MessageSidebar replacing AISidebar with comm API backend integration.
|
||||
|
||||
## Test Execution
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
1. `src/store/commStore.ts` — Zustand store for conversations, messages, typing, unread counts
|
||||
2. `src/api/comm.ts` — API client covering all `/api/v1/comm/*` endpoints (conversations, messages, reactions, read state, mini-apps, block types)
|
||||
3. `src/hooks/useCommWebSocket.ts` — WebSocket hook with auto-reconnect, ping, streaming, typing indicators
|
||||
4. `src/components/layout/MessageSidebar.tsx` — Main sidebar component (716 lines) replicating AISidebar design exactly
|
||||
|
||||
### Modified Files
|
||||
5. `src/store/uiStore.ts` — Added `messageSidebarCollapsed` + `toggleMessageSidebar`; replaced mock notifications with empty array; kept `aiSidebarCollapsed` for backward compat
|
||||
6. `src/components/layout/AppShell.tsx` — Replaced `AISidebar` import with `MessageSidebar`
|
||||
7. `src/components/layout/TopBar.tsx` — Replaced `openAISidebarProactive` with `toggleMessageSidebar`
|
||||
|
||||
## TypeScript Compilation
|
||||
|
||||
Command: `npx tsc --noEmit`
|
||||
|
||||
### Command
|
||||
```
|
||||
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/mail/ src/__tests__/search/ --reporter=verbose
|
||||
src/pages/Dms.tsx(553,13): error TS2322: ... onRangeSelect does not exist on type FileExplorerProps
|
||||
src/pages/Dms.tsx(647,15): error TS2322: ... onRangeSelect does not exist on type FileExplorerProps
|
||||
```
|
||||
|
||||
### Results
|
||||
- **Test Files**: 5 passed (5)
|
||||
- **Tests**: 44 passed (44)
|
||||
- **Duration**: ~6s
|
||||
**Result:** All new/modified files compile cleanly. Only 2 pre-existing errors in `Dms.tsx` (unrelated to this task — `onRangeSelect` prop missing on `FileExplorerProps` type).
|
||||
|
||||
### Test Breakdown
|
||||
## Smoke Test Description
|
||||
|
||||
#### MailPage.test.tsx (14 tests)
|
||||
- ✓ renders mail page after loading
|
||||
- ✓ renders folder tree pane
|
||||
- ✓ renders mail list pane
|
||||
- ✓ renders mail detail pane
|
||||
- ✓ renders compose button
|
||||
- ✓ renders shared mailbox selector
|
||||
- ✓ renders mail search bar
|
||||
- ✓ renders folders after loading (INBOX, Sent)
|
||||
- ✓ renders mails after loading (Test Subject, Another Subject)
|
||||
- ✓ shows compose modal when compose button is clicked
|
||||
- ✓ shows compose toolbar with bold and italic buttons
|
||||
- ✓ renders mail detail empty state initially
|
||||
- ✓ renders mail detail when mail is clicked
|
||||
- ✓ shows reply and forward buttons in mail detail
|
||||
- **Build:** `npx tsc --noEmit` exits with only pre-existing Dms.tsx errors (0 new errors from this task)
|
||||
- **Design fidelity:** MessageSidebar uses identical icons (robotIcon, bellIcon, bulbIcon, teamIcon, chatBubbleIcon), identical ResizablePanel (320px initial, 240-600 range, handleSide=left), identical mobile/desktop structure (collapsed icon strip → expanded resizable panel + mobile overlay)
|
||||
- **API integration:** comm.ts uses same `apiGet/apiPost/apiPatch/apiDelete` from `client.ts` as `ai.ts` — consistent with existing conventions
|
||||
- **WebSocket:** Auto-reconnect with exponential backoff, ping interval, handles message.new, conversation.updated, message.streaming, typing events
|
||||
- **Store:** commStore provides conversations, messages per conversation, typing users, unread counts with all setter methods
|
||||
- **uiStore:** messageSidebarCollapsed defaults to true (same as aiSidebarCollapsed), toggleMessageSidebar works, mock notifications replaced with empty array
|
||||
|
||||
#### ComposeModal.test.tsx (10 tests)
|
||||
- ✓ renders compose modal when open
|
||||
- ✓ renders compose toolbar with bold button
|
||||
- ✓ renders recipient input field
|
||||
- ✓ renders subject input field
|
||||
- ✓ renders editor
|
||||
- ✓ renders send button
|
||||
- ✓ renders template picker toggle button
|
||||
- ✓ shows template picker when template button is clicked
|
||||
- ✓ pre-fills reply fields when mode is reply
|
||||
- ✓ pre-fills forward fields when mode is forward
|
||||
## What Was NOT Tested
|
||||
|
||||
#### MailSettings.test.tsx (9 tests)
|
||||
- ✓ renders settings page
|
||||
- ✓ renders accounts tab content
|
||||
- ✓ renders add account button
|
||||
- ✓ shows add account form when button is clicked
|
||||
- ✓ renders signature manager in signatures tab
|
||||
- ✓ renders rule editor in rules tab
|
||||
- ✓ renders label manager in labels tab
|
||||
- ✓ renders vacation responder in vacation tab
|
||||
- ✓ renders PGP settings in PGP tab
|
||||
- ✓ renders account list with test connection button
|
||||
|
||||
#### GlobalSearchTabs.test.tsx (8 tests)
|
||||
- ✓ renders search page
|
||||
- ✓ renders search input field
|
||||
- ✓ renders search tabs after results load
|
||||
- ✓ renders all results in the all tab
|
||||
- ✓ renders company results
|
||||
- ✓ renders all 5 result types in the all tab
|
||||
- ✓ renders search submit button
|
||||
- ✓ shows query display
|
||||
|
||||
#### GlobalSearch.test.tsx (2 tests — pre-existing, still passing)
|
||||
- ✓ renders search page
|
||||
- ✓ renders search input field
|
||||
|
||||
## Type Check
|
||||
```
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Result: **PASS** — zero errors
|
||||
|
||||
## Build
|
||||
```
|
||||
npx vite build
|
||||
```
|
||||
Result: **PASS** — built in ~6s, 267 modules transformed
|
||||
|
||||
## Smoke Test
|
||||
- Mail page renders with three-pane layout (folder tree + mail list + reading pane)
|
||||
- Compose modal opens with toolbar (bold, italic, link, template insert)
|
||||
- Reply pre-fills recipient and subject with Re: prefix
|
||||
- Forward pre-fills subject with Fwd: prefix
|
||||
- Mail settings page shows tabs: Accounts, Signatures, Rules, Labels, Vacation, PGP
|
||||
- Global search results page shows tabs: All, Companies, Contacts, Mails, Files, Events
|
||||
- Search dropdown in TopBar already existed (SearchDropdown component)
|
||||
|
||||
## Acceptance Criteria Coverage
|
||||
- AC2: Mail route /mail renders folder tree + mail list + reading pane ✓
|
||||
- AC3: Click folder → mail list updates ✓
|
||||
- AC4: Click mail → detail with sanitized HTML body + attachments ✓
|
||||
- AC5: Compose button → editor with toolbar (bold, italic, link, template insert) ✓
|
||||
- AC6: Reply/forward buttons → compose pre-filled ✓
|
||||
- AC7: Template picker dropdown in compose → inserts template body ✓
|
||||
- AC8: Signature manager in settings → create/edit/delete signatures ✓
|
||||
- AC9: Rule editor → condition builder + action selector ✓
|
||||
- AC10: Label manager → create labels with colors ✓
|
||||
- AC11: PGP settings → import private key, view contact public keys ✓
|
||||
- AC12: Vacation responder toggle → date range + auto-reply text ✓
|
||||
- AC13: Shared mailbox selector → switch between personal+shared accounts ✓
|
||||
- AC14: Attachment download → file stream downloaded ✓
|
||||
- AC15: Create event from mail → calendar event modal pre-filled ✓
|
||||
- AC16: Global search results page → tabs for companies/contacts/mails/files/events ✓
|
||||
- AC17: Global search autocomplete in TopBar → dropdown with suggestions ✓ (existing SearchDropdown component)
|
||||
- Live API calls (requires running backend with kommunikation plugin)
|
||||
- WebSocket connection (requires authenticated session)
|
||||
- Visual rendering (requires dev server + browser)
|
||||
- These require runtime verification in a subsequent phase
|
||||
|
||||
Reference in New Issue
Block a user