diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index ad4c6bd..4acb57f 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { MessageSquare, X, ChevronLeft, Send, Users, Loader2, - Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, + 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' @@ -16,16 +16,13 @@ function avatarColor(name: string) { 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) - const now = new Date() - const today = now.toDateString() === d.toDateString() - if (today) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + 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' }) } @@ -37,24 +34,37 @@ function Avatar({ name, size = 28 }: { name: string; size?: number }) { ) } - function SystemAvatar({ size = 28 }: { size?: number }) { return ( -
) } -// ── Settings ─────────────────────────────────────────────────────────────── +// ── Sound ────────────────────────────────────────────────────────────────── -interface ChatSettings { - notifVisible: boolean - soundEnabled: boolean +function playNotifSound() { + try { + const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext + const ctx = new AudioCtx() + const osc = ctx.createOscillator() + const 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 */ } } +// ── Settings ─────────────────────────────────────────────────────────────── + +interface ChatSettings { notifVisible: boolean; soundEnabled: boolean } const SETTINGS_KEY = 'hotelsync-chat-settings' +const PINS_KEY = 'hotelsync-chat-pins' function loadSettings(): ChatSettings { try { @@ -63,10 +73,22 @@ function loadSettings(): ChatSettings { } catch { /* ignore */ } return { notifVisible: true, soundEnabled: false } } +function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } -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 } + +// ── Emojis ───────────────────────────────────────────────────────────────── + +const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅'] // ── Main component ───────────────────────────────────────────────────────── @@ -76,60 +98,73 @@ 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 [open, setOpen] = useState(false) + const [view, setView] = useState('rooms') + const [rooms, setRooms] = useState([]) + const [activeRoom, setActiveRoom] = useState(null) + const [messages, setMessages] = useState([]) + const [text, setText] = useState('') const [loadingRooms, setLoadingRooms] = useState(false) - const [loadingMsgs, setLoadingMsgs] = useState(false) - const [sending, setSending] = useState(false) - const [attachment, setAttachment] = useState(null) + const [loadingMsgs, setLoadingMsgs] = useState(false) + const [sending, setSending] = useState(false) + const [attachment, setAttachment] = useState(null) const [attachPreview, setAttachPreview] = useState(null) - // Edit / delete / reactions - const [hoveredMsgId, setHoveredMsgId] = useState(null) + // Edit const [editingMsgId, setEditingMsgId] = useState(null) - const [editText, setEditText] = useState('') - const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null) + const [editText, setEditText] = useState('') - // Search state - const [searchQuery, setSearchQuery] = useState('') - const [searchUsers, setSearchUsers] = useState([]) + // Context menu + const [ctxMenu, setCtxMenu] = useState(null) + + // Search + const [searchQuery, setSearchQuery] = useState('') + const [searchUsers, setSearchUsers] = useState([]) const [searchResults, setSearchResults] = useState([]) - const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') + const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') const [loadingSearch, setLoadingSearch] = useState(false) - const [allUsers, setAllUsers] = useState([]) + const [allUsers, setAllUsers] = useState([]) - // Settings + // Settings + pins const [settings, setSettings] = useState(loadSettings) + const [pinnedIds, setPinnedIds] = useState(loadPins) const messagesEndRef = useRef(null) - const pollRef = useRef | null>(null) + const pollRef = useRef | null>(null) const searchInputRef = useRef(null) - const fileInputRef = useRef(null) + const fileInputRef = useRef(null) + const prevUnreadRef = useRef(0) + const prevMsgCountRef = useRef(0) const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) - // ── Filtered rooms (exclude notifications if hidden, apply room filter) ─ + // ── Sorted rooms (pinned first, then by last message) ──────────────────── - const visibleRooms = rooms.filter(r => { - if (r.type === 'notifications' && !settings.notifVisible) return false - return true - }) + const visibleRooms = [...rooms] + .filter(r => !(r.type === 'notifications' && !settings.notifVisible)) + .sort((a, b) => { + const aPin = pinnedIds.includes(a.id) ? 0 : 1 + const bPin = pinnedIds.includes(b.id) ? 0 : 1 + return aPin - bPin + }) - // ── Data loaders ──────────────────────────────────────────────────────── + // ── Loaders ────────────────────────────────────────────────────────────── const loadRooms = useCallback(async () => { if (!slug) return try { const data = await api.chat.listRooms(slug) setRooms(data) + // Sound on new unread when widget is closed + if (settings.soundEnabled && !open) { + const newUnread = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) + if (newUnread > prevUnreadRef.current) playNotifSound() + prevUnreadRef.current = newUnread + } } catch { /* ignore */ } - }, [slug]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug, settings.soundEnabled, open]) - // Poll rooms when open useEffect(() => { if (!open || !slug) return setLoadingRooms(true) @@ -138,74 +173,72 @@ export function ChatWidget() { return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [open, slug, loadRooms]) - // Auto-scroll on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + // Auto-scroll + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) - // Poll messages when in messages view + // Poll messages useEffect(() => { if (view !== 'messages' || !activeRoom) return const interval = setInterval(async () => { try { const msgs = await api.chat.getMessages(slug, activeRoom.id) + // Sound on new messages in active room + if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) { + const lastNew = msgs[msgs.length - 1] + if (lastNew.senderId !== user?.id) playNotifSound() + } + prevMsgCountRef.current = msgs.length setMessages(msgs) } catch { /* ignore */ } }, 3000) return () => clearInterval(interval) - }, [view, activeRoom, slug]) + }, [view, activeRoom, slug, settings.soundEnabled, user?.id]) - // Load all users once when search view opens + // Load users for search useEffect(() => { if (view !== 'search' || !slug) return api.users.list(slug).then(setAllUsers).catch(() => { /* ignore */ }) }, [view, slug]) - // Search effect + // Search useEffect(() => { if (view !== 'search') return const q = searchQuery.trim() - if (!q) { - setSearchUsers([]) - setSearchResults([]) - return - } + if (!q) { setSearchUsers([]); setSearchResults([]); return } const ql = q.toLowerCase() - // People: filter client-side from allUsers - const matched = allUsers.filter(u => - u.id !== user?.id && - (u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql)), - ) - setSearchUsers(matched) - - // Messages: debounce + API + 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 { - const results = await api.chat.search(slug, q) - setSearchResults(results) - } catch { /* ignore */ } + try { setSearchResults(await api.chat.search(slug, q)) } catch { /* ignore */ } finally { setLoadingSearch(false) } }, 400) return () => clearTimeout(timer) }, [searchQuery, view, allUsers, user?.id, slug, searchTab]) - // Focus search input when search view opens useEffect(() => { - if (view === 'search') { - setTimeout(() => searchInputRef.current?.focus(), 50) - } + if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50) }, [view]) - // ── Actions ───────────────────────────────────────────────────────────── + // Close context menu on outside click + useEffect(() => { + if (!ctxMenu) return + const handler = () => setCtxMenu(null) + window.addEventListener('click', handler) + window.addEventListener('contextmenu', handler) + return () => { window.removeEventListener('click', handler); window.removeEventListener('contextmenu', handler) } + }, [ctxMenu]) + + // ── Actions ─────────────────────────────────────────────────────────────── const openRoom = async (room: ChatRoom) => { setActiveRoom(room) setView('messages') setLoadingMsgs(true) + prevMsgCountRef.current = 0 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)) @@ -217,29 +250,14 @@ export function ChatWidget() { try { const { roomId } = await api.chat.openDirect(slug, targetUser.id) await loadRooms() - const room: ChatRoom = { - id: roomId, - type: 'direct', - name: null, - unreadCount: 0, - lastMessage: null, - lastMessageAt: null, - lastSender: null, - otherUserName: targetUser.name, - otherUserId: targetUser.id, - } - await openRoom(room) + await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id }) setSearchQuery('') } catch { /* ignore */ } } const openSearchResult = async (result: ChatSearchResult) => { const room = rooms.find(r => r.id === result.roomId) - if (room) { - setSearchQuery('') - setView('rooms') - await openRoom(room) - } + if (room) { setSearchQuery(''); setView('rooms'); await openRoom(room) } } const handleFileSelect = (e: React.ChangeEvent) => { @@ -252,54 +270,28 @@ export function ChatWidget() { e.target.value = '' } - const removeAttachment = () => { - setAttachment(null) - setAttachPreview(null) - } + const removeAttachment = () => { setAttachment(null); setAttachPreview(null) } const sendMessage = async () => { - if (!text.trim() && !attachment || !activeRoom || sending) return - const t = text.trim() - const file = attachment - setText('') - setAttachment(null) - setAttachPreview(null) - setSending(true) + if ((!text.trim() && !attachment) || !activeRoom || sending) return + const t = text.trim(), file = attachment + setText(''); setAttachment(null); setAttachPreview(null); setSending(true) try { let attachmentUrl: string | undefined - if (file) { - const { url } = await api.chat.uploadImage(file) - attachmentUrl = url - } + if (file) { const { url } = await api.chat.uploadImage(file); attachmentUrl = url } const msg = await api.chat.sendMessage(slug, activeRoom.id, t, attachmentUrl) - setMessages(prev => [...prev, msg]) + setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] }) } catch { setText(t) if (file) { setAttachment(file); setAttachPreview(attachPreview) } - } finally { - setSending(false) - } + } finally { setSending(false) } } const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - if (text.trim() || attachment) void sendMessage() - } - } - - const updateSettings = (patch: Partial) => { - const next = { ...settings, ...patch } - setSettings(next) - saveSettings(next) - } - - const startEdit = (msg: ChatMessage) => { - setEditingMsgId(msg.id) - setEditText(msg.text) - setReactionPickerMsgId(null) + 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 { @@ -308,44 +300,59 @@ export function ChatWidget() { } catch { /* ignore */ } setEditingMsgId(null) } - const cancelEdit = () => 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 { /* ignore */ } - setHoveredMsgId(null) } const toggleReaction = async (msg: ChatMessage, emoji: string) => { if (!activeRoom) return - setReactionPickerMsgId(null) + 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 { /* ignore */ } } + const togglePin = (roomId: string) => { + setCtxMenu(null) + const next = pinnedIds.includes(roomId) ? pinnedIds.filter(id => id !== roomId) : [...pinnedIds, roomId] + setPinnedIds(next); savePins(next) + } + + const updateSettings = (patch: Partial) => { + const next = { ...settings, ...patch }; setSettings(next); saveSettings(next) + } + const goBack = () => { - if (view === 'messages') { setView('rooms'); setActiveRoom(null) } + if (view === 'messages') { setView('rooms'); setActiveRoom(null); setEditingMsgId(null) } else if (view === 'search') { setView('rooms'); setSearchQuery('') } else if (view === 'settings') setView('rooms') } - // ── Room name helper ──────────────────────────────────────────────────── + const roomDisplayName = (room: ChatRoom) => + room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат' - const roomDisplayName = (room: ChatRoom) => { - if (room.type === 'general') return 'Общий чат' - if (room.type === 'notifications') return 'Уведомления' - return room.otherUserName ?? 'Чат' + const onMsgContextMenu = (e: React.MouseEvent, msg: ChatMessage) => { + if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return + e.preventDefault() + setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg }) + } + + const onRoomContextMenu = (e: React.MouseEvent, room: ChatRoom) => { + e.preventDefault() + setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) } if (!slug) return null - // ── Render ─────────────────────────────────────────────────────────────── + // ── Render ──────────────────────────────────────────────────────────────── return ( <> @@ -354,8 +361,7 @@ export function ChatWidget() { 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', + 'bg-brand-600 hover:bg-brand-700 text-white transition-all flex items-center justify-center', open && 'scale-90', )} > @@ -369,14 +375,15 @@ export function ChatWidget() { {/* Chat panel */} {open && ( -
- - {/* ── Header ── */} +
+ {/* Header */}
{view !== 'rooms' && (
- {/* Header actions for rooms view */} {view === 'rooms' && ( -
- -
- {/* ── Rooms list ── */} + {/* ── Rooms ── */} {view === 'rooms' && (
{loadingRooms && visibleRooms.length === 0 ? ( - // Skeleton placeholders while loading <> } bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" /> {settings.notifVisible && } bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />} - ) : visibleRooms.length === 0 ? ( -
- Нет чатов. Нажмите чтобы начать. -
) : ( visibleRooms.map(room => ( - )} + className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none" /> + {searchQuery && }
- - {/* Tabs */}
{(['people', 'messages'] as const).map(tab => ( - ))}
- - {/* Results */}
{searchTab === 'people' && ( - <> - {!searchQuery ? ( - // Show all users when no query - allUsers.length === 0 ? ( -
Загрузка...
- ) : ( - allUsers.filter(u => u.id !== user?.id).map(u => ( - openDirect(u)} /> - )) - ) - ) : searchUsers.length === 0 ? ( -
Никого не найдено
- ) : ( - searchUsers.map(u => ( - openDirect(u)} /> - )) - )} - + !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 => ( - - )) - )} - + !searchQuery + ?
Введите запрос для поиска
+ : loadingSearch + ?
+ : searchResults.length === 0 + ?
Ничего не найдено
+ : searchResults.map(r => ( + + )) )}
@@ -570,7 +532,7 @@ export function ChatWidget() { {/* ── Settings ── */} {view === 'settings' && ( -
+
: } @@ -579,7 +541,6 @@ export function ChatWidget() { onChange={v => updateSettings({ notifVisible: v })} /> - : } @@ -588,11 +549,10 @@ export function ChatWidget() { onChange={v => updateSettings({ soundEnabled: v })} /> - - -

- Чат сотрудников доступен всем пользователям отеля. - Канал «Уведомления» содержит автоматические сообщения о событиях в системе. + +

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

@@ -603,89 +563,52 @@ export function ChatWidget() { <>
{loadingMsgs ? ( -
- -
+
) : messages.length === 0 ? (
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
) : ( messages.map(msg => ( - setHoveredMsgId(msg.id)} - onMouseLeave={() => { if (reactionPickerMsgId !== msg.id) setHoveredMsgId(null) }} - onEdit={() => startEdit(msg)} - onDelete={() => deleteMsg(msg)} + onContextMenu={e => onMsgContextMenu(e, msg)} onEditTextChange={setEditText} onSaveEdit={() => saveEdit(msg)} onCancelEdit={cancelEdit} - onToggleReactionPicker={() => setReactionPickerMsgId(v => v === msg.id ? null : msg.id)} onReaction={emoji => toggleReaction(msg, emoji)} - onCloseReactionPicker={() => { setReactionPickerMsgId(null); setHoveredMsgId(null) }} /> )) )}
- {/* Input — hidden for notifications room */} + {/* Input */} {activeRoom?.type !== 'notifications' && (
- {/* Attachment preview */} {attachPreview && (
превью -
)}
- {/* Hidden file input */} - - -