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
+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
|
||||
|
||||
Reference in new issue
Block a user