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 (
{initials(name)}
{online && ( )}
) } function SystemAvatar({ size = 28 }: { size?: number }) { return (
) } // ── Sound ────────────────────────────────────────────────────────────────── function playNotifSound() { try { const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext const ctx = new AudioCtx() const osc = ctx.createOscillator(), gain = ctx.createGain() osc.connect(gain); gain.connect(ctx.destination) osc.frequency.value = 880 gain.gain.setValueAtTime(0.18, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25) osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.25) setTimeout(() => ctx.close(), 600) } catch { /* ignore */ } } // ── Persist ──────────────────────────────────────────────────────────────── interface ChatSettings { notifVisible: boolean soundEnabled: boolean 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 } } 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 {part} return ( {part} ) })} } // ── 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('rooms') const [rooms, setRooms] = useState([]) const [activeRoom, setActiveRoom] = useState(null) const [messages, setMessages] = useState([]) const [text, setText] = useState('') const [loadingRooms, setLoadingRooms] = useState(false) const [loadingMsgs, setLoadingMsgs] = useState(false) const [loadingOlder, setLoadingOlder] = useState(false) const [hasMoreMsgs, setHasMoreMsgs] = useState(false) const [sending, setSending] = useState(false) const [attachment, setAttachment] = useState(null) const [attachPreview, setAttachPreview] = useState(null) // Edit const [editingMsgId, setEditingMsgId] = useState(null) const [editText, setEditText] = useState('') // Context menu const [ctxMenu, setCtxMenu] = useState(null) // Typing & presence const [typingNames, setTypingNames] = useState([]) const [onlineIds, setOnlineIds] = useState([]) // Search const [searchQuery, setSearchQuery] = useState('') const [searchUsers, setSearchUsers] = useState([]) const [searchResults, setSearchResults] = useState([]) const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') const [loadingSearch, setLoadingSearch] = useState(false) const [allUsers, setAllUsers] = useState([]) const [chatMembers, setChatMembers] = useState<{ id: string; name: string; role: string }[]>([]) // Settings + pins + muted const [settings, setSettings] = useState(loadSettings) const [pinnedIds, setPinnedIds] = useState(loadPins) const [mutedIds, setMutedIds] = useState(loadMuted) // Toast notifications const [toasts, setToasts] = useState([]) // In-room search const [roomSearch, setRoomSearch] = useState('') const [roomSearchOpen, setRoomSearchOpen] = useState(false) const roomSearchInputRef = useRef(null) // @mentions const [mentionQuery, setMentionQuery] = useState(null) // Create group const [groupName, setGroupName] = useState('') const [groupMemberIds, setGroupMemberIds] = useState([]) const [creatingGroup, setCreatingGroup] = useState(false) // Group info edit const [editGroupName, setEditGroupName] = useState('') const [savingGroup, setSavingGroup] = useState(false) const groupAvatarInputRef = useRef(null) const widgetRef = useRef(null) const textareaRef = useRef(null) const messagesContainerRef = useRef(null) const messagesEndRef = useRef(null) const searchInputRef = useRef(null) const fileInputRef = useRef(null) const prevRoomsRef = useRef>({}) const activeRoomIdRef = useRef(null) const prevMsgCountRef = useRef(0) const typingTimerRef = useRef | null>(null) const isTypingRef = useRef(false) const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) const visibleRooms = [...rooms] .filter(r => !(r.type === 'notifications' && !settings.notifVisible)) .sort((a, b) => (pinnedIds.includes(a.id) ? 0 : 1) - (pinnedIds.includes(b.id) ? 0 : 1)) // ── Effects ─────────────────────────────────────────────────────────────── // 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) => { 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) => { 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 (
{/* Transparent overlay — closes chat when clicking outside panel */} {open &&
setOpen(false)} />} {/* Float button */} {/* Panel */} {open && (
{/* Header */}
{view !== 'rooms' && ( )}

{view === 'rooms' && 'Чат сотрудников'} {view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')} {view === 'search' && 'Поиск'} {view === 'settings' && 'Настройки чата'} {view === 'create-group' && 'Новая группа'} {view === 'group-info' && 'Настройки группы'}

{/* Online dot in messages header */} {view === 'messages' && activeRoom && isOtherOnline(activeRoom) && ( )}
{view === 'rooms' && visibleRooms.length > 0 &&

{visibleRooms.length} чатов

} {view === 'messages' && activeRoom?.type === 'direct' && (() => { const roleLabels: Record = { 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 (

{online && в сети} {online && roleLabel && ·} {roleLabel && {roleLabel}}

) })()} {view === 'messages' && activeRoom?.type === 'group' && activeRoom.memberCount && (

{activeRoom.memberCount} участников

)} {view === 'messages' && activeRoom?.type === 'notifications' && (

Только чтение

)}
{view === 'rooms' && (
)} {view === 'messages' && activeRoom?.type !== 'notifications' && (
{activeRoom?.type === 'group' && ( )}
)}
{/* ── Rooms ── */} {view === 'rooms' && (
{loadingRooms && visibleRooms.length === 0 ? ( <> } bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" /> {settings.notifVisible && } bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />} ) : ( visibleRooms.map(room => ( openRoom(room)} onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }} /> )) )}
)} {/* ── Search ── */} {view === 'search' && (
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 && }
{(['people', 'messages'] as const).map(tab => ( ))}
{searchTab === 'people' && ( !searchQuery ? allUsers.length === 0 ?
Загрузка...
: allUsers.filter(u => u.id !== user?.id).map(u => ( openDirect(u)} /> )) : searchUsers.length === 0 ?
Никого не найдено
: searchUsers.map(u => openDirect(u)} />) )} {searchTab === 'messages' && ( !searchQuery ?
Введите запрос для поиска
: loadingSearch ?
: searchResults.length === 0 ?
Ничего не найдено
: searchResults.map(r => ( )) )}
)} {/* ── Settings ── */} {view === 'settings' && (
: } label="Показывать канал уведомлений" checked={settings.notifVisible} onChange={v => updateSettings({ notifVisible: v })} /> : } label="Звуковое уведомление" checked={settings.soundEnabled} onChange={v => updateSettings({ soundEnabled: v })} /> } label="Всплывающие уведомления" checked={settings.popupEnabled !== false} onChange={v => updateSettings({ popupEnabled: v })} /> } label="Показывать текст в уведомлении" checked={settings.notifShowText !== false} onChange={v => updateSettings({ notifShowText: v })} />

Правый клик на сообщении — реакции, редактирование, удаление.
Кнопка ⋮ на чате — закрепить / открепить сверху.

)} {/* ── Create Group ── */} {view === 'create-group' && (
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 && (

Выбрано: {groupMemberIds.length} участника(-ов)

)}
{chatMembers.length === 0 ? (
) : ( chatMembers.filter(u => u.id !== user?.id).map(u => { const selected = groupMemberIds.includes(u.id) const roleLabels: Record = { hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная', receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник', } return ( ) }) )}
)} {/* ── Group Info ── */} {view === 'group-info' && activeRoom && ( groupAvatarInputRef.current?.click()} onSave={handleSaveGroupInfo} /> )} { const f = e.target.files?.[0]; if (f) void handleGroupAvatarUpload(f); e.target.value = '' }} /> {/* ── Messages ── */} {view === 'messages' && ( <> {/* In-room search bar */} {roomSearchOpen && (
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 && }
)}
{ if ((e.target as HTMLDivElement).scrollTop < 60 && hasMoreMsgs && !loadingOlder) void loadOlderMessages() }}> {/* Load more older messages */} {hasMoreMsgs && !loadingOlder && (
)} {loadingOlder && (
)} {loadingMsgs ? (
) : messages.length === 0 ? (
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
) : (() => { const filtered = roomSearch.trim() ? messages.filter(m => !m.deletedAt && m.text.toLowerCase().includes(roomSearch.toLowerCase())) : messages if (filtered.length === 0) return (
Ничего не найдено
) return filtered.map(msg => ( { if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return e.preventDefault() setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg }) }} onEditTextChange={setEditText} onSaveEdit={() => saveEdit(msg)} onCancelEdit={() => setEditingMsgId(null)} onReaction={emoji => toggleReaction(msg, emoji)} /> )) })()} {/* Typing indicator */} {typingNames.length > 0 && (
{typingNames.length === 1 ? `${typingNames[0].split(' ')[0]} печатает...` : `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`}
)}
{activeRoom?.type !== 'notifications' && (
{attachPreview && (
превью
)} {/* @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 (
{candidates.map(u => ( ))}
) })()}