diff --git a/client/app.go b/client/app.go index 79471f5..7399751 100644 --- a/client/app.go +++ b/client/app.go @@ -973,17 +973,20 @@ func (a *App) connectWS() { case "stop_webcam_stream": runtime.EventsEmit(a.ctx, "stop_webcam_stream") case "chat:message": - if staffVal, ok := msg.Data["staffId"].(float64); ok { - a.mu.Lock() - a.replyStaffID = uint(staffVal) - a.chatUnread++ - unread := a.chatUnread - a.mu.Unlock() - runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{ - "unread": unread, - "preview": msg.Data["body"], - "from": msg.Data["staffName"], - }) + senderRole, _ := msg.Data["senderRole"].(string) + if senderRole == "staff" { + if staffVal, ok := msg.Data["staffId"].(float64); ok { + a.mu.Lock() + a.replyStaffID = uint(staffVal) + a.chatUnread++ + unread := a.chatUnread + a.mu.Unlock() + runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{ + "unread": unread, + "preview": msg.Data["body"], + "from": msg.Data["staffName"], + }) + } } runtime.EventsEmit(a.ctx, "chat:message", msg.Data) } diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 5cd42f7..0a69052 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -27,6 +27,34 @@ let chatOpen = false; let chatMessages = []; let chatConversations = []; let activeStaffId = 0; +let chatAudioCtx = null; + +function playChatSound() { + try { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!chatAudioCtx) chatAudioCtx = new Ctx(); + const ctx = chatAudioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const tone = (freq, start, dur) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.15, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(start); + osc.stop(start + dur + 0.02); + }; + const t = ctx.currentTime; + tone(880, t, 0.12); + tone(1174, t + 0.14, 0.14); + } catch { + /* ignore */ + } +} // Quản lý webcam let webcamStream = null; @@ -51,14 +79,18 @@ function init() { window.runtime.EventsOn('start_webcam_stream', startWebcam); window.runtime.EventsOn('stop_webcam_stream', stopWebcam); - window.runtime.EventsOn('chat:message', () => { + window.runtime.EventsOn('chat:message', (data) => { updateChatBadge(); loadChatConversations().catch(console.error); if (chatOpen && activeStaffId) loadChatMessages(activeStaffId).catch(console.error); + if (data?.senderRole === 'staff') { + playChatSound(); + } }); window.runtime.EventsOn('chat:notify', (data) => { updateChatBadge(); showChatToast(data); + playChatSound(); }); checkLogin(); } diff --git a/management/src/App.tsx b/management/src/App.tsx index 487a83b..c5e331b 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -6,6 +6,7 @@ import { LearningTab } from './components/LearningTab'; import { NetworkTab } from './components/NetworkTab'; import { AccountsTab } from './components/AccountsTab'; import { ChatWidget } from './components/ChatWidget'; +import { StaffChatSocket } from './hooks/useStaffChatSocket'; import { ClassWorkspace } from './components/ClassWorkspace'; import { NavHistoryBar, useRoute } from './components/NavHistoryBar'; import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation'; @@ -186,6 +187,7 @@ function App() { )} + > ); diff --git a/management/src/api.ts b/management/src/api.ts index 282f437..08859e4 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -113,6 +113,7 @@ export interface ChatStudent { export interface ChatMessage { id: number; staffId: number; + targetStaffId?: number; studentRkId: number; senderRole: 'staff' | 'student'; body: string; diff --git a/management/src/components/ChatWidget.tsx b/management/src/components/ChatWidget.tsx index 75863fd..c37da21 100644 --- a/management/src/components/ChatWidget.tsx +++ b/management/src/components/ChatWidget.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api'; import { useAuth } from '../auth/AuthContext'; +import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket'; export function ChatWidget() { const { staff } = useAuth(); @@ -14,12 +15,13 @@ export function ChatWidget() { const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); + const [toast, setToast] = useState<{ title: string; body: string } | null>(null); const listRef = useRef(null); const activeRef = useRef(null); + const openRef = useRef(false); - useEffect(() => { - activeRef.current = active; - }, [active]); + useEffect(() => { activeRef.current = active; }, [active]); + useEffect(() => { openRef.current = open; }, [open]); const scrollBottom = () => { requestAnimationFrame(() => { @@ -32,6 +34,7 @@ export function ChatWidget() { const loadConversations = useCallback(async () => { const res = await apiChat.listConversations(); setConversations(res.data); + return res.data; }, []); const loadMessages = useCallback(async (studentRkId: number) => { @@ -46,6 +49,46 @@ export function ChatWidget() { setStudents(res.data); }, []); + const showToast = (title: string, body: string) => { + setToast({ title, body }); + setTimeout(() => setToast(null), 4500); + }; + + const handleIncoming = useCallback((msg: ChatMessage) => { + const studentId = Number(msg.studentRkId); + const current = activeRef.current; + + void loadConversations(); + + if (msg.senderRole === 'student') { + playChatSound(); + const preview = msg.body?.slice(0, 80) || 'Tin nhắn mới'; + if (!openRef.current || !current || Number(current.studentRkId) !== studentId) { + showToast('Sinh viên nhắn tin', preview); + } + } + + if (current && Number(current.studentRkId) === studentId) { + setMessages((prev) => { + if (prev.some((m) => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + scrollBottom(); + } + }, [loadConversations]); + + useEffect(() => { + const off = onStaffChatMessage(handleIncoming); + return () => { off(); }; + }, [handleIncoming]); + + useEffect(() => { + if (!staff?.id) return; + loadConversations().catch(console.error); + const t = setInterval(() => loadConversations().catch(console.error), 5000); + return () => clearInterval(t); + }, [staff?.id, loadConversations]); + const pickStudent = async (s: ChatStudent) => { setActive(s); setPickerOpen(false); @@ -69,32 +112,6 @@ export function ChatWidget() { searchStudents('').catch(console.error); }, [open, staff?.id, loadConversations, searchStudents]); - useEffect(() => { - if (!staff?.id) return; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher&staffId=${staff.id}`; - const ws = new WebSocket(wsUrl); - ws.onmessage = (ev) => { - try { - const payload = JSON.parse(ev.data); - if (payload.event !== 'chat:message') return; - const msg = payload.data as ChatMessage; - loadConversations().catch(console.error); - const current = activeRef.current; - if (current && msg.studentRkId === current.studentRkId) { - setMessages((prev) => { - if (prev.some((m) => m.id === msg.id)) return prev; - return [...prev, msg]; - }); - scrollBottom(); - } - } catch { - /* ignore */ - } - }; - return () => ws.close(); - }, [staff?.id, loadConversations]); - const send = async () => { if (!active || !draft.trim()) return; setSending(true); @@ -113,6 +130,12 @@ export function ChatWidget() { const dock = ( + {toast && ( + + {toast.title} + {toast.body} + + )} {!open ? ( setOpen(true)} title="Tin nhắn"> 💬 diff --git a/management/src/hooks/useStaffChatSocket.ts b/management/src/hooks/useStaffChatSocket.ts new file mode 100644 index 0000000..53d2470 --- /dev/null +++ b/management/src/hooks/useStaffChatSocket.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { type ChatMessage } from '../api'; +import { useAuth } from '../auth/AuthContext'; +import { playChatSound } from '../utils/notifySound'; + +type ChatIncomingHandler = (msg: ChatMessage) => void; + +const handlers = new Set(); + +export function onStaffChatMessage(handler: ChatIncomingHandler): () => void { + handlers.add(handler); + return () => { handlers.delete(handler); }; +} + +/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */ +export function StaffChatSocket() { + const { staff, token } = useAuth(); + const staffIdRef = useRef(0); + + useEffect(() => { + staffIdRef.current = staff?.id ?? 0; + }, [staff?.id]); + + const dispatch = useCallback((msg: ChatMessage) => { + handlers.forEach((h) => h(msg)); + }, []); + + useEffect(() => { + if (!token || !staff?.id) return; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher&staffId=${staff.id}`; + let ws: WebSocket | null = null; + let retryTimer: ReturnType | null = null; + let closed = false; + + const connect = () => { + if (closed) return; + ws = new WebSocket(wsUrl); + ws.onmessage = (ev) => { + try { + const payload = JSON.parse(ev.data); + if (payload.event !== 'chat:message') return; + const msg = payload.data as ChatMessage; + const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0); + if (targetStaff > 0 && targetStaff !== staffIdRef.current) return; + dispatch(msg); + } catch { + /* ignore */ + } + }; + ws.onclose = () => { + if (!closed) { + retryTimer = setTimeout(connect, 3000); + } + }; + }; + + connect(); + + return () => { + closed = true; + if (retryTimer) clearTimeout(retryTimer); + ws?.close(); + }; + }, [token, staff?.id, dispatch]); + + return null; +} + +export { playChatSound }; diff --git a/management/src/index.css b/management/src/index.css index 9c2ebed..dc18732 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -3371,6 +3371,33 @@ input:checked + .slider:before { font-family: var(--font-sans); } +.chat-toast { + position: absolute; + right: 0; + bottom: 72px; + width: 280px; + padding: 0.7rem 0.85rem; + border-radius: 10px; + background: #1e293b; + color: #fff; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.82rem; + animation: chat-toast-in 0.25s ease; +} + +.chat-toast strong { + font-size: 0.78rem; + color: #93c5fd; +} + +@keyframes chat-toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + .chat-fab { position: relative; width: 56px; diff --git a/management/src/utils/notifySound.ts b/management/src/utils/notifySound.ts new file mode 100644 index 0000000..59f0d6c --- /dev/null +++ b/management/src/utils/notifySound.ts @@ -0,0 +1,37 @@ +let audioCtx: AudioContext | null = null; + +function getAudioCtx(): AudioContext | null { + if (audioCtx) return audioCtx; + try { + const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + audioCtx = new Ctx(); + return audioCtx; + } catch { + return null; + } +} + +/** Short two-tone ping for chat notifications */ +export function playChatSound() { + const ctx = getAudioCtx(); + if (!ctx) return; + if (ctx.state === 'suspended') { + void ctx.resume(); + } + const playTone = (freq: number, start: number, duration: number) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.12, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(start); + osc.stop(start + duration + 0.02); + }; + const t = ctx.currentTime; + playTone(880, t, 0.12); + playTone(1174, t + 0.14, 0.14); +} diff --git a/server/internal/handlers/handlers_chat.go b/server/internal/handlers/handlers_chat.go index da03a76..79c6103 100644 --- a/server/internal/handlers/handlers_chat.go +++ b/server/internal/handlers/handlers_chat.go @@ -13,8 +13,8 @@ import ( "gorm.io/gorm" ) -func chatMessageDTO(m models.ChatMessage, staffName string) fiber.Map { - return fiber.Map{ +func chatMessageDTO(m models.ChatMessage, staffName string) map[string]any { + return map[string]any{ "id": m.ID, "staffId": m.StaffID, "studentRkId": m.StudentRkID, diff --git a/server/internal/websocket/websocket.go b/server/internal/websocket/websocket.go index e0f2c71..ff846a5 100644 --- a/server/internal/websocket/websocket.go +++ b/server/internal/websocket/websocket.go @@ -58,15 +58,15 @@ func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) { } func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) { - h.mu.RLock() - addrs := append([]string(nil), h.teachersByStaff[staffID]...) - h.mu.RUnlock() + if data == nil { + data = map[string]any{} + } + data["targetStaffId"] = staffID msg := SocketMsg{Event: "chat:message", Data: data} - for _, addr := range addrs { - h.mu.RLock() - t, found := h.teachers[addr] - h.mu.RUnlock() - if found && t != nil { + h.mu.RLock() + defer h.mu.RUnlock() + for _, t := range h.teachers { + if t != nil && t.Role == "teacher" { _ = t.Conn.WriteJSON(msg) } }