eleven / messenger Public

web: restore WS keepalive + reconnect pill; Esc closes the lightbox

The rewrite dropped the WebSocket keepalive and the connection-status UI, so idle
proxies silently dropped the socket (esp. on mobile) with no cue. Restore:
- a 25s keepalive ping while connected (server ignores unknown frames)
- a floating 'Reconnecting…' pill shown only after a sustained outage (>2.5s), and
  cleared on reconnect; ws.onerror closes to trigger the reconnect path
Also: pressing Esc now closes the image lightbox (was click-to-dismiss only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell a2ee70fcec888315cf5805b248ca033f31fd02ad parent a0a6d84
3 files changed, +66 −2
  • web/pages/index.html +1 −0
  • web/static/chat.js +49 −2
  • web/static/style.css +16 −0
diff --git a/web/pages/index.html b/web/pages/index.html
index c79284a..37a408a 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -16,6 +16,7 @@
On mobile the two panes stack and we toggle .show-thread to navigate. -->
<template id="app-tpl">
<div class="tt" id="tt">
+ <div class="conn-status hidden" id="conn-status" role="status" aria-live="polite">Reconnecting…</div>
<aside class="sidebar">
<header class="side-head">
<button class="avatar-btn" id="me-btn" aria-label="Edit profile" title="Edit profile">
diff --git a/web/static/chat.js b/web/static/chat.js
index e1fec51..023253d 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -124,6 +124,8 @@ const threadPreview = {}; // thread id -> last-message snippet
const unread = {}; // thread id -> bool
let activeId = null;
let ws = null;
+let pingTimer = null; // keepalive ping interval while the socket is open
+let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip
route();
@@ -1466,15 +1468,45 @@ function openLightbox(url) {
const img = document.createElement("img");
img.src = url;
back.appendChild(img);
- back.onclick = () => back.remove();
+ const close = () => {
+ back.remove();
+ document.removeEventListener("keydown", onKey);
+ };
+ const onKey = (e) => {
+ if (e.key === "Escape") close();
+ };
+ back.onclick = close;
+ document.addEventListener("keydown", onKey);
document.body.appendChild(back);
}
// ---- websocket ----------------------------------------------------------
+// Show (or hide, with "") the floating connection-status pill.
+function showConnStatus(text) {
+ const el = document.getElementById("conn-status");
+ if (!el) return;
+ el.textContent = text || "Reconnecting…";
+ el.classList.toggle("hidden", !text);
+}
+
function connectWS() {
const proto = location.protocol === "https:" ? "wss://" : "ws://";
ws = new WebSocket(`${proto}${location.host}/ws?token=${encodeURIComponent(session.token)}`);
+ ws.onopen = () => {
+ // (Re)connected: hide the pill and (re)start the keepalive.
+ if (reconnectStatusTimer) {
+ clearTimeout(reconnectStatusTimer);
+ reconnectStatusTimer = null;
+ }
+ showConnStatus("");
+ if (pingTimer) clearInterval(pingTimer);
+ // Idle proxies drop WebSockets after ~60s; a tiny ping every 25s keeps it open.
+ // The server ignores unknown frame types (see hub.go).
+ pingTimer = setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
+ }, 25000);
+ };
ws.onmessage = async (ev) => {
let msg;
try {
@@ -1574,7 +1606,22 @@ function connectWS() {
}
renderThreadList();
};
- ws.onclose = () => setTimeout(connectWS, 2000);
+ ws.onclose = () => {
+ if (pingTimer) {
+ clearInterval(pingTimer);
+ pingTimer = null;
+ }
+ // Only flash "Reconnecting…" if the outage persists past a brief blip.
+ if (!reconnectStatusTimer) {
+ reconnectStatusTimer = setTimeout(() => showConnStatus("Reconnecting…"), 2500);
+ }
+ setTimeout(connectWS, 2000);
+ };
+ ws.onerror = () => {
+ try {
+ ws.close();
+ } catch {}
+ };
}
function bumpThread(id) {
diff --git a/web/static/style.css b/web/static/style.css
index b25b341..3efdfca 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -858,6 +858,22 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* ---- multi-thread layout (docs/threads-and-dms.md) ---- */
.tt { display: flex; height: 100%; width: 100%; background: var(--panel); }
+/* floating connection-status pill (only shown during a sustained outage) */
+.conn-status {
+ position: fixed;
+ top: calc(10px + env(safe-area-inset-top, 0px));
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 90;
+ background: var(--ink);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 6px 14px;
+ border-radius: 999px;
+ box-shadow: 0 6px 20px rgba(20, 30, 80, 0.25);
+}
+.conn-status.hidden { display: none; }
.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--border); min-height: 0; }
/* Both headers share a height so their bottom borders line up across the panes. */
.side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; border-bottom: 1px solid var(--border); }