Эксперимент: интеграция @chatscope/chat-ui-kit-react для мессенджера

- Установлены пакеты @chatscope/chat-ui-kit-react и @chatscope/chat-ui-kit-styles
- Создана новая страница /messages-chatscope с полнофункциональным мессенджером
- Добавлен пункт 'Мессенджер (NEW)' в навигационное меню
- Реализована интеграция с существующим Django API
- Добавлена поддержка WebSocket для real-time сообщений
- Список чатов с поиском и аватарами
- Отображение сообщений с датами и вложениями
- Индикатор 'печатает...' через WebSocket
This commit is contained in:
igor_nadein 2026-02-25 12:21:40 +03:00
parent e6243e895c
commit e328e31d32
4 changed files with 521 additions and 1 deletions

View File

@ -8,6 +8,8 @@
"name": "corp.front",
"version": "0.1.0",
"dependencies": {
"@chatscope/chat-ui-kit-react": "^2.1.1",
"@chatscope/chat-ui-kit-styles": "^1.4.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.574.0",
"next": "16.1.6",
@ -274,6 +276,32 @@
"node": ">=6.9.0"
}
},
"node_modules/@chatscope/chat-ui-kit-react": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-react/-/chat-ui-kit-react-2.1.1.tgz",
"integrity": "sha512-rCtE9abdmAbBDkAAUYBC1TDTBMZHquqFIZhADptAfHcJ8z8W3XH/z/ZuwBSJXtzi6h1mwCNc3tBmm1A2NLGhNg==",
"license": "MIT",
"dependencies": {
"@chatscope/chat-ui-kit-styles": "^1.2.0",
"@fortawesome/fontawesome-free": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"classnames": "^2.2.6",
"prop-types": "^15.7.2"
},
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0",
"react-dom": "^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0"
}
},
"node_modules/@chatscope/chat-ui-kit-styles": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-styles/-/chat-ui-kit-styles-1.4.0.tgz",
"integrity": "sha512-016mBJD3DESw7Nh+lkKcPd22xG92ghA0VpIXIbjQtmXhC7Ve6wRazTy8z1Ahut+Tbv179+JxrftuMngsj/yV8Q==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@ -445,6 +473,63 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
"integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.6.tgz",
"integrity": "sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==",
"deprecated": "v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater.",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
"react": "^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2495,6 +2580,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",

View File

@ -10,6 +10,8 @@
"lint": "eslint"
},
"dependencies": {
"@chatscope/chat-ui-kit-react": "^2.1.1",
"@chatscope/chat-ui-kit-styles": "^1.4.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.574.0",
"next": "16.1.6",

View File

@ -0,0 +1,426 @@
"use client";
import React, { useState, useEffect, useRef } from 'react';
import {
MainContainer,
Sidebar,
Search,
ConversationList,
Conversation,
Avatar,
ChatContainer,
ConversationHeader,
MessageList,
Message,
MessageInput,
MessageSeparator,
TypingIndicator,
} from '@chatscope/chat-ui-kit-react';
import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css';
import { AppShell } from '@/components/AppShell';
import { apiClient } from '@/lib/api';
import { useUser } from '@/contexts/UserContext';
import type { Chat as ChatType, Message as MessageType } from '@/types/api';
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
function resolveAvatarUrl(url?: string | null): string {
if (!url) return "";
if (url.startsWith("data:")) return url;
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith("//")) return `https:${url}`;
if (url.startsWith("/") && BACKEND_URL) {
return `${BACKEND_URL.replace(/\/$/, "")}${url}`;
}
if (BACKEND_URL) {
return `${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`;
}
return url;
}
function getChatTitle(chat: ChatType, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
const rawName = (chat.name || "").trim();
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
if (chat.interlocutor?.name?.trim()) {
return chat.interlocutor.name.trim();
}
const detailsOther = (chat.participant_details || []).find((p) => p.id !== currentUserId);
if (detailsOther?.name?.trim()) {
return detailsOther.name.trim();
}
const participants = (chat.participants || []).filter(
(p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null
);
const other = participants.find((p) => p.id !== currentUserId);
if (other && typeof other === "object") {
const name = `${other.last_name || ""} ${other.first_name || ""}`.trim();
if (name) return name;
if (other.email) return other.email;
}
}
return rawName || "Диалог";
}
function getChatAvatar(chat: ChatType, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private") {
if (chat.interlocutor?.avatar) return resolveAvatarUrl(chat.interlocutor.avatar);
const detailsOther = (chat.participant_details || []).find((p) => p.id !== currentUserId);
if (detailsOther?.avatar) return resolveAvatarUrl(detailsOther.avatar);
const participants = (chat.participants || []).filter(
(p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null
);
const other = participants.find((p) => p.id !== currentUserId);
if (other?.avatar) return resolveAvatarUrl(other.avatar);
}
return resolveAvatarUrl(chat.avatar);
}
export default function ChatscapeMessenger() {
const { user } = useUser();
const currentUserId = user?.id;
const [chats, setChats] = useState<ChatType[]>([]);
const [selectedChatId, setSelectedChatId] = useState<number | null>(null);
const [messages, setMessages] = useState<MessageType[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [sending, setSending] = useState(false);
const [typing, setTyping] = useState(false);
const [typingTimeout, setTypingTimeout] = useState<NodeJS.Timeout | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Загрузка списка чатов
useEffect(() => {
const loadChats = async () => {
try {
setLoading(true);
const response = await apiClient.getChats();
const chatsList = response.results || [];
setChats(chatsList);
// Автоматически открываем первый чат
if (chatsList.length > 0 && !selectedChatId) {
setSelectedChatId(chatsList[0].id);
}
} catch (error) {
console.error('Error loading chats:', error);
} finally {
setLoading(false);
}
};
loadChats();
}, []);
// Загрузка сообщений выбранного чата
useEffect(() => {
if (!selectedChatId) return;
const loadMessages = async () => {
try {
setMessagesLoading(true);
const data = await apiClient.getChatMessages(selectedChatId, { limit: 50 });
setMessages(data.messages || []);
} catch (error) {
console.error('Error loading messages:', error);
} finally {
setMessagesLoading(false);
}
};
loadMessages();
}, [selectedChatId]);
// WebSocket для real-time обновлений
useEffect(() => {
if (!selectedChatId) return;
const ws = new WebSocket('ws://localhost:9000/ws/');
wsRef.current = ws;
ws.onopen = () => {
console.log('✅ WebSocket connected');
// Открываем чат
ws.send(JSON.stringify({
action: 'open_chat',
chat_id: selectedChatId,
load_history: false
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('📨 WebSocket message:', data);
if (data.type === 'chat_message') {
// Новое сообщение
setMessages(prev => {
// Проверяем, нет ли уже такого сообщения
if (prev.some(m => m.id === data.payload.id)) {
return prev;
}
return [...prev, data.payload];
});
} else if (data.type === 'chat_message_edited') {
// Редактирование
setMessages(prev =>
prev.map(m => m.id === data.payload.id ? { ...m, ...data.payload } : m)
);
} else if (data.type === 'chat_message_deleted') {
// Удаление
setMessages(prev =>
prev.filter(m => m.id !== data.message_id)
);
} else if (data.type === 'chat_user_typing') {
// Показываем "печатает..."
if (data.user_id !== currentUserId) {
setTyping(true);
if (typingTimeout) clearTimeout(typingTimeout);
const timeout = setTimeout(() => setTyping(false), 3000);
setTypingTimeout(timeout);
}
}
};
ws.onerror = (error) => {
console.error('❌ WebSocket error:', error);
};
ws.onclose = () => {
console.log('🔌 WebSocket disconnected');
};
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'close_chat',
chat_id: selectedChatId
}));
}
ws.close();
if (typingTimeout) clearTimeout(typingTimeout);
};
}, [selectedChatId, currentUserId]);
// Отправка индикатора печати
const handleTyping = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action: 'typing' }));
}
};
// Отправка сообщения
const handleSend = async (text: string) => {
if (!selectedChatId || !text.trim() || sending) return;
try {
setSending(true);
// Отправляем через REST API
await apiClient.sendMessage(selectedChatId, text);
// WebSocket автоматически добавит сообщение через chat_message event
} catch (error) {
console.error('Error sending message:', error);
alert('Ошибка отправки сообщения');
} finally {
setSending(false);
}
};
// Группировка сообщений по датам
const groupedMessages = messages.reduce((acc, msg, index) => {
const date = new Date(msg.created_at || '').toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric'
});
const prevDate = index > 0
? new Date(messages[index - 1].created_at || '').toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric'
})
: null;
if (date !== prevDate) {
acc.push({ type: 'separator', date });
}
acc.push({ type: 'message', data: msg });
return acc;
}, [] as Array<{ type: 'separator' | 'message'; date?: string; data?: MessageType }>);
const selectedChat = chats.find(c => c.id === selectedChatId);
const filteredChats = search
? chats.filter(c => {
const title = getChatTitle(c, currentUserId).toLowerCase();
return title.includes(search.toLowerCase());
})
: chats;
return (
<AppShell>
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 overflow-hidden" style={{ height: '80vh' }}>
<div className="mb-4">
<h1 className="text-2xl font-bold text-gray-900">Мессенджер (Chatscope Experiment)</h1>
<p className="text-sm text-gray-600 mt-1">Экспериментальная версия с @chatscope/chat-ui-kit-react</p>
</div>
<MainContainer responsive style={{ height: 'calc(100% - 60px)' }}>
{/* Боковая панель со списком чатов */}
<Sidebar position="left" scrollable={false}>
<Search
placeholder="Поиск чатов..."
value={search}
onChange={(v) => setSearch(v)}
/>
<ConversationList loading={loading}>
{filteredChats.map(chat => {
const title = getChatTitle(chat, currentUserId);
const avatar = getChatAvatar(chat, currentUserId);
const lastMessage = chat.last_message?.content || 'Нет сообщений';
const unreadCount = chat.unread_count || 0;
return (
<Conversation
key={chat.id}
name={title}
lastSenderName={chat.last_message?.author_name}
info={lastMessage.length > 50 ? lastMessage.slice(0, 50) + '...' : lastMessage}
active={chat.id === selectedChatId}
unreadCnt={unreadCount}
onClick={() => setSelectedChatId(chat.id)}
>
<Avatar
src={avatar}
name={title}
/>
</Conversation>
);
})}
</ConversationList>
</Sidebar>
{/* Основной чат */}
{selectedChat ? (
<ChatContainer>
<ConversationHeader>
<Avatar
src={getChatAvatar(selectedChat, currentUserId)}
name={getChatTitle(selectedChat, currentUserId)}
/>
<ConversationHeader.Content
userName={getChatTitle(selectedChat, currentUserId)}
info={selectedChat.participant_details?.length
? `${selectedChat.participant_details.length} участников`
: ''
}
/>
</ConversationHeader>
<MessageList
loading={messagesLoading}
typingIndicator={typing ? <TypingIndicator content="Печатает..." /> : undefined}
>
{groupedMessages.map((item, index) => {
if (item.type === 'separator') {
return (
<MessageSeparator key={`sep-${index}`}>
{item.date}
</MessageSeparator>
);
}
const msg = item.data!;
const isOwn = msg.author_id === currentUserId;
return (
<Message
key={msg.id}
model={{
message: msg.content || '',
sender: msg.author_name || 'Неизвестно',
direction: isOwn ? 'outgoing' : 'incoming',
position: 'single'
}}
>
{!isOwn && msg.author && (
<Avatar
src={resolveAvatarUrl(msg.author.avatar)}
name={msg.author_name || ''}
size="sm"
/>
)}
{/* Вложения */}
{msg.attachments && msg.attachments.length > 0 && (
<Message.CustomContent>
<div className="mt-2 space-y-2">
{msg.attachments.map((att) => {
const isImage = att.file_type === 'image' || att.mime_type?.startsWith('image/');
return (
<div key={att.id}>
{isImage ? (
<img
src={resolveAvatarUrl(att.file_url)}
alt={att.file_name}
className="max-w-xs rounded shadow"
/>
) : (
<a
href={resolveAvatarUrl(att.file_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sky-600 hover:underline text-sm"
>
📎 {att.file_name}
</a>
)}
</div>
);
})}
</div>
</Message.CustomContent>
)}
</Message>
);
})}
</MessageList>
<MessageInput
placeholder="Введите сообщение..."
onSend={handleSend}
onChange={handleTyping}
disabled={sending}
attachButton={false}
/>
</ChatContainer>
) : (
<ChatContainer>
<div className="flex items-center justify-center h-full">
<div className="text-center text-gray-500">
<p className="text-lg font-medium">Выберите чат</p>
<p className="text-sm mt-1">Выберите диалог из списка слева</p>
</div>
</div>
</ChatContainer>
)}
</MainContainer>
</div>
</AppShell>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { Bell, Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X } from "lucide-react";
import { Bell, Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X, Sparkles } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useState, useCallback } from "react";
@ -38,6 +38,7 @@ type LeftNavContentProps = {
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon },
{ href: "/messages", label: "Сообщения", icon: MessageSquare },
{ href: "/messages-chatscope", label: "Мессенджер (NEW)", icon: Sparkles },
{ href: "/calendar", label: "Календарь", icon: CalendarDays },
{ href: "/employees", label: "Сотрудники", icon: Users },
{ href: "/departments", label: "Отделы", icon: Building2 },