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
56 changes: 56 additions & 0 deletions migrations/0009_add_peer_messaging.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Migration 0009: Peer connections, peer messages, and secure messages

CREATE TABLE IF NOT EXISTS peer_connections (
id TEXT PRIMARY KEY,
sender_id TEXT NOT NULL,
receiver_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (sender_id <> receiver_id),
UNIQUE (sender_id, receiver_id),
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_pc_sender ON peer_connections(sender_id);
CREATE INDEX IF NOT EXISTS idx_pc_receiver ON peer_connections(receiver_id);
CREATE INDEX IF NOT EXISTS idx_pc_status ON peer_connections(status);

CREATE UNIQUE INDEX IF NOT EXISTS idx_pc_pair_unique
ON peer_connections (
CASE WHEN sender_id < receiver_id THEN sender_id ELSE receiver_id END,
CASE WHEN sender_id < receiver_id THEN receiver_id ELSE sender_id END
);

CREATE TABLE IF NOT EXISTS peer_messages (
id TEXT PRIMARY KEY,
sender_id TEXT NOT NULL,
receiver_id TEXT NOT NULL,
content_enc TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_pm_sender ON peer_messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_pm_receiver ON peer_messages(receiver_id);
CREATE INDEX IF NOT EXISTS idx_pm_thread ON peer_messages(sender_id, receiver_id);

CREATE TABLE IF NOT EXISTS secure_messages (
id TEXT PRIMARY KEY,
sender_id TEXT NOT NULL,
receiver_id TEXT NOT NULL,
content_enc TEXT NOT NULL,
is_starred INTEGER NOT NULL DEFAULT 0,
is_read INTEGER NOT NULL DEFAULT 0,
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_sm_receiver ON secure_messages(receiver_id);
CREATE INDEX IF NOT EXISTS idx_sm_sender ON secure_messages(sender_id);
9 changes: 8 additions & 1 deletion public/partials/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
</div>

<!-- Messaging Button -->
<a href="#" title="Messages" class="flex items-center p-2 hover:bg-teal-700 rounded-lg">
<a href="/peers.html" title="Messages" class="flex items-center p-2 hover:bg-teal-700 rounded-lg">
<i class="fas fa-comments text-xl"></i>
</a>

Expand Down Expand Up @@ -240,6 +240,10 @@
<i class="fas fa-chart-line mr-2 text-teal-500"></i>
<span>Dashboard</span>
</a>
<a href="/peers.html" class="flex items-center px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-teal-50 dark:hover:bg-teal-900 hover:text-teal-600 dark:hover:text-teal-300">
<i class="fas fa-users mr-2 text-teal-500"></i>
<span>Peers</span>
</a>
<hr class="border-gray-200 dark:border-gray-700 my-1">
<button onclick="logout()" class="w-full text-left flex items-center px-4 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20">
<i class="fas fa-sign-out-alt mr-2"></i>
Expand Down Expand Up @@ -306,6 +310,9 @@
<a href="/notifications.html" class="flex items-center py-2 px-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md">
<i class="fas fa-bell mr-2 text-teal-500"></i><span>Notifications</span>
</a>
<a href="/peers.html" class="flex items-center py-2 px-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md">
<i class="fas fa-users mr-2 text-teal-500"></i><span>Peers</span>
</a>
<button onclick="logout()" class="w-full flex items-center py-2 px-3 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md mt-2">
<i class="fas fa-sign-out-alt mr-2"></i><span>Logout</span>
</button>
Expand Down
167 changes: 167 additions & 0 deletions public/peer-messages.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Peer Messages - Alpha One Labs">
<title>Peer Messages - Alpha One Labs</title>
<link rel="icon" type="image/png" href="https://alphaonelabs.com/static/images/logo.png" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<script>
tailwind.config = { darkMode: 'class', theme: { extend: {} } };
(function () {
if (localStorage.getItem('darkMode') === 'true') document.documentElement.classList.add('dark');
})();
</script>
</head>
<body class="min-h-screen flex flex-col bg-white text-gray-900 dark:bg-black dark:text-gray-100 transition-colors duration-300 overflow-x-hidden">

<div id="site-navbar"></div>

<section class="border-b border-gray-200 dark:border-gray-800 bg-white/70 dark:bg-black/40 backdrop-blur">
<div class="max-w-3xl mx-auto px-4 py-5 flex items-center justify-between gap-4">
<div>
<a href="/peers.html" class="text-sm text-teal-600 dark:text-teal-400 hover:underline mb-1 inline-block">
<i class="fas fa-arrow-left mr-1"></i> Back to Peers
</a>
<h1 id="peer-title" class="text-2xl font-bold flex items-center gap-2">
<i class="fas fa-comments text-teal-600 dark:text-teal-400"></i> Messages
</h1>
</div>
</div>
</section>

<main class="flex-1 max-w-3xl w-full mx-auto px-4 py-6">
<div class="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900">
<div id="messages-container" class="h-[480px] overflow-y-auto p-4 space-y-3">
<div class="animate-pulse h-16 bg-gray-200 dark:bg-gray-800 rounded-lg"></div>
</div>
<form id="send-form" class="border-t border-gray-200 dark:border-gray-700 p-4 flex gap-2">
<label for="message-input" class="sr-only">Message</label>
<input id="message-input" type="text" placeholder="Type a message…" required
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button type="submit"
class="bg-teal-600 hover:bg-teal-700 text-white font-semibold px-5 py-2 rounded-lg text-sm">
Send
</button>
</form>
</div>
</main>

<div id="site-footer"></div>

<script src="/js/layout.js" defer></script>
<script>
const API = '';
const token = localStorage.getItem('edu_token');
const user = JSON.parse(localStorage.getItem('edu_user') || 'null');
const esc = window.esc || (s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'));

const params = new URLSearchParams(window.location.search);
const peerId = params.get('peer_id');

if (!token || !user) window.location.href = '/login.html';
if (!peerId) window.location.href = '/peers.html';

let pollTimer = null;

async function apiFetch(path, opts = {}) {
const res = await fetch(`${API}${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(opts.headers || {}),
Authorization: `Bearer ${token}`,
},
});
if (res.status === 401) {
localStorage.removeItem('edu_token');
localStorage.removeItem('edu_user');
window.location.href = '/login.html';
return null;
}
return res;
}

function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}

function renderMessages(messages) {
const box = document.getElementById('messages-container');
if (!messages.length) {
box.innerHTML = '<p class="text-center text-gray-500 py-12">No messages yet. Start the conversation!</p>';
return;
}
box.innerHTML = messages.map(m => `
<div class="flex ${m.mine ? 'justify-end' : 'justify-start'}">
<div class="max-w-[75%] rounded-lg px-4 py-2 ${m.mine
? 'bg-teal-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'}">
<p class="text-sm whitespace-pre-wrap break-words">${esc(m.content)}</p>
<p class="text-xs mt-1 opacity-70">${formatTime(m.created_at)}</p>
</div>
</div>`).join('');
box.scrollTop = box.scrollHeight;
}

async function loadThread() {
const res = await apiFetch(`/api/peers/messages/${encodeURIComponent(peerId)}`);
if (!res) return;
if (res.status === 403) {
document.getElementById('messages-container').innerHTML =
'<p class="text-center text-red-600 py-12">You must be connected with this user to view messages.</p>';
document.getElementById('send-form').classList.add('hidden');
return;
}
if (!res.ok) throw new Error('Failed to load messages');
const body = await res.json();
const data = body.data || {};
const peer = data.peer || {};
document.getElementById('peer-title').innerHTML =
`<i class="fas fa-comments text-teal-600 dark:text-teal-400"></i> ${esc(peer.name || peer.username || 'Peer')}`;
renderMessages(data.messages || []);
}

document.getElementById('send-form').addEventListener('submit', async e => {
e.preventDefault();
const input = document.getElementById('message-input');
const submit = e.submitter || document.querySelector('#send-form button[type="submit"]');
const content = input.value.trim();
if (!content) return;
input.disabled = true;
if (submit) submit.disabled = true;
try {
const res = await apiFetch(`/api/peers/messages/${encodeURIComponent(peerId)}`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!res) return;
const body = await res.json();
if (!res.ok) {
alert(body.error || 'Send failed');
return;
}
input.value = '';
await loadThread();
} finally {
input.disabled = false;
if (submit) submit.disabled = false;
input.focus();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

loadThread().catch(e => {
document.getElementById('messages-container').innerHTML =
`<p class="text-red-600 text-center py-8">${esc(e.message)}</p>`;
});

pollTimer = setInterval(() => loadThread().catch(() => {}), 15000);
window.addEventListener('beforeunload', () => clearInterval(pollTimer));
</script>
</body>
</html>
Loading
Loading