sozsoft-platform/ui/src/components/template/MessengerWidget.tsx

443 lines
18 KiB
TypeScript
Raw Normal View History

2026-06-12 17:31:41 +00:00
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import Notification from '@/components/ui/Notification'
import Tooltip from '@/components/ui/Tooltip'
import { toast } from '@/components/ui'
import { AVATAR_URL } from '@/constants/app.constant'
import {
getMessengerContacts,
MessengerAttachmentDto,
MessengerContactDto,
uploadMessengerAttachment,
} from '@/services/messenger.service'
import { messengerSignalR, MessengerMessageDto } from '@/services/messenger.signalr'
import { useStoreState } from '@/store'
import dayjs from 'dayjs'
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaArrowLeft, FaPaperclip, FaRegSmile, FaSearch, FaTimes, FaTrash } from 'react-icons/fa'
import { IoCheckmarkDone, IoChatbubbleEllipsesOutline, IoSend } from 'react-icons/io5'
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024
const formatFileSize = (size: number) => {
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
const MessengerWidget = () => {
const auth = useStoreState((state) => state.auth)
const tenantId = auth.user.tenantId || auth.tenant?.tenantId
const fileInputRef = useRef<HTMLInputElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [contacts, setContacts] = useState<MessengerContactDto[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [messages, setMessages] = useState<MessengerMessageDto[]>([])
const [text, setText] = useState('')
const [filter, setFilter] = useState('')
const [attachments, setAttachments] = useState<MessengerAttachmentDto[]>([])
const [connected, setConnected] = useState(false)
const [uploading, setUploading] = useState(false)
const [showEmoji, setShowEmoji] = useState(false)
const [multiSelect, setMultiSelect] = useState(false)
const [unread, setUnread] = useState(0)
useEffect(() => {
if (!auth.session.signedIn) return
const unsubscribeMessage = messengerSignalR.onMessage((message) => {
setMessages((prev) => (prev.some((item) => item.id === message.id) ? prev : [...prev, message]))
if (!open && message.senderId !== auth.user.id) {
setUnread((count) => count + 1)
}
})
const unsubscribeState = messengerSignalR.onStateChange(setConnected)
messengerSignalR.start()
return () => {
unsubscribeMessage()
unsubscribeState()
}
}, [auth.session.signedIn, auth.user.id, open])
useEffect(() => {
if (!auth.session.signedIn) {
messengerSignalR.stop()
}
}, [auth.session.signedIn])
useEffect(() => {
if (!open) return
setUnread(0)
getMessengerContacts(filter)
.then((response) => setContacts(response.data))
.catch(() => setContacts([]))
}, [filter, open])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, open])
const selectedContacts = useMemo(
() => contacts.filter((contact) => selectedIds.includes(contact.id)),
[contacts, selectedIds],
)
const visibleMessages = useMemo(() => {
if (selectedIds.length === 0) return messages.slice(-30)
return messages.filter((message) => {
const isOwn = message.senderId === auth.user.id
const participants = new Set([message.senderId, ...message.recipientIds])
return selectedIds.some((id) => participants.has(id)) && (isOwn || selectedIds.includes(message.senderId))
})
}, [auth.user.id, messages, selectedIds])
const toggleContact = (id: string) => {
if (!multiSelect) {
setSelectedIds([id])
return
}
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
}
const toggleMultiSelect = () => {
setMultiSelect((value) => {
if (value) {
setSelectedIds((prev) => prev.slice(0, 1))
}
return !value
})
}
const handleFiles = async (files: FileList | null) => {
if (!files?.length) return
const nextFiles = Array.from(files)
const tooLarge = nextFiles.find((file) => file.size > MAX_UPLOAD_SIZE)
if (tooLarge) {
toast.push(<Notification title="Dosya 10 MB sınırını aşamaz" type="warning" />, {
placement: 'top-end',
})
return
}
setUploading(true)
try {
const uploaded = await Promise.all(
nextFiles.map((file) => uploadMessengerAttachment(file).then((response) => response.data)),
)
setAttachments((prev) => [...prev, ...uploaded])
} catch {
toast.push(<Notification title="Dosya yüklenemedi" type="danger" />, { placement: 'top-end' })
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const sendMessage = async () => {
const trimmedText = text.trim()
if (selectedIds.length === 0 || (!trimmedText && attachments.length === 0)) return
try {
await messengerSignalR.sendMessage({
recipientIds: selectedIds,
text: trimmedText,
attachments,
})
setText('')
setAttachments([])
setShowEmoji(false)
} catch {
toast.push(<Notification title="Mesaj gönderilemedi" type="danger" />, { placement: 'top-end' })
}
}
const onEmojiClick = (emoji: EmojiClickData) => {
setText((current) => `${current}${emoji.emoji}`)
}
if (!auth.session.signedIn) return null
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-50 h-14 w-14">
{open && (
<div className="pointer-events-auto fixed inset-x-2 bottom-20 top-3 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 sm:absolute sm:inset-auto sm:bottom-16 sm:right-0 sm:h-[min(70vh,640px)] sm:w-[min(78vw,800px)]">
<div className="flex h-full min-h-0">
<aside
className={`min-h-0 w-full shrink-0 flex-col border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 sm:flex sm:w-72 sm:border-r ${
selectedIds.length > 0 ? 'hidden' : 'flex'
}`}
>
<div className="flex h-16 items-center justify-between border-b border-gray-200 px-4 dark:border-gray-700">
<div className="flex min-w-0 items-center gap-3">
<Avatar size={36} shape="circle" src={auth.user.avatar || AVATAR_URL(auth.user.id, tenantId)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{auth.user.name || auth.user.userName || 'Messenger'}
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-emerald-500' : 'bg-gray-400'}`} />
{connected ? 'Bağlı' : 'Bağlanıyor'}
</div>
</div>
</div>
<Button size="xs" variant="plain" icon={<FaTimes />} onClick={() => setOpen(false)} />
</div>
<div className="space-y-2 p-3">
<Input
size="sm"
value={filter}
prefix={<FaSearch />}
placeholder="Kişi ara"
onChange={(event) => setFilter(event.target.value)}
/>
<Button
size="xs"
variant={multiSelect ? 'twoTone' : 'default'}
block
onClick={toggleMultiSelect}
>
{multiSelect ? 'Çoklu seçim açık' : 'Çoklu seçim'}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{contacts.map((contact) => {
const active = selectedIds.includes(contact.id)
return (
<button
key={contact.id}
type="button"
onClick={() => toggleContact(contact.id)}
className={`mb-1 flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition ${
active
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100'
: 'hover:bg-white dark:hover:bg-gray-800'
}`}
>
<Avatar size={36} shape="circle" src={AVATAR_URL(contact.id, contact.tenantId || tenantId)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{contact.fullName}</span>
<span className="block truncate text-xs text-gray-500">{contact.email || contact.userName}</span>
</span>
{active && <IoCheckmarkDone className="shrink-0 text-lg" />}
</button>
)
})}
</div>
</aside>
<main
className={`min-w-0 flex-1 flex-col ${
selectedIds.length === 0 ? 'hidden sm:flex' : 'flex'
}`}
>
<div className="flex h-16 items-center justify-between border-b border-gray-200 px-4 dark:border-gray-700">
<div className="flex min-w-0 items-center gap-3">
<Button
size="xs"
variant="plain"
icon={<FaArrowLeft />}
className="shrink-0 sm:hidden"
onClick={() => setSelectedIds([])}
/>
{selectedContacts.length === 1 && (
<Avatar
size={36}
shape="circle"
src={AVATAR_URL(selectedContacts[0].id, selectedContacts[0].tenantId || tenantId)}
/>
)}
{selectedContacts.length > 1 && (
<Avatar size={36} shape="circle" className="bg-emerald-100 text-emerald-700">
{selectedContacts.length}
</Avatar>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{selectedContacts.length === 1
? selectedContacts[0].fullName
: selectedContacts.length > 1
? `${selectedContacts.length} kişi seçildi`
: 'Sohbet'}
</div>
<div className="truncate text-xs text-gray-500">
{selectedContacts.length === 1
? selectedContacts[0].email || selectedContacts[0].userName
: selectedContacts.length > 1
? 'Toplu mesaj'
: 'Mesaj göndermek için kişi seçin'}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto bg-white px-4 py-3 dark:bg-gray-800">
{visibleMessages.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
Henüz mesaj yok
</div>
)}
{visibleMessages.map((message) => {
const own = message.senderId === auth.user.id
return (
<div key={message.id} className={`mb-3 flex ${own ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[76%] rounded-lg px-3 py-2 shadow-sm ${
own
? 'bg-emerald-600 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
}`}
>
{!own && <div className="mb-1 text-xs font-semibold">{message.senderName}</div>}
{message.text && <div className="whitespace-pre-wrap text-sm leading-5">{message.text}</div>}
{message.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{message.attachments.map((file) => (
<a
key={file.savedFileName}
href={file.url}
target="_blank"
rel="noreferrer"
className={`block rounded-md px-2 py-1 text-xs ${
own ? 'bg-emerald-700 text-white' : 'bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-100'
}`}
>
<span className="block truncate">{file.fileName}</span>
<span className="opacity-75">{formatFileSize(file.fileSize)}</span>
</a>
))}
</div>
)}
<div className={`mt-1 text-[11px] ${own ? 'text-emerald-100' : 'text-gray-500'}`}>
{dayjs(message.sentAt).format('HH:mm')}
</div>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
{selectedIds.length > 0 && attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-gray-200 px-4 py-2 dark:border-gray-700">
{attachments.map((file) => (
<span
key={file.savedFileName}
className="inline-flex max-w-[220px] items-center gap-2 rounded-md bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
>
<span className="truncate">{file.fileName}</span>
<button
type="button"
onClick={() =>
setAttachments((prev) =>
prev.filter((item) => item.savedFileName !== file.savedFileName),
)
}
>
<FaTrash />
</button>
</span>
))}
</div>
)}
{selectedIds.length > 0 && (
<div className="relative border-t border-gray-200 p-3 dark:border-gray-700">
{showEmoji && (
<div className="absolute bottom-16 left-2 z-10 sm:left-10">
<EmojiPicker
onEmojiClick={onEmojiClick}
width="min(320px, calc(100vw - 32px))"
height={380}
/>
</div>
)}
<div className="flex min-h-[52px] items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 focus-within:border-emerald-500 focus-within:ring-1 focus-within:ring-emerald-500 dark:border-gray-600 dark:bg-gray-800">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => handleFiles(event.target.files)}
/>
<Tooltip title="Dosya ekle">
<Button
type="button"
size="sm"
variant="plain"
loading={uploading}
icon={<FaPaperclip />}
className="shrink-0"
onClick={() => fileInputRef.current?.click()}
/>
</Tooltip>
<Tooltip title="Emoji">
<Button
type="button"
size="sm"
variant="plain"
icon={<FaRegSmile />}
className="shrink-0"
onClick={() => setShowEmoji((value) => !value)}
/>
</Tooltip>
<textarea
value={text}
placeholder="Mesaj yazın"
rows={1}
autoFocus
className="min-h-[36px] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-100"
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage()
}
}}
/>
<Button
type="button"
size="sm"
variant="solid"
icon={<IoSend />}
className="shrink-0"
disabled={!text.trim() && attachments.length === 0}
onClick={sendMessage}
/>
</div>
</div>
)}
</main>
</div>
</div>
)}
<Tooltip title="Messenger">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="pointer-events-auto relative flex h-12 w-12 items-center justify-center rounded-full bg-emerald-600 text-2xl text-white shadow-xl transition hover:bg-emerald-700"
>
<IoChatbubbleEllipsesOutline />
{unread > 0 && (
<span className="absolute min-w-[20px] rounded-full bg-red-500 px-1.5 py-0.5 text-xs font-semibold text-white">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
</Tooltip>
</div>
)
}
export default MessengerWidget