684 lines
26 KiB
TypeScript
684 lines
26 KiB
TypeScript
|
|
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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|