diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 177e2aa..5806c55 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -66,16 +66,21 @@ function playNotifSound() { // ── Persist ──────────────────────────────────────────────────────────────── -interface ChatSettings { notifVisible: boolean; soundEnabled: boolean } +interface ChatSettings { + notifVisible: boolean + soundEnabled: boolean + popupEnabled: boolean + notifShowText: boolean +} const SETTINGS_KEY = 'hotelsync-chat-settings' const PINS_KEY = 'hotelsync-chat-pins' function loadSettings(): ChatSettings { try { const s = localStorage.getItem(SETTINGS_KEY) - if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial } + if (s) return { notifVisible: true, soundEnabled: false, popupEnabled: true, notifShowText: true, ...JSON.parse(s) as Partial } } catch { /**/ } - return { notifVisible: true, soundEnabled: false } + return { notifVisible: true, soundEnabled: false, popupEnabled: true, notifShowText: true } } function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } function loadPins(): string[] { @@ -91,6 +96,10 @@ type CtxMenu = const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅'] +interface ToastNotif { + id: string; roomId: string; roomName: string; senderName: string; text: string +} + // ── Main ─────────────────────────────────────────────────────────────────── type View = 'rooms' | 'messages' | 'search' | 'settings' @@ -134,11 +143,14 @@ export function ChatWidget() { const [settings, setSettings] = useState(loadSettings) const [pinnedIds, setPinnedIds] = useState(loadPins) + // Toast notifications + const [toasts, setToasts] = useState([]) + const messagesEndRef = useRef(null) - const pollRef = useRef | null>(null) const searchInputRef = useRef(null) const fileInputRef = useRef(null) - const prevUnreadRef = useRef(0) + const prevRoomsRef = useRef>({}) + const activeRoomIdRef = useRef(null) const prevMsgCountRef = useRef(0) const typingTimerRef = useRef | null>(null) const isTypingRef = useRef(false) @@ -151,28 +163,53 @@ export function ChatWidget() { // ── Effects ─────────────────────────────────────────────────────────────── - const loadRooms = useCallback(async () => { - if (!slug) return - try { - const data = await api.chat.listRooms(slug) - setRooms(data) - if (settings.soundEnabled && !open) { - const n = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) - if (n > prevUnreadRef.current) playNotifSound() - prevUnreadRef.current = n - } - } catch { /**/ } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slug, settings.soundEnabled, open]) + // Keep activeRoomIdRef in sync for use inside intervals + useEffect(() => { activeRoomIdRef.current = activeRoom?.id ?? null }, [activeRoom]) - // Poll rooms + // Room poll — always running (closed = 10s, open = 5s) for badge, sound, toasts useEffect(() => { - if (!open || !slug) return - setLoadingRooms(true) - loadRooms().finally(() => setLoadingRooms(false)) - pollRef.current = setInterval(loadRooms, 5000) - return () => { if (pollRef.current) clearInterval(pollRef.current) } - }, [open, slug, loadRooms]) + if (!slug) return + let cancelled = false + + const poll = async (initial = false) => { + if (cancelled) return + if (initial) setLoadingRooms(true) + try { + const data = await api.chat.listRooms(slug) + if (cancelled) return + setRooms(data) + data.forEach(room => { + const prev = prevRoomsRef.current[room.id] + const curr = Number(room.unreadCount) || 0 + if (prev === undefined) { prevRoomsRef.current[room.id] = curr; return } + if (curr > prev) { + const isViewing = open && view === 'messages' && activeRoomIdRef.current === room.id + if (!isViewing) { + if (settings.soundEnabled) playNotifSound() + if (settings.popupEnabled !== false) { + const rName = room.type === 'general' ? 'Общий чат' + : room.type === 'notifications' ? 'Уведомления' + : room.otherUserName ?? 'Чат' + setToasts(p => [...p.slice(-2), { + id: `${room.id}-${Date.now()}`, + roomId: room.id, roomName: rName, + senderName: room.lastSender ?? '', + text: room.lastMessage ?? '', + }]) + } + } + } + prevRoomsRef.current[room.id] = curr + }) + } catch { /**/ } finally { + if (initial && !cancelled) setLoadingRooms(false) + } + } + + poll(true) + const interval = setInterval(() => poll(), open ? 5000 : 10000) + return () => { cancelled = true; clearInterval(interval) } + }, [slug, open, view, settings.soundEnabled, settings.popupEnabled]) // Presence heartbeat useEffect(() => { @@ -266,6 +303,14 @@ export function ChatWidget() { // ── Actions ─────────────────────────────────────────────────────────────── + const openRoomById = async (roomId: string) => { + setToasts(p => p.filter(t => t.roomId !== roomId)) + const room = rooms.find(r => r.id === roomId) + if (!room) { setOpen(true); return } + setOpen(true) + await openRoom(room) + } + const openRoom = async (room: ChatRoom) => { setActiveRoom(room); setView('messages'); setLoadingMsgs(true) prevMsgCountRef.current = 0; isTypingRef.current = false @@ -282,7 +327,8 @@ export function ChatWidget() { const openDirect = async (targetUser: User) => { try { const { roomId } = await api.chat.openDirect(slug, targetUser.id) - await loadRooms() + const data = await api.chat.listRooms(slug) + setRooms(data) await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id, otherUserLastRead: null }) setSearchQuery('') } catch { /**/ } @@ -522,10 +568,16 @@ export function ChatWidget() { label="Показывать канал уведомлений" checked={settings.notifVisible} onChange={v => updateSettings({ notifVisible: v })} /> - + : } label="Звуковое уведомление" checked={settings.soundEnabled} onChange={v => updateSettings({ soundEnabled: v })} /> + } + label="Всплывающие уведомления" checked={settings.popupEnabled !== false} + onChange={v => updateSettings({ popupEnabled: v })} /> + } + label="Показывать текст в уведомлении" checked={settings.notifShowText !== false} + onChange={v => updateSettings({ notifShowText: v })} />

@@ -624,6 +676,21 @@ export function ChatWidget() { )} + {/* Toast notifications */} + {toasts.length > 0 && ( +

+ {toasts.map(toast => ( + setToasts(p => p.filter(t => t.id !== toast.id))} + onClick={() => void openRoomById(toast.roomId)} + /> + ))} +
+ )} + {/* Context menu */} {ctxMenu && ( void; onClick: () => void +}) { + useEffect(() => { + const t = setTimeout(onClose, 4500) + return () => clearTimeout(t) + }, [onClose]) + + return ( +
+
+ +
+
+

{toast.roomName}

+ {showText ? ( +

+ {toast.senderName ? `${toast.senderName.split(' ')[0]}: ` : ''}{toast.text || 'Новое сообщение'} +

+ ) : ( +

Новое сообщение

+ )} +
+ +
+ ) +} + // ── RoomRow ──────────────────────────────────────────────────────────────── function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {