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,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.
|
||||
Reference in New Issue
Block a user