feat: chat — background badge polling, toast notifications, notification settings

- Room poll now runs always (10s when closed, 5s when open) — badge + sound work when minimized
- Toast pop-up (top-right) on new messages when chat is minimized or room not active
- Auto-dismiss toasts after 4.5s, click to open room
- Settings: popup enable/disable + show/hide message text in notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 13:39:23 +03:00
parent a3db2e9d5a
commit 58ad4060c1

View File

@@ -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<ChatSettings> }
if (s) return { notifVisible: true, soundEnabled: false, popupEnabled: true, notifShowText: true, ...JSON.parse(s) as Partial<ChatSettings> }
} 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<ChatSettings>(loadSettings)
const [pinnedIds, setPinnedIds] = useState<string[]>(loadPins)
// Toast notifications
const [toasts, setToasts] = useState<ToastNotif[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const prevUnreadRef = useRef<number>(0)
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)
@@ -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 })} />
</SettingsSection>
<SettingsSection title="Звук">
<SettingsSection title="Уведомления">
<ToggleRow icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
label="Звуковое уведомление" checked={settings.soundEnabled}
onChange={v => updateSettings({ soundEnabled: v })} />
<ToggleRow icon={<Bell size={15} />}
label="Всплывающие уведомления" checked={settings.popupEnabled !== false}
onChange={v => updateSettings({ popupEnabled: v })} />
<ToggleRow icon={<MessageSquare size={15} />}
label="Показывать текст в уведомлении" checked={settings.notifShowText !== false}
onChange={v => updateSettings({ notifShowText: v })} />
</SettingsSection>
<SettingsSection title="Подсказка">
<p className="text-xs text-slate-500 leading-relaxed">
@@ -624,6 +676,21 @@ export function ChatWidget() {
</div>
)}
{/* Toast notifications */}
{toasts.length > 0 && (
<div className="fixed top-4 right-4 z-[70] flex flex-col gap-2 w-72">
{toasts.map(toast => (
<ToastNotifCard
key={toast.id}
toast={toast}
showText={settings.notifShowText !== false}
onClose={() => setToasts(p => p.filter(t => t.id !== toast.id))}
onClick={() => void openRoomById(toast.roomId)}
/>
))}
</div>
)}
{/* Context menu */}
{ctxMenu && (
<ContextMenu menu={ctxMenu} currentUserId={user?.id ?? ''} pinnedIds={pinnedIds}
@@ -638,6 +705,44 @@ export function ChatWidget() {
)
}
// ── ToastNotifCard ─────────────────────────────────────────────────────────
function ToastNotifCard({ toast, showText, onClose, onClick }: {
toast: ToastNotif; showText: boolean; onClose: () => void; onClick: () => void
}) {
useEffect(() => {
const t = setTimeout(onClose, 4500)
return () => clearTimeout(t)
}, [onClose])
return (
<div
onClick={onClick}
className="flex items-start gap-3 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-2xl shadow-lg px-4 py-3 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/80 transition-colors"
>
<div className="shrink-0 mt-0.5 w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<MessageSquare size={15} className="text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{toast.roomName}</p>
{showText ? (
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
{toast.senderName ? `${toast.senderName.split(' ')[0]}: ` : ''}{toast.text || 'Новое сообщение'}
</p>
) : (
<p className="text-xs text-slate-400 mt-0.5">Новое сообщение</p>
)}
</div>
<button
onClick={e => { e.stopPropagation(); onClose() }}
className="shrink-0 text-slate-300 hover:text-slate-500 dark:text-slate-600 dark:hover:text-slate-400 transition-colors p-0.5 mt-0.5"
>
<X size={13} />
</button>
</div>
)
}
// ── RoomRow ────────────────────────────────────────────────────────────────
function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {