eleven / messenger Public

web+server: group/UX polish + close-chat + remove-from-group (#34)

* web: group auto-titles from members + disable send while reconnecting

Two small UX fixes:

- A group with no explicit name now shows its members instead of the
  generic "Group": "Paul & Ciara", "Paul, Ciara & Luan", collapsing to
  "Paul, Ciara & 24 more" past three. Logic is a pure autoGroupTitle() in
  format.js (unit-tested); displayName() uses it as the fallback, and a
  nameless new group no longer stores a placeholder "Group" name.

- While the socket is down ("Reconnecting…"), the send button greys out
  (disabled) so a message can't be silently dropped, and Enter keeps the
  typed text instead of clearing it. Re-enables the moment the socket is
  back. Tracked with a wsConnected flag set in ws.onopen/onclose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: edit-group name field shows the stored name, not the auto-title

The Edit-group field pre-filled with threadName[id] — now the *generated*
member-name title — so a nameless group looked like its members were its
set name. Show the actual stored name instead (blank when none), with the
auto-title as placeholder, and let a blank save clear the name so the
group reverts to its dynamic member-name title. New-group name field
marked "(optional)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web+server: close (hide) a chat, and remove someone from a group

Close a chat: a per-user "closed" marker (hidden_threads), synced across
your devices. Closed chats drop off the list but come back on new
activity (server clears the flag on insertMessage) and are revealed by
search so you can reopen them; opening reopens. New "Close chat" item in
the thread kebab, now shown for every chat (group-only items gated).

Remove from a group: new POST /threads/{id}/remove (any member may;
groups only; not yourself — use Leave). No key rotation, matching Leave.
The members sheet gets a Remove action per other member.

Also fixes a regression from the feed-cache change (#31): openThread now
early-returns for the already-open thread, so addPeople could no longer
reuse it to refresh the header. Extracted paintThreadHeader() and call it
from add/remove instead.

Server-blind throughout: hidden_threads holds only (user_id, thread_id).
Adds handler tests for hide / reopen-on-activity / remove.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 5034a45a8ba1b67c17b8bf81b3c386142a525f4b parent 73bdd85
8 files changed, +411 −38
  • db.go +35 −2
  • handlers_test.go +93 −0
  • main.go +77 −0
  • test/format.test.mjs +17 −1
  • web/pages/index.html +1 −0
  • web/static/chat.js +162 −35
  • web/static/format.js +19 −0
  • web/static/style.css +7 −0
diff --git a/db.go b/db.go
index bdb9844..0b132eb 100644
--- a/db.go
+++ b/db.go
@@ -153,6 +153,14 @@ CREATE TABLE IF NOT EXISTS dm_pairs (
CREATE TABLE IF NOT EXISTS instance_keys (
user_id TEXT PRIMARY KEY,
wrapped TEXT NOT NULL
+);
+-- per-user "closed" chats: a thread hidden from that user's list (synced across
+-- their devices). Just a marker — no content. Cleared for everyone on new
+-- activity (see insertMessage) so a closed chat returns when it's messaged.
+CREATE TABLE IF NOT EXISTS hidden_threads (
+ user_id TEXT NOT NULL,
+ thread_id TEXT NOT NULL,
+ PRIMARY KEY (user_id, thread_id)
);`)
if err != nil {
return err
@@ -620,6 +628,8 @@ type Thread struct {
LastAt int64 `json:"last_at,omitempty"`
// Avatar is the group photo's blob id (encrypted with the thread key).
Avatar string `json:"avatar,omitempty"`
+ // Hidden is true when the requesting user has closed (hidden) this chat.
+ Hidden bool `json:"hidden,omitempty"`
}
// ThreadMemberKey pairs a member with the thread key wrapped to their pubkey.
@@ -739,7 +749,8 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
SELECT t.id, t.kind, COALESCE(t.name_blob, ''), t.created_at, COALESCE(k.wrapped, ''),
COALESCE((SELECT blob FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), ''),
COALESCE((SELECT created_at FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), 0),
- COALESCE(t.avatar_blob, '')
+ COALESCE(t.avatar_blob, ''),
+ EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id)
FROM threads t
JOIN thread_members m ON m.thread_id = t.id
LEFT JOIN thread_keys k ON k.thread_id = t.id AND k.user_id = m.user_id
@@ -753,7 +764,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
out := []Thread{}
for rows.Next() {
var t Thread
- if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar); err != nil {
+ if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Hidden); err != nil {
return nil, err
}
out = append(out, t)
@@ -787,6 +798,26 @@ func (db *DB) removeThreadMember(threadID, userID string) error {
return tx.Commit()
}
+// setThreadHidden closes (hidden=true) or reopens (hidden=false) a chat for one
+// user — a per-user marker, synced across their devices via getThreads.
+func (db *DB) setThreadHidden(userID, threadID string, hidden bool) error {
+ if hidden {
+ _, err := db.sql.Exec(
+ "INSERT OR IGNORE INTO hidden_threads(user_id, thread_id) VALUES(?, ?)", userID, threadID,
+ )
+ return err
+ }
+ _, err := db.sql.Exec("DELETE FROM hidden_threads WHERE user_id = ? AND thread_id = ?", userID, threadID)
+ return err
+}
+
+// clearThreadHidden reopens a thread for every user who had closed it — called on
+// new activity so a closed chat returns to the list when it's messaged.
+func (db *DB) clearThreadHidden(threadID string) error {
+ _, err := db.sql.Exec("DELETE FROM hidden_threads WHERE thread_id = ?", threadID)
+ return err
+}
+
// setThreadName updates a group's (client-encrypted) name. Server-blind.
func (db *DB) setThreadName(threadID, nameBlob string) error {
_, err := db.sql.Exec("UPDATE threads SET name_blob = ? WHERE id = ?", nameBlob, threadID)
@@ -870,6 +901,8 @@ func (db *DB) insertMessage(threadID, blob, authorHash string) (Message, error)
if err != nil {
return Message{}, err
}
+ // New activity reopens the thread for anyone who'd closed it (best-effort).
+ _ = db.clearThreadHidden(threadID)
return Message{ID: id, CreatedAt: now, Blob: blob}, nil
}
diff --git a/handlers_test.go b/handlers_test.go
index ad92161..ba9ff5a 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -544,6 +544,99 @@ func TestPostThreadDedupesDM(t *testing.T) {
}
}
+// Closing a chat hides it for that user (synced via getThreads); new activity
+// reopens it; and a group member can be removed (but not from a DM).
+func TestThreadHiddenAndRemove(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ mkUser := func(label string) (tok, uid string) {
+ u, err := srv.db.createUser(label)
+ if err != nil {
+ t.Fatalf("createUser %s: %v", label, err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", 0); err != nil {
+ t.Fatalf("createKeyInvite %s: %v", label, err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label)
+ if err != nil || !ok {
+ t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey %s: %v", label, err)
+ }
+ return tk, u
+ }
+ tokA, uidA := mkUser("Alice")
+ _, uidB := mkUser("Bob")
+ gid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
+ if err != nil {
+ t.Fatalf("createThread: %v", err)
+ }
+
+ post := func(path, body string) int {
+ resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post %s: %v", path, err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+ hiddenForAlice := func() bool {
+ resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA)
+ if err != nil {
+ t.Fatalf("getThreads: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ Threads []Thread `json:"threads"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ for _, th := range out.Threads {
+ if th.ID == gid {
+ return th.Hidden
+ }
+ }
+ t.Fatalf("group %q missing from Alice's threads", gid)
+ return false
+ }
+
+ if code := post("/api/threads/"+gid+"/hidden", `{"token":"`+tokA+`","hidden":true}`); code != 200 {
+ t.Fatalf("hide: got %d", code)
+ }
+ if !hiddenForAlice() {
+ t.Fatal("chat should be hidden for Alice after closing")
+ }
+ // New activity reopens it.
+ if _, err := srv.db.insertMessage(gid, "blob", "author"); err != nil {
+ t.Fatalf("insertMessage: %v", err)
+ }
+ if hiddenForAlice() {
+ t.Fatal("new activity should reopen the closed chat")
+ }
+
+ // Remove Bob from the group.
+ if code := post("/api/threads/"+gid+"/remove", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 200 {
+ t.Fatalf("remove: got %d", code)
+ }
+ if srv.db.threadMember(gid, uidB) {
+ t.Fatal("Bob should no longer be a member")
+ }
+ if !srv.db.threadMember(gid, uidA) {
+ t.Fatal("Alice should still be a member")
+ }
+
+ // Removal is groups-only — a DM rejects it.
+ dm, err := srv.db.createThread("dm", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
+ if err != nil {
+ t.Fatalf("createThread dm: %v", err)
+ }
+ if code := post("/api/threads/"+dm+"/remove", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 400 {
+ t.Fatalf("remove from DM should be 400, got %d", code)
+ }
+}
+
// joinedMember sets up a real "joined" member — a user with a device token and a
// registered public key — plus a thread they belong to, for exercising the
// thread-scoped APIs. Returns the device token, the user id, and the thread id.
diff --git a/main.go b/main.go
index 0bc733a..bf4f7fb 100644
--- a/main.go
+++ b/main.go
@@ -126,6 +126,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/threads/{id}/name", s.postThreadName)
mux.HandleFunc("POST /api/threads/{id}/avatar", s.postThreadAvatar)
mux.HandleFunc("POST /api/threads/{id}/leave", s.postThreadLeave)
+ mux.HandleFunc("POST /api/threads/{id}/remove", s.postThreadRemove)
+ mux.HandleFunc("POST /api/threads/{id}/hidden", s.postThreadHidden)
// Encrypted attachments: bytes are E2E-encrypted in the browser; the server
// stores/serves opaque blobs only. Both ends are token-gated.
@@ -748,6 +750,81 @@ func (s *Server) postThreadLeave(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// postThreadHidden closes (hidden=true) or reopens (hidden=false) a chat for the
+// caller — a per-user marker that syncs across their devices. Any member may set
+// it on their own view; it never affects anyone else.
+func (s *Server) postThreadHidden(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Hidden bool `json:"hidden"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "no user for token")
+ return
+ }
+ threadID := r.PathValue("id")
+ if !s.db.threadMember(threadID, uid) {
+ httpError(w, http.StatusForbidden, "not a thread member")
+ return
+ }
+ if err := s.db.setThreadHidden(uid, threadID, body.Hidden); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postThreadRemove removes another member from a group. Any member may remove
+// any other (matching how adding/renaming work today — the group is flat). Like
+// leaving, the key isn't rotated: the server just stops delivering to them.
+func (s *Server) postThreadRemove(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ UserID string `json:"user_id"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "no user for token")
+ return
+ }
+ threadID := r.PathValue("id")
+ if !s.db.threadMember(threadID, uid) {
+ httpError(w, http.StatusForbidden, "not a thread member")
+ return
+ }
+ // Only groups: removing from a DM would break the one-thread-per-pair model.
+ if s.db.threadKind(threadID) != "group" {
+ httpError(w, http.StatusBadRequest, "can only remove people from a group")
+ return
+ }
+ if body.UserID == "" || body.UserID == uid || !s.db.threadMember(threadID, body.UserID) {
+ httpError(w, http.StatusBadRequest, "not a member of this group") // self uses /leave
+ return
+ }
+ if err := s.db.removeThreadMember(threadID, body.UserID); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// ---- attachments --------------------------------------------------------
func (s *Server) uploadBlob(w http.ResponseWriter, r *http.Request) {
diff --git a/test/format.test.mjs b/test/format.test.mjs
index c66b09d..0603f59 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,6 +1,6 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
-import { tokenizeText, richTokens, formatBytes, replySnippet, emojiOnlyCount, typingSummary } from "../web/static/format.js";
+import { tokenizeText, richTokens, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle } from "../web/static/format.js";
import { emojify } from "../web/static/emoji.js";
let failures = 0;
@@ -237,5 +237,21 @@ await test("typingSummary ignores empty names", () => {
assert.equal(typingSummary(["Sophie", ""]), "Sophie is typing…");
});
+await test("autoGroupTitle lists members and collapses past three", () => {
+ assert.equal(autoGroupTitle([]), "Group");
+ assert.equal(autoGroupTitle(["Paul"]), "Paul");
+ assert.equal(autoGroupTitle(["Paul", "Ciara"]), "Paul & Ciara");
+ assert.equal(autoGroupTitle(["Paul", "Ciara", "Luan"]), "Paul, Ciara & Luan");
+ assert.equal(autoGroupTitle(["Paul", "Ciara", "Luan", "Mabel"]), "Paul, Ciara & 2 more");
+ // The worked example: two shown + the rest counted.
+ const many = ["Paul", "Ciara", ...Array.from({ length: 24 }, (_, i) => "P" + i)];
+ assert.equal(autoGroupTitle(many), "Paul, Ciara & 24 more");
+});
+
+await test("autoGroupTitle ignores empty names", () => {
+ assert.equal(autoGroupTitle(["Paul", "", "Ciara"]), "Paul & Ciara");
+ assert.equal(autoGroupTitle(null), "Group");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/index.html b/web/pages/index.html
index 2eb431d..6f7fdbf 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -94,6 +94,7 @@
<button class="menu-item" id="thread-add" role="menuitem">Add people</button>
<button class="menu-item" id="thread-edit" role="menuitem">Edit group</button>
<button class="menu-item" id="thread-members" role="menuitem">View members</button>
+ <button class="menu-item" id="thread-close" role="menuitem">Close chat</button>
<button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button>
</div>
</div>
diff --git a/web/static/chat.js b/web/static/chat.js
index 1a1ee5d..3d92666 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -28,7 +28,7 @@ import {
normalizeAppearance,
colorScheme,
} from "/static/ui.js";
-import { richTokens, typingSummary, replySnippet, formatBytes } from "/static/format.js";
+import { richTokens, typingSummary, replySnippet, formatBytes, autoGroupTitle } from "/static/format.js";
import { emojify } from "/static/emoji.js";
import { MESSAGE_TYPE_TYPING } from "/static/message.js";
import { qrCanvas } from "/static/qrcode.js";
@@ -259,10 +259,12 @@ const threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new me
const threadName = {}; // thread id -> display name
const threadPreview = {}; // thread id -> last-message snippet
const unread = {}; // thread id -> bool
+const hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server
let chatFilter = ""; // lowercased substring filtering the thread list (search box)
let activeId = null;
let ws = null;
let everConnected = false; // has the socket ever opened? (distinguishes reconnect from first connect)
+let wsConnected = false; // is the socket open right now? (gates the send button while reconnecting)
let pingTimer = null; // keepalive ping interval while the socket is open
let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip
@@ -460,6 +462,7 @@ async function loadThreads() {
}
}
threadName[t.id] = await displayName(t);
+ hidden[t.id] = !!t.hidden; // closed chats (server-synced) are dropped from the list
if (t.last_blob && threadKeys[t.id]) {
try {
threadPreview[t.id] = previewText(t, await decrypt(threadKeys[t.id], t.last_blob));
@@ -548,15 +551,18 @@ async function displayName(t) {
const other = (t.members || []).find((id) => id !== myUserId);
return rosterLabel(other);
}
- // group: the name is encrypted in name_blob under the thread key.
+ // group: an explicit name is encrypted in name_blob under the thread key;
+ // with no name (or if it won't decrypt), fall back to the member names —
+ // "Paul, Ciara & Luan", collapsing to "Paul, Ciara & 24 more" past three.
+ const fallback = autoGroupTitle((t.members || []).filter((id) => id !== myUserId).map(rosterLabel));
if (t.name_blob && threadKeys[t.id]) {
try {
- return (await decrypt(threadKeys[t.id], t.name_blob)).name || "Group";
+ return (await decrypt(threadKeys[t.id], t.name_blob)).name || fallback;
} catch {
- return "Group";
+ return fallback;
}
}
- return "Group";
+ return fallback;
}
// ---- shell --------------------------------------------------------------
@@ -646,6 +652,10 @@ function renderApp() {
closeTMenu();
showMembers();
};
+ document.getElementById("thread-close").onclick = () => {
+ closeTMenu();
+ closeChat();
+ };
document.getElementById("thread-leave").onclick = () => {
closeTMenu();
leaveGroup();
@@ -767,9 +777,11 @@ function renderThreadList() {
list.appendChild(empty);
return;
}
+ // A search reveals everything that matches (so a closed chat can be found and
+ // reopened); the default list hides closed chats.
const shown = chatFilter
? threads.filter((t) => (threadName[t.id] || "").toLowerCase().includes(chatFilter))
- : threads;
+ : threads.filter((t) => !hidden[t.id]);
if (!shown.length) {
const empty = document.createElement("p");
empty.className = "thread-empty-note";
@@ -1024,6 +1036,29 @@ function stashFeed(id) {
c.nodes = frag;
}
+// Paint the thread header (title, member-count subtitle, kebab). Separate from
+// openThread so membership changes (add/remove people) can refresh it in place —
+// openThread now early-returns for the already-open thread (feed cache), so it
+// can't be reused to re-render the header.
+function paintThreadHeader(id) {
+ const t = threads.find((x) => x.id === id);
+ const isGroup = t && t.kind === "group";
+ document.getElementById("thread-title").textContent = threadName[id] || "Chat";
+ const sub = document.getElementById("thread-sub");
+ const n = t ? (t.members || []).length : 0;
+ sub.textContent = isGroup ? `${n} member${n === 1 ? "" : "s"}` : "";
+ sub.onclick = isGroup ? () => showMembers() : null; // tap "N members" → member list
+ sub.style.cursor = isGroup ? "pointer" : "";
+ // The kebab is always available (every chat can be closed); the group-only
+ // items (add / edit / members / leave) show only for groups.
+ document.getElementById("thread-menu-wrap").style.display = "";
+ for (const gid of ["thread-add", "thread-edit", "thread-members", "thread-leave"]) {
+ const el = document.getElementById(gid);
+ if (el) el.style.display = isGroup ? "" : "none";
+ }
+ paintHeaderAvatar(id);
+}
+
async function openThread(id) {
// Re-tapping the thread that's already open must not rebuild it — just reveal
// the pane (mobile navigates list → thread). No flash, no wiped draft.
@@ -1039,20 +1074,18 @@ async function openThread(id) {
const myOpen = ++openSeq;
activeId = id;
unread[id] = false;
+ // Opening a closed chat reopens it (locally + server, synced to your devices).
+ if (hidden[id]) {
+ hidden[id] = false;
+ fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, hidden: false }),
+ }).catch(() => {});
+ }
document.getElementById("thread-empty").classList.add("hidden");
document.getElementById("thread-view").classList.remove("hidden");
- document.getElementById("thread-title").textContent = threadName[id] || "Chat";
- // Subtitle + kebab: a member count and rename/members menu, for groups only.
- const t = threads.find((x) => x.id === id);
- const isGroup = t && t.kind === "group";
- const sub = document.getElementById("thread-sub");
- const n = t ? (t.members || []).length : 0;
- sub.textContent = isGroup ? `${n} member${n === 1 ? "" : "s"}` : "";
- // Tapping "N members" opens the member list (groups only).
- sub.onclick = isGroup ? () => showMembers() : null;
- sub.style.cursor = isGroup ? "pointer" : "";
- document.getElementById("thread-menu-wrap").style.display = isGroup ? "" : "none";
- paintHeaderAvatar(id);
+ paintThreadHeader(id);
document.getElementById("tt").classList.add("show-thread");
renderThreadList();
@@ -1654,7 +1687,10 @@ function cancelReply() {
// uploading, so the caller keeps the typed text rather than wiping it.
function sendMessage(raw) {
const text = emojify((raw || "").trim()); // :tada: → 🎉 before encrypting
- if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return true;
+ // Can't send right now (no open thread, or the socket is down / reconnecting) —
+ // return false so the composer keeps what's typed rather than clearing it. The
+ // send button is greyed meanwhile, but Enter can still land here.
+ if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return false;
// Editing an existing message: re-encrypt the original payload with new text
// (preserving its name + any attachments) and send an edit.
@@ -1892,7 +1928,9 @@ function updateSendVisibility() {
const sendBtn = document.getElementById("send-btn");
if (!text || !sendBtn) return;
sendBtn.hidden = text.value.trim() === "" && pending.length === 0;
- sendBtn.disabled = pending.some((p) => p.loading);
+ // Disabled while an attachment is still uploading, or while the socket is down
+ // (a send would be silently dropped) — greyed via .send-btn:disabled in CSS.
+ sendBtn.disabled = pending.some((p) => p.loading) || !wsConnected;
}
// Fetch + decrypt a blob (with the active thread's key), cached as an object URL.
@@ -2036,6 +2074,8 @@ function connectWS() {
reconnectStatusTimer = null;
}
showConnStatus("");
+ wsConnected = true;
+ updateSendVisibility(); // re-enable the send button now the socket's back
if (pingTimer) clearInterval(pingTimer);
// Idle proxies drop WebSockets after ~60s; a tiny ping every 25s keeps it open.
// The server ignores unknown frame types (see hub.go).
@@ -2151,6 +2191,8 @@ function connectWS() {
renderThreadList();
return;
}
+ // New activity reopens a closed chat (the server clears its hidden flag too).
+ hidden[tid] = false;
if (tid === activeId) {
await appendMessage(tid, msg, true);
} else {
@@ -2168,6 +2210,8 @@ function connectWS() {
renderThreadList();
};
ws.onclose = () => {
+ wsConnected = false;
+ updateSendVisibility(); // grey out send — a message now would be dropped
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
@@ -2305,10 +2349,29 @@ function resetTyping() {
// Edit group: photo + name in one modal (mirrors editProfile). The group photo
// rides on the thread key (member-readable, server-blind), like its messages.
-function editGroup() {
+// The explicit name stored for a group (decrypted from name_blob), or "" when
+// none is set — distinct from the display title, which falls back to the member
+// names. The edit field shows THIS, so a nameless group reads as blank (and
+// keeps auto-generating) rather than looking like its members are its name.
+async function storedGroupName(t) {
+ if (t && t.kind === "group" && t.name_blob && threadKeys[t.id]) {
+ try {
+ return (await decrypt(threadKeys[t.id], t.name_blob)).name || "";
+ } catch {
+ return "";
+ }
+ }
+ return "";
+}
+
+async function editGroup() {
const t = threads.find((x) => x.id === activeId);
if (!t || t.kind !== "group") return;
const id = activeId;
+ // The auto-title (member names) — shown as the field's placeholder, and the
+ // display name to fall back to if the explicit name is cleared.
+ const autoName = autoGroupTitle((t.members || []).filter((x) => x !== myUserId).map(rosterLabel));
+ const stored = await storedGroupName(t);
const backdrop = modal();
const card = backdrop.querySelector(".sheet");
const h = document.createElement("h2");
@@ -2371,9 +2434,11 @@ function editGroup() {
label.className = "field-label";
label.textContent = "Group name";
const input = document.createElement("input");
- input.value = threadName[id] || "";
+ input.value = stored; // the explicit name, blank if none — not the auto-title
+ input.placeholder = autoName; // ghost text previews the auto-generated name
input.maxLength = 60;
- input.addEventListener("input", () => paintAvatar(av, input.value || "?", loader));
+ // A blank field still shows the auto-name's initial on the avatar.
+ input.addEventListener("input", () => paintAvatar(av, input.value || autoName || "?", loader));
card.appendChild(label);
card.appendChild(input);
@@ -2387,10 +2452,12 @@ function editGroup() {
save.textContent = "Save";
save.onclick = async () => {
const next = input.value.trim();
- if (next && next !== threadName[id]) {
+ if (next !== stored) {
save.disabled = true;
try {
- const name_blob = await encrypt(threadKeys[id], { name: next });
+ // A blank name clears it (name_blob=""), so the group reverts to its
+ // auto-generated member-name title.
+ const name_blob = next ? await encrypt(threadKeys[id], { name: next }) : "";
const r = await fetch(`/api/threads/${encodeURIComponent(id)}/name`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -2398,8 +2465,8 @@ function editGroup() {
});
if (!r.ok) throw new Error("server");
t.name_blob = name_blob;
- threadName[id] = next;
- if (activeId === id) document.getElementById("thread-title").textContent = next;
+ threadName[id] = next || autoName;
+ if (activeId === id) document.getElementById("thread-title").textContent = threadName[id];
renderThreadList();
} catch {
save.disabled = false;
@@ -2423,20 +2490,24 @@ function showMembers() {
const h = document.createElement("h2");
h.textContent = "Members";
card.appendChild(h);
+ const isGroup = t.kind === "group";
const list = document.createElement("div");
list.className = "pick-list";
for (const uid of t.members || []) {
const name = rosterLabel(uid);
const me = uid === myUserId;
- const row = document.createElement(me ? "div" : "button");
+ const row = document.createElement("div");
row.className = "list-row" + (me ? " static" : "");
- row.appendChild(avatarEl(name, () => avatarFor(uid)));
+
+ // The identity area (avatar + name); tapping a member opens/reopens a DM.
+ const main = document.createElement(me ? "div" : "button");
+ main.className = "member-main";
+ main.appendChild(avatarEl(name, () => avatarFor(uid)));
const lbl = document.createElement("span");
lbl.textContent = me ? `${name} (you)` : name;
- row.appendChild(lbl);
- // Tapping another member opens (or reopens) a DM with them.
+ main.appendChild(lbl);
if (!me)
- row.onclick = async () => {
+ main.onclick = async () => {
backdrop.remove();
const dm = existingDM(uid);
if (dm) {
@@ -2452,11 +2523,65 @@ function showMembers() {
console.error(e);
}
};
+ row.appendChild(main);
+
+ // Remove-from-group (groups only, not yourself — use Leave for that).
+ if (!me && isGroup) {
+ const rm = document.createElement("button");
+ rm.className = "member-remove";
+ rm.textContent = "Remove";
+ rm.setAttribute("aria-label", `Remove ${name}`);
+ rm.onclick = () => removeMember(uid, name, backdrop);
+ row.appendChild(rm);
+ }
list.appendChild(row);
}
card.appendChild(list);
}
+// Remove another member from the current group (any member may; no key rotation —
+// the server just stops delivering to them, like leaving). Confirms first.
+async function removeMember(uid, name, backdrop) {
+ const id = activeId;
+ const t = threads.find((x) => x.id === id);
+ if (!t || t.kind !== "group") return;
+ if (!confirm(`Remove ${name} from the group?`)) return;
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/remove`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, user_id: uid }),
+ });
+ if (!r.ok) throw new Error("server");
+ } catch {
+ alert("Couldn't remove them. Try again.");
+ return;
+ }
+ if (backdrop) backdrop.remove();
+ await loadThreads(); // refresh members + the (possibly member-derived) name
+ renderThreadList();
+ if (activeId === id) paintThreadHeader(id); // update the member count / title
+}
+
+// Close (hide) the open chat: it drops off the list until there's new activity or
+// you find it via search. Per-user, synced across your devices.
+async function closeChat() {
+ const id = activeId;
+ if (!id) return;
+ hidden[id] = true;
+ fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, hidden: true }),
+ }).catch(() => {});
+ stashFeed(id); // keep its rendered feed cached for a quick reopen
+ activeId = null;
+ renderThreadList();
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ showList();
+}
+
// addPeople adds joined instance members (not already in the group) by wrapping
// the group key to each of their roster public keys.
function addPeople() {
@@ -2535,7 +2660,7 @@ function addPeople() {
backdrop.remove();
await loadThreads();
renderThreadList();
- openThread(activeId); // refresh the header member count
+ if (activeId) paintThreadHeader(activeId); // refresh the member count / name
} catch (e) {
console.error(e);
add.disabled = false;
@@ -2630,7 +2755,7 @@ function showNewChat() {
let nameInput;
if (mode === "group") {
nameInput = document.createElement("input");
- nameInput.placeholder = "Group name";
+ nameInput.placeholder = "Group name (optional)";
nameInput.maxLength = 60;
nameInput.className = "sheet-input";
card.appendChild(nameInput);
@@ -2727,7 +2852,9 @@ async function createThread(otherIds, opts = {}) {
members.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) });
}
const body = { token: session.token, kind: group ? "group" : "dm", members };
- if (group) body.name_blob = await encrypt(key, { name: opts.name || "Group" });
+ // Only store a name when one was actually given — a nameless group falls back
+ // to its member names for display (see displayName), rather than "Group".
+ if (group && opts.name) body.name_blob = await encrypt(key, { name: opts.name });
const r = await (
await fetch("/api/threads", {
method: "POST",
diff --git a/web/static/format.js b/web/static/format.js
index d767cb5..31e3488 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -155,6 +155,25 @@ export function typingSummary(names) {
return "Several people are typing…";
}
+// Auto-title for a group with no explicit name: the member names (pass the
+// OTHER members — the viewer excludes themselves), joined naturally and
+// collapsed past three into "A, B & N more" so the header never grows unbounded:
+// [] → "Group" (fallback: just you / unknown)
+// [A] → "A"
+// [A, B] → "A & B"
+// [A, B, C] → "A, B & C"
+// [A, B, C, D, …] → "A, B & N more" (N = names.length - 2)
+// Pure so the header/list rendering in chat.js stays testable.
+export function autoGroupTitle(names) {
+ const list = Array.isArray(names) ? names.filter(Boolean) : [];
+ const n = list.length;
+ if (n === 0) return "Group";
+ if (n === 1) return list[0];
+ if (n === 2) return `${list[0]} & ${list[1]}`;
+ if (n === 3) return `${list[0]}, ${list[1]} & ${list[2]}`;
+ return `${list[0]}, ${list[1]} & ${n - 2} more`;
+}
+
// Human-readable file size, e.g. 1536 → "1.5 KB".
export function formatBytes(n) {
if (!Number.isFinite(n) || n < 0) return "";
diff --git a/web/static/style.css b/web/static/style.css
index 287c575..b219a75 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -952,6 +952,13 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.list-row:hover:not(.static) { background: var(--bubble-them); }
.list-row.static { cursor: default; }
.list-row input { width: auto; }
+/* member row: tappable identity on the left, a Remove action on the right */
+.member-main {
+ flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px;
+ background: transparent; color: inherit; border: none; text-align: left; font: inherit; padding: 0; cursor: pointer;
+}
+.member-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.member-remove { flex: none; background: transparent; color: var(--danger); font-weight: 600; font-size: 14px; padding: 4px 8px; }
.sheet-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
/* theme picker (kebab → Theme) */