Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "catchbox",
"version": "1.4.1",
"version": "1.5.0",
"description": "A disposable inbox for developers — grab an address, catch the mail, pull out the link or the code.",
"type": "module",
"bin": {
Expand Down
94 changes: 84 additions & 10 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,35 @@ let pendingDelete = null; // { items, timer }
// Set by the macOS app before this script runs. It posts its own notifications, natively.
const inApp = Boolean(window.catchboxApp);

/* --- motion --------------------------------------------------------------- */
// The list is rebuilt from scratch on every render, so a plain CSS animation would start
// over each time. Instead every animated row remembers when it started, and a rebuild
// hands it a negative delay that resumes it where it was.
const motion = {
known: null, // ids in the mailbox at the last refresh; null = show it entering
entrance: 0, // when the current mailbox came into view
arrivals: new Map(), // id -> when it arrived
ticked: null, // { id, at } — the tick that just landed
unread: new Map(), // mailbox id -> unread count last drawn, to bump when it rises
};
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;

// Rows slide out and fold shut before the list closes over them.
const leave = (ids) => {
const rows = [...el.list.querySelectorAll("li[data-id]")].filter((li) => ids.has(li.dataset.id));
if (!rows.length || reducedMotion() || !Element.prototype.animate) return Promise.resolve();
return Promise.all(rows.map((li) => {
const { paddingTop, paddingBottom } = getComputedStyle(li);
const h = `${li.offsetHeight}px`;
li.style.overflow = "hidden";
return li.animate([
{ opacity: 1, transform: "none", height: h, paddingTop, paddingBottom },
{ opacity: 0, transform: "translateX(-28px)", height: h, paddingTop, paddingBottom, offset: 0.45 },
{ opacity: 0, transform: "translateX(-28px)", height: "0px", paddingTop: "0px", paddingBottom: "0px" },
], { duration: 340, easing: "cubic-bezier(.2, .8, .2, 1)", fill: "forwards" }).finished.catch(() => {});
}));
};

/* --- helpers -------------------------------------------------------------- */
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
Expand Down Expand Up @@ -230,6 +259,13 @@ const notify = (m, acc) => {
};

/* --- mailbox rail --------------------------------------------------------- */
// " bump" when a count went up since it was last drawn — once, not on every redraw.
const bumped = (id, n) => {
const before = motion.unread.get(id);
motion.unread.set(id, n);
return before !== undefined && n > before ? " bump" : "";
};

const renderRail = () => {
const many = accounts.length > 1;
const rows = accounts.map((a, i) => `
Expand All @@ -240,15 +276,15 @@ const renderRail = () => {
<span class="mb-sub">${esc(a.address)}</span>
</span>
${a.id === serverCurrent && many ? `<span class="cli-pin" title="The mailbox the catchbox command reads">CLI</span>` : ""}
${a.unread ? `<span class="badge">${a.unread}</span>` : ""}
${a.unread ? `<span class="badge${bumped(a.id, a.unread)}">${a.unread}</span>` : ""}
</button>`).join("");

const total = accounts.reduce((n, a) => n + a.unread, 0);
const all = many ? `
<button class="mailbox all" data-box="all" aria-current="${box === "all"}" title="All mailboxes ( g a )">
<span class="mb-icon" style="--hue-color:var(--muted)">${icon.stack}</span>
<span class="mb-body"><span class="mb-name">All mailboxes</span></span>
${total ? `<span class="badge">${total}</span>` : ""}
${total ? `<span class="badge${bumped("all", total)}">${total}</span>` : ""}
</button>` : "";

el.rail.innerHTML = `
Expand Down Expand Up @@ -276,6 +312,7 @@ const switchBox = async (next) => {
box = next;
currentId = null;
picked.clear();
motion.known = null;
renderRail();
try {
await refresh();
Expand Down Expand Up @@ -363,7 +400,21 @@ const renderList = () => {
return;
}

el.list.innerHTML = visible.map((m) => {
const now = performance.now();
const sinceEntrance = now - motion.entrance;
const animation = (m, i) => {
const arrived = motion.arrivals.get(m.id);
if (arrived !== undefined) {
if (now - arrived < 2400) return ["arrive", now - arrived];
motion.arrivals.delete(m.id);
}
// Only the first screenful staggers in; further down nobody sees it, and waiting on it would feel slow.
if (i < 14 && sinceEntrance < i * 28 + 400) return ["enter", sinceEntrance - i * 28];
return null;
};
const tickedNow = motion.ticked && now - motion.ticked.at < 300 ? motion.ticked.id : null;

el.list.innerHTML = visible.map((m, i) => {
const acc = accounts.find((a) => a.id === m.account);
const chips = [
m.code ? `<span class="tag code" title="Code — press c to copy">${esc(m.code)}</span>` : "",
Expand All @@ -374,8 +425,11 @@ const renderList = () => {
const preview = snippet(m);

const isPicked = picked.has(m.id);
const anim = animation(m, i);
const cls = [m.seen ? "" : "unread", isPicked ? "picked" : "",
isPicked && m.id === tickedNow ? "ticked" : "", anim?.[0] ?? ""].filter(Boolean).join(" ");
return `<li role="option" tabindex="0" data-id="${esc(m.id)}" data-account="${esc(m.account ?? "")}"
aria-selected="${m.id === currentId}" class="${[m.seen ? "" : "unread", isPicked ? "picked" : ""].join(" ").trim()}">
aria-selected="${m.id === currentId}" class="${cls}"${anim ? ` style="--d:${Math.round(-anim[1])}ms"` : ""}>
<span class="pick" data-pick role="checkbox" aria-checked="${isPicked}" aria-label="Select this message" title="Select ( x )">
<span class="avatar" style="--h:${senderHue(m)}" aria-hidden="true">${esc(senderInitial(m))}</span>
<span class="tick" aria-hidden="true">${icon.check}</span>
Expand Down Expand Up @@ -486,7 +540,7 @@ const renderDetail = async (m) => {
<div class="value">
<span class="label">Verification code</span>
<button class="otp${code.length > 10 ? " long" : ""}" data-copy="${esc(code)}" data-what="Code copied" title="Click to copy">
${code.length > 10 ? `<span>${esc(code)}</span>` : [...code].map((ch) => `<span>${esc(ch)}</span>`).join("")}
${code.length > 10 ? `<span>${esc(code)}</span>` : [...code].map((ch, i) => `<span style="--i:${i}">${esc(ch)}</span>`).join("")}
</button>
</div>
<span class="actions"><button class="btn primary" data-copy="${esc(code)}" data-what="Code copied">${icon.copy}Copy <kbd>c</kbd></button></span>
Expand Down Expand Up @@ -632,6 +686,7 @@ const bulk = (action, items) => api("/api/messages", {
const togglePick = (id) => {
if (picked.has(id)) picked.delete(id); else picked.add(id);
anchor = id;
motion.ticked = { id, at: performance.now() };
renderList();
};

Expand Down Expand Up @@ -664,12 +719,16 @@ const prunePicked = () => {

// mail.tm has no bin to restore from, so the undo lives here: the messages leave the list
// at once, and the delete itself is only sent when the undo window closes.
const removeMessages = (list) => {
const removeMessages = async (list) => {
if (!list.length) return;
if (demoMode) return toast("Demo mode — nothing is deleted here");
flushDelete(); // a second delete commits the first, so there is only ever one to undo

for (const m of list) { hiddenIds.add(m.id); picked.delete(m.id); }
// Hidden at once, so a refresh during the animation cannot bring them back; and
// forgotten, so an undo brings them back in with the arrival animation.
const ids = new Set(list.map((m) => m.id));
for (const id of ids) { hiddenIds.add(id); picked.delete(id); motion.known?.delete(id); }
await leave(ids);
messages = messages.filter((m) => !hiddenIds.has(m.id));
if (currentId && hiddenIds.has(currentId)) currentId = null;
renderList();
Expand Down Expand Up @@ -877,8 +936,18 @@ const renderSettings = () => {
</div>`;
};

const openSettings = () => { el.settings.hidden = false; renderSettings(); };
const closeSettings = () => { el.settings.hidden = true; };
const openSettings = () => { el.settings.classList.remove("closing"); el.settings.hidden = false; renderSettings(); };
const closeSettings = () => {
if (el.settings.hidden || el.settings.classList.contains("closing")) return;
const done = () => {
if (!el.settings.classList.contains("closing")) return; // reopened in the meantime
el.settings.hidden = true;
el.settings.classList.remove("closing");
};
if (reducedMotion()) return done();
el.settings.classList.add("closing");
setTimeout(done, 160);
};

// A click on the dimmed backdrop closes Settings — but only when it also started there.
// Pressing inside the card and releasing outside it (say, drag-selecting a name) reports
Expand Down Expand Up @@ -1006,6 +1075,11 @@ const refresh = async () => {
el.addrText.textContent = address || `${accounts.length} inboxes, newest first`;
el.addr.disabled = !address;
messages = data.messages.filter((m) => !hiddenIds.has(m.id));
// First load of a mailbox: the rows stagger in. After that, anything not seen before arrives.
const now = performance.now();
if (!motion.known) motion.entrance = now;
else for (const m of messages) if (!motion.known.has(m.id)) motion.arrivals.set(m.id, now);
motion.known = new Set(messages.map((m) => m.id));
prunePicked();
renderList();
if (!currentId) emptyDetail();
Expand Down Expand Up @@ -1065,7 +1139,7 @@ el.detail.onclick = (e) => {
copy(c.dataset.copy, c.dataset.what || "Copied").then((ok) => {
if (!ok || !otp) return;
otp.classList.add("flash");
setTimeout(() => otp.classList.remove("flash"), 700);
setTimeout(() => otp.classList.remove("flash"), 900);
});
return;
}
Expand Down
64 changes: 63 additions & 1 deletion src/ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ kbd {
transition: box-shadow .15s, color .15s, transform .15s;
}
.otp:hover span { box-shadow: 0 0 0 1.5px var(--accent); color: var(--accent); }
.otp.flash span { transform: translateY(-2px); box-shadow: 0 0 0 2px var(--ok); color: var(--ok); }
.otp.flash span { box-shadow: 0 0 0 2px var(--ok); color: var(--ok); }
.otp.long span { width: auto; padding: 0 14px; letter-spacing: .12em; }

.link-host { display: block; font-size: 15px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Expand Down Expand Up @@ -1244,6 +1244,68 @@ table.headers td:last-child { overflow-wrap: anywhere; }
}
#toast button:hover { background: rgba(255, 255, 255, .12); }

/* --- motion --------------------------------------------------------------- */
/* Rows are rebuilt on every render, so their animations carry --d: how far along they
already were (as a negative delay), and a rebuild resumes them instead of restarting. */
:root {
--ease-out: cubic-bezier(.2, .8, .2, 1);
--spring: cubic-bezier(.34, 1.56, .64, 1);
}

/* Switching mailbox: the rows come in one after the other. */
.list li.enter { animation: rise-in .38s var(--ease-out) var(--d, 0ms) both; }
@keyframes rise-in { from { opacity: 0; transform: translateY(8px); } }

/* New mail: drops in from the top and glows once in the accent. */
.list li.arrive {
animation:
arrive .6s var(--spring) var(--d, 0ms) both,
glow 2.2s ease-out var(--d, 0ms);
}
@keyframes arrive { from { opacity: 0; transform: translateY(-16px) scale(.96); } }
@keyframes glow {
from { background: color-mix(in srgb, var(--accent) 18%, transparent); }
}
.list li.arrive .avatar { animation: pop .5s var(--spring) var(--d, 0ms) both; }
@keyframes pop { from { transform: scale(.4); opacity: 0; } }

/* A tick lands with a little bounce. */
.list li.ticked .tick svg { animation: pop .3s var(--spring); }

/* An unread count that went up. */
.badge.bump { display: inline-block; animation: bump .45s var(--spring); }
@keyframes bump { 40% { transform: scale(1.45); color: var(--accent); } }

/* Opening a message: its parts settle in from top to bottom. */
.letter > * { animation: rise-in .32s var(--ease-out) both; }
.letter > :nth-child(2) { animation-delay: 30ms; }
.letter > :nth-child(3) { animation-delay: 60ms; }
.letter > :nth-child(4) { animation-delay: 90ms; }
.letter > :nth-child(n+5) { animation-delay: 120ms; }

/* The code, one digit at a time — and a wave across it when copied. Both stay in the
list on .flash, so removing .flash does not replay the entrance. */
.otp span { animation: digit .45s var(--spring) calc(var(--i, 0) * 45ms + 140ms) both; }
.otp.flash span {
animation:
digit .45s var(--spring) calc(var(--i, 0) * 45ms + 140ms) both,
hop .5s var(--spring) calc(var(--i, 0) * 35ms);
}
@keyframes digit { from { opacity: 0; transform: translateY(10px) scale(.8); } }
@keyframes hop { 35% { transform: translateY(-7px); } }

/* Waiting for mail: the envelope floats while the ripples go out. */
.pulse:not(.still) svg { animation: float 3s ease-in-out infinite; }
@keyframes float { 50% { transform: translateY(-3px) rotate(-4deg); } }

/* Theme switch: the new icon spins in. */
#theme svg { animation: spin-in .45s var(--spring); }
@keyframes spin-in { from { transform: rotate(-90deg) scale(.5); opacity: 0; } }

/* Settings leave the way they came. */
.settings.closing { animation: fade .16s ease-in reverse both; }
.settings.closing .settings-inner { animation: rise .16s ease-in reverse both; }

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
Expand Down
Loading