diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4496e85d..3c5690a5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b50096bc..c9c94c86 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/messages-chatscope/page.tsx b/frontend/src/app/messages-chatscope/page.tsx new file mode 100644 index 00000000..a17bc99c --- /dev/null +++ b/frontend/src/app/messages-chatscope/page.tsx @@ -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 === "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 === "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([]); + const [selectedChatId, setSelectedChatId] = useState(null); + const [messages, setMessages] = useState([]); + 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(null); + + const wsRef = useRef(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 ( + +
+
+

Мессенджер (Chatscope Experiment)

+

Экспериментальная версия с @chatscope/chat-ui-kit-react

+
+ + + {/* Боковая панель со списком чатов */} + + setSearch(v)} + /> + + + {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 ( + 50 ? lastMessage.slice(0, 50) + '...' : lastMessage} + active={chat.id === selectedChatId} + unreadCnt={unreadCount} + onClick={() => setSelectedChatId(chat.id)} + > + + + ); + })} + + + + {/* Основной чат */} + {selectedChat ? ( + + + + + + + : undefined} + > + {groupedMessages.map((item, index) => { + if (item.type === 'separator') { + return ( + + {item.date} + + ); + } + + const msg = item.data!; + const isOwn = msg.author_id === currentUserId; + + return ( + + {!isOwn && msg.author && ( + + )} + + {/* Вложения */} + {msg.attachments && msg.attachments.length > 0 && ( + +
+ {msg.attachments.map((att) => { + const isImage = att.file_type === 'image' || att.mime_type?.startsWith('image/'); + return ( +
+ {isImage ? ( + {att.file_name} + ) : ( + + 📎 {att.file_name} + + )} +
+ ); + })} +
+
+ )} +
+ ); + })} +
+ + +
+ ) : ( + +
+
+

Выберите чат

+

Выберите диалог из списка слева

+
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index c66a71cb..568853b1 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 },