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:
1 parent
9f85e484c4
commit
6c0ca4373b
8 files changed
+547
-20
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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})`);
|
||||
}
|
||||
} 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
@@ -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()) -->
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user