Files
hotelsync/src/components/chat/ChatWidget.tsx

1511 lines
76 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, UserPlus,
} 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
popupEnabled: boolean
notifShowText: 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, popupEnabled: true, notifShowText: true, ...JSON.parse(s) as Partial<ChatSettings> }
} catch { /**/ }
return { notifVisible: true, soundEnabled: false, popupEnabled: true, notifShowText: true }
}
function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) }
const MUTED_KEY = 'hotelsync-chat-muted'
function loadMuted(): string[] {
try { return JSON.parse(localStorage.getItem(MUTED_KEY) ?? '[]') as string[] } catch { return [] }
}
function saveMuted(ids: string[]) { localStorage.setItem(MUTED_KEY, JSON.stringify(ids)) }
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 = ['👍','❤️','😂','😮','😢','🔥','👏','✅']
interface ToastNotif {
id: string; roomId: string; roomName: string; senderName: string; text: string
}
function renderWithMentions(text: string, myName?: string, isOwn = false) {
const parts = text.split(/(@\S+)/g)
if (parts.length === 1) return <>{text}</>
return <>
{parts.map((part, i) => {
if (!part.startsWith('@')) return <span key={i}>{part}</span>
return (
<span key={i} className={cn('underline underline-offset-2', isOwn ? 'decoration-white/70' : 'decoration-current')}>
{part}
</span>
)
})}
</>
}
// ── Main ───────────────────────────────────────────────────────────────────
type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group' | 'group-info'
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 [loadingOlder, setLoadingOlder] = useState(false)
const [hasMoreMsgs, setHasMoreMsgs] = 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[]>([])
const [chatMembers, setChatMembers] = useState<{ id: string; name: string; role: string }[]>([])
// Settings + pins + muted
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
const [pinnedIds, setPinnedIds] = useState<string[]>(loadPins)
const [mutedIds, setMutedIds] = useState<string[]>(loadMuted)
// Toast notifications
const [toasts, setToasts] = useState<ToastNotif[]>([])
// In-room search
const [roomSearch, setRoomSearch] = useState('')
const [roomSearchOpen, setRoomSearchOpen] = useState(false)
const roomSearchInputRef = useRef<HTMLInputElement>(null)
// @mentions
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
// Create group
const [groupName, setGroupName] = useState('')
const [groupMemberIds, setGroupMemberIds] = useState<string[]>([])
const [creatingGroup, setCreatingGroup] = useState(false)
// Group info edit
const [editGroupName, setEditGroupName] = useState('')
const [savingGroup, setSavingGroup] = useState(false)
const groupAvatarInputRef = useRef<HTMLInputElement>(null)
const widgetRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const prevRoomsRef = useRef<Record<string, number>>({})
const activeRoomIdRef = useRef<string | null>(null)
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 ───────────────────────────────────────────────────────────────
// Keep activeRoomIdRef in sync for use inside intervals
useEffect(() => { activeRoomIdRef.current = activeRoom?.id ?? null }, [activeRoom])
// Room poll — always running (closed = 10s, open = 5s) for badge, sound, toasts
useEffect(() => {
if (!slug) return
let cancelled = false
const poll = async (initial = false) => {
if (cancelled) return
if (initial) setLoadingRooms(true)
try {
const data = await api.chat.listRooms(slug)
if (cancelled) return
setRooms(data)
data.forEach(room => {
const prev = prevRoomsRef.current[room.id]
const curr = Number(room.unreadCount) || 0
if (prev === undefined) { prevRoomsRef.current[room.id] = curr; return }
if (curr > prev) {
const isViewing = open && view === 'messages' && activeRoomIdRef.current === room.id
const isMuted = mutedIds.includes(room.id)
// Bypass mute if current user is mentioned
const myFirst = (user as { name?: string } | null | undefined)?.name?.split(' ')[0]
const isMentioned = myFirst ? (room.lastMessage ?? '').includes(`@${myFirst}`) : false
if (!isViewing && (!isMuted || isMentioned)) {
if (settings.soundEnabled) playNotifSound()
if (settings.popupEnabled !== false) {
const rName = room.type === 'general' ? 'Общий чат'
: room.type === 'notifications' ? 'Уведомления'
: room.type === 'group' ? (room.name ?? 'Группа')
: room.otherUserName ?? 'Чат'
setToasts(p => [...p.slice(-2), {
id: `${room.id}-${Date.now()}`,
roomId: room.id, roomName: rName,
senderName: room.lastSender ?? '',
text: room.lastMessage ?? '',
}])
}
}
}
prevRoomsRef.current[room.id] = curr
})
} catch { /**/ } finally {
if (initial && !cancelled) setLoadingRooms(false)
}
}
poll(true)
const interval = setInterval(() => poll(), open ? 5000 : 10000)
return () => { cancelled = true; clearInterval(interval) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug, open, view, settings.soundEnabled, settings.popupEnabled, mutedIds.join(',')])
// 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 on polling — only if user is already near the bottom
useEffect(() => {
const el = messagesContainerRef.current
if (!el) return
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100
if (nearBottom) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Poll messages + typing
useEffect(() => {
if (view !== 'messages' || !activeRoom) return
const interval = setInterval(async () => {
try {
const [fresh, typing] = await Promise.all([
api.chat.getMessages(slug, activeRoom.id),
api.chat.getTyping(slug, activeRoom.id),
])
setMessages(prev => {
// Merge: keep older history + update/append fresh messages
const freshIds = new Set(fresh.map(m => m.id))
const older = prev.filter(m => !freshIds.has(m.id) && new Date(m.createdAt) < new Date(fresh[0]?.createdAt ?? 0))
const merged = [...older, ...fresh]
if (settings.soundEnabled && merged.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) {
const last = merged[merged.length - 1]
if (last.senderId !== user?.id) playNotifSound()
}
prevMsgCountRef.current = merged.length
return merged
})
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])
// Load full users (manager+ only) for search view
useEffect(() => {
if (!slug || view !== 'search') return
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
}, [view, slug, allUsers.length])
// Load chat members (all roles) for @mentions, create-group, group-info
useEffect(() => {
if (!slug) return
if (view === 'create-group' || view === 'group-info' || (view === 'messages' && activeRoom?.type === 'general')) {
if (chatMembers.length === 0) api.chat.listMembers(slug).then(setChatMembers).catch(() => {/**/})
}
}, [view, slug, activeRoom?.type, chatMembers.length])
// 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])
// close-on-outside handled by overlay div in JSX
// ── 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)
// Detect @mention: find last @ before cursor position
const cursor = textareaRef.current?.selectionStart ?? val.length
const before = val.slice(0, cursor)
const match = before.match(/@([^\s@]*)$/)
setMentionQuery(match ? match[1] : null)
if (!val.trim()) { sendTyping(false); return }
sendTyping(true)
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
}
const insertMention = (mentionUser: { id: string; name: string; role: string }) => {
const cursor = textareaRef.current?.selectionStart ?? text.length
const before = text.slice(0, cursor)
const match = before.match(/@([^\s@]*)$/)
if (!match) return
const start = cursor - match[0].length
const newText = text.slice(0, start) + `@${mentionUser.name.split(' ')[0]} ` + text.slice(cursor)
setText(newText)
setMentionQuery(null)
setTimeout(() => textareaRef.current?.focus(), 0)
}
// ── Actions ───────────────────────────────────────────────────────────────
const openRoomById = async (roomId: string) => {
setToasts(p => p.filter(t => t.roomId !== roomId))
const room = rooms.find(r => r.id === roomId)
if (!room) { setOpen(true); return }
setOpen(true)
await openRoom(room)
}
const openRoom = async (room: ChatRoom) => {
setActiveRoom(room); setView('messages'); setLoadingMsgs(true)
setRoomSearch(''); setRoomSearchOpen(false)
prevMsgCountRef.current = 0; isTypingRef.current = false; setHasMoreMsgs(false)
try {
const msgs = await api.chat.getMessages(slug, room.id)
prevMsgCountRef.current = msgs.length
setHasMoreMsgs(msgs.length === 50)
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)
// Scroll to bottom after React paints the message list
requestAnimationFrame(() => requestAnimationFrame(() => {
const el = messagesContainerRef.current
if (el) el.scrollTop = el.scrollHeight
}))
}
}
const openDirect = async (targetUser: User) => {
try {
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
const data = await api.chat.listRooms(slug)
setRooms(data)
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null, memberCount: 0, memberNames: null, avatarUrl: 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); setMentionQuery(null)
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 toggleMute = (roomId: string) => {
setCtxMenu(null)
const next = mutedIds.includes(roomId) ? mutedIds.filter(id => id !== roomId) : [...mutedIds, roomId]
setMutedIds(next); saveMuted(next)
}
const updateSettings = (patch: Partial<ChatSettings>) => {
const next = { ...settings, ...patch }; setSettings(next); saveSettings(next)
}
const loadOlderMessages = async () => {
if (!activeRoom || loadingOlder || !hasMoreMsgs || messages.length === 0) return
const oldest = messages[0].createdAt
setLoadingOlder(true)
try {
const older = await api.chat.getMessages(slug, activeRoom.id, 50, oldest)
if (older.length === 0) { setHasMoreMsgs(false); return }
setHasMoreMsgs(older.length === 50)
// Preserve scroll position after prepending
const el = messagesContainerRef.current
const prevHeight = el?.scrollHeight ?? 0
setMessages(prev => [...older, ...prev])
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight
})
} catch { /**/ }
finally { setLoadingOlder(false) }
}
const openGroupInfo = () => {
if (!activeRoom) return
setEditGroupName(activeRoom.name ?? '')
setView('group-info')
}
const handleGroupAvatarUpload = async (file: File) => {
if (!activeRoom) return
try {
const { url } = await api.chat.uploadImage(file)
await api.chat.updateGroup(slug, activeRoom.id, { avatarUrl: url })
setActiveRoom(prev => prev ? { ...prev, avatarUrl: url } : prev)
setRooms(prev => prev.map(r => r.id === activeRoom.id ? { ...r, avatarUrl: url } : r))
} catch { /**/ }
}
const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => {
if (!activeRoom || savingGroup) return
setSavingGroup(true)
try {
await api.chat.updateGroup(slug, activeRoom.id, patch)
const data = await api.chat.listRooms(slug)
setRooms(data)
const updated = data.find(r => r.id === activeRoom.id)
if (updated) setActiveRoom(updated)
} catch { /**/ }
finally { setSavingGroup(false) }
}
const handleCreateGroup = async () => {
if (!groupName.trim() || groupMemberIds.length === 0 || creatingGroup) return
setCreatingGroup(true)
try {
const { roomId } = await api.chat.createGroup(slug, groupName.trim(), groupMemberIds)
const data = await api.chat.listRooms(slug)
setRooms(data)
const room = data.find(r => r.id === roomId)
setGroupName(''); setGroupMemberIds([])
if (room) await openRoom(room)
else setView('rooms')
} catch { /**/ }
finally { setCreatingGroup(false) }
}
const goBack = () => {
if (view === 'messages') {
sendTyping(false)
setRoomSearch(''); setRoomSearchOpen(false)
setHasMoreMsgs(false)
setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([])
}
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
else if (view === 'settings') setView('rooms')
else if (view === 'create-group') { setView('rooms'); setGroupName(''); setGroupMemberIds([]) }
else if (view === 'group-info') { setView('messages') }
}
const roomDisplayName = (room: ChatRoom) =>
room.type === 'general' ? 'Общий чат'
: room.type === 'notifications' ? 'Уведомления'
: room.type === 'group' ? (room.name ?? 'Группа')
: 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 (
<div ref={widgetRef}>
{/* Transparent overlay — closes chat when clicking outside panel */}
{open && <div className="fixed inset-0 z-40" onMouseDown={() => setOpen(false)} />}
{/* 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' && 'Настройки чата'}
{view === 'create-group' && 'Новая группа'}
{view === 'group-info' && 'Настройки группы'}
</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' && (() => {
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
const roleLabel = activeRoom.otherUserRole ? roleLabels[activeRoom.otherUserRole] : null
const online = isOtherOnline(activeRoom)
if (!online && !roleLabel) return null
return (
<p className="text-xs flex items-center gap-1">
{online && <span className="text-green-300">в сети</span>}
{online && roleLabel && <span className="text-white/40">·</span>}
{roleLabel && <span className="text-white/50 text-[10px]">{roleLabel}</span>}
</p>
)
})()}
{view === 'messages' && activeRoom?.type === 'group' && activeRoom.memberCount && (
<p className="text-xs text-white/70">{activeRoom.memberCount} участников</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" title="Поиск и новый чат"><PenSquare size={15} /></button>
<button onClick={() => { setGroupName(''); setGroupMemberIds([]); setView('create-group') }} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Новая группа"><UserPlus size={15} /></button>
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Настройки"><Settings size={15} /></button>
</div>
)}
{view === 'messages' && activeRoom?.type !== 'notifications' && (
<div className="flex items-center gap-0.5">
<button onClick={() => { setRoomSearchOpen(v => !v); setTimeout(() => roomSearchInputRef.current?.focus(), 50) }}
className={cn('p-1.5 rounded-lg transition-colors shrink-0', roomSearchOpen ? 'bg-white/30' : 'hover:bg-white/20')}>
<Search size={15} />
</button>
{activeRoom?.type === 'group' && (
<button onClick={openGroupInfo} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors shrink-0" title="Настройки группы">
<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)}
isMuted={mutedIds.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 })} />
<ToggleRow icon={<Bell size={15} />}
label="Всплывающие уведомления" checked={settings.popupEnabled !== false}
onChange={v => updateSettings({ popupEnabled: v })} />
<ToggleRow icon={<MessageSquare size={15} />}
label="Показывать текст в уведомлении" checked={settings.notifShowText !== false}
onChange={v => updateSettings({ notifShowText: v })} />
</SettingsSection>
<SettingsSection title="Подсказка">
<p className="text-xs text-slate-500 leading-relaxed">
Правый клик на сообщении реакции, редактирование, удаление.<br />
Кнопка на чате закрепить / открепить сверху.
</p>
</SettingsSection>
</div>
)}
{/* ── Create Group ── */}
{view === 'create-group' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-3 pt-3 pb-2 shrink-0 border-b border-slate-100 dark:border-slate-700">
<input
value={groupName}
onChange={e => setGroupName(e.target.value)}
placeholder="Название группы..."
maxLength={50}
autoFocus
className="w-full px-3 py-2 rounded-xl text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none border border-transparent focus:border-brand-400"
/>
{groupMemberIds.length > 0 && (
<p className="text-xs text-slate-500 mt-1.5 px-0.5">Выбрано: {groupMemberIds.length} участника(-ов)</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{chatMembers.length === 0 ? (
<div className="flex items-center justify-center py-8"><Loader2 size={18} className="animate-spin text-slate-400" /></div>
) : (
chatMembers.filter(u => u.id !== user?.id).map(u => {
const selected = groupMemberIds.includes(u.id)
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
return (
<button key={u.id}
onClick={() => setGroupMemberIds(prev => selected ? prev.filter(id => id !== u.id) : [...prev, u.id])}
className={cn('w-full flex items-center gap-3 px-4 py-2.5 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left', selected ? 'bg-brand-50 dark:bg-brand-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50')}>
<div className="relative">
<Avatar name={u.name} size={32} />
{selected && (
<span className="absolute inset-0 rounded-full bg-brand-600/80 flex items-center justify-center text-white text-xs font-bold"></span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{u.name}</p>
<p className="text-xs text-slate-400 truncate">{roleLabels[u.role] ?? u.role}</p>
</div>
</button>
)
})
)}
</div>
<div className="px-3 py-3 border-t border-slate-100 dark:border-slate-700 shrink-0">
<button
onClick={() => void handleCreateGroup()}
disabled={!groupName.trim() || groupMemberIds.length === 0 || creatingGroup}
className="w-full py-2 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2">
{creatingGroup ? <Loader2 size={15} className="animate-spin" /> : <Users size={15} />}
Создать группу
</button>
</div>
</div>
)}
{/* ── Group Info ── */}
{view === 'group-info' && activeRoom && (
<GroupInfoView
room={activeRoom}
allMembers={chatMembers}
currentUserId={user?.id ?? ''}
saving={savingGroup}
editName={editGroupName}
onEditName={setEditGroupName}
onAvatarClick={() => groupAvatarInputRef.current?.click()}
onSave={handleSaveGroupInfo}
/>
)}
<input ref={groupAvatarInputRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) void handleGroupAvatarUpload(f); e.target.value = '' }} />
{/* ── Messages ── */}
{view === 'messages' && (
<>
{/* In-room search bar */}
{roomSearchOpen && (
<div className="px-3 py-2 border-b border-slate-200 dark:border-slate-700 shrink-0 bg-white dark:bg-slate-800">
<div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-700 rounded-xl px-3 py-1.5">
<Search size={13} className="text-slate-400 shrink-0" />
<input ref={roomSearchInputRef} value={roomSearch} onChange={e => setRoomSearch(e.target.value)}
placeholder="Поиск в чате..."
className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none" />
{roomSearch && <button onClick={() => setRoomSearch('')} className="text-slate-400 hover:text-slate-600"><X size={12} /></button>}
</div>
</div>
)}
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-2"
onScroll={e => { if ((e.target as HTMLDivElement).scrollTop < 60 && hasMoreMsgs && !loadingOlder) void loadOlderMessages() }}>
{/* Load more older messages */}
{hasMoreMsgs && !loadingOlder && (
<div className="flex justify-center pt-1 pb-2">
<button onClick={() => void loadOlderMessages()}
className="text-xs text-brand-600 hover:text-brand-700 hover:underline transition-colors">
Загрузить ещё
</button>
</div>
)}
{loadingOlder && (
<div className="flex justify-center py-2">
<Loader2 size={14} className="animate-spin text-slate-400" />
</div>
)}
{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>
) : (() => {
const filtered = roomSearch.trim()
? messages.filter(m => !m.deletedAt && m.text.toLowerCase().includes(roomSearch.toLowerCase()))
: messages
if (filtered.length === 0) return (
<div className="text-center py-10 text-sm text-slate-400">Ничего не найдено</div>
)
return filtered.map(msg => (
<MessageBubble
key={msg.id}
msg={msg}
isOwn={!msg.isSystem && msg.senderId === user?.id}
isEditing={editingMsgId === msg.id}
editText={editText}
isRead={isRead(msg)}
currentUserName={user?.name}
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>
)}
{/* @mention dropdown */}
{mentionQuery !== null && (() => {
const q = mentionQuery.toLowerCase()
const candidates = chatMembers
.filter(u => u.id !== user?.id && u.name.toLowerCase().includes(q))
.slice(0, 6)
if (candidates.length === 0) return null
return (
<div className="mb-1.5 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl shadow-lg overflow-hidden">
{candidates.map(u => (
<button key={u.id} onMouseDown={e => { e.preventDefault(); insertMention(u) }}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors text-left">
<Avatar name={u.name} size={22} />
<span className="text-sm text-slate-800 dark:text-slate-200 truncate">{u.name}</span>
</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 ref={textareaRef} 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>
)}
{/* Toast notifications */}
{toasts.length > 0 && (
<div className="fixed top-4 right-4 z-[70] flex flex-col gap-2 w-72">
{toasts.map(toast => (
<ToastNotifCard
key={toast.id}
toast={toast}
showText={settings.notifShowText !== false}
onClose={() => setToasts(p => p.filter(t => t.id !== toast.id))}
onClick={() => void openRoomById(toast.roomId)}
/>
))}
</div>
)}
{/* Context menu */}
{ctxMenu && (
<ContextMenu menu={ctxMenu} currentUserId={user?.id ?? ''} pinnedIds={pinnedIds} mutedIds={mutedIds}
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)}
onToggleMute={() => ctxMenu.type === 'room' && toggleMute(ctxMenu.room.id)}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
)
}
// ── ToastNotifCard ─────────────────────────────────────────────────────────
function ToastNotifCard({ toast, showText, onClose, onClick }: {
toast: ToastNotif; showText: boolean; onClose: () => void; onClick: () => void
}) {
useEffect(() => {
const t = setTimeout(onClose, 4500)
return () => clearTimeout(t)
}, [onClose])
return (
<div
onClick={onClick}
className="flex items-start gap-3 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-2xl shadow-lg px-4 py-3 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/80 transition-colors"
>
<div className="shrink-0 mt-0.5 w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<MessageSquare size={15} className="text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{toast.roomName}</p>
{showText ? (
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
{toast.senderName ? `${toast.senderName.split(' ')[0]}: ` : ''}{toast.text || 'Новое сообщение'}
</p>
) : (
<p className="text-xs text-slate-400 mt-0.5">Новое сообщение</p>
)}
</div>
<button
onClick={e => { e.stopPropagation(); onClose() }}
className="shrink-0 text-slate-300 hover:text-slate-500 dark:text-slate-600 dark:hover:text-slate-400 transition-colors p-0.5 mt-0.5"
>
<X size={13} />
</button>
</div>
)
}
// ── RoomRow ────────────────────────────────────────────────────────────────
function RoomRow({ room, isPinned, isMuted, isOnline, onClick, onMenuClick }: {
room: ChatRoom; isPinned: boolean; isMuted: 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>
) : room.type === 'group' ? (
<div className="w-9 h-9 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<Users size={16} className="text-violet-600 dark:text-violet-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" />}
{isMuted && <BellOff size={10} className="text-slate-400 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.type === 'group' ? (room.name ?? 'Группа') : 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, currentUserName, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string; isRead: boolean
currentUserName?: string
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 && (() => {
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
const roleLabel = !isSystem && msg.senderRole ? roleLabels[msg.senderRole] : null
return (
<div className="group/name relative inline-block mb-0.5 ml-1 cursor-default">
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
</p>
{roleLabel && (
<span className="absolute left-full top-0 ml-1.5 px-1.5 py-0.5 rounded bg-slate-700 dark:bg-slate-900 text-white text-[9px] whitespace-nowrap opacity-0 group-hover/name:opacity-100 transition-opacity z-10 pointer-events-none shadow-sm">
{roleLabel}
</span>
)}
</div>
)
})()}
{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')}>
{renderWithMentions(msg.text, currentUserName, isOwn)}
</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, mutedIds, onReaction, onEdit, onDelete, onTogglePin, onToggleMute, onClose }: {
menu: CtxMenu; currentUserId: string; pinnedIds: string[]; mutedIds: string[]
onReaction: (emoji: string) => void; onEdit: () => void; onDelete: () => void
onTogglePin: () => void; onToggleMute: () => void; onClose: () => void
}) {
const menuW = 190, menuH = menu.type === 'message' ? 160 : 90
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>
<button onClick={() => { onToggleMute(); 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">
{mutedIds.includes(menu.room.id) ? <><Bell size={14} /> Включить уведомления</> : <><BellOff size={14} /> Отключить уведомления</>}
</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 overflow-hidden', checked ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}>
<span className={cn('absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform', checked ? 'translate-x-5' : 'translate-x-0')} />
</button>
</div>
)
}
// ── GroupInfoView ──────────────────────────────────────────────────────────
function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: {
room: ChatRoom
allMembers: { id: string; name: string; role: string }[]
currentUserId: string
saving: boolean
editName: string
onEditName: (v: string) => void
onAvatarClick: () => void
onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => void
}) {
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
const [addingMembers, setAddingMembers] = useState(false)
const [selectedAdd, setSelectedAdd] = useState<string[]>([])
// Current member ids from allMembers that are in this room (from memberNames we don't have IDs, so use a different approach)
// We track removes locally until saved
const [removedIds, setRemovedIds] = useState<string[]>([])
const currentMemberIds = allMembers
.filter(u => room.memberNames?.some(n => n === u.name) || u.id === currentUserId)
.map(u => u.id)
.filter(id => !removedIds.includes(id))
const removableMemberIds = allMembers.filter(u =>
(room.memberNames?.some(n => n === u.name) || u.id === currentUserId) && u.id !== currentUserId
).map(u => u.id)
const notMembers = allMembers.filter(u =>
u.id !== currentUserId &&
!currentMemberIds.includes(u.id) &&
!removedIds.includes(u.id)
)
const handleRemove = (userId: string) => {
setRemovedIds(p => [...p, userId])
onSave({ removeMemberIds: [userId] })
}
const handleAddMembers = () => {
if (selectedAdd.length === 0) { setAddingMembers(false); return }
onSave({ addMemberIds: selectedAdd })
setSelectedAdd([])
setAddingMembers(false)
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Avatar + name */}
<div className="flex flex-col items-center gap-3 px-4 pt-5 pb-4 border-b border-slate-100 dark:border-slate-700 shrink-0">
<button onClick={onAvatarClick} className="relative group">
{room.avatarUrl ? (
<img src={room.avatarUrl} alt={room.name ?? ''} className="w-16 h-16 rounded-full object-cover" />
) : (
<div className="w-16 h-16 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<Users size={28} className="text-violet-600 dark:text-violet-400" />
</div>
)}
<span className="absolute inset-0 rounded-full bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</span>
</button>
<div className="flex items-center gap-2 w-full max-w-[200px]">
<input value={editName} onChange={e => onEditName(e.target.value)}
className="flex-1 text-center text-sm font-semibold bg-transparent border-b border-slate-300 dark:border-slate-600 focus:border-brand-500 outline-none py-0.5 text-slate-800 dark:text-slate-100"
placeholder="Название группы" />
<button onClick={() => onSave({ name: editName })} disabled={!editName.trim() || saving}
className="text-brand-600 hover:text-brand-700 disabled:opacity-30 transition-colors text-xs font-medium shrink-0">
{saving ? <Loader2 size={12} className="animate-spin" /> : 'Сохранить'}
</button>
</div>
</div>
{/* Members list */}
<div className="flex-1 overflow-y-auto">
<div className="px-4 pt-3 pb-1 flex items-center justify-between">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
Участники · {currentMemberIds.length}
</p>
<button onClick={() => { setAddingMembers(v => !v); setSelectedAdd([]) }}
className="text-xs text-brand-600 hover:text-brand-700 font-medium flex items-center gap-1">
<UserPlus size={12} /> Добавить
</button>
</div>
{/* Add members panel */}
{addingMembers && notMembers.length > 0 && (
<div className="mx-3 mb-2 rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
{notMembers.map(u => (
<button key={u.id} onClick={() => setSelectedAdd(p => p.includes(u.id) ? p.filter(id => id !== u.id) : [...p, u.id])}
className={cn('w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-b border-slate-100 dark:border-slate-700 last:border-0',
selectedAdd.includes(u.id) ? 'bg-brand-50 dark:bg-brand-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50')}>
<div className="relative shrink-0">
<Avatar name={u.name} size={28} />
{selectedAdd.includes(u.id) && (
<span className="absolute inset-0 rounded-full bg-brand-600/80 flex items-center justify-center text-white text-[10px] font-bold"></span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-800 dark:text-slate-200 truncate">{u.name}</p>
<p className="text-xs text-slate-400">{roleLabels[u.role] ?? u.role}</p>
</div>
</button>
))}
{selectedAdd.length > 0 && (
<button onClick={handleAddMembers}
className="w-full py-2 bg-brand-600 hover:bg-brand-700 text-white text-xs font-medium transition-colors flex items-center justify-center gap-1.5">
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
Добавить {selectedAdd.length} чел.
</button>
)}
</div>
)}
{/* Current members */}
{allMembers.filter(u => currentMemberIds.includes(u.id)).map(u => (
<div key={u.id} className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-100 dark:border-slate-700/50">
<Avatar name={u.name} size={32} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
{u.name} {u.id === currentUserId && <span className="text-xs text-slate-400 font-normal">(вы)</span>}
</p>
<p className="text-xs text-slate-400">{roleLabels[u.role] ?? u.role}</p>
</div>
{u.id !== currentUserId && (
<button onClick={() => handleRemove(u.id)} title="Удалить из группы"
className="shrink-0 p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors">
<X size={14} />
</button>
)}
</div>
))}
</div>
</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>
)
}