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:
2026-04-14 16:25:24 +03:00
parent 1c3045f083
commit ef1fd4246e

View File

@@ -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>
)