feat: Implement zero-latency WebSocket live message broadcast with luxury audio chime and Apple-style toast notifications
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user