import { useState, useEffect, useRef, useCallback } from 'react'
import {
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin,
} from 'lucide-react'
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
import type { User } from '../../types'
import { useAuth } from '../../contexts/AuthContext'
import { cn } from '../../lib/utils'
// ── Helpers ────────────────────────────────────────────────────────────────
function avatarColor(name: string) {
const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % colors.length
return colors[h]
}
function initials(name: string) {
return name.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2)
}
function fmtTime(iso: string) {
const d = new Date(iso), now = new Date()
if (now.toDateString() === d.toDateString())
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
function Avatar({ name, size = 28, online = false }: { name: string; size?: number; online?: boolean }) {
return (
{initials(name)}
{online && (
)}
)
}
function SystemAvatar({ size = 28 }: { size?: number }) {
return (
)
}
// ── Sound ──────────────────────────────────────────────────────────────────
function playNotifSound() {
try {
const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
const ctx = new AudioCtx()
const osc = ctx.createOscillator(), gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.frequency.value = 880
gain.gain.setValueAtTime(0.18, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.25)
setTimeout(() => ctx.close(), 600)
} catch { /* ignore */ }
}
// ── Persist ────────────────────────────────────────────────────────────────
interface ChatSettings { notifVisible: boolean; soundEnabled: boolean }
const SETTINGS_KEY = 'hotelsync-chat-settings'
const PINS_KEY = 'hotelsync-chat-pins'
function loadSettings(): ChatSettings {
try {
const s = localStorage.getItem(SETTINGS_KEY)
if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial }
} catch { /**/ }
return { notifVisible: true, soundEnabled: false }
}
function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) }
function loadPins(): string[] {
try { return JSON.parse(localStorage.getItem(PINS_KEY) ?? '[]') as string[] } catch { return [] }
}
function savePins(ids: string[]) { localStorage.setItem(PINS_KEY, JSON.stringify(ids)) }
// ── Context menu ───────────────────────────────────────────────────────────
type CtxMenu =
| { type: 'message'; x: number; y: number; msg: ChatMessage }
| { type: 'room'; x: number; y: number; room: ChatRoom }
const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅']
// ── Main ───────────────────────────────────────────────────────────────────
type View = 'rooms' | 'messages' | 'search' | 'settings'
export function ChatWidget() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [open, setOpen] = useState(false)
const [view, setView] = useState('rooms')
const [rooms, setRooms] = useState([])
const [activeRoom, setActiveRoom] = useState(null)
const [messages, setMessages] = useState([])
const [text, setText] = useState('')
const [loadingRooms, setLoadingRooms] = useState(false)
const [loadingMsgs, setLoadingMsgs] = useState(false)
const [sending, setSending] = useState(false)
const [attachment, setAttachment] = useState(null)
const [attachPreview, setAttachPreview] = useState(null)
// Edit
const [editingMsgId, setEditingMsgId] = useState(null)
const [editText, setEditText] = useState('')
// Context menu
const [ctxMenu, setCtxMenu] = useState(null)
// Typing & presence
const [typingNames, setTypingNames] = useState([])
const [onlineIds, setOnlineIds] = useState([])
// Search
const [searchQuery, setSearchQuery] = useState('')
const [searchUsers, setSearchUsers] = useState([])
const [searchResults, setSearchResults] = useState([])
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
const [loadingSearch, setLoadingSearch] = useState(false)
const [allUsers, setAllUsers] = useState([])
// Settings + pins
const [settings, setSettings] = useState(loadSettings)
const [pinnedIds, setPinnedIds] = useState(loadPins)
const messagesEndRef = useRef(null)
const pollRef = useRef | null>(null)
const searchInputRef = useRef(null)
const fileInputRef = useRef(null)
const prevUnreadRef = useRef(0)
const prevMsgCountRef = useRef(0)
const typingTimerRef = useRef | null>(null)
const isTypingRef = useRef(false)
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
const visibleRooms = [...rooms]
.filter(r => !(r.type === 'notifications' && !settings.notifVisible))
.sort((a, b) => (pinnedIds.includes(a.id) ? 0 : 1) - (pinnedIds.includes(b.id) ? 0 : 1))
// ── Effects ───────────────────────────────────────────────────────────────
const loadRooms = useCallback(async () => {
if (!slug) return
try {
const data = await api.chat.listRooms(slug)
setRooms(data)
if (settings.soundEnabled && !open) {
const n = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
if (n > prevUnreadRef.current) playNotifSound()
prevUnreadRef.current = n
}
} catch { /**/ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug, settings.soundEnabled, open])
// Poll rooms
useEffect(() => {
if (!open || !slug) return
setLoadingRooms(true)
loadRooms().finally(() => setLoadingRooms(false))
pollRef.current = setInterval(loadRooms, 5000)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [open, slug, loadRooms])
// Presence heartbeat
useEffect(() => {
if (!open || !slug) return
api.chat.setPresence(slug).catch(() => {/**/})
api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/})
const t = setInterval(async () => {
api.chat.setPresence(slug).catch(() => {/**/})
api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/})
}, 30_000)
return () => clearInterval(t)
}, [open, slug])
// Auto-scroll
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
// Poll messages + typing
useEffect(() => {
if (view !== 'messages' || !activeRoom) return
const interval = setInterval(async () => {
try {
const [msgs, typing] = await Promise.all([
api.chat.getMessages(slug, activeRoom.id),
api.chat.getTyping(slug, activeRoom.id),
])
if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) {
const last = msgs[msgs.length - 1]
if (last.senderId !== user?.id) playNotifSound()
}
prevMsgCountRef.current = msgs.length
setMessages(msgs)
setTypingNames(typing)
} catch { /**/ }
}, 2000)
return () => clearInterval(interval)
}, [view, activeRoom, slug, settings.soundEnabled, user?.id])
// Clear typing names when leaving room
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
// Search users
useEffect(() => {
if (view !== 'search' || !slug) return
api.users.list(slug).then(setAllUsers).catch(() => {/**/})
}, [view, slug])
// Search effect
useEffect(() => {
if (view !== 'search') return
const q = searchQuery.trim()
if (!q) { setSearchUsers([]); setSearchResults([]); return }
const ql = q.toLowerCase()
setSearchUsers(allUsers.filter(u => u.id !== user?.id && (u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql))))
const timer = setTimeout(async () => {
if (searchTab !== 'messages') return
setLoadingSearch(true)
try { setSearchResults(await api.chat.search(slug, q)) } catch { /**/ }
finally { setLoadingSearch(false) }
}, 400)
return () => clearTimeout(timer)
}, [searchQuery, view, allUsers, user?.id, slug, searchTab])
useEffect(() => {
if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50)
}, [view])
// Close ctx menu on outside click
useEffect(() => {
if (!ctxMenu) return
const h = () => setCtxMenu(null)
window.addEventListener('click', h)
return () => window.removeEventListener('click', h)
}, [ctxMenu])
// ── Typing helpers ────────────────────────────────────────────────────────
const sendTyping = useCallback((typing: boolean) => {
if (!activeRoom || !slug) return
if (typing === isTypingRef.current) return
isTypingRef.current = typing
api.chat.setTyping(slug, activeRoom.id, typing).catch(() => {/**/})
}, [activeRoom, slug])
const handleTextChange = (val: string) => {
setText(val)
if (!val.trim()) { sendTyping(false); return }
sendTyping(true)
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
}
// ── Actions ───────────────────────────────────────────────────────────────
const openRoom = async (room: ChatRoom) => {
setActiveRoom(room); setView('messages'); setLoadingMsgs(true)
prevMsgCountRef.current = 0; isTypingRef.current = false
try {
const msgs = await api.chat.getMessages(slug, room.id)
prevMsgCountRef.current = msgs.length
setMessages(msgs)
await api.chat.markRead(slug, room.id)
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
} catch { /**/ }
finally { setLoadingMsgs(false) }
}
const openDirect = async (targetUser: User) => {
try {
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
await loadRooms()
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id, otherUserLastRead: null })
setSearchQuery('')
} catch { /**/ }
}
const openSearchResult = async (result: ChatSearchResult) => {
const room = rooms.find(r => r.id === result.roomId)
if (room) { setSearchQuery(''); setView('rooms'); await openRoom(room) }
}
const handleFileSelect = (e: React.ChangeEvent) => {
const file = e.target.files?.[0]; if (!file) return
setAttachment(file)
const reader = new FileReader()
reader.onload = ev => setAttachPreview(ev.target?.result as string)
reader.readAsDataURL(file); e.target.value = ''
}
const sendMessage = async () => {
if ((!text.trim() && !attachment) || !activeRoom || sending) return
const t = text.trim(), file = attachment
setText(''); setAttachment(null); setAttachPreview(null); setSending(true)
sendTyping(false)
try {
let url: string | undefined
if (file) { url = (await api.chat.uploadImage(file)).url }
const msg = await api.chat.sendMessage(slug, activeRoom.id, t, url)
setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] })
} catch {
setText(t)
if (file) { setAttachment(file); setAttachPreview(attachPreview) }
} finally { setSending(false) }
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (text.trim() || attachment) void sendMessage() }
}
const startEdit = (msg: ChatMessage) => { setEditingMsgId(msg.id); setEditText(msg.text); setCtxMenu(null) }
const saveEdit = async (msg: ChatMessage) => {
if (!editText.trim() || !activeRoom) return
try {
const updated = await api.chat.editMessage(slug, activeRoom.id, msg.id, editText)
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
} catch { /**/ }
setEditingMsgId(null)
}
const deleteMsg = async (msg: ChatMessage) => {
if (!activeRoom) return; setCtxMenu(null)
try {
await api.chat.deleteMessage(slug, activeRoom.id, msg.id)
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, deletedAt: new Date().toISOString(), text: '', attachmentUrl: null } : m))
} catch { /**/ }
}
const toggleReaction = async (msg: ChatMessage, emoji: string) => {
if (!activeRoom) return; setCtxMenu(null)
try {
const updated = await api.chat.toggleReaction(slug, activeRoom.id, msg.id, emoji)
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
} catch { /**/ }
}
const togglePin = (roomId: string) => {
setCtxMenu(null)
const next = pinnedIds.includes(roomId) ? pinnedIds.filter(id => id !== roomId) : [...pinnedIds, roomId]
setPinnedIds(next); savePins(next)
}
const updateSettings = (patch: Partial) => {
const next = { ...settings, ...patch }; setSettings(next); saveSettings(next)
}
const goBack = () => {
if (view === 'messages') {
sendTyping(false)
setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([])
}
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
else if (view === 'settings') setView('rooms')
}
const roomDisplayName = (room: ChatRoom) =>
room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат'
const isOtherOnline = (room: ChatRoom) =>
room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false
// Read receipt: is message read by other side?
const isRead = (msg: ChatMessage) => {
if (!activeRoom || activeRoom.type !== 'direct' || !activeRoom.otherUserLastRead) return false
return new Date(msg.createdAt) <= new Date(activeRoom.otherUserLastRead)
}
if (!slug) return null
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
{/* Float button */}
{/* Panel */}
{open && (
{/* Header */}
{view !== 'rooms' && (
)}
{view === 'rooms' && 'Чат сотрудников'}
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
{view === 'search' && 'Поиск'}
{view === 'settings' && 'Настройки чата'}
{/* Online dot in messages header */}
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
)}
{view === 'rooms' && visibleRooms.length > 0 &&
{visibleRooms.length} чатов
}
{view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && (
в сети
)}
{view === 'messages' && activeRoom?.type === 'notifications' && (
Только чтение
)}
{view === 'rooms' && (
)}
{/* ── Rooms ── */}
{view === 'rooms' && (
{loadingRooms && visibleRooms.length === 0 ? (
<>
} bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" />
{settings.notifVisible && } bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />}
>
) : (
visibleRooms.map(room => (
openRoom(room)}
onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }}
/>
))
)}
)}
{/* ── Search ── */}
{view === 'search' && (
{(['people', 'messages'] as const).map(tab => (
))}
{searchTab === 'people' && (
!searchQuery
? allUsers.length === 0
?
Загрузка...
: allUsers.filter(u => u.id !== user?.id).map(u => (
openDirect(u)} />
))
: searchUsers.length === 0
? Никого не найдено
: searchUsers.map(u => openDirect(u)} />)
)}
{searchTab === 'messages' && (
!searchQuery
? Введите запрос для поиска
: loadingSearch
?
: searchResults.length === 0
? Ничего не найдено
: searchResults.map(r => (
))
)}
)}
{/* ── Settings ── */}
{view === 'settings' && (
: }
label="Показывать канал уведомлений" checked={settings.notifVisible}
onChange={v => updateSettings({ notifVisible: v })} />
: }
label="Звуковое уведомление" checked={settings.soundEnabled}
onChange={v => updateSettings({ soundEnabled: v })} />
Правый клик на сообщении — реакции, редактирование, удаление.
Кнопка ⋮ на чате — закрепить / открепить сверху.
)}
{/* ── Messages ── */}
{view === 'messages' && (
<>
{loadingMsgs ? (
) : messages.length === 0 ? (
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
) : (
messages.map(msg => (
{
if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return
e.preventDefault()
setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg })
}}
onEditTextChange={setEditText}
onSaveEdit={() => saveEdit(msg)}
onCancelEdit={() => setEditingMsgId(null)}
onReaction={emoji => toggleReaction(msg, emoji)}
/>
))
)}
{/* Typing indicator */}
{typingNames.length > 0 && (
{typingNames.length === 1
? `${typingNames[0].split(' ')[0]} печатает...`
: `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`}
)}
{activeRoom?.type !== 'notifications' && (
{attachPreview && (
)}
Enter — отправить · Shift+Enter — перенос
)}
{activeRoom?.type === 'notifications' && (
)}
>
)}
)}
{/* Context menu */}
{ctxMenu && (
ctxMenu.type === 'message' && toggleReaction(ctxMenu.msg, emoji)}
onEdit={() => ctxMenu.type === 'message' && startEdit(ctxMenu.msg)}
onDelete={() => ctxMenu.type === 'message' && deleteMsg(ctxMenu.msg)}
onTogglePin={() => ctxMenu.type === 'room' && togglePin(ctxMenu.room.id)}
onClose={() => setCtxMenu(null)}
/>
)}
>
)
}
// ── RoomRow ────────────────────────────────────────────────────────────────
function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {
room: ChatRoom; isPinned: boolean; isOnline: boolean
onClick: () => void; onMenuClick: (e: React.MouseEvent) => void
}) {
const [hovered, setHovered] = useState(false)
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Avatar */}
{room.type === 'general' ? (
) : room.type === 'notifications' ? (
) : (
)}
{Number(room.unreadCount) > 0 && (
{room.unreadCount}
)}
{/* Text */}
{isPinned &&
}
0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
{room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName}
{!hovered && room.lastMessageAt && (
{fmtTime(room.lastMessageAt)}
)}
{room.lastMessage && (
0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}{room.lastMessage}
)}
{/* ⋮ button — appears on hover */}
{hovered && (
)}
)
}
// ── MessageBubble ──────────────────────────────────────────────────────────
function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string; isRead: boolean
onContextMenu: (e: React.MouseEvent) => void
onEditTextChange: (v: string) => void
onSaveEdit: () => void; onCancelEdit: () => void
onReaction: (emoji: string) => void
}) {
const isDeleted = !!msg.deletedAt, isSystem = msg.isSystem
return (
{!isOwn && (isSystem ?
:
)}
{!isOwn && !isDeleted && (
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
)}
{isDeleted ? (
Сообщение удалено
) : isEditing ? (
) : (
{msg.attachmentUrl && (
)}
{msg.text && (
{msg.text}
)}
)}
{/* Meta: time + edited + read receipt */}
{!isDeleted && (
{fmtTime(msg.createdAt)}
{msg.editedAt &&
· изм.
}
{isOwn && (
{isRead ? '✓✓' : '✓'}
)}
)}
{/* Reactions */}
{!isDeleted && msg.reactions?.length > 0 && (
{(msg.reactions as ChatReaction[]).map(r => (
))}
)}
)
}
// ── ContextMenu ────────────────────────────────────────────────────────────
function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDelete, onTogglePin, onClose }: {
menu: CtxMenu; currentUserId: string; pinnedIds: string[]
onReaction: (emoji: string) => void; onEdit: () => void; onDelete: () => void
onTogglePin: () => void; onClose: () => void
}) {
const menuW = 182, menuH = menu.type === 'message' ? 160 : 56
const x = Math.min(menu.x, window.innerWidth - menuW - 8)
const y = Math.min(menu.y, window.innerHeight - menuH - 8)
return (
e.stopPropagation()}>
{menu.type === 'message' && (
<>
{EMOJIS.map(e => (
))}
{menu.msg.senderId === currentUserId && (
)}
{menu.msg.senderId === currentUserId && (
)}
>
)}
{menu.type === 'room' && (
)}
)
}
// ── Misc sub-components ───────────────────────────────────────────────────
function TypingDots() {
return (
{[0, 1, 2].map(i => (
))}
)
}
function UserRow({ user, online, onClick }: { user: User; online: boolean; onClick: () => void }) {
const roleLabels: Record = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
return (
)
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
)
}
function ToggleRow({ icon, label, checked, onChange }: {
icon: React.ReactNode; label: string; checked: boolean; onChange: (v: boolean) => void
}) {
return (
{icon}
{label}
)
}
function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) {
return (
)
}