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;