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>
971 lines
38 KiB
JavaScript
971 lines
38 KiB
JavaScript
// hyprcrd relay UI
|
|
|
|
let me = null;
|
|
let allTenants = [];
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
|
|
/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'.
|
|
* Multiple toasts stack vertically, oldest at the top, and each one
|
|
* slides in from the right + fades out after a fixed timeout. The
|
|
* legacy `toast(msg, true)` second-arg style still works (true → err). */
|
|
/** Custom confirm dialog — returns a Promise<boolean>. Replaces native
|
|
* confirm() across the relay UI so all confirmations are styled. */
|
|
function relayConfirm(msg, { okLabel = 'Confirm', danger = true } = {}) {
|
|
return new Promise(resolve => {
|
|
const el = $('relay-confirm');
|
|
if (!el) { resolve(confirm(msg)); return; } // fallback
|
|
$('relay-confirm-msg').textContent = msg;
|
|
const yesBtn = $('relay-confirm-yes');
|
|
yesBtn.textContent = okLabel;
|
|
yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
|
|
el.classList.remove('hidden');
|
|
const cleanup = (result) => {
|
|
el.classList.add('hidden');
|
|
yesBtn.replaceWith(yesBtn.cloneNode(true)); // strip listeners
|
|
$('relay-confirm-no').replaceWith($('relay-confirm-no').cloneNode(true));
|
|
resolve(result);
|
|
};
|
|
$('relay-confirm-yes').addEventListener('click', () => cleanup(true), { once: true });
|
|
$('relay-confirm-no').addEventListener('click', () => cleanup(false), { once: true });
|
|
});
|
|
}
|
|
|
|
function toast(msg, kind = 'info') {
|
|
if (kind === true) kind = 'err';
|
|
if (kind === false) kind = 'info';
|
|
const stack = $('toast-stack');
|
|
if (!stack) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'toast ' + kind;
|
|
const icon = kind === 'err' ? 'ph-warning-circle'
|
|
: kind === 'ok' ? 'ph-check-circle'
|
|
: 'ph-info';
|
|
el.innerHTML = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
|
|
el.querySelector('.toast-msg').textContent = msg;
|
|
stack.appendChild(el);
|
|
// Trigger the slide-in next frame so the transition runs
|
|
requestAnimationFrame(() => el.classList.add('show'));
|
|
setTimeout(() => {
|
|
el.classList.add('fade-out');
|
|
setTimeout(() => el.remove(), 350);
|
|
}, 2800);
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(path, {
|
|
method: opts.method || 'GET',
|
|
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
credentials: 'same-origin',
|
|
});
|
|
return res.json().catch(() => ({ error: 'invalid response' }));
|
|
}
|
|
|
|
function showView(name) {
|
|
// If the user hasn't changed their password yet, force them to that view
|
|
// unless they're currently in setup/login/change-password (admin/anon flows)
|
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
|
name = 'change-password';
|
|
}
|
|
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
|
|
$('view-' + v)?.classList.add('hidden');
|
|
});
|
|
$('view-' + name)?.classList.remove('hidden');
|
|
|
|
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');
|
|
// Picking a view also collapses the mobile menu so it doesn't
|
|
// hover over the freshly-shown view.
|
|
$('nav-buttons').classList.remove('menu-open');
|
|
} else {
|
|
// Setup, login, and forced change-password hide all nav
|
|
$('nav-buttons').classList.add('hidden');
|
|
$('nav-burger')?.classList.add('hidden');
|
|
}
|
|
|
|
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') {
|
|
loadAccountSecret();
|
|
} else if (name === 'change-password') {
|
|
$('cp-current')?.focus();
|
|
$('cp-err').textContent = '';
|
|
}
|
|
}
|
|
|
|
// ── Inbox auto-poll ──
|
|
//
|
|
// Refresh the inbox every 15s while the compose view is showing so new
|
|
// messages from other tenants surface without a manual reload. Stops
|
|
// the moment the user navigates away (account/admin/login) so we don't
|
|
// hammer the server in the background.
|
|
let inboxPollHandle = null;
|
|
let inboxBusy = false;
|
|
function startInboxPoll() {
|
|
stopInboxPoll();
|
|
inboxPollHandle = setInterval(() => {
|
|
if (inboxBusy) return;
|
|
if (!$('view-compose')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); }
|
|
}, 15000);
|
|
}
|
|
function stopInboxPoll() {
|
|
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
|
}
|
|
|
|
// Suppress poll re-renders while the user is typing in any input/textarea
|
|
document.addEventListener('focusin', e => {
|
|
if (e.target.matches('input, textarea, select')) inboxBusy = true;
|
|
});
|
|
document.addEventListener('focusout', e => {
|
|
if (e.target.matches('input, textarea, select')) inboxBusy = false;
|
|
});
|
|
|
|
/** Burger toggle for the mobile nav. The button is hidden on desktop
|
|
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
|
|
* positioned under the header. */
|
|
function toggleNavMenu() {
|
|
$('nav-buttons')?.classList.toggle('menu-open');
|
|
}
|
|
window.toggleNavMenu = toggleNavMenu;
|
|
|
|
/** Tap-target on the header inbox count: switches to the compose view
|
|
* if needed, then smooth-scrolls #inbox-section into view and briefly
|
|
* highlights it so the user can't miss it after the scroll lands.
|
|
* Same handler on desktop and mobile — desktop just scrolls a few px
|
|
* if at all (the inbox is already on screen). */
|
|
function jumpToInbox() {
|
|
if (!me) return;
|
|
if ($('view-compose')?.classList.contains('hidden')) {
|
|
showView('compose');
|
|
}
|
|
// Wait one frame so the (possibly just-shown) view has measurable
|
|
// dimensions before scrollIntoView fires
|
|
requestAnimationFrame(() => {
|
|
const target = $('inbox-section');
|
|
if (!target) return;
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
target.classList.remove('flash-highlight');
|
|
// Restart the animation by toggling the class
|
|
void target.offsetWidth;
|
|
target.classList.add('flash-highlight');
|
|
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
|
});
|
|
}
|
|
window.jumpToInbox = jumpToInbox;
|
|
// Tap outside the menu closes it
|
|
document.addEventListener('click', (e) => {
|
|
const nav = $('nav-buttons');
|
|
const burger = $('nav-burger');
|
|
if (!nav?.classList.contains('menu-open')) return;
|
|
if (nav.contains(e.target) || burger?.contains(e.target)) return;
|
|
nav.classList.remove('menu-open');
|
|
});
|
|
|
|
async function doChangePassword() {
|
|
const current = $('cp-current').value;
|
|
const newPw = $('cp-new').value;
|
|
const newPw2 = $('cp-new2').value;
|
|
const err = $('cp-err');
|
|
err.textContent = '';
|
|
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
|
|
if (newPw.length < 4) { err.textContent = 'new password too short'; return; }
|
|
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
|
|
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
|
|
|
|
const res = await api('api/account/password', { method: 'POST', body: { current, new: newPw } });
|
|
if (res.ok) {
|
|
toast('password changed');
|
|
$('cp-current').value = '';
|
|
$('cp-new').value = '';
|
|
$('cp-new2').value = '';
|
|
// After password change, route based on whether they have a secret
|
|
const status = await refreshMe();
|
|
if (!status.has_secret) {
|
|
showView('account');
|
|
toast('now set your client secret');
|
|
} else {
|
|
showView('compose');
|
|
}
|
|
} else {
|
|
err.textContent = res.error || 'failed';
|
|
}
|
|
}
|
|
|
|
// ── Setup ──
|
|
|
|
async function doSetup() {
|
|
const username = $('setup-username').value.trim();
|
|
const password = $('setup-password').value;
|
|
const superSecret = $('setup-super-secret').value;
|
|
const err = $('setup-err');
|
|
err.textContent = '';
|
|
if (!username) { err.textContent = 'username required'; return; }
|
|
if (password.length < 4) { err.textContent = 'password too short'; return; }
|
|
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
|
|
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
|
|
if (res.ok) {
|
|
await refreshMe();
|
|
showView('account');
|
|
toast('admin created — now set your client secret');
|
|
} else {
|
|
err.textContent = res.error || 'failed';
|
|
}
|
|
}
|
|
|
|
// ── Login ──
|
|
|
|
async function doLogin() {
|
|
const username = $('login-username').value.trim();
|
|
const password = $('login-password').value;
|
|
const err = $('login-err');
|
|
err.textContent = '';
|
|
if (!username || !password) { err.textContent = 'username and password required'; return; }
|
|
const res = await api('api/login', { method: 'POST', body: { username, password } });
|
|
if (res.ok) {
|
|
const status = await refreshMe();
|
|
routePostLogin(status);
|
|
} else {
|
|
err.textContent = res.error || 'login failed';
|
|
}
|
|
}
|
|
|
|
// Decide which view to land on after a successful login / on initial load.
|
|
// Order: forced password change → set client secret → compose.
|
|
function routePostLogin(status) {
|
|
if (!status.password_changed) {
|
|
showView('change-password');
|
|
// Pre-fill the current-password input from what they just typed if
|
|
// we're coming from the login form (otherwise it's empty)
|
|
const lp = $('login-password');
|
|
if (lp && lp.value) {
|
|
$('cp-current').value = lp.value;
|
|
lp.value = '';
|
|
}
|
|
toast('change your password before continuing');
|
|
} else if (!status.has_secret) {
|
|
showView('account');
|
|
toast('set your client secret to start using the relay');
|
|
} else {
|
|
showView('compose');
|
|
}
|
|
}
|
|
|
|
|
|
async function doLogout() {
|
|
await api('api/logout', { method: 'POST' });
|
|
me = null;
|
|
$('nav-user').textContent = '';
|
|
$('nav-admin').classList.add('hidden');
|
|
showView('login');
|
|
}
|
|
|
|
async function refreshMe() {
|
|
const status = await api('api/status');
|
|
if (status.logged_in) {
|
|
me = {
|
|
username: status.username,
|
|
is_admin: status.is_admin,
|
|
has_secret: status.has_secret,
|
|
password_changed: status.password_changed,
|
|
};
|
|
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
|
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
|
const n = status.inbox_count || 0;
|
|
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
|
|
$('header-status').classList.toggle('has-unread', n > 0);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
// ── Compose ──
|
|
|
|
async function loadTenants() {
|
|
const res = await api('api/tenants');
|
|
if (res.tenants) allTenants = res.tenants;
|
|
}
|
|
|
|
function populateRecipientPicker() {
|
|
const hidden = $('compose-recipient');
|
|
const btn = $('compose-recipient-btn');
|
|
const label = $('compose-recipient-label');
|
|
const dd = $('compose-recipient-dropdown');
|
|
const field = $('compose-recipient-field');
|
|
if (!hidden || !btn || !dd) return;
|
|
|
|
// Build the options list: self first, then all other tenants
|
|
const options = [];
|
|
if (me) options.push({ value: me.username, text: `${me.username} (you)` });
|
|
for (const t of allTenants) {
|
|
if (me && t.username === me.username) continue;
|
|
options.push({ value: t.username, text: t.username });
|
|
}
|
|
|
|
// Default to self
|
|
if (options.length && !hidden.value) {
|
|
hidden.value = options[0].value;
|
|
label.textContent = options[0].text;
|
|
}
|
|
|
|
// Render dropdown items
|
|
dd.innerHTML = options.map(o => `
|
|
<button class="recipient-option" data-value="${escapeHtml(o.value)}" style="display:block;width:100%;text-align:left;padding:7px 12px;font-size:12px;font-family:var(--mono);color:var(--text);background:none;border:none;cursor:pointer;transition:background .1s;">
|
|
<i class="ph ph-user-circle" style="font-size:12px;color:var(--accent2);margin-right:6px;"></i>${escapeHtml(o.text)}
|
|
</button>
|
|
`).join('');
|
|
|
|
dd.querySelectorAll('.recipient-option').forEach(opt => {
|
|
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--raised)');
|
|
opt.addEventListener('mouseleave', () => opt.style.background = 'none');
|
|
opt.addEventListener('click', () => {
|
|
hidden.value = opt.dataset.value;
|
|
label.textContent = opt.textContent.trim();
|
|
dd.classList.add('hidden');
|
|
updateCcSelfVisibility();
|
|
});
|
|
});
|
|
|
|
// Toggle dropdown on button click
|
|
btn.onclick = () => dd.classList.toggle('hidden');
|
|
// Close on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!btn.contains(e.target) && !dd.contains(e.target)) dd.classList.add('hidden');
|
|
});
|
|
|
|
// 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() {
|
|
$('compose-title').value = '';
|
|
$('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 = '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;
|
|
let body = $('compose-body').value;
|
|
const recipient = $('compose-recipient').value;
|
|
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 ccSelf = $('compose-cc-self')?.checked && recipient;
|
|
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } });
|
|
if (res.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');
|
|
}
|
|
}
|
|
|
|
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
|
|
|
let lastInboxJson = '';
|
|
|
|
async function loadInbox({ quiet = false } = {}) {
|
|
const list = $('inbox-list');
|
|
if (!quiet) {
|
|
list.innerHTML = `
|
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
|
<div class="skeleton-row"><div class="skel"></div></div>
|
|
`;
|
|
}
|
|
const res = await api('api/messages/inbox');
|
|
const messages = res?.messages || [];
|
|
|
|
// In quiet mode, skip DOM update if nothing changed
|
|
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
|
|
if (quiet && json === lastInboxJson) return;
|
|
lastInboxJson = json;
|
|
|
|
$('inbox-count').textContent = messages.length;
|
|
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
|
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
|
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
|
|
|
if (!messages.length) {
|
|
list.innerHTML = `
|
|
<div class="empty-state">
|
|
<i class="ph ph-tray icon"></i>
|
|
<div class="label">inbox empty</div>
|
|
<div class="hint">Messages from other tenants will show up 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-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</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)" aria-label="delete"><i class="ph ph-trash"></i></button>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
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
|
|
// passing escaped HTML through attributes.
|
|
const row = btn.closest('.inbox-item');
|
|
if (!row) return;
|
|
const origHtml = btn.outerHTML;
|
|
const wrap = document.createElement('span');
|
|
wrap.className = 'del-confirm';
|
|
wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;';
|
|
wrap.innerHTML = `
|
|
<span style="color:var(--danger);">delete?</span>
|
|
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
|
|
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
|
|
`;
|
|
btn.replaceWith(wrap);
|
|
wrap.querySelector('.del-yes').addEventListener('click', async () => {
|
|
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
|
inboxBusy = false;
|
|
if (res?.ok) {
|
|
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
|
if (source === 'outbox') loadOutbox(); else loadInbox();
|
|
refreshMe();
|
|
toast('message deleted', 'ok');
|
|
} else {
|
|
toast(res?.error || 'delete failed', 'err');
|
|
}
|
|
});
|
|
wrap.querySelector('.del-no').addEventListener('click', () => {
|
|
inboxBusy = false;
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = origHtml;
|
|
wrap.replaceWith(temp.firstChild);
|
|
});
|
|
}
|
|
window.deleteMessage = deleteMessage;
|
|
|
|
async function deleteAllMessages() {
|
|
inboxBusy = true;
|
|
try {
|
|
const res = await api('api/messages/inbox');
|
|
const messages = res?.messages || [];
|
|
if (!messages.length) return;
|
|
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
|
|
let ok = 0;
|
|
for (const m of messages) {
|
|
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
|
|
if (r?.ok) ok++;
|
|
}
|
|
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
|
loadInbox();
|
|
refreshMe();
|
|
} finally { inboxBusy = false; }
|
|
}
|
|
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
|
|
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
|
|
if (isNaN(t.getTime())) return sqliteUtc;
|
|
const diff = (Date.now() - t.getTime()) / 1000;
|
|
if (diff < 60) return 'just now';
|
|
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
|
if (diff < 86400) {
|
|
const today = new Date();
|
|
const sameDay = today.toDateString() === t.toDateString();
|
|
if (sameDay) return 'today ' + t.toTimeString().slice(0, 5);
|
|
return Math.floor(diff / 3600) + ' h ago';
|
|
}
|
|
if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago';
|
|
return t.toISOString().slice(0, 10);
|
|
}
|
|
|
|
// ── Admin: users ──
|
|
|
|
async function loadUsers() {
|
|
const list = $('users-list');
|
|
list.innerHTML = `
|
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
|
<div class="skeleton-row"><div class="skel"></div></div>
|
|
`;
|
|
const res = await api('api/users');
|
|
if (res.error) { toast(res.error, 'err'); return; }
|
|
const users = res.users || [];
|
|
$('users-count').textContent = users.length;
|
|
if (!users.length) {
|
|
list.innerHTML = `
|
|
<div class="empty-state">
|
|
<i class="ph ph-users icon"></i>
|
|
<div class="label">no users yet</div>
|
|
<div class="hint">Create the first one with the form on the right.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = users.map(u => `
|
|
<div class="user-row" data-id="${u.id}">
|
|
<i class="ph ph-user-circle user-icon"></i>
|
|
<div class="info">
|
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
|
<div class="meta">created ${u.created_at}</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
|
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function createUser() {
|
|
const username = $('new-username').value.trim();
|
|
const password = $('new-password').value;
|
|
const isAdmin = $('new-is-admin').checked;
|
|
const status = $('create-user-status');
|
|
status.textContent = 'creating...';
|
|
status.className = 'status';
|
|
const res = await api('api/users', { method: 'POST', body: { username, password, is_admin: isAdmin } });
|
|
if (res.ok) {
|
|
status.textContent = 'created — they set their own client secret on first login';
|
|
status.className = 'status ok';
|
|
$('new-username').value = '';
|
|
$('new-password').value = '';
|
|
$('new-is-admin').checked = false;
|
|
loadUsers();
|
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
|
} else {
|
|
status.textContent = res.error || 'failed';
|
|
status.className = 'status err';
|
|
}
|
|
}
|
|
|
|
async function resetUserPassword(id, username) {
|
|
const newPw = prompt(`new password for ${username} (min 4 chars):`);
|
|
if (!newPw || newPw.length < 4) { toast('password too short', true); return; }
|
|
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
|
|
if (res.ok) toast('password updated');
|
|
else toast(res.error || 'failed', true);
|
|
}
|
|
|
|
async function deleteUser(id, username) {
|
|
if (!await relayConfirm(`Delete user "${username}"? All their messages and attachments will be removed.`, { okLabel: 'Delete user' })) return;
|
|
const res = await api('api/users/' + id, { method: 'DELETE' });
|
|
if (res.ok) { toast('user deleted'); loadUsers(); }
|
|
else toast(res.error || 'failed', true);
|
|
}
|
|
|
|
// ── Account: client secret management ──
|
|
|
|
let currentAccountSecret = null;
|
|
|
|
async function loadAccountSecret() {
|
|
setAccountStatus('');
|
|
const res = await api('api/account/secret');
|
|
if (res.has_secret) {
|
|
currentAccountSecret = res.secret;
|
|
$('account-current').classList.remove('hidden');
|
|
$('account-no-secret').classList.add('hidden');
|
|
$('account-secret-display').type = 'password';
|
|
$('account-secret-display').value = currentAccountSecret;
|
|
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye"></i> show';
|
|
} else {
|
|
currentAccountSecret = null;
|
|
$('account-current').classList.add('hidden');
|
|
$('account-no-secret').classList.remove('hidden');
|
|
}
|
|
$('account-secret-input').value = '';
|
|
}
|
|
|
|
function toggleAccountSecret() {
|
|
const inp = $('account-secret-display');
|
|
const btn = $('account-secret-toggle');
|
|
if (inp.type === 'password') {
|
|
inp.type = 'text';
|
|
btn.innerHTML = '<i class="ph ph-eye-slash"></i> hide';
|
|
} else {
|
|
inp.type = 'password';
|
|
btn.innerHTML = '<i class="ph ph-eye"></i> show';
|
|
}
|
|
}
|
|
|
|
async function copyAccountSecret() {
|
|
if (!currentAccountSecret) return;
|
|
try {
|
|
await navigator.clipboard.writeText(currentAccountSecret);
|
|
toast('secret copied to clipboard');
|
|
} catch {
|
|
toast('copy failed — show and copy manually', true);
|
|
}
|
|
}
|
|
|
|
async function saveAccountSecret() {
|
|
const secret = $('account-secret-input').value;
|
|
if (secret.length < 8) { setAccountStatus('secret needs at least 8 chars', 'err'); return; }
|
|
setAccountStatus('saving...');
|
|
const res = await api('api/account/secret', { method: 'POST', body: { secret } });
|
|
if (res.ok) {
|
|
setAccountStatus('saved', 'ok');
|
|
await loadAccountSecret();
|
|
await refreshMe();
|
|
setTimeout(() => setAccountStatus(''), 2000);
|
|
} else {
|
|
setAccountStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
async function generateAccountSecret() {
|
|
if (currentAccountSecret) {
|
|
if (!await relayConfirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.', { okLabel: 'Replace', danger: false })) return;
|
|
}
|
|
setAccountStatus('generating...');
|
|
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
|
|
if (res.ok) {
|
|
setAccountStatus('generated — copy it to your hyprcrd clients now', 'ok');
|
|
await loadAccountSecret();
|
|
await refreshMe();
|
|
// Auto-show the new secret so user can copy
|
|
$('account-secret-display').type = 'text';
|
|
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye-slash"></i> hide';
|
|
} else {
|
|
setAccountStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
async function clearAccountSecret() {
|
|
if (!await relayConfirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.', { okLabel: 'Clear secret' })) return;
|
|
const res = await api('api/account/secret', { method: 'DELETE' });
|
|
if (res.ok) {
|
|
setAccountStatus('cleared', 'ok');
|
|
await loadAccountSecret();
|
|
await refreshMe();
|
|
} else {
|
|
setAccountStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
function setAccountStatus(text, kind = '') {
|
|
const el = $('account-status');
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = 'status ' + kind;
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
|
|
// ── Init ──
|
|
|
|
(async () => {
|
|
const status = await api('api/status');
|
|
if (status.setup) {
|
|
showView('setup');
|
|
} else if (!status.logged_in) {
|
|
showView('login');
|
|
} else {
|
|
await refreshMe();
|
|
routePostLogin(status);
|
|
}
|
|
})();
|
|
|
|
// Keyboard
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
if (document.activeElement?.id === 'login-password') { e.preventDefault(); doLogin(); }
|
|
if (document.activeElement?.id === 'setup-secret') { e.preventDefault(); doSetup(); }
|
|
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
|
|
}
|
|
});
|