From ef1fd4246e311735b99c30ffad679807fd4afa53 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 14 Apr 2026 16:25:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20smart=20scroll,=20se?= =?UTF-8?q?nder=20role,=20close=20on=20outside=20click,=20per-room=20mute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/chat/ChatWidget.tsx | 130 ++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 41 deletions(-) diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 2041951..a98ec49 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -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([]) - // Settings + pins + // 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 [roomSearch, setRoomSearch] = useState('') const [roomSearchOpen, setRoomSearchOpen] = useState(false) const roomSearchInputRef = useRef(null) + const widgetRef = 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 instantScrollRef = useRef(false) + 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) @@ -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) => { const next = { ...settings, ...patch }; setSettings(next); saveSettings(next) } @@ -448,7 +476,7 @@ export function ChatWidget() { // ── Render ──────────────────────────────────────────────────────────────── return ( - <> +
{/* Float button */}
) } @@ -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 }: {
{isPinned && } + {isMuted && }

0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}> {room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName}

@@ -866,11 +897,22 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu,
{!isOwn && (isSystem ? : )}
- {!isOwn && !isDeleted && ( -

- {isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]} -

- )} + {!isOwn && !isDeleted && (() => { + const roleLabels: Record = { + hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная', + receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник', + } + return ( +
+

+ {isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]} +

+ {!isSystem && msg.senderRole && roleLabels[msg.senderRole] && ( +

{roleLabels[msg.senderRole]}

+ )} +
+ ) + })()} {isDeleted ? (

@@ -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' && ( - + <> + + + )}

)