Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions migrations/0014_add_chat_message.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Migration: 0014_add_chat_message
-- Adds the chat_message table used by ChatDO for encrypted message persistence.
-- Content is encrypted with AES-256-GCM before storage (via ChatDO._persist_message).

CREATE TABLE IF NOT EXISTS chat_message (
id TEXT PRIMARY KEY,
classroom_id TEXT NOT NULL,
user_id TEXT NOT NULL,
display_name TEXT,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_message(classroom_id, created_at);
101 changes: 91 additions & 10 deletions public/classroom_poc.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ <h1 class="text-3xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-300
</div>

<!--CLASSROOM SCREEN-->
<div id="classroomScreen" class="flex-1 flex-col hidden">
<div id="classroomScreen" class="flex-1 flex-col h-screen max-h-screen overflow-hidden hidden">

<!-- Top Bar -->
<header class="bg-gray-900/90 backdrop-blur-xl border-b border-gray-800 px-4 py-2.5 flex items-center justify-between shrink-0">
Expand Down Expand Up @@ -266,12 +266,12 @@ <h1 class="text-3xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-300
</div>

<!-- Chat Sidebar -->
<div id="chatPanel" class="w-full lg:w-80 h-64 lg:h-auto shrink-0 bg-gray-900/70 border-t lg:border-t-0 lg:border-l border-gray-800 flex flex-col">
<div id="chatPanel" class="w-full lg:w-80 h-72 lg:h-full max-h-[380px] lg:max-h-full min-h-0 shrink-0 bg-gray-900/70 border-t lg:border-t-0 lg:border-l border-gray-800 flex flex-col">
<div class="px-4 py-3 border-b border-gray-800 flex items-center gap-2">
<i data-lucide="message-circle" class="w-4 h-4 text-teal-400"></i>
<h2 class="text-sm font-semibold text-gray-200">Room Chat</h2>
</div>
<div id="chatMessages" class="flex-1 overflow-y-auto p-3 space-y-2">
<div id="chatMessages" role="log" aria-live="polite" aria-relevant="additions" class="flex-1 overflow-y-auto min-h-0 p-3 space-y-2">
<div class="text-center text-xs text-gray-600 py-6">No messages yet!</div>
</div>
<form id="chatForm" class="p-3 border-t border-gray-800 flex gap-2">
Expand Down Expand Up @@ -379,6 +379,9 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
// State
let ws = null;
let presenceWs = null;
let chatWs = null;
let chatReconnectAttempts = 0;
let chatReconnectTimer = null;
let roomId = '';
let myParticipantId = '';
let myDisplayName = '';
Expand Down Expand Up @@ -626,6 +629,7 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
lobby.classList.add('hidden');
classroomScreen.classList.remove('hidden');
classroomScreen.classList.add('flex');
document.body.classList.add('overflow-hidden', 'h-screen');

renderDesks();
connectWS();
Expand All @@ -647,6 +651,7 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
classroomScreen.classList.add('hidden');
classroomScreen.classList.remove('flex');
lobby.classList.remove('hidden');
document.body.classList.remove('overflow-hidden', 'h-screen');
Object.keys(participants).forEach(k => delete participants[k]);
Object.keys(presenceByUser).forEach(k => delete presenceByUser[k]);
avatarLayer.innerHTML = '';
Expand Down Expand Up @@ -675,6 +680,7 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
reconnectAttempts = 0;
setConnStatus(true);
connectPresenceWS();
connectChatWS();
showToast(canInteract ? 'Connected to classroom' : 'Viewing classroom', 'success');
};

Expand All @@ -698,8 +704,10 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
intentionalDisconnect = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (presenceReconnectTimer) { clearTimeout(presenceReconnectTimer); presenceReconnectTimer = null; }
if (chatReconnectTimer) { clearTimeout(chatReconnectTimer); chatReconnectTimer = null; }
if (ws) { ws.close(); ws = null; }
if (presenceWs) { presenceWs.close(); presenceWs = null; }
if (chatWs) { chatWs.close(); chatWs = null; }
setConnStatus(false);
}

Expand Down Expand Up @@ -769,6 +777,79 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
};
}

// Chat WebSocket (ChatDO — /ws/chat/:id)
function connectChatWS() {
if (chatWs && (chatWs.readyState === WebSocket.OPEN || chatWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const params = new URLSearchParams();
const token = localStorage.getItem('auth_token') || localStorage.getItem('edu_token');
if (token) {
// Prefer authenticated token so the server derives identity server-side.
params.set('token', token);
} else {
// Fallback to anonymous identity only when explicitly enabled server-side.
params.set('user_id', myParticipantId);
params.set('display_name', myDisplayName);
}
const qs = params.toString();
const url = qs
? `${proto}//${location.host}/ws/chat/${encodeURIComponent(roomId)}?${qs}`
: `${proto}//${location.host}/ws/chat/${encodeURIComponent(roomId)}`;
Comment thread
Ananya44444 marked this conversation as resolved.
chatWs = new WebSocket(url);
chatWs.onopen = () => {
chatReconnectAttempts = 0;
if (chatReconnectTimer) { clearTimeout(chatReconnectTimer); chatReconnectTimer = null; }
};
chatWs.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.type === 'chat_history') {
// Always clear the placeholder, even if history is empty
chatMessages.innerHTML = '';
chatFirstMessage = false;
(data.messages || []).forEach((msg) => appendChatMessage(
msg.display_name || msg.user_id,
msg.text,
msg.user_id === myParticipantId
));
} else if (data.type === 'chat_error') {
showToast(data.message || 'Failed to send chat message', 'error');
} else if (data.type === 'chat_message') {
appendChatMessage(
data.display_name || data.user_id,
data.text,
data.user_id === myParticipantId
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (err) {
console.warn('[chat] dropped malformed ws frame', err, ev.data);
}
};
chatWs.onerror = () => {
console.warn('[chat] WebSocket error');
};
chatWs.onclose = () => {
if (intentionalDisconnect) return;
if (chatReconnectAttempts >= MAX_RECONNECT) {
showToast('Lost connection to chat — please rejoin.', 'warning');
return;
}
const delay = Math.min(BASE_DELAY * Math.pow(2, chatReconnectAttempts), 30000);
chatReconnectAttempts++;
chatReconnectTimer = setTimeout(() => {
if (!chatWs || chatWs.readyState === WebSocket.CLOSED) connectChatWS();
}, delay);
};
}

function chatSend(obj) {
if (chatWs && chatWs.readyState === WebSocket.OPEN) {
chatWs.send(JSON.stringify(obj));
return true;
}
return false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function presenceSend(obj) {
if (!canInteract) return;
if (presenceWs && presenceWs.readyState === WebSocket.OPEN) {
Expand Down Expand Up @@ -920,10 +1001,6 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
}
break;

case 'chat_message':
appendChatMessage(data.display_name, data.text, data.participant_id === myParticipantId);
break;

case 'seat_updated':
if (!participants[data.participant_id] && data.participant_id !== myParticipantId) {
participants[data.participant_id] = {
Expand Down Expand Up @@ -1164,14 +1241,18 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
presenceSend({ type: 'presence', hand_raised: nextHandRaised });
});

// Chat
// Chat
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
if (!canInteract) { loginForInteraction(); return; }
const text = chatInput.value.trim();
if (!text) return;
wsSend({ type: 'chat_message', text, timestamp: new Date().toISOString() });
chatInput.value = '';
const ok = chatSend({ type: 'chat_message', text });
if (ok) {
chatInput.value = '';
} else {
showToast('Chat is not connected. Retry in a moment…', 'warning');
}
});

function appendChatMessage(name, text, isSelf) {
Expand Down
12 changes: 12 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,15 @@ CREATE TABLE IF NOT EXISTS message_requests (
CREATE INDEX IF NOT EXISTS idx_message_requests_to_user ON message_requests(to_user_id, status);
CREATE INDEX IF NOT EXISTS idx_message_requests_from_user ON message_requests(from_user_id);
CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id);

-- Chat messages table (virtual classroom chat persistence)
CREATE TABLE IF NOT EXISTS chat_message (
id TEXT PRIMARY KEY,
classroom_id TEXT NOT NULL,
user_id TEXT NOT NULL,
display_name TEXT,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_message(classroom_id, created_at);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading