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,
|
2026-06-12 19:35:48 +00:00
|
|
|
|
getMessengerMessages,
|
2026-06-12 17:31:41 +00:00
|
|
|
|
MessengerAttachmentDto,
|
|
|
|
|
|
MessengerContactDto,
|
2026-06-12 19:35:48 +00:00
|
|
|
|
MessengerMessageDto,
|
2026-06-12 17:31:41 +00:00
|
|
|
|
uploadMessengerAttachment,
|
|
|
|
|
|
} from '@/services/messenger.service'
|
2026-06-12 19:35:48 +00:00
|
|
|
|
import { messengerSignalR } from '@/services/messenger.signalr'
|
2026-06-12 17:31:41 +00:00
|
|
|
|
import { useStoreState } from '@/store'
|
|
|
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'
|
|
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
2026-06-12 19:35:48 +00:00
|
|
|
|
import {
|
|
|
|
|
|
FaArrowLeft,
|
|
|
|
|
|
FaCompress,
|
|
|
|
|
|
FaExpand,
|
|
|
|
|
|
FaPaperclip,
|
|
|
|
|
|
FaRegSmile,
|
|
|
|
|
|
FaSearch,
|
|
|
|
|
|
FaTimes,
|
|
|
|
|
|
FaTrash,
|
|
|
|
|
|
} from 'react-icons/fa'
|
2026-06-12 17:31:41 +00:00
|
|
|
|
import { IoCheckmarkDone, IoChatbubbleEllipsesOutline, IoSend } from 'react-icons/io5'
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
2026-06-13 18:50:39 +00:00
|
|
|
|
const CONTACT_REFRESH_INTERVAL = 30000
|
2026-06-12 17:31:41 +00:00
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (size: number) => {
|
|
|
|
|
|
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
|
|
|
|
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 19:35:48 +00:00
|
|
|
|
const canDeleteMessage = (message: MessengerMessageDto, userId: string) =>
|
|
|
|
|
|
message.senderId === userId && dayjs().diff(dayjs(message.sentAt), 'minute', true) < 10
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
const MessengerWidget = () => {
|
|
|
|
|
|
const auth = useStoreState((state) => state.auth)
|
|
|
|
|
|
const tenantId = auth.user.tenantId || auth.tenant?.tenantId
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
2026-06-12 19:35:48 +00:00
|
|
|
|
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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)
|
2026-06-12 19:35:48 +00:00
|
|
|
|
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],
|
|
|
|
|
|
)
|
2026-06-12 17:31:41 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!auth.session.signedIn) return
|
|
|
|
|
|
|
|
|
|
|
|
const unsubscribeMessage = messengerSignalR.onMessage((message) => {
|
|
|
|
|
|
setMessages((prev) => (prev.some((item) => item.id === message.id) ? prev : [...prev, message]))
|
2026-06-12 19:35:48 +00:00
|
|
|
|
if (message.senderId !== auth.user.id && (!open || !selectedIds.includes(message.senderId))) {
|
|
|
|
|
|
setUnreadByContact((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
[message.senderId]: (current[message.senderId] || 0) + 1,
|
|
|
|
|
|
}))
|
2026-06-12 17:31:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-06-12 19:35:48 +00:00
|
|
|
|
const unsubscribeMessageDeleted = messengerSignalR.onMessageDeleted((message) => {
|
|
|
|
|
|
setMessages((prev) => prev.filter((item) => item.id !== message.messageId))
|
|
|
|
|
|
})
|
2026-06-12 17:31:41 +00:00
|
|
|
|
const unsubscribeState = messengerSignalR.onStateChange(setConnected)
|
|
|
|
|
|
messengerSignalR.start()
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
unsubscribeMessage()
|
2026-06-12 19:35:48 +00:00
|
|
|
|
unsubscribeMessageDeleted()
|
2026-06-12 17:31:41 +00:00
|
|
|
|
unsubscribeState()
|
|
|
|
|
|
}
|
2026-06-12 19:35:48 +00:00
|
|
|
|
}, [auth.session.signedIn, auth.user.id, open, selectedIds])
|
2026-06-12 17:31:41 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!auth.session.signedIn) {
|
|
|
|
|
|
messengerSignalR.stop()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [auth.session.signedIn])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return
|
2026-06-13 18:50:39 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
}, [filter, open])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
|
|
}, [messages, open])
|
|
|
|
|
|
|
2026-06-12 19:35:48 +00:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
const selectedContacts = useMemo(
|
|
|
|
|
|
() => contacts.filter((contact) => selectedIds.includes(contact.id)),
|
|
|
|
|
|
[contacts, selectedIds],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-13 18:50:39 +00:00
|
|
|
|
const canSendToSelectedContacts =
|
|
|
|
|
|
selectedContacts.length > 0 && selectedContacts.every((contact) => contact.isOnline)
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
const visibleMessages = useMemo(() => {
|
2026-06-12 19:35:48 +00:00
|
|
|
|
if (selectedIds.length === 0) return []
|
2026-06-12 17:31:41 +00:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-06-13 18:50:39 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 19:35:48 +00:00
|
|
|
|
setUnreadByContact((current) => {
|
|
|
|
|
|
if (!current[id]) return current
|
|
|
|
|
|
|
|
|
|
|
|
const next = { ...current }
|
|
|
|
|
|
delete next[id]
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 19:35:48 +00:00
|
|
|
|
const closeWidget = () => {
|
|
|
|
|
|
setOpen(false)
|
|
|
|
|
|
setMaximized(false)
|
|
|
|
|
|
setSelectedIds([])
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
setText('')
|
|
|
|
|
|
setAttachments([])
|
|
|
|
|
|
setShowEmoji(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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
|
2026-06-13 18:50:39 +00:00
|
|
|
|
if (!canSendToSelectedContacts) {
|
|
|
|
|
|
toast.push(<Notification title="Sadece online kullanıcılara mesaj gönderebilirsiniz" type="warning" />, {
|
|
|
|
|
|
placement: 'top-end',
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await messengerSignalR.sendMessage({
|
|
|
|
|
|
recipientIds: selectedIds,
|
|
|
|
|
|
text: trimmedText,
|
|
|
|
|
|
attachments,
|
|
|
|
|
|
})
|
|
|
|
|
|
setText('')
|
|
|
|
|
|
setAttachments([])
|
|
|
|
|
|
setShowEmoji(false)
|
2026-06-12 19:35:48 +00:00
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
messageInputRef.current?.focus()
|
|
|
|
|
|
}, 0)
|
2026-06-12 17:31:41 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
toast.push(<Notification title="Mesaj gönderilemedi" type="danger" />, { placement: 'top-end' })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 19:35:48 +00:00
|
|
|
|
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' })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
const onEmojiClick = (emoji: EmojiClickData) => {
|
|
|
|
|
|
setText((current) => `${current}${emoji.emoji}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!auth.session.signedIn) return null
|
|
|
|
|
|
|
2026-06-12 19:35:48 +00:00
|
|
|
|
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)]'
|
|
|
|
|
|
|
2026-06-12 17:31:41 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="pointer-events-none fixed bottom-5 right-5 z-50 h-14 w-14">
|
|
|
|
|
|
{open && (
|
2026-06-12 19:35:48 +00:00
|
|
|
|
<div className={panelClassName}>
|
2026-06-12 17:31:41 +00:00
|
|
|
|
<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>
|
2026-06-12 19:35:48 +00:00
|
|
|
|
<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>
|
2026-06-12 17:31:41 +00:00
|
|
|
|
</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)
|
2026-06-12 19:35:48 +00:00
|
|
|
|
const contactUnread = unreadByContact[contact.id] || 0
|
2026-06-12 17:31:41 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={contact.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => toggleContact(contact.id)}
|
2026-06-13 18:50:39 +00:00
|
|
|
|
disabled={!contact.isOnline}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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'
|
2026-06-13 18:50:39 +00:00
|
|
|
|
: contact.isOnline
|
|
|
|
|
|
? 'hover:bg-white dark:hover:bg-gray-800'
|
|
|
|
|
|
: 'cursor-not-allowed opacity-60'
|
2026-06-12 17:31:41 +00:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2026-06-13 18:50:39 +00:00
|
|
|
|
<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>
|
2026-06-12 17:31:41 +00:00
|
|
|
|
</span>
|
2026-06-12 19:35:48 +00:00
|
|
|
|
{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" />}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
</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
|
2026-06-13 18:50:39 +00:00
|
|
|
|
? `${selectedContacts[0].isOnline ? 'Online' : 'Offline'} - ${
|
|
|
|
|
|
selectedContacts[0].email || selectedContacts[0].userName
|
|
|
|
|
|
}`
|
2026-06-12 17:31:41 +00:00
|
|
|
|
: selectedContacts.length > 1
|
2026-06-13 18:50:39 +00:00
|
|
|
|
? canSendToSelectedContacts
|
|
|
|
|
|
? 'Toplu mesaj'
|
|
|
|
|
|
: 'Offline kullanici secili'
|
2026-06-12 17:31:41 +00:00
|
|
|
|
: 'Mesaj göndermek için kişi seçin'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-12 19:35:48 +00:00
|
|
|
|
<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>
|
2026-06-12 17:31:41 +00:00
|
|
|
|
</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
|
2026-06-12 19:35:48 +00:00
|
|
|
|
const deletable = canDeleteMessage(message, auth.user.id)
|
2026-06-12 17:31:41 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={message.id} className={`mb-3 flex ${own ? 'justify-end' : 'justify-start'}`}>
|
2026-06-12 19:35:48 +00:00
|
|
|
|
<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>
|
2026-06-12 17:31:41 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-12 19:35:48 +00:00
|
|
|
|
<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>
|
2026-06-12 17:31:41 +00:00
|
|
|
|
</div>
|
2026-06-12 19:35:48 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
</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"
|
2026-06-13 18:50:39 +00:00
|
|
|
|
disabled={!canSendToSelectedContacts}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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
|
2026-06-12 19:35:48 +00:00
|
|
|
|
ref={messageInputRef}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
value={text}
|
|
|
|
|
|
placeholder="Mesaj yazın"
|
|
|
|
|
|
rows={1}
|
|
|
|
|
|
autoFocus
|
2026-06-13 18:50:39 +00:00
|
|
|
|
disabled={!canSendToSelectedContacts}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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"
|
2026-06-13 18:50:39 +00:00
|
|
|
|
disabled={!canSendToSelectedContacts || (!text.trim() && attachments.length === 0)}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
onClick={sendMessage}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Tooltip title="Messenger">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-06-12 19:35:48 +00:00
|
|
|
|
onClick={() => (open ? closeWidget() : setOpen(true))}
|
2026-06-12 17:31:41 +00:00
|
|
|
|
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 && (
|
2026-06-12 19:35:48 +00:00
|
|
|
|
<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">
|
2026-06-12 17:31:41 +00:00
|
|
|
|
{unread > 99 ? '99+' : unread}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default MessengerWidget
|