diff --git a/config.php b/config.php
index f130f54..1ec76e9 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
- if ($decrypted !== false) file_put_contents($dest, $decrypted);
+ if ($decrypted !== false) {
+ $fileBytes = base64_decode($decrypted);
+ if ($fileBytes !== false) $decrypted = $fileBytes;
+ file_put_contents($dest, $decrypted);
+ }
}
}
}
diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php
index 76edd97..0e2da47 100644
--- a/relay/inc/routes/chat.php
+++ b/relay/inc/routes/chat.php
@@ -17,8 +17,93 @@
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
-// $clientTenant is set by messages.php earlier in the chain via authClient().
-// We re-resolve here to keep this file self-contained.
+// ── Session-based chat (relay UI) ──
+
+if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
+ $interKey = getInterTenantKey($db, $MASTER_KEY);
+ if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
+ $myId = $sessionTenant['id'];
+ $stmt = $db->prepare('
+ SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
+ FROM chat_messages WHERE recipient_id = :r
+ GROUP BY sender_username
+ UNION
+ SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
+ FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
+ WHERE cm.sender_id = :s AND cm.recipient_id != :s2
+ GROUP BY t.username
+ ');
+ $stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
+ $rows = $stmt->fetchAll();
+ $threads = [];
+ foreach ($rows as $row) {
+ $peer = $row['peer'];
+ if (!isset($threads[$peer])) $threads[$peer] = ['peer' => $peer, 'last_at' => $row['last_at'], 'count' => 0];
+ $threads[$peer]['count'] += (int)$row['count'];
+ if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
+ }
+ usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at']));
+ // Add unread count per peer
+ foreach ($threads as &$t) {
+ $stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0');
+ $stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
+ $t['unread'] = (int)$stmt2->fetchColumn();
+ }
+ jsonResponse(array_values($threads));
+}
+
+if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
+ $peer = trim($_GET['peer'] ?? '');
+ if (!$peer) jsonResponse(['error' => 'peer required'], 400);
+ $interKey = getInterTenantKey($db, $MASTER_KEY);
+ if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
+ $myId = $sessionTenant['id'];
+ $peerTenant = getTenantByUsername($db, $peer);
+ if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
+ $peerId = $peerTenant['id'];
+ $stmt = $db->prepare('
+ SELECT id, sender_id, sender_username, body, created_at, seen
+ FROM chat_messages
+ WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
+ ORDER BY created_at ASC LIMIT 500
+ ');
+ $stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
+ $rows = $stmt->fetchAll();
+ $out = [];
+ foreach ($rows as $row) {
+ $out[] = [
+ 'id' => (int)$row['id'],
+ 'from' => $row['sender_username'],
+ 'mine' => (int)$row['sender_id'] === $myId,
+ 'body' => clientDecrypt($row['body'] ?? '', $interKey),
+ 'created_at' => $row['created_at'],
+ 'seen' => (int)$row['seen'],
+ ];
+ }
+ // Mark incoming as seen
+ $db->prepare('UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_id = :p AND seen = 0')
+ ->execute(['r' => $myId, 'p' => $peerId]);
+ jsonResponse($out);
+}
+
+if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $body = trim((string)($input['body'] ?? ''));
+ $recipientName = trim((string)($input['recipient'] ?? ''));
+ if ($body === '') jsonResponse(['error' => 'body required'], 400);
+ if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
+ $recipient = getTenantByUsername($db, $recipientName);
+ if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
+ $interKey = getInterTenantKey($db, $MASTER_KEY);
+ if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
+ $stored = clientEncrypt($body, $interKey);
+ $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
+ ->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
+ jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
+}
+
+// ── Client API ──
+
$clientTenant = $clientTenant ?? authClient($db);
if ($route === '/api/chat' && $method === 'POST') {
diff --git a/relay/inc/routes/messages.php b/relay/inc/routes/messages.php
index 9035d07..bf3f901 100644
--- a/relay/inc/routes/messages.php
+++ b/relay/inc/routes/messages.php
@@ -27,6 +27,7 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$storedTitle = clientEncrypt($title, $interKey);
$storedBody = clientEncrypt($body, $interKey);
+ $relayUid = $input['relay_uid'] ?? bin2hex(random_bytes(16));
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
->execute([
@@ -37,9 +38,43 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
'b' => $storedBody,
'c' => $input['color'] ?? '#38bdf8',
'tg' => $input['tags'] ?? '',
- 'uid' => $input['relay_uid'] ?? null,
+ 'uid' => $relayUid,
]);
- jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
+ jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName, 'relay_uid' => $relayUid]);
+}
+
+// ── UI attachment upload: session stores files encrypted at rest ──
+
+if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant) {
+ if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
+ jsonResponse(['error' => 'no file'], 400);
+ }
+ $relayUid = $_POST['relay_uid'] ?? '';
+ $recipientName = trim($_POST['recipient'] ?? $sessionTenant['username']);
+ if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
+
+ $recipient = getTenantByUsername($db, $recipientName);
+ if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
+
+ $interKey = getInterTenantKey($db, $MASTER_KEY);
+ if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
+
+ // Generate hex filename so hyprcrd's poll handler accepts it
+ $origName = basename($_FILES['file']['name']);
+ $ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
+ $hexName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
+
+ $plainBytes = file_get_contents($_FILES['file']['tmp_name']);
+ // Store as base64(raw) encrypted — matches client API format.
+ // The GET endpoint re-encrypts for recipient; hyprcrd decrypts
+ // and base64_decodes to get the original file bytes.
+ $storedData = clientEncrypt(base64_encode($plainBytes), $interKey);
+
+ $db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn')
+ ->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName]);
+ $db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)')
+ ->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName, 'data' => $storedData]);
+ jsonResponse(['ok' => true, 'filename' => $hexName, 'original' => $origName]);
}
// ── UI inbox view: session reads decrypted messages ──
@@ -71,6 +106,28 @@ if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
jsonResponse(['messages' => $out]);
}
+// ── UI sent view: messages this user sent to others ──
+
+if ($route === '/api/messages/sent' && $method === 'GET' && $sessionTenant) {
+ $interKey = getInterTenantKey($db, $MASTER_KEY);
+ if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
+ $stmt = $db->prepare('SELECT m.id, m.title, m.body, m.created_at, m.fetched, t.username AS recipient FROM messages m JOIN tenants t ON t.id = m.recipient_id WHERE m.sender_id = :s AND m.recipient_id != :s2 ORDER BY m.created_at DESC LIMIT 200');
+ $stmt->execute(['s' => $sessionTenant['id'], 's2' => $sessionTenant['id']]);
+ $rows = $stmt->fetchAll();
+ $out = [];
+ foreach ($rows as $row) {
+ $out[] = [
+ 'id' => (int)$row['id'],
+ 'recipient' => $row['recipient'] ?? '?',
+ 'title' => clientDecrypt($row['title'] ?? '', $interKey),
+ 'body' => clientDecrypt($row['body'] ?? '', $interKey),
+ 'created_at' => $row['created_at'],
+ 'fetched' => (int)$row['fetched'],
+ ];
+ }
+ jsonResponse(['messages' => $out]);
+}
+
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
$clientTenant = authClient($db);
@@ -155,8 +212,8 @@ if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
$tenant = $clientTenant ?: $sessionTenant;
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
$id = (int)$m[1];
- $db->prepare('DELETE FROM messages WHERE id = :id AND recipient_id = :r')
- ->execute(['id' => $id, 'r' => $tenant['id']]);
+ $db->prepare('DELETE FROM messages WHERE id = :id AND (recipient_id = :r OR sender_id = :s)')
+ ->execute(['id' => $id, 'r' => $tenant['id'], 's' => $tenant['id']]);
jsonResponse(['ok' => true]);
}
diff --git a/relay/ui/app.js b/relay/ui/app.js
index 04312d0..3cde133 100644
--- a/relay/ui/app.js
+++ b/relay/ui/app.js
@@ -68,14 +68,15 @@ function showView(name) {
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password';
}
- ['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
+ ['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
- if (['compose', 'account', 'admin'].includes(name)) {
+ if (['compose', 'account', 'admin', 'chat'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose');
+ $('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin');
$('nav-burger')?.classList.remove('hidden');
@@ -91,11 +92,18 @@ function showView(name) {
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
loadInbox();
+ loadOutbox();
startInboxPoll();
$('compose-body')?.focus();
} else {
stopInboxPoll();
}
+ if (name === 'chat') {
+ loadChatThreads();
+ startChatPoll();
+ } else {
+ stopChatPoll();
+ }
if (name === 'admin') {
loadUsers();
} else if (name === 'account') {
@@ -118,7 +126,7 @@ function startInboxPoll() {
stopInboxPoll();
inboxPollHandle = setInterval(() => {
if (inboxBusy) return;
- if (!$('view-compose')?.classList.contains('hidden')) loadInbox({ quiet: true });
+ if (!$('view-compose')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); }
}, 15000);
}
function stopInboxPoll() {
@@ -333,6 +341,7 @@ function populateRecipientPicker() {
hidden.value = opt.dataset.value;
label.textContent = opt.textContent.trim();
dd.classList.add('hidden');
+ updateCcSelfVisibility();
});
});
@@ -345,6 +354,17 @@ function populateRecipientPicker() {
// Solo tenant → hide the picker
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
+ updateCcSelfVisibility();
+}
+
+let composeFiles = [];
+
+function updateCcSelfVisibility() {
+ const wrap = $('compose-cc-self-wrap');
+ if (!wrap) return;
+ const recipient = $('compose-recipient')?.value || '';
+ const isSelf = !recipient || (me && recipient === me.username);
+ wrap.style.display = isSelf ? 'none' : '';
}
function clearCompose() {
@@ -352,27 +372,140 @@ function clearCompose() {
$('compose-body').value = '';
$('compose-body').focus();
setComposeStatus('');
+ composeFiles = [];
+ renderComposeAttachments();
}
+$('compose-attach-btn')?.addEventListener('click', () => $('compose-file')?.click());
+$('compose-file')?.addEventListener('change', e => {
+ for (const f of e.target.files) {
+ if (composeFiles.some(x => x.name === f.name && x.size === f.size)) continue;
+ composeFiles.push(f);
+ }
+ e.target.value = '';
+ renderComposeAttachments();
+});
+
+function formatSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+}
+
+function fileIcon(name) {
+ if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'ph-image';
+ if (/\.(mp3|ogg|wav|m4a)$/i.test(name)) return 'ph-music-note';
+ if (/\.pdf$/i.test(name)) return 'ph-file-pdf';
+ return 'ph-file';
+}
+
+function renderComposeAttachments() {
+ const el = $('compose-attachments');
+ if (!el) return;
+ el.innerHTML = composeFiles.map((f, i) => `
+
+
+ ${escapeHtml(f.name)}
+ ${formatSize(f.size)}
+
+
+ `).join('');
+ el.querySelectorAll('.att-remove').forEach(btn => {
+ btn.addEventListener('click', () => {
+ composeFiles.splice(parseInt(btn.dataset.idx), 1);
+ renderComposeAttachments();
+ });
+ });
+}
+
+let composeStatusTimer = null;
function setComposeStatus(text, kind = '') {
+ clearTimeout(composeStatusTimer);
const el = $('compose-status');
+ if (!el) return;
el.textContent = text;
- el.className = 'status ' + kind;
+ el.className = 'compose-status-bar' + (kind ? ' ' + kind : '');
+ if (text) {
+ el.classList.add('visible');
+ if (kind === 'ok') {
+ composeStatusTimer = setTimeout(() => { el.classList.remove('visible'); }, 4000);
+ }
+ } else {
+ el.classList.remove('visible');
+ }
+}
+
+function randomHex(n) {
+ const a = new Uint8Array(n);
+ crypto.getRandomValues(a);
+ return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
}
async function sendCompose() {
const title = $('compose-title').value;
- const body = $('compose-body').value;
+ let body = $('compose-body').value;
const recipient = $('compose-recipient').value;
- if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
+ if (!body.trim() && !composeFiles.length) { setComposeStatus('body or attachment required', 'err'); return; }
+
+ const relayUid = randomHex(16);
+ const hasFiles = composeFiles.length > 0;
+
+ if (hasFiles) {
+ setComposeStatus('uploading files...');
+ const hexNames = [];
+ for (const f of composeFiles) {
+ const form = new FormData();
+ form.append('file', f);
+ form.append('relay_uid', relayUid);
+ form.append('recipient', recipient || '');
+ try {
+ const r = await fetch('api/attachments/upload', { method: 'POST', body: form });
+ const data = await r.json();
+ if (data.ok && data.filename) {
+ const ext = (data.original || f.name).split('.').pop().toLowerCase();
+ const isImg = /^(png|jpe?g|gif|webp)$/.test(ext);
+ const label = data.original || f.name;
+ hexNames.push(isImg
+ ? ``
+ : `[📄 ${label}](uploads/${data.filename})`);
+ }
+ } catch {}
+ }
+ if (hexNames.length) {
+ body = body ? body + '\n\n' + hexNames.join('\n') : hexNames.join('\n');
+ }
+ }
+
setComposeStatus('sending...');
- const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
+ const ccSelf = $('compose-cc-self')?.checked && recipient;
+ const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } });
if (res.ok) {
- setComposeStatus(`sent to ${res.recipient}`, 'ok');
+ // Send a copy to self tagged with recipient name
+ if (ccSelf) {
+ const ccTag = `#sent/${res.recipient}`;
+ const ccBody = body ? body + '\n\n' + ccTag : ccTag;
+ const ccUid = randomHex(16);
+ await api('api/messages', { method: 'POST', body: { title, body: ccBody, relay_uid: ccUid, tags: `sent/${res.recipient}` } });
+ // Also copy attachments to the self-message
+ if (hasFiles) {
+ for (const f of composeFiles) {
+ const form = new FormData();
+ form.append('file', f);
+ form.append('relay_uid', ccUid);
+ form.append('recipient', '');
+ try { await fetch('api/attachments/upload', { method: 'POST', body: form }); } catch {}
+ }
+ }
+ }
+ setComposeStatus(`sent to ${res.recipient}${ccSelf ? ' + copy to self' : ''}${hasFiles ? ` · ${composeFiles.length} file${composeFiles.length !== 1 ? 's' : ''}` : ''}`, 'ok');
$('compose-title').value = '';
$('compose-body').value = '';
+ $('compose-cc-self').checked = false;
+ composeFiles = [];
+ renderComposeAttachments();
$('compose-body').focus();
loadInbox();
+ loadOutbox();
refreshMe();
} else {
setComposeStatus(res.error || 'failed', 'err');
@@ -426,7 +559,42 @@ async function loadInbox({ quiet = false } = {}) {
`).join('');
}
-async function deleteMessage(id, btn) {
+let lastOutboxJson = '';
+
+async function loadOutbox({ quiet = false } = {}) {
+ const list = $('outbox-list');
+ if (!list) return;
+ const res = await api('api/messages/sent');
+ const messages = res?.messages || [];
+
+ const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
+ if (quiet && json === lastOutboxJson) return;
+ lastOutboxJson = json;
+
+ $('outbox-count').textContent = messages.length;
+
+ if (!messages.length) {
+ list.innerHTML = `
+
+
+
no sent messages
+
Messages you send to other tenants will appear here.
+
`;
+ return;
+ }
+ list.innerHTML = messages.map(m => `
+
+
+
to ${escapeHtml(m.recipient)}${m.fetched ? ' synced' : ' pending'}
+
${m.title ? escapeHtml(m.title) : 'No preview'}
+
+
${escapeHtml(formatRelTime(m.created_at))}
+
+
+ `).join('');
+}
+
+async function deleteMessage(id, btn, source) {
inboxBusy = true;
// Inline confirmation: replace the trash button with yes/no.
// Event listeners are wired via JS (not inline onclick) to avoid
@@ -448,7 +616,7 @@ async function deleteMessage(id, btn) {
inboxBusy = false;
if (res?.ok) {
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
- loadInbox();
+ if (source === 'outbox') loadOutbox(); else loadInbox();
refreshMe();
toast('message deleted', 'ok');
} else {
@@ -486,6 +654,102 @@ window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */
+// ── Chat ──
+
+let chatActivePeer = null;
+let chatPollHandle = null;
+
+function startChatPoll() {
+ stopChatPoll();
+ chatPollHandle = setInterval(() => {
+ loadChatThreads(true);
+ if (chatActivePeer) loadChatHistory(chatActivePeer, true);
+ }, 5000);
+}
+function stopChatPoll() {
+ if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
+}
+
+async function loadChatThreads(quiet = false) {
+ const list = $('chat-thread-list');
+ if (!list) return;
+ const threads = await api('api/chat/threads') || [];
+ if (!Array.isArray(threads)) return;
+
+ // If no threads and not quiet, show empty state
+ if (!threads.length && !quiet) {
+ list.innerHTML = ``;
+ return;
+ }
+ if (!threads.length) return;
+
+ list.innerHTML = threads.map(t => `
+
+
+
+
${escapeHtml(t.peer)}
+
${formatRelTime(t.last_at)}
+
+ ${t.unread ? `
${t.unread}` : ''}
+
+ `).join('');
+
+ list.querySelectorAll('.chat-thread').forEach(el => {
+ el.addEventListener('click', () => {
+ chatActivePeer = el.dataset.peer;
+ loadChatThreads(true);
+ loadChatHistory(chatActivePeer);
+ $('chat-input-bar')?.classList.remove('hidden');
+ $('chat-peer-name').textContent = chatActivePeer;
+ $('chat-input')?.focus();
+ });
+ });
+}
+
+async function loadChatHistory(peer, quiet = false) {
+ const container = $('chat-messages');
+ if (!container) return;
+ const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
+ if (!Array.isArray(messages)) return;
+
+ const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
+
+ if (!messages.length) {
+ container.innerHTML = ``;
+ return;
+ }
+
+ container.innerHTML = messages.map(m => `
+
+
${escapeHtml(m.body)}
+
${formatRelTime(m.created_at)}${m.mine && m.seen ? ' ' : ''}
+
+ `).join('');
+
+ if (wasAtBottom || !quiet) {
+ container.scrollTop = container.scrollHeight;
+ }
+
+ // Refresh thread list to clear unread badges
+ if (!quiet) loadChatThreads(true);
+}
+
+async function sendChatMessage() {
+ const input = $('chat-input');
+ if (!input || !chatActivePeer) return;
+ const body = input.value.trim();
+ if (!body) return;
+ input.value = '';
+ await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
+ loadChatHistory(chatActivePeer);
+ loadChatThreads(true);
+}
+
+$('chat-send-btn')?.addEventListener('click', sendChatMessage);
+$('chat-input')?.addEventListener('keydown', e => {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
+});
+
function formatRelTime(sqliteUtc) {
if (!sqliteUtc) return '';
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
diff --git a/relay/ui/layout.php b/relay/ui/layout.php
index dc69585..e0292a5 100644
--- a/relay/ui/layout.php
+++ b/relay/ui/layout.php
@@ -22,6 +22,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
+
@@ -91,12 +92,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
+
+
+
+
-
+
your inbox 0
@@ -105,6 +112,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
+
@@ -193,6 +204,28 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
+
+
+
diff --git a/relay/ui/style.css b/relay/ui/style.css
index b14c2f2..148c02f 100644
--- a/relay/ui/style.css
+++ b/relay/ui/style.css
@@ -232,10 +232,78 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
#view-compose:not(.hidden) {
display: flex; flex-direction: column; gap: 16px;
}
+.compose-attachments {
+ display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
+}
+.compose-attachments:empty { display: none; }
+.compose-att {
+ display: flex; align-items: center; gap: 5px;
+ padding: 4px 10px; border-radius: var(--radius);
+ background: var(--raised); border: 1px solid var(--border);
+ font-family: var(--pixel); font-size: 9px; color: var(--text);
+}
+.compose-att .att-remove {
+ background: none; border: none; color: var(--muted); cursor: pointer;
+ padding: 0; font-size: 11px; line-height: 1;
+ transition: color 0.15s;
+}
+.compose-att .att-remove:hover { color: var(--danger); }
+.compose-att .att-icon { font-size: 12px; color: var(--dim); }
+.compose-att .att-size { color: var(--dim); }
.compose-card .actions {
display:flex; gap:8px; align-items:center;
}
.compose-card .actions .spacer { flex:1; }
+/* Chat view */
+.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
+.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
+.chat-sidebar-header { padding: 12px; border-bottom: 1px solid var(--border); }
+#chat-thread-list { flex: 1; overflow-y: auto; }
+.chat-thread {
+ display: flex; align-items: center; gap: 8px; padding: 10px 12px;
+ cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s;
+}
+.chat-thread:hover { background: var(--raised); }
+.chat-thread.active { background: var(--raised); border-left: 3px solid var(--accent); }
+.chat-thread .peer-name { font-family: var(--pixel); font-size: 10px; color: var(--bright); flex: 1; }
+.chat-thread .unread-badge {
+ font-family: var(--pixel); font-size: 8px; background: var(--accent);
+ color: var(--bg); padding: 1px 6px; border-radius: 8px; min-width: 16px; text-align: center;
+}
+.chat-thread .thread-time { font-family: var(--pixel); font-size: 8px; color: var(--dim); }
+.chat-main { flex: 1; display: flex; flex-direction: column; min-height: 0; }
+.chat-header { padding: 12px 16px; border-bottom: 1px solid var(--border); }
+.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 6px; }
+.chat-msg {
+ max-width: 75%; padding: 8px 12px; border-radius: var(--radius-lg);
+ font-size: 12px; line-height: 1.6; word-break: break-word;
+}
+.chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; }
+.chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; }
+.chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; }
+.chat-msg.mine .msg-time { text-align: right; }
+.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
+.chat-input-bar input {
+ flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--bright);
+ padding: 8px 12px; font-size: 12px; font-family: var(--mono);
+ border-radius: var(--radius); outline: none;
+}
+.chat-input-bar input:focus { border-color: var(--accent); }
+.chat-empty {
+ flex: 1; display: flex; flex-direction: column; align-items: center;
+ justify-content: center; gap: 8px; color: var(--dim);
+ font-family: var(--pixel); font-size: 10px;
+}
+
+.compose-status-bar {
+ font-family: var(--pixel); font-size: 10px; color: var(--muted);
+ min-height: 0; overflow: hidden;
+ transition: opacity 0.3s ease, max-height 0.3s ease;
+ max-height: 0; opacity: 0;
+}
+.compose-status-bar.visible { max-height: 32px; opacity: 1; padding-top: 8px; }
+.compose-status-bar.ok { color: var(--accent); }
+.compose-status-bar.err { color: var(--danger); }
/* Status text (used in compose, account, create-user) */
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
@@ -564,6 +632,22 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
}
#view-admin .section h2 { margin-bottom:10px; }
#view-admin .section .create-user-form { margin-top:14px; }
+
+ .chat-layout { flex-direction: column; height: calc(100vh - 160px); }
+ .chat-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: none; }
+ #chat-thread-list {
+ display: flex; overflow-x: auto; overflow-y: hidden; gap: 0;
+ scrollbar-width: none; -ms-overflow-style: none;
+ }
+ #chat-thread-list::-webkit-scrollbar { display: none; }
+ .chat-thread {
+ flex-shrink: 0; flex-direction: column; align-items: center;
+ gap: 2px; padding: 8px 14px; border-bottom: none; border-right: 1px solid var(--border);
+ min-width: 70px; text-align: center;
+ }
+ .chat-thread.active { border-left: none; border-bottom: 3px solid var(--accent); }
+ .chat-thread .thread-time { display: none; }
+ .chat-sidebar-header { display: none; }
}
/* ─────────────────────────────────────────────────────────────────