sozsoft-platform/ui/src/components/template/MessengerWidget.tsx
2026-06-15 09:20:12 +03:00

638 lines
26 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.

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,
getMessengerMessages,
MessengerAttachmentDto,
MessengerContactDto,
MessengerMessageDto,
uploadMessengerAttachment,
} from '@/services/messenger.service'
import { messengerSignalR } 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,
FaCompress,
FaExpand,
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 CONTACT_REFRESH_INTERVAL = 30000
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 canDeleteMessage = (message: MessengerMessageDto, userId: string) =>
message.senderId === userId && dayjs().diff(dayjs(message.sentAt), 'minute', true) < 10
const MessengerWidget = () => {
const auth = useStoreState((state) => state.auth)
const tenantId = auth.user.tenantId || auth.tenant?.tenantId
const fileInputRef = useRef<HTMLInputElement>(null)
const messageInputRef = useRef<HTMLTextAreaElement>(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 [maximized, setMaximized] = useState(false)
const [unreadByContact, setUnreadByContact] = useState<Record<string, number>>({})
const unread = useMemo(
() => Object.values(unreadByContact).reduce((total, count) => total + count, 0),
[unreadByContact],
)
useEffect(() => {
if (!auth.session.signedIn) return
const unsubscribeMessage = messengerSignalR.onMessage((message) => {
setMessages((prev) => (prev.some((item) => item.id === message.id) ? prev : [...prev, message]))
if (message.senderId !== auth.user.id && (!open || !selectedIds.includes(message.senderId))) {
setUnreadByContact((current) => ({
...current,
[message.senderId]: (current[message.senderId] || 0) + 1,
}))
}
})
const unsubscribeMessageDeleted = messengerSignalR.onMessageDeleted((message) => {
setMessages((prev) => prev.filter((item) => item.id !== message.messageId))
})
const unsubscribeState = messengerSignalR.onStateChange(setConnected)
messengerSignalR.start()
return () => {
unsubscribeMessage()
unsubscribeMessageDeleted()
unsubscribeState()
}
}, [auth.session.signedIn, auth.user.id, open, selectedIds])
useEffect(() => {
if (!auth.session.signedIn) {
messengerSignalR.stop()
}
}, [auth.session.signedIn])
useEffect(() => {
if (!open) return
let active = true
const loadContacts = () => {
getMessengerContacts(filter)
.then((response) => {
if (active) setContacts(response.data)
})
.catch(() => {
if (active) setContacts([])
})
}
loadContacts()
const timer = window.setInterval(loadContacts, CONTACT_REFRESH_INTERVAL)
return () => {
active = false
window.clearInterval(timer)
}
}, [filter, open])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, open])
useEffect(() => {
if (!open || selectedIds.length === 0) return
const timer = window.setTimeout(() => {
messageInputRef.current?.focus()
}, 0)
return () => window.clearTimeout(timer)
}, [open, selectedIds])
useEffect(() => {
if (!open || selectedIds.length === 0) return
getMessengerMessages({
participantIds: selectedIds,
maxResultCount: 50,
sorting: 'sentAt desc',
})
.then((response) => setMessages(response.data))
.catch(() => setMessages([]))
}, [open, selectedIds])
const selectedContacts = useMemo(
() => contacts.filter((contact) => selectedIds.includes(contact.id)),
[contacts, selectedIds],
)
const canSendToSelectedContacts =
selectedContacts.length > 0 && selectedContacts.every((contact) => contact.isOnline)
const visibleMessages = useMemo(() => {
if (selectedIds.length === 0) return []
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) => {
const contact = contacts.find((item) => item.id === id)
if (!contact?.isOnline) {
toast.push(<Notification title="Offline kullanıcılara mesaj gönderilemez" type="warning" />, {
placement: 'top-end',
})
return
}
setUnreadByContact((current) => {
if (!current[id]) return current
const next = { ...current }
delete next[id]
return next
})
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 closeWidget = () => {
setOpen(false)
setMaximized(false)
setSelectedIds([])
setMessages([])
setText('')
setAttachments([])
setShowEmoji(false)
}
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
if (!canSendToSelectedContacts) {
toast.push(<Notification title="Sadece online kullanıcılara mesaj gönderebilirsiniz" type="warning" />, {
placement: 'top-end',
})
return
}
try {
await messengerSignalR.sendMessage({
recipientIds: selectedIds,
text: trimmedText,
attachments,
})
setText('')
setAttachments([])
setShowEmoji(false)
window.setTimeout(() => {
messageInputRef.current?.focus()
}, 0)
} catch {
toast.push(<Notification title="Mesaj gönderilemedi" type="danger" />, { placement: 'top-end' })
}
}
const deleteMessage = async (messageId: string) => {
if (!window.confirm('Mesaj silinsin mi?')) return
try {
await messengerSignalR.deleteMessage(messageId)
} catch {
toast.push(<Notification title="Mesaj silinemedi" type="danger" />, { placement: 'top-end' })
}
}
const onEmojiClick = (emoji: EmojiClickData) => {
setText((current) => `${current}${emoji.emoji}`)
}
if (!auth.session.signedIn) return null
const panelClassName = maximized
? 'pointer-events-auto fixed inset-2 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 sm:inset-4'
: '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)]'
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-50 h-14 w-14">
{open && (
<div className={panelClassName}>
<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>
<div className="flex shrink-0 items-center gap-1 sm:hidden">
<Tooltip title={maximized ? 'Küçült' : 'Büyüt'}>
<Button
size="xs"
variant="plain"
icon={maximized ? <FaCompress /> : <FaExpand />}
onClick={() => setMaximized((value) => !value)}
/>
</Tooltip>
<Button size="xs" variant="plain" icon={<FaTimes />} onClick={closeWidget} />
</div>
</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)
const contactUnread = unreadByContact[contact.id] || 0
return (
<button
key={contact.id}
type="button"
onClick={() => toggleContact(contact.id)}
disabled={!contact.isOnline}
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'
: contact.isOnline
? 'hover:bg-white dark:hover:bg-gray-800'
: 'cursor-not-allowed opacity-60'
}`}
>
<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={`mt-1 inline-flex items-center gap-1 text-xs font-medium ${
contact.isOnline ? 'text-emerald-600' : 'text-red-500'
}`}
>
<span
className={`h-2 w-2 rounded-full ${
contact.isOnline ? 'bg-emerald-500' : 'bg-red-500'
}`}
/>
{contact.isOnline ? 'Online' : 'Offline'}
</span>
</span>
{contactUnread > 0 && (
<span className="shrink-0 rounded-full bg-red-500 px-2 py-0.5 text-xs font-semibold text-white">
{contactUnread > 99 ? '99+' : contactUnread}
</span>
)}
{active && contactUnread === 0 && <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].isOnline ? 'Online' : 'Offline'} - ${
selectedContacts[0].email || selectedContacts[0].userName
}`
: selectedContacts.length > 1
? canSendToSelectedContacts
? 'Toplu mesaj'
: 'Offline kullanici secili'
: 'Mesaj göndermek için kişi seçin'}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip title={maximized ? 'Küçült' : 'Büyüt'}>
<Button
size="xs"
variant="plain"
icon={maximized ? <FaCompress /> : <FaExpand />}
onClick={() => setMaximized((value) => !value)}
/>
</Tooltip>
<Button size="xs" variant="plain" icon={<FaTimes />} onClick={closeWidget} />
</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
const deletable = canDeleteMessage(message, auth.user.id)
return (
<div key={message.id} className={`mb-3 flex ${own ? 'justify-end' : 'justify-start'}`}>
<div className={`group flex max-w-[76%] items-start gap-1.5 ${own ? 'flex-row-reverse' : ''}`}>
<div
className={`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'
}`}
>
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold">
<Avatar
size={20}
shape="circle"
src={
own
? auth.user.avatar || AVATAR_URL(auth.user.id, tenantId)
: AVATAR_URL(message.senderId, message.tenantId || tenantId)
}
/>
<span className="max-w-[160px] truncate">
{own ? auth.user.name || auth.user.userName || 'Ben' : message.senderName}
</span>
</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 flex items-center gap-2 text-[11px] ${
own ? 'justify-end text-emerald-100' : 'justify-start text-gray-500'
}`}
>
<span>{dayjs(message.sentAt).format('HH:mm')}</span>
</div>
</div>
{own && deletable && (
<button
type="button"
title="Mesaji sil"
className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] text-gray-400 opacity-0 transition hover:bg-red-500 hover:text-white group-hover:opacity-100 focus:opacity-100"
onClick={() => deleteMessage(message.id)}
>
<FaTrash />
</button>
)}
</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"
disabled={!canSendToSelectedContacts}
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
ref={messageInputRef}
value={text}
placeholder="Mesaj yazın"
rows={1}
autoFocus
disabled={!canSendToSelectedContacts}
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={!canSendToSelectedContacts || (!text.trim() && attachments.length === 0)}
onClick={sendMessage}
/>
</div>
</div>
)}
</main>
</div>
</div>
)}
<Tooltip title="Messenger">
<button
type="button"
onClick={() => (open ? closeWidget() : setOpen(true))}
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 -right-1 -top-1 flex min-h-[20px] min-w-[20px] items-center justify-center rounded-full bg-red-500 px-1.5 text-xs font-semibold leading-none text-white ring-2 ring-white dark:ring-gray-800">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
</Tooltip>
</div>
)
}
export default MessengerWidget