Files
sfera-new/src/components/messenger/messenger-chat.tsx

511 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useRef, useEffect, useMemo } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { GET_MESSAGES } from '@/graphql/queries'
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE, MARK_MESSAGES_AS_READ } from '@/graphql/mutations'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { EmojiPickerComponent } from '@/components/ui/emoji-picker'
import { VoiceRecorder } from '@/components/ui/voice-recorder'
import { VoicePlayer } from '@/components/ui/voice-player'
import { FileUploader } from '@/components/ui/file-uploader'
import { ImageMessage } from '@/components/ui/image-message'
import { FileMessage } from '@/components/ui/file-message'
import { Send, MoreVertical } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
interface Organization {
id: string
inn: string
name?: string
fullName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string
users?: Array<{ id: string, avatar?: string, managerName?: string }>
}
interface Message {
id: string
content?: string
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE' | null
voiceUrl?: string
voiceDuration?: number
fileUrl?: string
fileName?: string
fileSize?: number
fileType?: string
senderId: string
senderOrganization: Organization
receiverOrganization: Organization
createdAt: string
isRead: boolean
}
interface MessengerChatProps {
counterparty: Organization
}
export function MessengerChat({ counterparty }: MessengerChatProps) {
const { user } = useAuth()
const [message, setMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
// Загружаем сообщения с контрагентом
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id },
pollInterval: 3000, // Обновляем каждые 3 секунды для получения новых сообщений
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные
errorPolicy: 'all' // Показываем данные даже при ошибках
})
// Мутация для отметки сообщений как прочитанных
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
onError: (error) => {
console.error('Ошибка отметки сообщений как прочитанных:', error)
}
})
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки
},
onError: (error) => {
console.error('Ошибка отправки сообщения:', error)
}
})
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки
},
onError: (error) => {
console.error('Ошибка отправки голосового сообщения:', error)
}
})
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
onCompleted: () => {
refetch()
},
onError: (error) => {
console.error('Ошибка отправки изображения:', error)
}
})
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
onCompleted: () => {
refetch()
},
onError: (error) => {
console.error('Ошибка отправки файла:', error)
}
})
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
// Автоматически отмечаем сообщения как прочитанные при открытии чата
useEffect(() => {
if (user?.organization?.id && counterparty.id) {
const conversationId = `${user.organization.id}-${counterparty.id}`
markMessagesAsReadMutation({
variables: { conversationId },
})
}
}, [counterparty.id, user?.organization?.id, markMessagesAsReadMutation])
// Отмечаем сообщения как прочитанные при получении новых сообщений
useEffect(() => {
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
const hasUnreadMessages = messages.some((msg: Message) =>
!msg.isRead &&
msg.senderOrganization?.id !== user.organization?.id
)
if (hasUnreadMessages) {
const conversationId = `${user.organization.id}-${counterparty.id}`
markMessagesAsReadMutation({
variables: { conversationId },
})
}
}
}, [messages, counterparty.id, user?.organization?.id, markMessagesAsReadMutation])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const getOrganizationName = (org: Organization) => {
return org.name || org.fullName || 'Организация'
}
const getManagerName = (org: Organization) => {
return org.users?.[0]?.managerName || 'Управляющий'
}
const getInitials = (org: Organization) => {
const name = getOrganizationName(org)
return name.charAt(0).toUpperCase()
}
const getShortCompanyName = (fullName: string) => {
if (!fullName) return 'Полное название не указано'
// Словарь для замены полных форм на сокращенные
const legalFormReplacements: { [key: string]: string } = {
'Общество с ограниченной ответственностью': 'ООО',
'Открытое акционерное общество': 'ОАО',
'Закрытое акционерное общество': 'ЗАО',
'Публичное акционерное общество': 'ПАО',
'Непубличное акционерное общество': 'НАО',
'Акционерное общество': 'АО',
'Индивидуальный предприниматель': 'ИП',
'Товарищество с ограниченной ответственностью': 'ТОО',
'Частное предприятие': 'ЧП',
'Субъект предпринимательской деятельности': 'СПД'
}
let result = fullName
// Заменяем полные формы на сокращенные
for (const [fullForm, shortForm] of Object.entries(legalFormReplacements)) {
const regex = new RegExp(`^${fullForm}\\s+`, 'i')
if (regex.test(result)) {
result = result.replace(regex, `${shortForm} `)
break
}
}
return result
}
const getTypeLabel = (type: string) => {
switch (type) {
case 'FULFILLMENT':
return 'Фулфилмент'
case 'SELLER':
return 'Селлер'
case 'LOGIST':
return 'Логистика'
case 'WHOLESALE':
return 'Оптовик'
default:
return type
}
}
const getTypeColor = (type: string) => {
switch (type) {
case 'FULFILLMENT':
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
case 'SELLER':
return 'bg-green-500/20 text-green-300 border-green-500/30'
case 'LOGIST':
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
case 'WHOLESALE':
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
default:
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
}
}
const handleSendMessage = async () => {
if (!message.trim()) return
try {
await sendMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
content: message.trim()
}
})
setMessage('')
} catch (error) {
console.error('Ошибка отправки сообщения:', error)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const handleEmojiSelect = (emoji: string) => {
setMessage(prev => prev + emoji)
}
const handleSendVoice = async (audioUrl: string, duration: number) => {
try {
await sendVoiceMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
voiceUrl: audioUrl,
voiceDuration: duration
}
})
} catch (error) {
console.error('Ошибка отправки голосового сообщения:', error)
}
}
const handleSendFile = async (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => {
try {
if (messageType === 'IMAGE') {
await sendImageMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
fileUrl,
fileName,
fileSize,
fileType
}
})
} else {
await sendFileMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
fileUrl,
fileName,
fileSize,
fileType
}
})
}
} catch (error) {
console.error('Ошибка отправки файла:', error)
}
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return '00:00'
}
return date.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return 'Неизвестная дата'
}
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Сегодня'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Вчера'
} else {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long'
})
}
}
return (
<div className="flex flex-col h-full">
{/* Заголовок чата */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center space-x-3">
<Avatar className="h-10 w-10">
{counterparty.users?.[0]?.avatar ? (
<AvatarImage
src={counterparty.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(counterparty)}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center space-x-3">
<h3 className="text-white font-medium">
{getOrganizationName(counterparty)}
</h3>
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
{getTypeLabel(counterparty.type)}
</Badge>
</div>
<p className="text-white/60 text-sm">
{getShortCompanyName(counterparty.fullName || '')}
</p>
</div>
</div>
<Button variant="ghost" size="sm" className="text-white/60 hover:text-white">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
{/* Область сообщений */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-white/60">Загрузка сообщений...</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Send className="h-6 w-6 text-white/40" />
</div>
<p className="text-white/60 text-sm mb-1">Начните беседу</p>
<p className="text-white/40 text-xs">
Отправьте первое сообщение {getOrganizationName(counterparty)}
</p>
</div>
</div>
) : (
messages.map((msg: Message, index: number) => {
const isCurrentUser = msg.senderOrganization?.id === user?.organization?.id
const showDate = index === 0 ||
formatDate(messages[index - 1].createdAt) !== formatDate(msg.createdAt)
return (
<div key={msg.id}>
{showDate && (
<div className="flex justify-center mb-4">
<div className="bg-white/10 rounded-full px-3 py-1">
<span className="text-white/60 text-xs">
{formatDate(msg.createdAt)}
</span>
</div>
</div>
)}
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div className="flex flex-col max-w-xs lg:max-w-md break-words">
{/* Имя отправителя */}
{!isCurrentUser && (
<div className="flex items-center space-x-2 mb-1 px-1">
<Avatar className="h-6 w-6">
{msg.senderOrganization?.users?.[0]?.avatar ? (
<AvatarImage
src={msg.senderOrganization.users[0].avatar}
alt="Аватар отправителя"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-xs">
{getInitials(msg.senderOrganization)}
</AvatarFallback>
</Avatar>
<span className="text-white/60 text-xs">
{getManagerName(msg.senderOrganization)}
</span>
<Badge className={`${getTypeColor(msg.senderOrganization.type)} text-xs`}>
{getTypeLabel(msg.senderOrganization.type)}
</Badge>
</div>
)}
{/* Сообщение */}
<div className="space-y-2">
{msg.type === 'VOICE' && msg.voiceUrl ? (
<VoicePlayer
audioUrl={msg.voiceUrl}
duration={msg.voiceDuration || 0}
isCurrentUser={isCurrentUser}
/>
) : msg.type === 'IMAGE' && msg.fileUrl ? (
<ImageMessage
imageUrl={msg.fileUrl}
fileName={msg.fileName || 'image'}
fileSize={msg.fileSize}
isCurrentUser={isCurrentUser}
/>
) : msg.type === 'FILE' && msg.fileUrl ? (
<FileMessage
fileUrl={msg.fileUrl}
fileName={msg.fileName || 'file'}
fileSize={msg.fileSize}
fileType={msg.fileType}
isCurrentUser={isCurrentUser}
/>
) : msg.content ? (
<div className={`px-4 py-2 rounded-lg break-words ${
isCurrentUser
? 'bg-blue-500/20 text-white border border-blue-500/30'
: 'bg-white/10 text-white border border-white/20'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">{msg.content}</p>
</div>
) : null}
<p className={`text-xs ${
isCurrentUser ? 'text-blue-300/70' : 'text-white/50'
}`}>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Поле ввода сообщения */}
<div className="px-4 py-3 border-t border-white/10">
<div className="flex items-center space-x-2">
<div className="flex-1">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Введите сообщение..."
className="glass-input text-white placeholder:text-white/40 w-full resize-none overflow-y-auto rounded-lg py-2 px-3"
rows={1}
style={{
height: 'auto',
minHeight: '40px',
maxHeight: '120px'
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
const newHeight = Math.min(Math.max(target.scrollHeight, 40), 120)
target.style.height = newHeight + 'px'
}}
/>
</div>
<div className="flex items-center gap-2">
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
<FileUploader onSendFile={handleSendFile} />
<VoiceRecorder onSendVoice={handleSendVoice} />
<Button
onClick={handleSendMessage}
disabled={!message.trim()}
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer h-10 w-10 p-0"
variant="outline"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
)
}