diff --git a/config.php b/config.php
index 9bb033f..b9c49c8 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
. 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';
@@ -266,31 +288,53 @@ async function loadTenants() {
}
function populateRecipientPicker() {
- const sel = $('compose-recipient');
- const field = $('compose-recipient-field');
- sel.innerHTML = '';
+ 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;
- // Always include "you" first as the default recipient (the value
- // the form submits even when the picker is hidden)
- if (me) {
- const opt = document.createElement('option');
- opt.value = me.username;
- opt.textContent = `${me.username} (you)`;
- sel.appendChild(opt);
- }
+ // 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;
- const opt = document.createElement('option');
- opt.value = t.username;
- opt.textContent = t.username;
- sel.appendChild(opt);
+ options.push({ value: t.username, text: t.username });
}
- // Solo tenant → no choice to make. Hide the picker entirely so
- // the compose form is just title + body.
- if (field) {
- field.classList.toggle('hidden', allTenants.length <= 1);
+ // 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 => `
+
+ `).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');
+ });
+ });
+
+ // 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);
}
function clearCompose() {
@@ -337,6 +381,7 @@ async function loadInbox() {
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
$('inbox-count').textContent = messages.length;
+ $('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
// Mirror count into the header status so the unread dot stays in sync
$('header-status').innerHTML = `${messages.length} in inbox`;
$('header-status').classList.toggle('has-unread', messages.length > 0);
@@ -353,9 +398,8 @@ async function loadInbox() {
list.innerHTML = messages.map(m => `
-
${escapeHtml(m.sender)}${m.fetched ? '' : ' new'}
- ${m.title ? `
${escapeHtml(m.title)}
` : ''}
-
${escapeHtml(m.body)}
+
${escapeHtml(m.sender)}${m.fetched ? ' synced' : ' new'}
+
${m.title ? escapeHtml(m.title) : 'No preview'}
${escapeHtml(formatRelTime(m.created_at))}
@@ -364,19 +408,56 @@ async function loadInbox() {
}
async function deleteMessage(id, btn) {
- if (!confirm('Delete this message?')) return;
- const res = await api('api/messages/' + id, { method: 'DELETE' });
- if (res?.ok) {
- btn.closest('.inbox-item')?.remove();
- loadInbox();
- refreshMe();
- toast('message deleted', 'ok');
- } else {
- toast(res?.error || 'delete failed', 'err');
- }
+ // 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 = `
+
delete?
+
+
+ `;
+ btn.replaceWith(wrap);
+ wrap.querySelector('.del-yes').addEventListener('click', async () => {
+ const res = await api('api/messages/' + id, { method: 'DELETE' });
+ if (res?.ok) {
+ if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
+ loadInbox();
+ refreshMe();
+ toast('message deleted', 'ok');
+ } else {
+ toast(res?.error || 'delete failed', 'err');
+ }
+ });
+ wrap.querySelector('.del-no').addEventListener('click', () => {
+ const temp = document.createElement('div');
+ temp.innerHTML = origHtml;
+ wrap.replaceWith(temp.firstChild);
+ });
}
window.deleteMessage = deleteMessage;
+async function deleteAllMessages() {
+ 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();
+}
+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. */
@@ -466,7 +547,7 @@ async function resetUserPassword(id, username) {
}
async function deleteUser(id, username) {
- if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return;
+ 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);
@@ -533,7 +614,7 @@ async function saveAccountSecret() {
async function generateAccountSecret() {
if (currentAccountSecret) {
- if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return;
+ 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 } });
@@ -550,7 +631,7 @@ async function generateAccountSecret() {
}
async function clearAccountSecret() {
- if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return;
+ 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');
diff --git a/relay/ui/layout.php b/relay/ui/layout.php
index ddaa2d8..dc69585 100644
--- a/relay/ui/layout.php
+++ b/relay/ui/layout.php
@@ -74,7 +74,14 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
compose
@@ -92,7 +99,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
-
your inbox 0
+
your inbox 0
+
+
+
@@ -185,6 +195,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
+
+
+
+
+
+
+
+
+
+
+
diff --git a/relay/ui/style.css b/relay/ui/style.css
index b08821f..b14c2f2 100644
--- a/relay/ui/style.css
+++ b/relay/ui/style.css
@@ -326,6 +326,16 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
text-transform:uppercase; letter-spacing:0.05em;
margin-left:4px;
}
+.badge-synced {
+ display:inline-flex; align-items:center; gap:3px;
+ color:var(--ok, #4ade80);
+ padding:1px 6px; border-radius:var(--radius);
+ border:1px solid color-mix(in srgb, var(--ok, #4ade80) 40%, var(--border));
+ font-family:var(--pixel); font-size:8px;
+ text-transform:uppercase; letter-spacing:0.05em;
+ margin-left:4px;
+}
+.badge-synced .ph { font-size:9px; }
.inbox-item .del {
background:none; border:none; color:var(--dim); cursor:pointer;
padding:6px; font-size:14px; flex-shrink:0;