eleven / messenger Public

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>
Paul Campbell cff01a13fd48bde88a9910516018f736ae33033b parent fbff9d2
9 files changed, +271 −72
  • db.go +18 −0
  • main.go +60 −10
  • web/pages/admin.html +9 −0
  • web/pages/index.html +15 −13
  • web/static/admin.js +18 −0
  • web/static/chat.js +70 −33
  • web/static/manifest.webmanifest +0 −14
  • web/static/push.js +29 −0
  • web/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 &amp; 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 &amp; 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;