new-card unread dot + pulse fix, relay inbox readable, UI polish pass
Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a67bedc6f2
commit
c16659c2ad
9 files changed
+773
-246
No files matched your search
+153
-18
@@ -5,12 +5,29 @@ let allTenants = [];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function toast(msg, isError = false) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('err', isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2200);
|
||||
/** 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). */
|
||||
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 = {}) {
|
||||
@@ -52,8 +69,12 @@ function showView(name) {
|
||||
if (name === 'compose') {
|
||||
loadTenants().then(() => populateRecipientPicker());
|
||||
loadInbox();
|
||||
startInboxPoll();
|
||||
$('compose-body')?.focus();
|
||||
} else if (name === 'admin') {
|
||||
} else {
|
||||
stopInboxPoll();
|
||||
}
|
||||
if (name === 'admin') {
|
||||
loadUsers();
|
||||
} else if (name === 'account') {
|
||||
loadAccountSecret();
|
||||
@@ -63,6 +84,23 @@ function showView(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
function startInboxPoll() {
|
||||
stopInboxPoll();
|
||||
inboxPollHandle = setInterval(() => {
|
||||
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
|
||||
}, 15000);
|
||||
}
|
||||
function stopInboxPoll() {
|
||||
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -70,6 +108,31 @@ 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');
|
||||
@@ -188,7 +251,9 @@ async function refreshMe() {
|
||||
};
|
||||
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
||||
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
||||
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
|
||||
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;
|
||||
}
|
||||
@@ -263,31 +328,101 @@ async function sendCompose() {
|
||||
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
||||
|
||||
async function loadInbox() {
|
||||
const status = await api('api/status');
|
||||
$('inbox-count').textContent = status.inbox_count || 0;
|
||||
const list = $('inbox-list');
|
||||
if (!status.inbox_count) {
|
||||
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
|
||||
} else {
|
||||
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
|
||||
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 || [];
|
||||
$('inbox-count').textContent = messages.length;
|
||||
// Mirror count into the header status so the unread dot stays in sync
|
||||
$('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-new">new</span>'}</div>
|
||||
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''}
|
||||
<div class="body">${escapeHtml(m.body)}</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('');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
window.deleteMessage = deleteMessage;
|
||||
|
||||
/** 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. */
|
||||
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, true); return; }
|
||||
if (res.error) { toast(res.error, 'err'); return; }
|
||||
const users = res.users || [];
|
||||
$('users-count').textContent = users.length;
|
||||
const list = $('users-list');
|
||||
if (!users.length) {
|
||||
list.innerHTML = '<div class="empty">no users yet</div>';
|
||||
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" style="font-size:20px;color:var(--accent);"></i>
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user