feat: Implement zero-latency WebSocket live message broadcast with luxury audio chime and Apple-style toast notifications

This commit is contained in:
Hamza-Ayed
2026-08-27 15:22:24 +03:00
parent 7faf223583
commit 70176deb3b
3 changed files with 248 additions and 19 deletions
+42 -14
View File
@@ -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
+97 -2
View File
@@ -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 = `
<div style="font-size: 24px;">${icon}</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; font-weight: 700; color: #FFFFFF; margin-bottom: 2px;">${escapeHtml(title)}</div>
<div style="font-size: 12px; color: var(--text-muted, #94A3B8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(body)}</div>
</div>
`;
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();
+109 -3
View File
@@ -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 = `
<div style="font-size: 24px;">${icon}</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; font-weight: 700; color: #FFFFFF; margin-bottom: 2px;">${escapeHtml(title)}</div>
<div style="font-size: 12px; color: var(--text-muted, #94A3B8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(body)}</div>
</div>
`;
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) {