Files
hotelsync/src/components/chat/ChatWidget.tsx
HotelSync abf1920e6f feat: chat — ⋮ room menu, typing indicator, online presence, read receipts
- RoomRow: ⋮ button (hover) for pin/unpin context menu
- MessageBubble: right-click context menu with emoji reactions + edit/delete
- Typing indicator: debounced setTyping, animated TypingDots, polling getTyping every 2s
- Online presence: heartbeat setPresence every 30s, green dot on avatars/header
- Read receipts: ✓/✓✓ for own messages in direct rooms via otherUserLastRead
- Migration 075: chat_presence + chat_typing tables
- Context menus clamped to viewport bounds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:16:35 +03:00

902 lines
45 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 { 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 (
<div className="relative shrink-0" style={{ width: size, height: size }}>
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
className="rounded-full flex items-center justify-center text-white font-semibold">
{initials(name)}
</div>
{online && (
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-white dark:border-slate-800" />
)}
</div>
)
}
function SystemAvatar({ size = 28 }: { size?: number }) {
return (
<div style={{ width: size, height: size }}
className="rounded-full flex items-center justify-center shrink-0 bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Bell size={size * 0.5} />
</div>
)
}
// ── 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<ChatSettings> }
} 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<View>('rooms')
const [rooms, setRooms] = useState<ChatRoom[]>([])
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [text, setText] = useState('')
const [loadingRooms, setLoadingRooms] = useState(false)
const [loadingMsgs, setLoadingMsgs] = useState(false)
const [sending, setSending] = useState(false)
const [attachment, setAttachment] = useState<File | null>(null)
const [attachPreview, setAttachPreview] = useState<string | null>(null)
// Edit
const [editingMsgId, setEditingMsgId] = useState<string | null>(null)
const [editText, setEditText] = useState('')
// Context menu
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
// Typing & presence
const [typingNames, setTypingNames] = useState<string[]>([])
const [onlineIds, setOnlineIds] = useState<string[]>([])
// Search
const [searchQuery, setSearchQuery] = useState('')
const [searchUsers, setSearchUsers] = useState<User[]>([])
const [searchResults, setSearchResults] = useState<ChatSearchResult[]>([])
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
const [loadingSearch, setLoadingSearch] = useState(false)
const [allUsers, setAllUsers] = useState<User[]>([])
// Settings + pins
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
const [pinnedIds, setPinnedIds] = useState<string[]>(loadPins)
const messagesEndRef = useRef<HTMLDivElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const prevUnreadRef = useRef<number>(0)
const prevMsgCountRef = useRef<number>(0)
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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<ChatSettings>) => {
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 */}
<button onClick={() => setOpen(v => !v)}
className={cn('fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg bg-brand-600 hover:bg-brand-700 text-white transition-all flex items-center justify-center', open && 'scale-90')}>
{open ? <X size={22} /> : <MessageSquare size={22} />}
{!open && totalUnread > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
{totalUnread > 9 ? '9+' : totalUnread}
</span>
)}
</button>
{/* Panel */}
{open && (
<div className="fixed bottom-24 right-6 z-50 w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden" style={{ height: 520 }}>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
{view !== 'rooms' && (
<button onClick={goBack} className="p-1 hover:bg-white/20 rounded-lg transition-colors shrink-0">
<ChevronLeft size={16} />
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold text-sm truncate">
{view === 'rooms' && 'Чат сотрудников'}
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
{view === 'search' && 'Поиск'}
{view === 'settings' && 'Настройки чата'}
</p>
{/* Online dot in messages header */}
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
<span className="w-2 h-2 rounded-full bg-green-400 shrink-0" />
)}
</div>
{view === 'rooms' && visibleRooms.length > 0 && <p className="text-xs text-white/70">{visibleRooms.length} чатов</p>}
{view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && (
<p className="text-xs text-green-300">в сети</p>
)}
{view === 'messages' && activeRoom?.type === 'notifications' && (
<p className="text-xs text-white/70">Только чтение</p>
)}
</div>
{view === 'rooms' && (
<div className="flex items-center gap-0.5">
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Search size={15} /></button>
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><PenSquare size={15} /></button>
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Settings size={15} /></button>
</div>
)}
</div>
{/* ── Rooms ── */}
{view === 'rooms' && (
<div className="flex-1 overflow-y-auto">
{loadingRooms && visibleRooms.length === 0 ? (
<>
<RoomSkeleton icon={<Users size={16} className="text-brand-400" />} bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" />
{settings.notifVisible && <RoomSkeleton icon={<Bell size={16} className="text-amber-500" />} bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />}
</>
) : (
visibleRooms.map(room => (
<RoomRow
key={room.id}
room={room}
isPinned={pinnedIds.includes(room.id)}
isOnline={isOtherOnline(room)}
onClick={() => openRoom(room)}
onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }}
/>
))
)}
</div>
)}
{/* ── Search ── */}
{view === 'search' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-3 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-700 rounded-xl px-3 py-2">
<Search size={14} className="text-slate-400 shrink-0" />
<input ref={searchInputRef} value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
placeholder="Люди или сообщения..."
className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none" />
{searchQuery && <button onClick={() => setSearchQuery('')} className="text-slate-400 hover:text-slate-600"><X size={13} /></button>}
</div>
</div>
<div className="flex px-3 gap-1 shrink-0 mb-1">
{(['people', 'messages'] as const).map(tab => (
<button key={tab} onClick={() => setSearchTab(tab)}
className={cn('flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors', searchTab === tab ? 'bg-brand-600 text-white' : 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700')}>
{tab === 'people' ? 'Люди' : 'Сообщения'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{searchTab === 'people' && (
!searchQuery
? allUsers.length === 0
? <div className="text-center py-6 text-xs text-slate-400">Загрузка...</div>
: allUsers.filter(u => u.id !== user?.id).map(u => (
<UserRow key={u.id} user={u} online={onlineIds.includes(u.id)} onClick={() => openDirect(u)} />
))
: searchUsers.length === 0
? <div className="text-center py-6 text-xs text-slate-400">Никого не найдено</div>
: searchUsers.map(u => <UserRow key={u.id} user={u} online={onlineIds.includes(u.id)} onClick={() => openDirect(u)} />)
)}
{searchTab === 'messages' && (
!searchQuery
? <div className="text-center py-6 text-xs text-slate-400">Введите запрос для поиска</div>
: loadingSearch
? <div className="flex items-center justify-center py-6"><Loader2 size={18} className="animate-spin text-slate-400" /></div>
: searchResults.length === 0
? <div className="text-center py-6 text-xs text-slate-400">Ничего не найдено</div>
: searchResults.map(r => (
<button key={r.id} onClick={() => openSearchResult(r)}
className="w-full text-left px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50">
<p className="text-xs text-slate-500 mb-0.5">
{r.roomType === 'general' ? 'Общий чат' : r.roomType === 'notifications' ? 'Уведомления' : r.otherUserName ?? 'Чат'}
{' · '}{r.senderName.split(' ')[0]}
</p>
<p className="text-sm text-slate-800 dark:text-slate-200 truncate">{r.text}</p>
<p className="text-[10px] text-slate-400 mt-0.5">{fmtTime(r.createdAt)}</p>
</button>
))
)}
</div>
</div>
)}
{/* ── Settings ── */}
{view === 'settings' && (
<div className="flex-1 overflow-y-auto py-4 space-y-4">
<SettingsSection title="Канал уведомлений">
<ToggleRow icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
label="Показывать канал уведомлений" checked={settings.notifVisible}
onChange={v => updateSettings({ notifVisible: v })} />
</SettingsSection>
<SettingsSection title="Звук">
<ToggleRow icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
label="Звуковое уведомление" checked={settings.soundEnabled}
onChange={v => updateSettings({ soundEnabled: v })} />
</SettingsSection>
<SettingsSection title="Подсказка">
<p className="text-xs text-slate-500 leading-relaxed">
Правый клик на сообщении реакции, редактирование, удаление.<br />
Кнопка на чате закрепить / открепить сверху.
</p>
</SettingsSection>
</div>
)}
{/* ── Messages ── */}
{view === 'messages' && (
<>
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{loadingMsgs ? (
<div className="flex items-center justify-center h-32"><Loader2 size={20} className="animate-spin text-slate-400" /></div>
) : messages.length === 0 ? (
<div className="text-center py-10 text-sm text-slate-400">
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
</div>
) : (
messages.map(msg => (
<MessageBubble
key={msg.id}
msg={msg}
isOwn={!msg.isSystem && msg.senderId === user?.id}
isEditing={editingMsgId === msg.id}
editText={editText}
isRead={isRead(msg)}
onContextMenu={e => {
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 && (
<div className="flex items-center gap-2 px-1">
<TypingDots />
<span className="text-xs text-slate-400">
{typingNames.length === 1
? `${typingNames[0].split(' ')[0]} печатает...`
: `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`}
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{activeRoom?.type !== 'notifications' && (
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
{attachPreview && (
<div className="relative inline-block mb-2">
<img src={attachPreview} alt="превью" className="h-16 rounded-lg object-cover border border-slate-200 dark:border-slate-600" />
<button onClick={() => { setAttachment(null); setAttachPreview(null) }}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-slate-700 text-white flex items-center justify-center hover:bg-red-500 transition-colors">
<X size={10} />
</button>
</div>
)}
<div className="flex items-end gap-1.5">
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileSelect} />
<button onClick={() => fileInputRef.current?.click()}
className="p-2 rounded-xl text-slate-400 hover:text-brand-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors shrink-0">
<Paperclip size={16} />
</button>
<textarea value={text} onChange={e => handleTextChange(e.target.value)} onKeyDown={handleKeyDown}
placeholder="Сообщение..." rows={1}
className="flex-1 resize-none rounded-xl px-3 py-2 text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none max-h-24 overflow-y-auto" />
<button onClick={() => void sendMessage()} disabled={(!text.trim() && !attachment) || sending}
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0">
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
<p className="text-[10px] text-slate-400 mt-1.5">Enter отправить · Shift+Enter перенос</p>
</div>
)}
{activeRoom?.type === 'notifications' && (
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 bg-amber-50 dark:bg-amber-900/10 shrink-0">
<p className="text-xs text-amber-700 dark:text-amber-400 text-center flex items-center justify-center gap-1.5">
<Bell size={12} /> Канал только для чтения
</p>
</div>
)}
</>
)}
</div>
)}
{/* Context menu */}
{ctxMenu && (
<ContextMenu menu={ctxMenu} currentUserId={user?.id ?? ''} pinnedIds={pinnedIds}
onReaction={emoji => 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 (
<div
className="group relative flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 cursor-pointer"
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Avatar */}
<div className="relative shrink-0">
{room.type === 'general' ? (
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<Users size={16} className="text-brand-600 dark:text-brand-400" />
</div>
) : room.type === 'notifications' ? (
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<Bell size={16} className="text-amber-600 dark:text-amber-400" />
</div>
) : (
<Avatar name={room.otherUserName ?? '?'} size={36} online={isOnline} />
)}
{Number(room.unreadCount) > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
{room.unreadCount}
</span>
)}
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{isPinned && <Pin size={10} className="text-brand-500 shrink-0" />}
<p className={cn('text-sm font-medium truncate flex-1', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
{room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName}
</p>
{!hovered && room.lastMessageAt && (
<span className="text-[10px] text-slate-400 shrink-0">{fmtTime(room.lastMessageAt)}</span>
)}
</div>
{room.lastMessage && (
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 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}
</p>
)}
</div>
{/* ⋮ button — appears on hover */}
{hovered && (
<button
onClick={onMenuClick}
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors text-base leading-none"
title="Действия"
>
</button>
)}
</div>
)
}
// ── 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 (
<div className={cn('flex gap-2', isOwn && 'flex-row-reverse')} onContextMenu={onContextMenu}>
{!isOwn && (isSystem ? <SystemAvatar size={24} /> : <Avatar name={msg.senderName} size={24} />)}
<div className={cn('max-w-[80%]', isOwn && 'items-end flex flex-col')}>
{!isOwn && !isDeleted && (
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
</p>
)}
{isDeleted ? (
<p className="px-3 py-2 text-sm italic text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-700/50 rounded-2xl">
Сообщение удалено
</p>
) : isEditing ? (
<div className="w-48">
<textarea value={editText} onChange={e => onEditTextChange(e.target.value)} autoFocus rows={2}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSaveEdit() } if (e.key === 'Escape') onCancelEdit() }}
className="w-full resize-none rounded-xl px-3 py-2 text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 outline-none border border-brand-400" />
<div className="flex gap-1.5 mt-1">
<button onClick={onSaveEdit} className="text-[11px] px-2 py-0.5 rounded bg-brand-600 text-white hover:bg-brand-700">Сохранить</button>
<button onClick={onCancelEdit} className="text-[11px] px-2 py-0.5 rounded bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-300">Отмена</button>
</div>
</div>
) : (
<div className={cn('rounded-2xl text-sm overflow-hidden',
isSystem ? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
: isOwn ? 'bg-brand-600 rounded-tr-sm' : 'bg-slate-100 dark:bg-slate-700 rounded-tl-sm')}>
{msg.attachmentUrl && (
<a href={msg.attachmentUrl} target="_blank" rel="noreferrer">
<img src={msg.attachmentUrl} alt="вложение" className="max-w-full rounded-t-2xl block" style={{ maxHeight: 180, objectFit: 'cover', width: '100%' }} />
</a>
)}
{msg.text && (
<p className={cn('px-3 py-2', isSystem ? 'text-amber-900 dark:text-amber-200' : isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200')}>
{msg.text}
</p>
)}
</div>
)}
{/* Meta: time + edited + read receipt */}
{!isDeleted && (
<div className={cn('flex items-center gap-1 mt-0.5 mx-1', isOwn && 'flex-row-reverse')}>
<p className="text-[10px] text-slate-400">{fmtTime(msg.createdAt)}</p>
{msg.editedAt && <p className="text-[10px] text-slate-400">· изм.</p>}
{isOwn && (
<span className={cn('text-[11px] leading-none', isRead ? 'text-brand-400' : 'text-slate-300 dark:text-slate-600')}>
{isRead ? '✓✓' : '✓'}
</span>
)}
</div>
)}
{/* Reactions */}
{!isDeleted && msg.reactions?.length > 0 && (
<div className={cn('flex flex-wrap gap-1 mt-1', isOwn && 'justify-end')}>
{(msg.reactions as ChatReaction[]).map(r => (
<button key={r.emoji} onClick={() => onReaction(r.emoji)}
className={cn('flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors',
r.hasOwn
? 'bg-brand-100 dark:bg-brand-900/40 border border-brand-400 text-brand-700 dark:text-brand-300'
: 'bg-slate-100 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-200')}>
{r.emoji} <span className="text-[10px] font-medium">{r.count}</span>
</button>
))}
</div>
)}
</div>
</div>
)
}
// ── 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 (
<div style={{ position: 'fixed', top: y, left: x, zIndex: 9999, minWidth: menuW }}
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-xl py-1"
onClick={e => e.stopPropagation()}>
{menu.type === 'message' && (
<>
<div className="flex justify-around px-2 py-1.5 border-b border-slate-100 dark:border-slate-700">
{EMOJIS.map(e => (
<button key={e} onClick={() => { onReaction(e); onClose() }}
className="text-base hover:scale-125 transition-transform leading-none p-0.5">{e}</button>
))}
</div>
{menu.msg.senderId === currentUserId && (
<button onClick={() => { onEdit(); onClose() }}
className="w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Редактировать
</button>
)}
{menu.msg.senderId === currentUserId && (
<button onClick={() => { onDelete(); onClose() }}
className="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Удалить
</button>
)}
</>
)}
{menu.type === 'room' && (
<button onClick={() => { onTogglePin(); onClose() }}
className="w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 flex items-center gap-2">
<Pin size={14} />
{pinnedIds.includes(menu.room.id) ? 'Открепить' : 'Закрепить сверху'}
</button>
)}
</div>
)
}
// ── Misc sub-components ───────────────────────────────────────────────────
function TypingDots() {
return (
<div className="flex gap-0.5 items-center">
{[0, 1, 2].map(i => (
<span key={i} className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce" style={{ animationDelay: `${i * 0.15}s` }} />
))}
</div>
)
}
function UserRow({ user, online, onClick }: { user: User; online: boolean; onClick: () => void }) {
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
return (
<button onClick={onClick} className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left">
<Avatar name={user.name} size={32} online={online} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{user.name}</p>
<p className="text-xs text-slate-400 truncate">{roleLabels[user.role] ?? user.role}</p>
</div>
</button>
)
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="px-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">{title}</p>
<div className="space-y-1">{children}</div>
</div>
)
}
function ToggleRow({ icon, label, checked, onChange }: {
icon: React.ReactNode; label: string; checked: boolean; onChange: (v: boolean) => void
}) {
return (
<div className="flex items-center gap-3 py-2">
<span className="text-slate-500 shrink-0">{icon}</span>
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300 leading-tight">{label}</span>
<button onClick={() => onChange(!checked)}
className={cn('relative w-10 h-5 rounded-full transition-colors shrink-0', checked ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}>
<span className={cn('absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform', checked ? 'translate-x-5' : 'translate-x-0.5')} />
</button>
</div>
)
}
function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) {
return (
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700/50 animate-pulse">
<div className={cn('w-9 h-9 rounded-full flex items-center justify-center shrink-0', bg)}>{icon}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">{label}</p>
<div className="h-3 w-24 bg-slate-200 dark:bg-slate-600 rounded mt-1" />
</div>
</div>
)
}