feat: chat — smart scroll, sender role, close on outside click, per-room mute
- Scroll: only auto-scroll on poll when near bottom; openRoom uses double-RAF for instant jump - Sender role: show position (Менеджер / Горничная etc) under name in message bubbles - Close on outside click: mousedown listener when panel is open - Per-room mute: ⋮ menu → Отключить/Включить уведомления, BellOff badge on muted rooms, saved to localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,11 @@ function loadSettings(): ChatSettings {
|
||||
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 [] }
|
||||
}
|
||||
@@ -139,28 +144,29 @@ export function ChatWidget() {
|
||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
|
||||
// Settings + pins
|
||||
// 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 [roomSearch, setRoomSearch] = useState('')
|
||||
const [roomSearchOpen, setRoomSearchOpen] = useState(false)
|
||||
const roomSearchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const widgetRef = useRef<HTMLDivElement>(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 instantScrollRef = useRef(false)
|
||||
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)
|
||||
|
||||
@@ -191,7 +197,8 @@ export function ChatWidget() {
|
||||
if (prev === undefined) { prevRoomsRef.current[room.id] = curr; return }
|
||||
if (curr > prev) {
|
||||
const isViewing = open && view === 'messages' && activeRoomIdRef.current === room.id
|
||||
if (!isViewing) {
|
||||
const isMuted = mutedIds.includes(room.id)
|
||||
if (!isViewing && !isMuted) {
|
||||
if (settings.soundEnabled) playNotifSound()
|
||||
if (settings.popupEnabled !== false) {
|
||||
const rName = room.type === 'general' ? 'Общий чат'
|
||||
@@ -216,7 +223,8 @@ export function ChatWidget() {
|
||||
poll(true)
|
||||
const interval = setInterval(() => poll(), open ? 5000 : 10000)
|
||||
return () => { cancelled = true; clearInterval(interval) }
|
||||
}, [slug, open, view, settings.soundEnabled, settings.popupEnabled])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug, open, view, settings.soundEnabled, settings.popupEnabled, mutedIds.join(',')])
|
||||
|
||||
// Presence heartbeat
|
||||
useEffect(() => {
|
||||
@@ -230,16 +238,12 @@ export function ChatWidget() {
|
||||
return () => clearInterval(t)
|
||||
}, [open, slug])
|
||||
|
||||
// Auto-scroll — instant on initial load, smooth on new messages
|
||||
// Auto-scroll on polling — only if user is already near the bottom
|
||||
useEffect(() => {
|
||||
const el = messagesContainerRef.current
|
||||
if (!el) return
|
||||
if (instantScrollRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
instantScrollRef.current = false
|
||||
} else {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100
|
||||
if (nearBottom) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Poll messages + typing
|
||||
@@ -300,6 +304,18 @@ export function ChatWidget() {
|
||||
return () => window.removeEventListener('click', h)
|
||||
}, [ctxMenu])
|
||||
|
||||
// Close chat when clicking outside the widget
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const h = (e: MouseEvent) => {
|
||||
if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [open])
|
||||
|
||||
// ── Typing helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const sendTyping = useCallback((typing: boolean) => {
|
||||
@@ -334,12 +350,18 @@ export function ChatWidget() {
|
||||
try {
|
||||
const msgs = await api.chat.getMessages(slug, room.id)
|
||||
prevMsgCountRef.current = msgs.length
|
||||
instantScrollRef.current = true
|
||||
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) }
|
||||
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) => {
|
||||
@@ -417,6 +439,12 @@ export function ChatWidget() {
|
||||
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)
|
||||
}
|
||||
@@ -448,7 +476,7 @@ export function ChatWidget() {
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={widgetRef}>
|
||||
{/* 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')}>
|
||||
@@ -521,6 +549,7 @@ export function ChatWidget() {
|
||||
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 }) }}
|
||||
@@ -736,15 +765,16 @@ export function ChatWidget() {
|
||||
|
||||
{/* Context menu */}
|
||||
{ctxMenu && (
|
||||
<ContextMenu menu={ctxMenu} currentUserId={user?.id ?? ''} pinnedIds={pinnedIds}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -788,8 +818,8 @@ function ToastNotifCard({ toast, showText, onClose, onClick }: {
|
||||
|
||||
// ── RoomRow ────────────────────────────────────────────────────────────────
|
||||
|
||||
function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {
|
||||
room: ChatRoom; isPinned: boolean; isOnline: boolean
|
||||
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)
|
||||
@@ -824,6 +854,7 @@ function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {
|
||||
<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.otherUserName}
|
||||
</p>
|
||||
@@ -866,11 +897,22 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu,
|
||||
<div className={cn('flex gap-2', isOwn && 'flex-row-reverse')} onContextMenu={onContextMenu}>
|
||||
{!isOwn && (isSystem ? <SystemAvatar size={24} /> : <Avatar name={msg.senderName} size={24} />)}
|
||||
<div className={cn('max-w-[80%]', isOwn && 'items-end flex flex-col')}>
|
||||
{!isOwn && !isDeleted && (
|
||||
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">
|
||||
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
|
||||
</p>
|
||||
)}
|
||||
{!isOwn && !isDeleted && (() => {
|
||||
const roleLabels: Record<string, string> = {
|
||||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||
}
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5 mb-0.5 ml-1">
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
||||
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
|
||||
</p>
|
||||
{!isSystem && msg.senderRole && roleLabels[msg.senderRole] && (
|
||||
<p className="text-[9px] text-slate-400 dark:text-slate-500">{roleLabels[msg.senderRole]}</p>
|
||||
)}
|
||||
</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">
|
||||
@@ -937,12 +979,12 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu,
|
||||
|
||||
// ── ContextMenu ────────────────────────────────────────────────────────────
|
||||
|
||||
function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDelete, onTogglePin, onClose }: {
|
||||
menu: CtxMenu; currentUserId: string; pinnedIds: string[]
|
||||
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; onClose: () => void
|
||||
onTogglePin: () => void; onToggleMute: () => void; onClose: () => void
|
||||
}) {
|
||||
const menuW = 182, menuH = menu.type === 'message' ? 160 : 56
|
||||
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 (
|
||||
@@ -974,11 +1016,17 @@ function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDel
|
||||
</>
|
||||
)}
|
||||
{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={() => { 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user