Обновлены компоненты мессенджера: изменен интервал обновления списка чатов на 1 минуту и изменена политика выборки на 'cache-first' для повышения стабильности. В компоненте MessengerChat добавлена поддержка вложений и улучшена логика отметки сообщений как прочитанных с плавным обновлением. В компоненте MessengerConversations улучшено отображение загрузки и индикаторов непрочитанных сообщений. Также добавлен автофокус на поле ввода при открытии чата.
This commit is contained in:
315
src/components/messenger/messenger-attachments.tsx
Normal file
315
src/components/messenger/messenger-attachments.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { GET_MESSAGES } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
FileText,
|
||||
Image,
|
||||
Music,
|
||||
Video,
|
||||
Download,
|
||||
Calendar,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
content?: string
|
||||
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE'
|
||||
voiceUrl?: string
|
||||
voiceDuration?: number
|
||||
fileUrl?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileType?: string
|
||||
senderId: string
|
||||
senderOrganization: Organization
|
||||
receiverOrganization: Organization
|
||||
createdAt: string
|
||||
isRead: boolean
|
||||
}
|
||||
|
||||
interface MessengerAttachmentsProps {
|
||||
counterparty: Organization
|
||||
}
|
||||
|
||||
export function MessengerAttachments({ counterparty }: MessengerAttachmentsProps) {
|
||||
const { user } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
// Загружаем все сообщения для получения вложений
|
||||
const { data: messagesData, loading } = useQuery(GET_MESSAGES, {
|
||||
variables: { counterpartyId: counterparty.id, limit: 1000 }, // Увеличиваем лимит для получения всех файлов
|
||||
fetchPolicy: 'cache-first',
|
||||
})
|
||||
|
||||
const messages: Message[] = messagesData?.messages || []
|
||||
|
||||
// Фильтруем только сообщения с вложениями
|
||||
const attachmentMessages = messages.filter(msg =>
|
||||
msg.type && ['VOICE', 'IMAGE', 'FILE'].includes(msg.type) && msg.fileUrl
|
||||
)
|
||||
|
||||
// Группируем по типам
|
||||
const imageMessages = attachmentMessages.filter(msg => msg.type === 'IMAGE')
|
||||
const voiceMessages = attachmentMessages.filter(msg => msg.type === 'VOICE')
|
||||
const fileMessages = attachmentMessages.filter(msg => msg.type === 'FILE')
|
||||
|
||||
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 formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownload = (fileUrl: string, fileName: string) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = fileUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const renderFileIcon = (fileType?: string) => {
|
||||
if (!fileType) return <FileText className="h-4 w-4" />
|
||||
|
||||
if (fileType.startsWith('image/')) return <Image className="h-4 w-4" />
|
||||
if (fileType.startsWith('audio/')) return <Music className="h-4 w-4" />
|
||||
if (fileType.startsWith('video/')) return <Video className="h-4 w-4" />
|
||||
return <FileText className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const renderAttachmentCard = (message: Message) => {
|
||||
const isCurrentUser = message.senderOrganization?.id === user?.organization?.id
|
||||
const senderOrg = isCurrentUser ? user?.organization : message.senderOrganization
|
||||
|
||||
return (
|
||||
<Card key={message.id} className="glass-card p-4 mb-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Аватар отправителя */}
|
||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||
{(senderOrg as Organization)?.users?.[0]?.avatar ? (
|
||||
<AvatarImage
|
||||
src={(senderOrg as Organization).users![0].avatar!}
|
||||
alt="Аватар отправителя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-purple-500 text-white text-xs">
|
||||
{getInitials(senderOrg as Organization)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white/80 text-sm font-medium">
|
||||
{isCurrentUser ? 'Вы' : getOrganizationName(senderOrg as Organization)}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{isCurrentUser ? 'Исходящий' : 'Входящий'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs text-white/50">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(message.createdAt)}</span>
|
||||
<span>{formatTime(message.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Содержимое вложения */}
|
||||
<div className="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-white/10 rounded-lg">
|
||||
{message.type === 'IMAGE' ? (
|
||||
<Image className="h-5 w-5 text-blue-400" />
|
||||
) : message.type === 'VOICE' ? (
|
||||
<Music className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
renderFileIcon(message.fileType)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">
|
||||
{message.fileName ||
|
||||
(message.type === 'VOICE' ? 'Голосовое сообщение' :
|
||||
message.type === 'IMAGE' ? 'Изображение' : 'Файл')}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-white/60">
|
||||
{message.fileSize && (
|
||||
<span>{formatFileSize(message.fileSize)}</span>
|
||||
)}
|
||||
{message.voiceDuration && (
|
||||
<span>{Math.floor(message.voiceDuration / 60)}:{(message.voiceDuration % 60).toString().padStart(2, '0')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDownload(message.fileUrl!, message.fileName || 'file')}
|
||||
className="flex items-center justify-center w-8 h-8 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/60">Загрузка вложений...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center space-x-3 mb-4 px-4 pt-4">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Вложения</h3>
|
||||
<p className="text-white/60 text-sm">
|
||||
{attachmentMessages.length} файлов от {getOrganizationName(counterparty)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 pb-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-4">
|
||||
<TabsTrigger value="all" className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Все ({attachmentMessages.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="images" className="flex items-center space-x-2">
|
||||
<Image className="h-4 w-4" />
|
||||
<span>Фото ({imageMessages.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="voice" className="flex items-center space-x-2">
|
||||
<Music className="h-4 w-4" />
|
||||
<span>Аудио ({voiceMessages.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Файлы ({fileMessages.length})</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="h-full overflow-auto">
|
||||
<TabsContent value="all" className="mt-0">
|
||||
{attachmentMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">Нет вложений</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{attachmentMessages.map(renderAttachmentCard)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="images" className="mt-0">
|
||||
{imageMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<Image className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">Нет изображений</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{imageMessages.map(renderAttachmentCard)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="voice" className="mt-0">
|
||||
{voiceMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<Music className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">Нет аудио записей</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{voiceMessages.map(renderAttachmentCard)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-0">
|
||||
{fileMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">Нет файлов</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{fileMessages.map(renderAttachmentCard)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user