eleven / messenger Public

Push debugging + UX fixes

- push.go: refactor sender into sendToAll with per-send logging (sub count +
  status codes) to diagnose delivery; add sendTestPush.
- POST /api/admin/test-push + an admin "Send test notification" button → sends a
  generic push to all subscribers (bypasses the message flow). SW shows it even
  when focused, so delivery is testable solo.
- Menu fixes: "Add to Home Screen" and "Notifications blocked" items are now
  actionable (open instructions modals) instead of dead disabled rows; the blocked
  modal explains how to re-allow in browser settings. Added a "Refresh" item.
- Composer is now a textarea: Enter sends, Shift+Enter newline, auto-grows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell c55881cbf1fe34877e5a0e43749872f38633d982 parent 90833bb
8 files changed, +168 −29
  • main.go +8 −0
  • push.go +24 −7
  • web/pages/admin.html +6 −0
  • web/pages/index.html +2 −1
  • web/static/admin.js +10 −0
  • web/static/chat.js +77 −8
  • web/static/style.css +19 −1
  • web/static/sw.js +22 −12
diff --git a/main.go b/main.go
index 49d6722..bdb729f 100644
--- a/main.go
+++ b/main.go
@@ -109,6 +109,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/push/vapid", s.getVAPID)
mux.HandleFunc("POST /api/push/subscribe", s.subscribePush)
mux.HandleFunc("POST /api/push/unsubscribe", s.unsubscribePush)
+ mux.HandleFunc("POST /api/admin/test-push", s.testPush)
// PWA: the service worker must be served from the root scope; manifest for install
mux.HandleFunc("GET /sw.js", s.serveSW)
@@ -305,6 +306,13 @@ func (s *Server) unsubscribePush(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+func (s *Server) testPush(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": s.sendTestPush()})
+}
+
// serveSW serves the service worker from the root path so its scope covers the app.
func (s *Server) serveSW(w http.ResponseWriter, r *http.Request) {
b, err := webFS.ReadFile("web/static/sw.js")
diff --git a/push.go b/push.go
index 173bf7a..de6a5a9 100644
--- a/push.go
+++ b/push.go
@@ -82,15 +82,27 @@ func (db *DB) ensureVAPID() (pub, priv string, err error) {
// the service worker decrypts it locally with the group key. Expired subscriptions
// (404/410) are pruned. Best-effort; runs in its own goroutine.
func (s *Server) pushNewMessage(blob string) {
+ payload, _ := json.Marshal(map[string]string{"blob": blob})
+ s.sendToAll(payload, "msg")
+}
+
+// sendTestPush delivers a generic "it works" notification to every subscriber,
+// bypassing the message flow. Returns the number of successful sends.
+func (s *Server) sendTestPush() int {
+ payload, _ := json.Marshal(map[string]bool{"test": true})
+ return s.sendToAll(payload, "test")
+}
+
+// sendToAll delivers a payload to every subscription, pruning dead ones (404/410).
+// Returns the count of successful sends and logs per-send results for debugging.
+func (s *Server) sendToAll(payload []byte, kind string) int {
subs, err := s.db.listSubscriptions()
if err != nil {
log.Println("push: list subs:", err)
- return
- }
- if len(subs) == 0 {
- return
+ return 0
}
- payload, _ := json.Marshal(map[string]string{"blob": blob})
+ log.Printf("push[%s]: %d subscription(s)", kind, len(subs))
+ sent := 0
for _, sub := range subs {
resp, err := webpush.SendNotification(payload, &webpush.Subscription{
Endpoint: sub.Endpoint,
@@ -103,12 +115,17 @@ func (s *Server) pushNewMessage(blob string) {
Urgency: webpush.UrgencyHigh,
})
if err != nil {
- log.Println("push: send:", err)
+ log.Printf("push[%s]: send error: %v", kind, err)
continue
}
+ log.Printf("push[%s]: status %d (%.45s…)", kind, resp.StatusCode, sub.Endpoint)
resp.Body.Close()
- if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
+ switch {
+ case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
_ = s.db.deleteSubscription(sub.Endpoint)
+ case resp.StatusCode >= 200 && resp.StatusCode < 300:
+ sent++
}
}
+ return sent
}
diff --git a/web/pages/admin.html b/web/pages/admin.html
index ad71759..aeffa54 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -47,6 +47,12 @@
</form>
</section>
+ <section>
+ <h2>Notifications</h2>
+ <p class="sub" style="margin:0 0 8px">Send a test notification to everyone who has them enabled — a quick way to check delivery.</p>
+ <div class="row"><button class="secondary" id="testPush">Send test notification</button></div>
+ </section>
+
<section>
<h2>Invites</h2>
<div id="invites"><p class="sub">Loading…</p></div>
diff --git a/web/pages/index.html b/web/pages/index.html
index 8e33e14..2e01a24 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -22,6 +22,7 @@
<div class="menu-who" id="menu-who"></div>
<button class="menu-item" id="menu-notif" role="menuitem"></button>
<button class="menu-item" id="menu-rename" role="menuitem">Change screen name</button>
+ <button class="menu-item" id="menu-refresh" role="menuitem">Refresh</button>
<button class="menu-item danger" id="menu-leave" role="menuitem">Leave chat</button>
</div>
</div>
@@ -29,7 +30,7 @@
<div class="banner hidden" id="banner"></div>
<div id="feed"></div>
<form class="composer" id="composer" autocomplete="off">
- <input id="text" placeholder="Message" autocomplete="off" />
+ <textarea id="text" placeholder="Message" rows="1" autocomplete="off"></textarea>
<button type="submit">Send</button>
</form>
</div>
diff --git a/web/static/admin.js b/web/static/admin.js
index 734cb86..9b66314 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -31,10 +31,20 @@ async function init() {
await ensureGroupKey();
wireKeyControls();
wireGroupName();
+ wireTestPush();
wireCreate();
renderInvites((await res.json()).invites || []);
}
+function wireTestPush() {
+ document.getElementById("testPush").onclick = async () => {
+ const res = await api("POST", "/api/admin/test-push");
+ if (!res.ok) return toast("Couldn't send test");
+ const { sent } = await res.json();
+ toast(`Test sent to ${sent} device(s)`);
+ };
+}
+
// ---- group name ---------------------------------------------------------
async function wireGroupName() {
diff --git a/web/static/chat.js b/web/static/chat.js
index 8d598c0..03271fd 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -62,6 +62,24 @@ function showAddToHomeScreen(done) {
};
}
+// Re-show the Add-to-Home-Screen instructions as a dismissible modal (from the menu),
+// without tearing down the live chat.
+function showA2HSInstructions() {
+ const backdrop = document.createElement("div");
+ backdrop.className = "modal-backdrop";
+ backdrop.appendChild(tpl("a2hs-tpl"));
+ const btn = backdrop.querySelector("#a2hs-continue");
+ if (btn) {
+ btn.textContent = "Got it";
+ btn.classList.remove("secondary");
+ btn.onclick = () => backdrop.remove();
+ }
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ });
+ document.body.appendChild(backdrop);
+}
+
// Full-screen "Enable notifications" onboarding step (after joining).
function onboardPush(session) {
return new Promise((resolve) => {
@@ -103,17 +121,32 @@ function setupMenu(session) {
refreshNotifItem();
document.getElementById("menu-notif").onclick = async (e) => {
e.stopPropagation();
+ if (needsHomeScreen()) {
+ close();
+ showA2HSInstructions();
+ return;
+ }
+ if (pushDenied()) {
+ close();
+ showBlockedInstructions();
+ return;
+ }
+ if (!pushAvailable()) return;
const item = document.getElementById("menu-notif");
- if (item.classList.contains("disabled")) return;
item.textContent = "…";
if ((await isSubscribed()) && pushEnabled()) {
await unsubscribePush();
- } else if (pushAvailable()) {
+ } else {
await subscribePush(session);
}
refreshNotifItem();
};
+ document.getElementById("menu-refresh").onclick = (e) => {
+ e.stopPropagation();
+ location.reload();
+ };
+
document.getElementById("menu-rename").onclick = (e) => {
e.stopPropagation();
close();
@@ -137,16 +170,13 @@ function setupMenu(session) {
async function refreshNotifItem() {
const item = document.getElementById("menu-notif");
if (!item) return;
- item.classList.remove("disabled");
+ item.style.display = "";
if (needsHomeScreen()) {
item.textContent = "🔔 Add to Home Screen for alerts";
- item.classList.add("disabled");
} else if (!pushSupported()) {
item.style.display = "none";
} else if (pushDenied()) {
- item.textContent = "🔕 Notifications blocked";
- item.title = "Turn them back on in your browser/OS settings";
- item.classList.add("disabled");
+ item.textContent = "🔕 Notifications blocked — how to fix";
} else if ((await isSubscribed()) && pushEnabled()) {
item.textContent = "🔔 Notifications: On";
} else {
@@ -154,6 +184,33 @@ async function refreshNotifItem() {
}
}
+// Generic dismissible modal (used for the "blocked" recovery instructions).
+function openModal(innerHTML) {
+ const backdrop = document.createElement("div");
+ backdrop.className = "modal-backdrop";
+ backdrop.innerHTML =
+ `<div class="center"><div class="card onboard">${innerHTML}` +
+ `<div class="row"><button class="modal-close">Got it</button></div></div></div>`;
+ backdrop.querySelector(".modal-close").onclick = () => backdrop.remove();
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ });
+ document.body.appendChild(backdrop);
+}
+
+function showBlockedInstructions() {
+ openModal(
+ `<div class="onboard-icon">🔕</div>` +
+ `<h1>Notifications are blocked</h1>` +
+ `<p>Your browser is blocking notifications for this site. To turn them on:</p>` +
+ `<ol class="steps">` +
+ `<li>Click the lock / site-info icon in the address bar.</li>` +
+ `<li>Set <strong>Notifications</strong> to <strong>Allow</strong>.</li>` +
+ `<li>Reload, then choose <strong>Turn on notifications</strong>.</li>` +
+ `</ol>`
+ );
+}
+
// No session (e.g. opened directly, or an installed iOS PWA which has its own
// storage). Let the user paste their invite link as text — pasted text keeps the
// #key fragment, which navigating to the link in an installed PWA can't.
@@ -304,14 +361,26 @@ async function start(session) {
}
connect();
- // 3) Sending.
+ // 3) Sending. Enter sends; Shift+Enter inserts a newline (it's a textarea).
const form = document.getElementById("composer");
const input = document.getElementById("text");
+ const autosize = () => {
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 140) + "px";
+ };
+ input.addEventListener("input", autosize);
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ form.requestSubmit();
+ }
+ });
form.onsubmit = async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
input.value = "";
+ autosize();
const blob = await encrypt(key, { name: session.name, text, ts: Date.now() });
ws.send(JSON.stringify({ type: "msg", blob }));
};
diff --git a/web/static/style.css b/web/static/style.css
index 41be5d2..9f92eb6 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -172,8 +172,26 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
padding: 14px;
border-top: 1px solid var(--border);
background: var(--panel);
+ align-items: flex-end;
+}
+.composer textarea {
+ flex: 1;
+ resize: none;
+ max-height: 140px;
+ line-height: 1.4;
+ border-radius: 20px;
+ font-family: inherit;
+ overflow-y: auto;
+}
+.composer button { align-self: flex-end; }
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background: rgba(20, 30, 80, 0.45);
+ overflow: auto;
}
-.composer input { border-radius: 999px; }
.banner {
text-align: center;
diff --git a/web/static/sw.js b/web/static/sw.js
index 2559f06..11d499f 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -48,22 +48,32 @@ self.addEventListener("push", (event) => {
});
async function handlePush(event) {
- // Don't notify if the chat is already open and focused — they'll see it live.
- const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
- if (wins.some((c) => c.visibilityState === "visible" && c.focused)) return;
+ let data = {};
+ try {
+ data = event.data ? event.data.json() : {};
+ } catch {}
let title = "New message";
let body = "Open the chat to read it";
- try {
- const data = event.data ? event.data.json() : {};
- const keyB64 = await idbGet("groupKey");
- if (data.blob && keyB64) {
- const msg = await decryptBlob(data.blob, keyB64);
- title = msg.name || "New message";
- body = msg.text || "";
+
+ if (data.test) {
+ // Test notification: always show, even if the app is focused.
+ title = "✅ Test notification";
+ body = "Push notifications are working!";
+ } else {
+ // Real message: skip if the chat is already open and focused (seen live).
+ const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ if (wins.some((c) => c.visibilityState === "visible" && c.focused)) return;
+ try {
+ const keyB64 = await idbGet("groupKey");
+ if (data.blob && keyB64) {
+ const msg = await decryptBlob(data.blob, keyB64);
+ title = msg.name || "New message";
+ body = msg.text || "";
+ }
+ } catch (e) {
+ // fall back to the generic notification
}
- } catch (e) {
- // Fall back to the generic notification above.
}
await self.registration.showNotification(title, {