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:
@@ -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 }: {
|
||||
|
||||
Reference in New Issue
Block a user