eleven / messenger Public

web: no content jump on refresh — loading cover + restore open thread (#40)

Two changes so a reload lands cleanly instead of flashing an empty shell
and then jumping as messages load and scroll to the bottom:

- A full-screen loading cover (theme-coloured, matches the pre-paint bg)
  shown until the app's first real paint. boot() awaits the initial
  thread's feed load + scroll, then fades the cover out — so the first
  thing you see is the finished layout, already at the bottom.
- Remember the open thread (localStorage) and reopen it on load, so a
  refresh returns you to the same conversation. Desktop falls back to the
  first *visible* chat (skipping closed ones); "back to list" and closing
  a chat clear it. Deep-links still win.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell f83ff5b1b445c482274737a83bc86fe542d4fcc4 parent 6e86837
3 files changed, +76 −7
  • web/pages/index.html +4 −0
  • web/static/chat.js +47 −7
  • web/static/style.css +25 −0
diff --git a/web/pages/index.html b/web/pages/index.html
index b0fef45..7644d5b 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -37,6 +37,10 @@
</script>
<div id="app"></div>
+<!-- Full-screen cover shown until the app's first meaningful paint (theme bg is
+ already set pre-paint above), so a refresh doesn't flash an empty shell and
+ then jump as messages load + scroll. chat.js removes it once ready. -->
+<div id="app-loading" class="app-loading"><div class="spinner" aria-label="Loading"></div></div>
<div id="toast" class="toast"></div>
<!-- The multi-thread shell: a thread list (master) + an open thread (detail).
diff --git a/web/static/chat.js b/web/static/chat.js
index 39f4b3a..855ffa8 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -46,6 +46,7 @@ import {
} from "/static/push.js";
const SESSION_KEY = "lchat_session";
+const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it
const isCoarse = matchMedia("(pointer: coarse)").matches;
const MAX_UPLOAD = 25 * 1024 * 1024; // matches maxUploadBytes (main.go)
const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser
@@ -285,7 +286,7 @@ async function boot() {
await ensurePubkey();
await loadRoster();
await Promise.all([setupInstanceKey(), loadThreads()]);
- renderApp();
+ const opened = renderApp(); // paints the shell; opens the initial thread (async)
connectWS();
registerSW(session); // best-effort; subscribes if notifications already on
startHeartbeat();
@@ -296,14 +297,28 @@ async function boot() {
if (e.data && e.data.type === "open-thread" && e.data.thread) openThreadById(e.data.thread);
});
}
+ // Reveal only once the initial feed is loaded + scrolled — so the first paint
+ // is the finished layout, not an empty feed that then jumps to the bottom.
+ await opened;
} catch (e) {
console.error(e);
app.innerHTML =
`<div class="center"><div class="card"><h1>Something went wrong</h1>` +
`<p>Couldn't load your chats. Try reloading.</p></div></div>`;
+ } finally {
+ hideLoading();
}
}
+// Fade out and remove the full-screen loading cover (shown until the app's first
+// meaningful paint). Idempotent — safe to call from any entry path.
+function hideLoading() {
+ const el = document.getElementById("app-loading");
+ if (!el) return;
+ el.classList.add("hidden");
+ setTimeout(() => el.remove(), 250); // after the CSS fade
+}
+
// ---- data ---------------------------------------------------------------
function ensurePubkey() {
@@ -326,6 +341,17 @@ function rosterLabel(userId) {
return e ? e.label : "Someone";
}
+// A small "BOT" tag to mark an agent in the roster picker, so it's clear before
+// you add it that it isn't a person. Self-contained styling (no CSS dependency).
+function agentTag() {
+ const t = document.createElement("span");
+ t.textContent = "BOT";
+ t.title = "This is an agent (bot), not a person";
+ t.style.cssText =
+ "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;background:var(--accent,#888);color:#fff;vertical-align:middle;";
+ return t;
+}
+
// The instance key is a single AES key shared by all members, used only for
// all-members content (profile photos). It's wrapped to each member's user
// pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if
@@ -741,16 +767,21 @@ function renderApp() {
});
renderThreadList();
- // A notification tapped while the app was closed opens with #thread=<id> —
- // land straight in it (on any platform). Otherwise desktop shows the first
- // chat; touch stays on the list.
+ // Choose the thread to land in, in priority order: a tapped-notification
+ // deep-link (#thread=), the one we were last in (so a refresh reloads where you
+ // were), else — desktop only — the first visible chat. Returns the open promise
+ // so boot() can drop the loading cover once the feed is in place (no jump).
const deep = initialThreadId();
+ let target = null;
if (deep && threads.some((t) => t.id === deep)) {
history.replaceState(null, "", location.pathname + location.search); // consume the deep link
- openThread(deep);
- } else if (!isCoarse && threads.length) {
- openThread(threads[0].id);
+ target = deep;
+ } else {
+ const saved = localStorage.getItem(ACTIVE_KEY);
+ if (saved && threads.some((t) => t.id === saved) && !hidden[saved]) target = saved;
+ else if (!isCoarse) target = (threads.find((t) => !hidden[t.id]) || {}).id || null;
}
+ return target ? openThread(target) : Promise.resolve();
}
// The thread id from a deep-link fragment (#thread=<id>), or null. Kept in the
@@ -965,6 +996,8 @@ function refreshSenderName(el) {
function showList() {
document.getElementById("tt").classList.remove("show-thread");
+ // Back to the list is the state to restore on refresh (mobile), not the thread.
+ localStorage.removeItem(ACTIVE_KEY);
}
// Header avatar: a group's photo, or the DM/self person's profile photo.
@@ -1080,6 +1113,7 @@ async function openThread(id) {
const myOpen = ++openSeq;
activeId = id;
+ localStorage.setItem(ACTIVE_KEY, id); // remember where we are, to restore on refresh
unread[id] = false;
// Opening a closed chat reopens it (locally + server, synced to your devices).
if (hidden[id]) {
@@ -2583,6 +2617,7 @@ async function closeChat() {
}).catch(() => {});
stashFeed(id); // keep its rendered feed cached for a quick reopen
activeId = null;
+ localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
renderThreadList();
document.getElementById("thread-view").classList.add("hidden");
document.getElementById("thread-empty").classList.remove("hidden");
@@ -2698,6 +2733,7 @@ async function leaveGroup() {
threads = threads.filter((x) => x.id !== activeId);
delete threadKeys[activeId];
activeId = null;
+ localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone
renderThreadList();
document.getElementById("thread-view").classList.add("hidden");
document.getElementById("thread-empty").classList.remove("hidden");
@@ -2805,6 +2841,7 @@ function showNewChat() {
};
const nm = document.createElement("span");
nm.textContent = e.label;
+ if (e.is_agent) nm.appendChild(agentTag());
row.appendChild(cb);
row.appendChild(nm);
list.appendChild(row);
@@ -2812,6 +2849,7 @@ function showNewChat() {
const b = document.createElement("button");
b.className = "list-row";
b.textContent = e.label;
+ if (e.is_agent) b.appendChild(agentTag());
b.onclick = () => {
const dm = existingDM(e.user_id);
if (dm) {
@@ -3157,6 +3195,7 @@ function parseLink(text) {
}
function showNoInvite() {
+ hideLoading();
app.appendChild(tpl("noinvite-tpl"));
const linkInput = document.getElementById("link");
const nameInput = document.getElementById("name");
@@ -3217,6 +3256,7 @@ function showNoInvite() {
}
function showA2HS(done) {
+ hideLoading();
app.innerHTML = "";
app.appendChild(tpl("a2hs-tpl"));
document.getElementById("a2hs-continue").onclick = () => {
diff --git a/web/static/style.css b/web/static/style.css
index 06f1380..99eabfe 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -138,6 +138,31 @@ html, body {
collapses to content height, and the composer floats with blank space below it. */
#app { height: 100%; }
+/* Full-screen loading cover (see index.html) — matches the pre-painted theme bg,
+ fades out once the app's first real paint is ready. */
+.app-loading {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: grid;
+ place-items: center;
+ background: var(--bg);
+ transition: opacity 0.25s ease;
+}
+.app-loading.hidden { opacity: 0; pointer-events: none; }
+.spinner {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 3px solid color-mix(in srgb, var(--ink) 15%, transparent);
+ border-top-color: var(--accent);
+ animation: spin 0.7s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) {
+ .spinner { animation: none; opacity: 0.5; }
+}
+
button {
font: inherit;
cursor: pointer;