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
15 changes: 15 additions & 0 deletions migrations/0007_add_chat_message.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Migration: 0007_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_classroom ON chat_message(classroom_id);
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_message(classroom_id, created_at);
95 changes: 87 additions & 8 deletions public/classroom_poc.html
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ <h1 class="text-3xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-300
<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 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 @@ -284,6 +284,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 @@ -536,6 +539,7 @@ <h2 class="text-sm font-semibold flex items-center gap-2">
reconnectAttempts = 0;
setConnStatus(true);
connectPresenceWS();
connectChatWS();
showToast('Connected to classroom', 'success');
};

Expand All @@ -559,8 +563,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 @@ -626,6 +632,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');
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 (presenceWs && presenceWs.readyState === WebSocket.OPEN) {
presenceWs.send(JSON.stringify(obj));
Expand Down Expand Up @@ -769,10 +848,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 @@ -997,13 +1072,17 @@ <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();
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
Loading
Loading