relay: file attachments, outbox, CC-self, chat view, mobile layout

Compose: attach files (encrypted, hex-named, linked via relay_uid),
CC-self checkbox tags copy with recipient name. Outbox section shows
sent messages with delete. Chat view: session-based threads/history/
send endpoints, split layout with sidebar, read receipts, 5s poll.
Mobile: threads as horizontal scroll bar above messages. Fix chat
input pushed offscreen by adding min-height:0 on flex container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-13 08:48:23 +02:00
1 parent 9f85e484c4
commit 6c0ca4373b
8 files changed
+547 -20

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.3.4');
define('APP_VERSION', '1.3.5');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.3.4",
"version": "1.3.5",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+5 -1
View File
@@ -900,7 +900,11 @@ if (str_starts_with($route, '/api/')) {
}
if (strlen($raw) >= 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);
}
}
}
}
+87 -2
View File
@@ -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') {
+61 -4
View File
@@ -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]);
}
+274 -10
View File
@@ -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) => `
<div class="compose-att">
<i class="ph ${fileIcon(f.name)} att-icon"></i>
<span>${escapeHtml(f.name)}</span>
<span class="att-size">${formatSize(f.size)}</span>
<button class="att-remove" data-idx="${i}" aria-label="remove"><i class="ph ph-x"></i></button>
</div>
`).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})`
: `[📄 ${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 = `
<div class="empty-state">
<i class="ph ph-paper-plane-tilt icon"></i>
<div class="label">no sent messages</div>
<div class="hint">Messages you send to other tenants will appear here.</div>
</div>`;
return;
}
list.innerHTML = messages.map(m => `
<div class="inbox-item" data-id="${m.id}">
<div class="content">
<div class="from"><i class="ph ph-arrow-right"></i> to ${escapeHtml(m.recipient)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">pending</span>'}</div>
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
</div>
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
<button class="del" onclick="deleteMessage(${m.id}, this, 'outbox')" aria-label="delete"><i class="ph ph-trash"></i></button>
</div>
`).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 = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
return;
}
if (!threads.length) return;
list.innerHTML = threads.map(t => `
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div class="peer-name">${escapeHtml(t.peer)}</div>
<div class="thread-time">${formatRelTime(t.last_at)}</div>
</div>
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
</div>
`).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 = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no messages yet</div></div>`;
return;
}
container.innerHTML = messages.map(m => `
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
<div>${escapeHtml(m.body)}</div>
<div class="msg-time">${formatRelTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
</div>
`).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
+34 -1
View File
@@ -22,6 +22,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<div id="nav-buttons" class="hidden">
<span id="nav-user" class="user"></span>
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
@@ -91,12 +92,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<label>body</label>
<textarea id="compose-body" placeholder="write something..."></textarea>
</div>
<div id="compose-attachments" class="compose-attachments"></div>
<label class="checkbox" id="compose-cc-self-wrap" style="margin-top:4px;">
<input type="checkbox" id="compose-cc-self"> keep a copy (tagged with recipient name)
</label>
<div class="actions">
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
<button id="compose-attach-btn" class="btn btn-secondary"><i class="ph ph-paperclip"></i> attach</button>
<input type="file" id="compose-file" multiple class="hidden">
<span class="spacer"></span>
<span id="compose-status" class="status"></span>
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
</div>
<div id="compose-status" class="compose-status-bar"></div>
</div>
<div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
@@ -105,6 +112,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</h2>
<div id="inbox-list"></div>
</div>
<div id="outbox-section" class="section">
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
<div id="outbox-list"></div>
</div>
</div>
<!-- ── Forced password change (first login) ── -->
@@ -193,6 +204,28 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</div>
</div>
<!-- ── Chat view ── -->
<div id="view-chat" class="hidden">
<div class="chat-layout">
<div class="chat-sidebar">
<div class="chat-sidebar-header">
<span class="pixel" style="font-size:10px;color:var(--accent);">conversations</span>
</div>
<div id="chat-thread-list"></div>
</div>
<div class="chat-main">
<div id="chat-header" class="chat-header">
<span id="chat-peer-name" class="pixel" style="font-size:11px;color:var(--bright);">select a conversation</span>
</div>
<div id="chat-messages" class="chat-messages"></div>
<div id="chat-input-bar" class="chat-input-bar hidden">
<input type="text" id="chat-input" placeholder="type a message..." autocomplete="off">
<button id="chat-send-btn" class="btn btn-primary" style="padding:6px 14px;"><i class="ph ph-paper-plane-tilt"></i></button>
</div>
</div>
</div>
</div>
</main>
<!-- Confirm dialog (replaces native confirm()) -->
+84
View File
@@ -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; }
}
/* ─────────────────────────────────────────────────────────────────