iOS push fixes + paste-to-join + label pre-fill + button wrap
- push.js: check the iOS/standalone case BEFORE the PushManager guard, so a plain iOS Safari tab shows the "Add to Home Screen" hint instead of nothing. - Paste-to-join: the no-session screen now accepts a pasted invite link (pasted text keeps the #key fragment), which is the only way into an installed iOS PWA since it has separate storage from Safari. - Pre-fill the screen name from the invite's label (join page + paste screen); /api/invite now returns the label. - CSS: white-space:nowrap so button labels (e.g. "Create invite link") stay on one line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 files changed,
+108
−14
db.go+11 −0main.go+5 −1web/pages/index.html+8 −2web/static/chat.js+64 −1web/static/join.js+4 −2web/static/push.js+14 −8web/static/style.css+2 −0
diff --git a/db.go b/db.go| index 043b270..9560d7c 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -202,6 +202,17 @@ func (db *DB) inviteStatus(token string) (exists, revoked bool, err error) { |
| return true, r != 0, nil |
| } |
| +// inviteLabel returns the admin's label for an invite (used to pre-fill the |
| +// screen name). Empty string if the invite doesn't exist or has no label. |
| +func (db *DB) inviteLabel(token string) (string, error) { |
| + var label string |
| + err := db.sql.QueryRow("SELECT label FROM invites WHERE token = ?", token).Scan(&label) |
| + if err == sql.ErrNoRows { |
| + return "", nil |
| + } |
| + return label, err |
| +} |
| + |
| // claimInvite marks the first use of an invite. Reusable afterwards (so a kid |
| // can reopen the link on another device) unless revoked. |
| func (db *DB) claimInvite(token string) error { |
diff --git a/main.go b/main.go| index ce6fc89..c3ae0b6 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -159,7 +159,11 @@ func (s *Server) getInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"ok": exists && !revoked, "revoked": revoked}) |
| + label := "" |
| + if exists { |
| + label, _ = s.db.inviteLabel(token) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": exists && !revoked, "revoked": revoked, "label": label}) |
| } |
| func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) { |
diff --git a/web/pages/index.html b/web/pages/index.html| index fb43cfe..5f2a351 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -36,8 +36,14 @@ |
| <template id="noinvite-tpl"> |
| <div class="center"> |
| <div class="card"> |
| - <h1>You need an invite link</h1> |
| - <p>Ask whoever runs this chat for your secret link. Opening it lets you pick a screen name and join.</p> |
| + <h1>Join the chat</h1> |
| + <p>Paste your secret invite link and pick a screen name.</p> |
| + <form id="paste-join" autocomplete="off"> |
| + <input id="link" placeholder="Paste your invite link" autocomplete="off" /> |
| + <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" style="margin-top: 10px" /> |
| + <div class="row"><button type="submit">Join the chat</button></div> |
| + </form> |
| + <p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p> |
| </div> |
| </div> |
| </template> |
diff --git a/web/static/chat.js b/web/static/chat.js| index 7100bc4..7119191 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -7,7 +7,7 @@ const app = document.getElementById("app"); |
| const session = loadSession(); |
| if (!session) { |
| - app.appendChild(tpl("noinvite-tpl")); |
| + showNoInvite(); |
| } else { |
| start(session).catch((err) => { |
| console.error(err); |
| @@ -15,6 +15,69 @@ if (!session) { |
| }); |
| } |
| +// 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. |
| +function showNoInvite() { |
| + app.appendChild(tpl("noinvite-tpl")); |
| + const linkInput = document.getElementById("link"); |
| + const nameInput = document.getElementById("name"); |
| + const err = document.getElementById("join-err"); |
| + const showErr = (m) => { |
| + err.textContent = m; |
| + err.classList.remove("hidden"); |
| + }; |
| + |
| + const parse = (text) => { |
| + try { |
| + const u = new URL(text.trim()); |
| + const token = u.pathname.split("/").filter(Boolean).pop(); |
| + const key = new URLSearchParams(u.hash.slice(1)).get("k"); |
| + return token && key ? { token, key } : null; |
| + } catch { |
| + return null; |
| + } |
| + }; |
| + |
| + // Pre-fill the screen name from the invite's label, if the field is empty. |
| + linkInput.addEventListener("change", async () => { |
| + const p = parse(linkInput.value); |
| + if (!p || nameInput.value.trim()) return; |
| + try { |
| + const info = await (await fetch(`/api/invite/${encodeURIComponent(p.token)}`)).json(); |
| + if (info.label) nameInput.value = info.label; |
| + } catch {} |
| + }); |
| + |
| + document.getElementById("paste-join").onsubmit = async (e) => { |
| + e.preventDefault(); |
| + err.classList.add("hidden"); |
| + const p = parse(linkInput.value); |
| + if (!p) return showErr("That doesn't look like a valid invite link."); |
| + const name = nameInput.value.trim(); |
| + if (!name) return showErr("Please enter a screen name."); |
| + try { |
| + await importKey(p.key); |
| + } catch { |
| + return showErr("This link's secret key is malformed."); |
| + } |
| + let info; |
| + try { |
| + info = await (await fetch(`/api/invite/${encodeURIComponent(p.token)}`)).json(); |
| + } catch { |
| + return showErr("Couldn't reach the server. Try again."); |
| + } |
| + if (!info.ok) { |
| + return showErr(info.revoked ? "This invite was turned off." : "This invite link is invalid."); |
| + } |
| + try { |
| + await fetch(`/api/invite/${encodeURIComponent(p.token)}/claim`, { method: "POST" }); |
| + } catch {} |
| + localStorage.setItem(SESSION_KEY, JSON.stringify({ token: p.token, key: p.key, name })); |
| + location.reload(); |
| + }; |
| +} |
| + |
| function loadSession() { |
| try { |
| const s = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); |
diff --git a/web/static/join.js b/web/static/join.js| index f8d4f13..26f1673 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -37,14 +37,16 @@ async function init() { |
| ); |
| } |
| - showForm(); |
| + showForm(info.label || ""); |
| } |
| -function showForm() { |
| +function showForm(label) { |
| card.innerHTML = ""; |
| card.appendChild(document.getElementById("form-tpl").content.cloneNode(true)); |
| const input = card.querySelector("#name"); |
| + if (label) input.value = label; // pre-fill from the invite's label |
| input.focus(); |
| + input.select(); |
| card.querySelector("#join-form").onsubmit = async (e) => { |
| e.preventDefault(); |
| const name = input.value.trim(); |
diff --git a/web/static/push.js b/web/static/push.js| index 35cd395..99110af 100644 |
| --- a/web/static/push.js |
| +++ b/web/static/push.js |
| @@ -30,24 +30,30 @@ const isStandalone = |
| window.navigator.standalone === true; |
| export async function initPush(session, btn) { |
| - if (!("serviceWorker" in navigator) || !("PushManager" in window)) return; |
| + if (!("serviceWorker" in navigator)) return; |
| // The service worker decrypts push payloads with this. |
| - await idbSet("groupKey", session.key); |
| - |
| - let reg; |
| try { |
| - reg = await navigator.serviceWorker.register("/sw.js"); |
| + await idbSet("groupKey", session.key); |
| } catch (e) { |
| - console.warn("service worker registration failed", e); |
| - return; |
| + console.warn("idb set failed", e); |
| } |
| - // iOS only delivers web push to installed (home-screen) PWAs. |
| + // iOS only delivers web push to installed (home-screen) PWAs — and a plain |
| + // Safari tab doesn't even expose PushManager, so check this BEFORE that guard. |
| if (isiOS && !isStandalone) { |
| setStatus(btn, "ios"); |
| return; |
| } |
| + if (!("PushManager" in window) || !("Notification" in window)) return; |
| + |
| + let reg; |
| + try { |
| + reg = await navigator.serviceWorker.register("/sw.js"); |
| + } catch (e) { |
| + console.warn("service worker registration failed", e); |
| + return; |
| + } |
| btn.onclick = async () => { |
| if (Notification.permission === "default") { |
diff --git a/web/static/style.css b/web/static/style.css| index 61b1e98..35af640 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -31,6 +31,8 @@ button { |
| background: var(--accent); |
| color: var(--accent-ink); |
| font-weight: 600; |
| + white-space: nowrap; |
| + line-height: 1.2; |
| } |
| button.secondary { |
| background: var(--bubble-them); |