eleven / messenger Public

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>
Paul Campbell 51a67021b793d34762501e59f393cfd2861bf8b3 parent afce628
7 files changed, +108 −14
  • db.go +11 −0
  • main.go +5 −1
  • web/pages/index.html +8 −2
  • web/static/chat.js +64 −1
  • web/static/join.js +4 −2
  • web/static/push.js +14 −8
  • web/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);