eleven / messenger Public

web: float the connection status pill + WebSocket keepalive (stop scroll jumps)

- the #banner ('Reconnecting…') was in normal flow between header and feed, so showing/hiding it resized the feed and jumped the scroll. It's now an absolutely-positioned pill over the top of the feed (wrapped in .feed-wrap), so it never reflows the message list.

- add a 25s app-level keepalive ping so idle proxies stop dropping the socket — 'Reconnecting…' now rarely appears at all. The server ignores unknown message types.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 16957ccec1c57ce9830a69155210bc3420d56ab3 parent d6babfd
3 files changed, +47 −2
  • web/pages/index.html +4 −2
  • web/static/chat.js +16 −0
  • web/static/style.css +27 −0
diff --git a/web/pages/index.html b/web/pages/index.html
index 2962bea..5104572 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -27,8 +27,10 @@
</div>
</div>
</header>
- <div class="banner hidden" id="banner"></div>
- <div id="feed"></div>
+ <div class="feed-wrap">
+ <div class="banner hidden" id="banner"></div>
+ <div id="feed"></div>
+ </div>
<div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
<div class="composer-preview hidden" id="composer-preview"></div>
<form class="composer" id="composer" autocomplete="off">
diff --git a/web/static/chat.js b/web/static/chat.js
index 96102d9..1bc3fee 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -724,11 +724,26 @@ async function start(session) {
// 2) Live updates over WebSocket, with reconnect + gap fill.
let ws;
+ let pingTimer = null;
+ const stopPing = () => {
+ if (pingTimer) clearInterval(pingTimer);
+ pingTimer = null;
+ };
+ // App-level keepalive: idle proxies drop WebSockets after ~60s, which caused the
+ // "Reconnecting…" flicker. A small ping every 25s keeps the socket alive. The
+ // server ignores unknown message types.
+ const startPing = () => {
+ stopPing();
+ pingTimer = setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
+ }, 25000);
+ };
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(session.token)}`);
ws.onopen = async () => {
showBanner("", false);
+ startPing();
// Fill anything missed while disconnected.
const missed = await fetchMessages(session.token, lastId);
for (const m of missed) await render(m);
@@ -745,6 +760,7 @@ async function start(session) {
} catch {}
};
ws.onclose = () => {
+ stopPing();
showBanner("Reconnecting…", false);
setTimeout(connect, 1500);
};
diff --git a/web/static/style.css b/web/static/style.css
index 2b52302..aebf1ae 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -144,8 +144,16 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.menu-item.disabled,
.menu-item.disabled:hover { color: var(--muted); cursor: default; background: transparent; }
+.feed-wrap {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
#feed {
flex: 1;
+ min-height: 0;
overflow-y: auto;
padding: 18px;
display: flex;
@@ -454,6 +462,25 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.banner.warn { color: #b4530a; }
+/* connection status as a floating pill so it never reflows the message list */
+#banner {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 6;
+ padding: 5px 14px;
+ border-radius: 999px;
+ background: var(--ink);
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ box-shadow: 0 6px 20px rgba(20, 30, 80, 0.18);
+ pointer-events: none;
+ white-space: nowrap;
+}
+#banner.warn { background: #b4530a; color: #fff; }
+
/* ---- admin ---- */
.admin { max-width: 760px; margin: 0 auto; padding: 24px; }
.admin h1 { margin: 0 0 4px; }