Group name (admin-set) as app/PWA name + kebab settings menu
- Admin sets a "Group name"; it becomes the PWA/Home-Screen name, the <title>, the apple-mobile-web-app-title, and the chat header title. Injected (HTML- escaped) into index.html; manifest generated dynamically. GET /api/group (public) + POST /api/admin/group (admin). - Chat header redesigned: group name as the title, with a kebab (⋮) settings menu holding the notifications on/off toggle, change screen name, and leave. - push.js: isSubscribed() + unsubscribePush() for the toggle. - Routes extracted into Server.handler() (also enables httptest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9 files changed,
+271
−72
db.go+18 −0main.go+60 −10web/pages/admin.html+9 −0web/pages/index.html+15 −13web/static/admin.js+18 −0web/static/chat.js+70 −33web/static/manifest.webmanifest+0 −14web/static/push.js+29 −0web/static/style.css+52 −2
diff --git a/db.go b/db.go| index 9560d7c..ea9c1a3 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -7,11 +7,29 @@ import ( |
| "database/sql" |
| "encoding/base64" |
| "encoding/hex" |
| + "strings" |
| "time" |
| _ "modernc.org/sqlite" |
| ) |
| +// defaultGroupName is used until an admin sets one. |
| +const defaultGroupName = "lchat" |
| + |
| +// getGroupName returns the chat's display name (used as the PWA/app name, page |
| +// title, and chat header), falling back to a default until an admin sets one. |
| +func (db *DB) getGroupName() string { |
| + v, ok, _ := db.getConfig("group_name") |
| + if !ok || strings.TrimSpace(v) == "" { |
| + return defaultGroupName |
| + } |
| + return v |
| +} |
| + |
| +func (db *DB) setGroupName(name string) error { |
| + return db.setConfig("group_name", name) |
| +} |
| + |
| // DB wraps the SQLite connection. Note: the server never stores message |
| // plaintext or any encryption key. Messages live here only as opaque |
| // ciphertext blobs produced by the browser. |
diff --git a/main.go b/main.go| index c3ae0b6..49d6722 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -4,6 +4,7 @@ import ( |
| "embed" |
| "encoding/json" |
| "flag" |
| + "html" |
| "io/fs" |
| "log" |
| "net/http" |
| @@ -66,6 +67,15 @@ func main() { |
| s.vapidPub, s.vapidPriv = pub, priv |
| } |
| + log.Printf("lchat listening on %s (db: %s)", *addr, *dbPath) |
| + if err := http.ListenAndServe(*addr, s.handler()); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
| + |
| +// handler wires up all routes. It's a method so tests can exercise the full |
| +// router via httptest without starting a real listener. |
| +func (s *Server) handler() http.Handler { |
| staticFS, err := fs.Sub(webFS, "web/static") |
| if err != nil { |
| log.Fatalf("static fs: %v", err) |
| @@ -86,6 +96,10 @@ func main() { |
| mux.HandleFunc("GET /api/invite/{token}", s.getInvite) |
| mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite) |
| + // Group name (public read so the page/manifest can show it; admin write) |
| + mux.HandleFunc("GET /api/group", s.getGroup) |
| + mux.HandleFunc("POST /api/admin/group", s.setGroup) |
| + |
| // Admin API (requires admin token) |
| mux.HandleFunc("POST /api/admin/invites", s.createInvite) |
| mux.HandleFunc("GET /api/admin/invites", s.listInvites) |
| @@ -100,10 +114,7 @@ func main() { |
| mux.HandleFunc("GET /sw.js", s.serveSW) |
| mux.HandleFunc("GET /manifest.webmanifest", s.serveManifest) |
| - log.Printf("lchat listening on %s (db: %s)", *addr, *dbPath) |
| - if err := http.ListenAndServe(*addr, mux); err != nil { |
| - log.Fatal(err) |
| - } |
| + return mux |
| } |
| // ---- pages -------------------------------------------------------------- |
| @@ -128,7 +139,9 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| return |
| } |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - w.Write(s.pages[name]) |
| + // Inject the configurable group name (HTML-escaped — it's admin-set). |
| + page := strings.ReplaceAll(string(s.pages[name]), "__GROUP_NAME__", html.EscapeString(s.db.getGroupName())) |
| + w.Write([]byte(page)) |
| } |
| } |
| @@ -304,14 +317,51 @@ func (s *Server) serveSW(w http.ResponseWriter, r *http.Request) { |
| w.Write(b) |
| } |
| +// serveManifest generates the PWA manifest with the configured group name, so the |
| +// Home Screen / install name matches the chat. |
| func (s *Server) serveManifest(w http.ResponseWriter, r *http.Request) { |
| - b, err := webFS.ReadFile("web/static/manifest.webmanifest") |
| - if err != nil { |
| - http.NotFound(w, r) |
| + name := s.db.getGroupName() |
| + w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8") |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "name": name, |
| + "short_name": name, |
| + "description": "A private, end-to-end encrypted group chat", |
| + "start_url": "/", |
| + "scope": "/", |
| + "display": "standalone", |
| + "background_color": "#f6f7fb", |
| + "theme_color": "#5b6cff", |
| + "icons": []map[string]any{ |
| + {"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, |
| + {"src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}, |
| + }, |
| + }) |
| +} |
| + |
| +// ---- group name --------------------------------------------------------- |
| + |
| +func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{"name": s.db.getGroupName()}) |
| +} |
| + |
| +func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| return |
| } |
| - w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8") |
| - w.Write(b) |
| + var body struct { |
| + Name string `json:"name"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&body) |
| + name := strings.TrimSpace(body.Name) |
| + if name == "" || len(name) > 60 { |
| + httpError(w, http.StatusBadRequest, "name must be 1–60 characters") |
| + return |
| + } |
| + if err := s.db.setGroupName(name); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) |
| } |
| // ---- helpers ------------------------------------------------------------ |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index f1500a4..ad71759 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -13,6 +13,15 @@ |
| </div> |
| <div id="console" class="hidden"> |
| + <section> |
| + <h2>Group name</h2> |
| + <p class="sub" style="margin:0 0 8px">Shown as the app name when added to a Home Screen, and as the chat's title.</p> |
| + <form id="nameForm" autocomplete="off"> |
| + <input id="groupName" placeholder="e.g. Family Chat" maxlength="60" /> |
| + <div class="row"><button type="submit">Save name</button></div> |
| + </form> |
| + </section> |
| + |
| <section> |
| <h2>The chat's encryption key</h2> |
| <p class="sub" style="margin:0 0 8px"> |
diff --git a/web/pages/index.html b/web/pages/index.html| index faa6c69..8e33e14 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -1,13 +1,13 @@ |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| <meta name="theme-color" content="#5b6cff" /> |
| -<title>lchat</title> |
| +<title>__GROUP_NAME__</title> |
| <link rel="stylesheet" href="/static/style.css" /> |
| <link rel="manifest" href="/manifest.webmanifest" /> |
| <link rel="icon" href="/static/icon-192.png" /> |
| <link rel="apple-touch-icon" href="/static/icon-180.png" /> |
| <meta name="apple-mobile-web-app-capable" content="yes" /> |
| -<meta name="apple-mobile-web-app-title" content="lchat" /> |
| +<meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" /> |
| <div id="app"></div> |
| <div id="toast" class="toast"></div> |
| @@ -15,13 +15,15 @@ |
| <template id="chat-tpl"> |
| <div class="chat"> |
| <header> |
| - <div> |
| - <div class="title">Chat</div> |
| - <div class="who"></div> |
| - </div> |
| - <div class="header-actions"> |
| - <button class="notif hidden" id="notif"></button> |
| - <button class="secondary" id="leave">Leave</button> |
| + <div class="title">__GROUP_NAME__</div> |
| + <div class="menu-wrap"> |
| + <button class="icon-btn" id="menu-btn" aria-label="Settings" aria-haspopup="true">⋮</button> |
| + <div class="menu hidden" id="menu" role="menu"> |
| + <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 danger" id="menu-leave" role="menuitem">Leave chat</button> |
| + </div> |
| </div> |
| </header> |
| <div class="banner hidden" id="banner"></div> |
| @@ -52,12 +54,12 @@ |
| <div class="center"> |
| <div class="card onboard"> |
| <div class="onboard-icon">📲</div> |
| - <h1>Add lchat to your Home Screen</h1> |
| - <p>On iPhone & iPad, add lchat to your Home Screen so it opens like an app — and can send you notifications.</p> |
| + <h1>Add __GROUP_NAME__ to your Home Screen</h1> |
| + <p>On iPhone & iPad, add this to your Home Screen so it opens like an app — and can send you notifications.</p> |
| <ol class="steps"> |
| <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari's toolbar.</li> |
| <li>Scroll down and choose <strong>Add to Home Screen</strong>.</li> |
| - <li>Open <strong>lchat</strong> from your Home Screen and join there.</li> |
| + <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen and join there.</li> |
| </ol> |
| <div class="row"><button id="a2hs-continue" class="secondary">Continue in browser</button></div> |
| </div> |
| @@ -69,7 +71,7 @@ |
| <div class="card onboard"> |
| <div class="onboard-icon">🔔</div> |
| <h1>Turn on notifications</h1> |
| - <p>Get an alert when there's a new message — even when lchat is closed.</p> |
| + <p>Get an alert when there's a new message — even when the chat is closed.</p> |
| <div class="row"><button id="push-enable">Enable notifications</button></div> |
| <div class="row"><button id="push-skip" class="secondary">Not now</button></div> |
| </div> |
diff --git a/web/static/admin.js b/web/static/admin.js| index a892ad5..734cb86 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -30,10 +30,28 @@ async function init() { |
| await ensureGroupKey(); |
| wireKeyControls(); |
| + wireGroupName(); |
| wireCreate(); |
| renderInvites((await res.json()).invites || []); |
| } |
| +// ---- group name --------------------------------------------------------- |
| + |
| +async function wireGroupName() { |
| + const input = document.getElementById("groupName"); |
| + try { |
| + const { name } = await (await fetch("/api/group")).json(); |
| + if (name) input.value = name; |
| + } catch {} |
| + document.getElementById("nameForm").onsubmit = async (e) => { |
| + e.preventDefault(); |
| + const name = input.value.trim(); |
| + if (!name) return; |
| + const res = await api("POST", "/api/admin/group", { name }); |
| + toast(res.ok ? "Group name saved" : "Couldn't save name"); |
| + }; |
| +} |
| + |
| // ---- group key ---------------------------------------------------------- |
| async function ensureGroupKey() { |
diff --git a/web/static/chat.js b/web/static/chat.js| index 3131a82..8d598c0 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,11 +1,14 @@ |
| import { importKey, encrypt, decrypt } from "/static/crypto.js"; |
| import { |
| needsHomeScreen, |
| + pushSupported, |
| pushAvailable, |
| pushEnabled, |
| pushDenied, |
| + isSubscribed, |
| registerSW, |
| subscribePush, |
| + unsubscribePush, |
| } from "/static/push.js"; |
| const SESSION_KEY = "lchat_session"; |
| @@ -80,31 +83,74 @@ function onboardPush(session) { |
| }); |
| } |
| -// Small header control so anyone who skipped onboarding can still toggle push. |
| -function updateNotifButton(session) { |
| - const btn = document.getElementById("notif"); |
| - if (!btn) return; |
| - if (pushEnabled()) { |
| - btn.classList.remove("hidden"); |
| - btn.textContent = "🔔 On"; |
| - btn.onclick = null; |
| - } else if (pushDenied()) { |
| - btn.classList.remove("hidden"); |
| - btn.textContent = "🔕 Blocked"; |
| - btn.title = "Notifications are blocked — turn them on in your browser/OS settings"; |
| - btn.onclick = null; |
| - } else if (pushAvailable()) { |
| - btn.classList.remove("hidden"); |
| - btn.textContent = "🔔 Notify me"; |
| - btn.onclick = async () => { |
| - btn.disabled = true; |
| - btn.textContent = "…"; |
| +// Kebab settings menu: notifications toggle, change screen name, leave. |
| +function setupMenu(session) { |
| + const menuBtn = document.getElementById("menu-btn"); |
| + const menu = document.getElementById("menu"); |
| + const who = document.getElementById("menu-who"); |
| + who.textContent = `Signed in as ${session.name}`; |
| + |
| + const close = () => menu.classList.add("hidden"); |
| + menuBtn.onclick = (e) => { |
| + e.stopPropagation(); |
| + menu.classList.toggle("hidden"); |
| + if (!menu.classList.contains("hidden")) refreshNotifItem(); |
| + }; |
| + document.addEventListener("click", (e) => { |
| + if (e.target !== menuBtn && !menu.contains(e.target)) close(); |
| + }); |
| + |
| + refreshNotifItem(); |
| + document.getElementById("menu-notif").onclick = async (e) => { |
| + e.stopPropagation(); |
| + const item = document.getElementById("menu-notif"); |
| + if (item.classList.contains("disabled")) return; |
| + item.textContent = "…"; |
| + if ((await isSubscribed()) && pushEnabled()) { |
| + await unsubscribePush(); |
| + } else if (pushAvailable()) { |
| await subscribePush(session); |
| - btn.disabled = false; |
| - updateNotifButton(session); |
| - }; |
| + } |
| + refreshNotifItem(); |
| + }; |
| + |
| + document.getElementById("menu-rename").onclick = (e) => { |
| + e.stopPropagation(); |
| + close(); |
| + const next = (prompt("Your screen name:", session.name) || "").trim(); |
| + if (next && next !== session.name) { |
| + session.name = next.slice(0, 32); |
| + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| + who.textContent = `Signed in as ${session.name}`; |
| + } |
| + }; |
| + |
| + document.getElementById("menu-leave").onclick = (e) => { |
| + e.stopPropagation(); |
| + if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) { |
| + localStorage.removeItem(SESSION_KEY); |
| + location.reload(); |
| + } |
| + }; |
| +} |
| + |
| +async function refreshNotifItem() { |
| + const item = document.getElementById("menu-notif"); |
| + if (!item) return; |
| + item.classList.remove("disabled"); |
| + 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"); |
| + } else if ((await isSubscribed()) && pushEnabled()) { |
| + item.textContent = "🔔 Notifications: On"; |
| } else { |
| - btn.classList.add("hidden"); |
| + item.textContent = "🔔 Turn on notifications"; |
| } |
| } |
| @@ -189,17 +235,8 @@ async function start(session) { |
| const key = await importKey(session.key); |
| const feed = document.getElementById("feed"); |
| - const who = document.querySelector(".who"); |
| - who.textContent = `You are ${session.name}`; |
| - updateNotifButton(session); |
| - |
| - document.getElementById("leave").onclick = () => { |
| - if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) { |
| - localStorage.removeItem(SESSION_KEY); |
| - location.reload(); |
| - } |
| - }; |
| + setupMenu(session); |
| const seen = new Set(); |
| let lastId = 0; |
diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest| deleted file mode 100644 |
| index d602680..0000000 |
| --- a/web/static/manifest.webmanifest |
| +++ /dev/null |
| @@ -1,14 +0,0 @@ |
| -{ |
| - "name": "lchat", |
| - "short_name": "lchat", |
| - "description": "A private, end-to-end encrypted group chat", |
| - "start_url": "/", |
| - "scope": "/", |
| - "display": "standalone", |
| - "background_color": "#f6f7fb", |
| - "theme_color": "#5b6cff", |
| - "icons": [ |
| - { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, |
| - { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } |
| - ] |
| -} |
diff --git a/web/static/push.js b/web/static/push.js| index f01109b..1bdd698 100644 |
| --- a/web/static/push.js |
| +++ b/web/static/push.js |
| @@ -106,6 +106,35 @@ export async function subscribePush(session) { |
| } |
| } |
| +export async function isSubscribed() { |
| + if (!("serviceWorker" in navigator)) return false; |
| + try { |
| + const reg = swReg || (await navigator.serviceWorker.getRegistration()); |
| + if (!reg) return false; |
| + return !!(await reg.pushManager.getSubscription()); |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| +// Turn push off: remove the subscription from the server, then the browser. |
| +export async function unsubscribePush() { |
| + try { |
| + const reg = swReg || (await navigator.serviceWorker.getRegistration()); |
| + if (!reg) return; |
| + const sub = await reg.pushManager.getSubscription(); |
| + if (!sub) return; |
| + await fetch("/api/push/unsubscribe", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ endpoint: sub.endpoint }), |
| + }); |
| + await sub.unsubscribe(); |
| + } catch (e) { |
| + console.warn("unsubscribe failed", e); |
| + } |
| +} |
| + |
| function sendSubscription(token, sub) { |
| return fetch("/api/push/subscribe", { |
| method: "POST", |
diff --git a/web/static/style.css b/web/static/style.css| index ee8ae8e..41be5d2 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -90,8 +90,58 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| justify-content: space-between; |
| background: var(--panel); |
| } |
| -.chat header .title { font-weight: 700; } |
| -.chat header .who { color: var(--muted); font-size: 13px; } |
| +.chat header .title { |
| + font-weight: 700; |
| + font-size: 17px; |
| + overflow: hidden; |
| + text-overflow: ellipsis; |
| + white-space: nowrap; |
| + padding-right: 10px; |
| +} |
| + |
| +/* kebab settings menu */ |
| +.icon-btn { |
| + background: transparent; |
| + color: var(--ink); |
| + font-size: 22px; |
| + line-height: 1; |
| + padding: 4px 10px; |
| + border-radius: 10px; |
| +} |
| +.icon-btn:hover { background: var(--bubble-them); } |
| +.menu-wrap { position: relative; flex: none; } |
| +.menu { |
| + position: absolute; |
| + top: calc(100% + 6px); |
| + right: 0; |
| + background: var(--panel); |
| + border: 1px solid var(--border); |
| + border-radius: 14px; |
| + box-shadow: 0 12px 40px rgba(20, 30, 80, 0.14); |
| + min-width: 230px; |
| + padding: 6px; |
| + z-index: 20; |
| +} |
| +.menu-who { |
| + font-size: 12px; |
| + color: var(--muted); |
| + padding: 8px 12px 6px; |
| +} |
| +.menu-item { |
| + display: block; |
| + width: 100%; |
| + text-align: left; |
| + background: transparent; |
| + color: var(--ink); |
| + border-radius: 10px; |
| + padding: 10px 12px; |
| + font-weight: 500; |
| + white-space: normal; |
| +} |
| +.menu-item:hover { background: var(--bubble-them); } |
| +.menu-item.danger { color: #c2410c; } |
| +.menu-item.disabled, |
| +.menu-item.disabled:hover { color: var(--muted); cursor: default; background: transparent; } |
| #feed { |
| flex: 1; |