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' && (
-
-