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})` + : `[📄 ${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 = `
no conversations yet
`; + 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 = `
no messages yet
`; + 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; } +
+
+ + -
+

your inbox 0 @@ -105,6 +112,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }

+
+

sent 0

+
+
@@ -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; } } /* ─────────────────────────────────────────────────────────────────