From 70176deb3b734c041057d6ec996337bca239b42f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 27 Aug 2026 15:22:24 +0300 Subject: [PATCH] feat: Implement zero-latency WebSocket live message broadcast with luxury audio chime and Apple-style toast notifications --- backend/app/Controllers/ChatController.php | 56 ++++++++--- backend/app/Views/StudentPortal.php | 99 +++++++++++++++++- backend/app/Views/TeacherPortal.php | 112 ++++++++++++++++++++- 3 files changed, 248 insertions(+), 19 deletions(-) diff --git a/backend/app/Controllers/ChatController.php b/backend/app/Controllers/ChatController.php index e217851..a222244 100644 --- a/backend/app/Controllers/ChatController.php +++ b/backend/app/Controllers/ChatController.php @@ -312,26 +312,54 @@ class ChatController [$uuid, $userId, $receiverId, $courseId, $lessonId, $messageText, $messageType, $mediaUrl] ); + $msgPayload = [ + 'id' => $messageId, + 'uuid' => $uuid, + 'sender_id' => $userId, + 'receiver_id' => $receiverId, + 'is_mine' => false, + 'course_id' => $courseId, + 'lesson_id' => $lessonId, + 'message' => $messageText, + 'message_type' => $messageType, + 'media_url' => $mediaUrl, + 'is_read' => false, + 'created_at' => date('Y-m-d H:i:s'), + ]; + + // Push directly to Workerman WebSocket server for instant 0ms live broadcast to receiver + self::pushToWorkerman('push_chat', $receiverId, $msgPayload); + $response->status(201)->json([ 'status' => 'success', 'message' => 'تم إرسال الرسالة بنجاح', - 'data' => [ - 'id' => $messageId, - 'uuid' => $uuid, - 'sender_id' => $userId, - 'receiver_id' => $receiverId, - 'is_mine' => true, - 'course_id' => $courseId, - 'lesson_id' => $lessonId, - 'message' => $messageText, - 'message_type' => $messageType, - 'media_url' => $mediaUrl, - 'is_read' => false, - 'created_at' => date('Y-m-d H:i:s'), - ] + 'data' => array_merge($msgPayload, ['is_mine' => true]) ]); } + /** + * Push internal event to Workerman's internal HTTP server (port 4241) + */ + public static function pushToWorkerman(string $action, int $targetUserId, array $payload): bool + { + try { + $ch = curl_init('http://127.0.0.1:4241'); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'action' => $action, + 'target_user_id' => $targetUserId, + 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE) + ])); + curl_setopt($ch, CURLOPT_TIMEOUT_MS, 400); // Fast non-blocking timeout + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_exec($ch); + curl_close($ch); + return true; + } catch (\Throwable $e) { + return false; + } + } + /** * Mark all messages in a conversation as read * POST /api/chat/read diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php index 766f215..56f030b 100644 --- a/backend/app/Views/StudentPortal.php +++ b/backend/app/Views/StudentPortal.php @@ -946,14 +946,109 @@ class StudentPortal } } + // ========================================== + // Luxury Audio Chime & Real-time Notifications + // ========================================== + function playChimeNotification() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const now = ctx.currentTime; + + const osc1 = ctx.createOscillator(); + const gain1 = ctx.createGain(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(587.33, now); + gain1.gain.setValueAtTime(0.2, now); + gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35); + osc1.connect(gain1); + gain1.connect(ctx.destination); + osc1.start(now); + osc1.stop(now + 0.35); + + const osc2 = ctx.createOscillator(); + const gain2 = ctx.createGain(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(880.00, now + 0.09); + gain2.gain.setValueAtTime(0.25, now + 0.09); + gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55); + osc2.connect(gain2); + gain2.connect(ctx.destination); + osc2.start(now + 0.09); + osc2.stop(now + 0.55); + } catch (e) { + console.warn('Audio Context note:', e); + } + } + + function showLuxuryToast(title, body, icon = '👨‍🏫') { + let container = document.getElementById('saqel_toast_stack'); + if (!container) { + container = document.createElement('div'); + container.id = 'saqel_toast_stack'; + container.style.cssText = 'position: fixed; top: 24px; left: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; pointer-events: none; direction: rtl;'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.style.cssText = ` + pointer-events: auto; + min-width: 280px; + max-width: 380px; + background: rgba(22, 27, 34, 0.92); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-right: 4px solid var(--accent-color, #38BDF8); + border-radius: 16px; + padding: 14px 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6), 0 0 25px rgba(56, 189, 248, 0.2); + display: flex; + align-items: center; + gap: 12px; + animation: toastSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; + cursor: pointer; + `; + + toast.innerHTML = ` +
${icon}
+
+
${escapeHtml(title)}
+
${escapeHtml(body)}
+
+ `; + + container.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(-30px)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 5000); + } + + function triggerDesktopNotification(title, body) { + if ('Notification' in window && Notification.permission === 'granted') { + try { + new Notification(title, { + body: body, + icon: '/favicon.ico' + }); + } catch (e) {} + } + } + function handleIncomingWsEvent(msg) { const event = msg.event; const data = msg.data; if (event === 'authenticated') { updateWsStatus(true); - } else if (event === 'chat_message' || event === 'chat_sent') { - appendChatMessage(data); + } else if (event === 'chat_message') { + appendChatMessage({ ...data, is_mine: false }); + playChimeNotification(); + showLuxuryToast('رسالة جديدة من الأستاذ حمزة الغويري 👨‍🏫', data.message || 'رد المعلم على استفسارك'); + triggerDesktopNotification('صَقِل — رد الأستاذ حمزة الغويري', data.message || 'رد المعلم على استفسارك'); } else if (event === 'drm_session_terminated') { alert('⚠️ تم فتح هذا الحساب من متصفح آخر.'); handleLogout(); diff --git a/backend/app/Views/TeacherPortal.php b/backend/app/Views/TeacherPortal.php index a39caf4..d130275 100644 --- a/backend/app/Views/TeacherPortal.php +++ b/backend/app/Views/TeacherPortal.php @@ -626,15 +626,121 @@ class TeacherPortal } } + // ========================================== + // Luxury Audio Chime & Real-time Notifications + // ========================================== + function playChimeNotification() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const now = ctx.currentTime; + + // Tone 1: High crisp bell (D5) + const osc1 = ctx.createOscillator(); + const gain1 = ctx.createGain(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(587.33, now); + gain1.gain.setValueAtTime(0.2, now); + gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35); + osc1.connect(gain1); + gain1.connect(ctx.destination); + osc1.start(now); + osc1.stop(now + 0.35); + + // Tone 2: Harmonic resolution (A5) + const osc2 = ctx.createOscillator(); + const gain2 = ctx.createGain(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(880.00, now + 0.09); + gain2.gain.setValueAtTime(0.25, now + 0.09); + gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55); + osc2.connect(gain2); + gain2.connect(ctx.destination); + osc2.start(now + 0.09); + osc2.stop(now + 0.55); + } catch (e) { + console.warn('Audio Context note:', e); + } + } + + function showLuxuryToast(title, body, icon = '💬') { + let container = document.getElementById('saqel_toast_stack'); + if (!container) { + container = document.createElement('div'); + container.id = 'saqel_toast_stack'; + container.style.cssText = 'position: fixed; top: 24px; left: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; pointer-events: none; direction: rtl;'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.style.cssText = ` + pointer-events: auto; + min-width: 280px; + max-width: 380px; + background: rgba(22, 27, 34, 0.92); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-right: 4px solid var(--accent-gold, #F59E0B); + border-radius: 16px; + padding: 14px 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6), 0 0 25px rgba(245, 158, 11, 0.2); + display: flex; + align-items: center; + gap: 12px; + animation: toastSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; + cursor: pointer; + `; + + toast.innerHTML = ` +
${icon}
+
+
${escapeHtml(title)}
+
${escapeHtml(body)}
+
+ `; + + container.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(-30px)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 5000); + } + + function triggerDesktopNotification(title, body) { + if ('Notification' in window && Notification.permission === 'granted') { + try { + new Notification(title, { + body: body, + icon: '/favicon.ico' + }); + } catch (e) {} + } + } + function handleIncomingWsEvent(msg) { const event = msg.event; const data = msg.data; if (event === 'authenticated') { updateWsStatus(true); - } else if (event === 'chat_message' || event === 'chat_sent') { - appendChatMessage(data); - loadConversations(); // refresh sidebar + } else if (event === 'chat_message') { + const senderId = parseInt(data.sender_id); + if (senderId === activeRecipientId) { + appendChatMessage({ ...data, is_mine: false }); + } + + playChimeNotification(); + const senderEl = document.querySelector(`.conv-item[data-user-id="${senderId}"] .conv-name span`); + const senderName = senderEl ? senderEl.textContent : 'طالب'; + showLuxuryToast(`رسالة جديدة من ${senderName}`, data.message || 'أرسل استفساراً جديداً'); + triggerDesktopNotification(`صَقِل — رسالة من ${senderName}`, data.message || 'استفسار جديد'); + + loadConversations(); + } else if (event === 'chat_sent') { + loadConversations(); } else if (event === 'user_typing') { const typingSpan = document.getElementById('active_chat_typing'); if (typingSpan && msg.sender_id === activeRecipientId) {