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>
8 files changed,
+168
−29
main.go+8 −0push.go+24 −7web/pages/admin.html+6 −0web/pages/index.html+2 −1web/static/admin.js+10 −0web/static/chat.js+77 −8web/static/style.css+19 −1web/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, { |