eleven / messenger Public

web+server: member invites, connections-only roster, roster mgmt + key-loss handling (#43)

Members invite new people in-app (a pending DM + join link that connects them on
join); the roster is restricted server-side to a member's connections; the admin
console becomes roster management (bootstrap-only invite, per-user remove, "who can
invite" setting). Adds key-loss handling (backup via add-a-device, last-device
sign-out warning + server-side sign-out) and mobile/UX polish (long-press message
menu with pop-out, double-tap react, flick-to-dismiss images, composer hugs the
keyboard, edge-to-edge list, smooth scroll-in). All E2EE-preserving. Hardened per
code review (single-claim user invites, set-once pubkeys, removeUser teardown,
token-gated sign-out).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell e29f0fa45d816336159bd9f097df682d9f1c6cc1 parent eeffd1c
13 files changed, +2159 −108
  • CLAUDE.md +5 −2
  • db.go +456 −24
  • db_test.go +3 −3
  • handlers_test.go +3 −3
  • invites_test.go +347 −0
  • main.go +295 −8
  • push.go +9 −4
  • web/pages/admin.html +19 −8
  • web/pages/index.html +4 −1
  • web/static/admin.js +60 −11
  • web/static/chat.js +728 −30
  • web/static/join.js +71 −13
  • web/static/style.css +159 −1
diff --git a/CLAUDE.md b/CLAUDE.md
index cf0e9de..b0b8879 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,8 +74,11 @@ Frontend module layout:
- **Platform split** via `isCoarse` (`matchMedia("(pointer: coarse)")`):
- Composer: desktop Enter sends / Shift+Enter newlines; touch Enter newlines,
the send button sends (`shouldSend`).
- - Reactions: **touch = double-tap, desktop = press-and-hold.** Long-press is
- left free on touch so native text selection/copy works.
+ - Message actions: **touch = long-press** opens the message menu (emoji
+ reactions above the bubble, a labelled Reply/Copy/Edit/Delete menu below);
+ **desktop = hover strip + press-and-hold** for the picker. Native touch
+ selection/callout is suppressed on bubbles so long-press opens the menu — Copy
+ lives in the menu instead. Swipe-right still quick-replies on touch.
- **Images** are downscaled in the browser before upload (`processImage`, max
2048px long edge) — there is no server-side image proxy (it only has
ciphertext). Re-encoding also strips EXIF/GPS.
diff --git a/db.go b/db.go
index 16db849..d0affee 100644
--- a/db.go
+++ b/db.go
@@ -30,6 +30,22 @@ func (db *DB) setGroupName(name string) error {
return db.setConfig("group_name", name)
}
+// usersCanInvite reports whether ordinary members (not just the admin) may invite
+// new people. Off by default — the safest posture — and loosened only as an
+// explicit admin opt-in.
+func (db *DB) usersCanInvite() bool {
+ v, ok, _ := db.getConfig("user_invites_enabled")
+ return ok && v == "1"
+}
+
+func (db *DB) setUsersCanInvite(on bool) error {
+ v := "0"
+ if on {
+ v = "1"
+ }
+ return db.setConfig("user_invites_enabled", v)
+}
+
// 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.
@@ -180,6 +196,12 @@ CREATE TABLE IF NOT EXISTS hidden_threads (
"ALTER TABLE users ADD COLUMN avatar_blob TEXT",
// Group photo: an encrypted blob id (encrypted with the thread key).
"ALTER TABLE threads ADD COLUMN avatar_blob TEXT",
+ // User invites: the pending DM thread an invite bootstraps the joiner into
+ // (null for admin auth-only / add-device invites). See createUserInvite.
+ "ALTER TABLE key_invites ADD COLUMN thread_id TEXT",
+ // Which user a push subscription belongs to, so removing a user can drop their
+ // subscriptions (sign-out clears them by token; removal has only the user id).
+ "ALTER TABLE push_subscriptions ADD COLUMN user_id TEXT",
// Bot flag: a user driven by an agent (see agent/), not a person. Only
// used to badge them in the UI — it grants no privilege.
"ALTER TABLE users ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0",
@@ -400,6 +422,10 @@ type User struct {
JoinedAt *int64 `json:"joined_at,omitempty"`
// Pending is true while an unused, unexpired invite link is still out there.
Pending bool `json:"pending"`
+ // PendingDM is true when that pending invite drops them into a DM (a member's
+ // in-app invite), vs a bare admin invite. The admin doesn't re-send DM invites —
+ // the inviting member does, in-app.
+ PendingDM bool `json:"pending_dm"`
}
func (db *DB) createUser(label string) (string, error) {
@@ -418,7 +444,9 @@ func (db *DB) listUsers() ([]User, error) {
(SELECT COUNT(*) FROM device_tokens d WHERE d.user_id = u.id),
(SELECT MIN(d.created_at) FROM device_tokens d WHERE d.user_id = u.id),
(SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
- AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?))
+ AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?)),
+ (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
+ AND k.thread_id IS NOT NULL AND k.used_at IS NULL)
FROM users u ORDER BY u.created_at DESC`, now)
if err != nil {
return nil, err
@@ -428,8 +456,8 @@ func (db *DB) listUsers() ([]User, error) {
for rows.Next() {
var u User
var joinedAt sql.NullInt64
- var pending int
- if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &u.Devices, &joinedAt, &pending); err != nil {
+ var pending, pendingDM int
+ if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &u.Devices, &joinedAt, &pending, &pendingDM); err != nil {
return nil, err
}
if joinedAt.Valid {
@@ -437,11 +465,19 @@ func (db *DB) listUsers() ([]User, error) {
u.JoinedAt = &j
}
u.Pending = pending > 0
+ u.PendingDM = pendingDM > 0
out = append(out, u)
}
return out, rows.Err()
}
+// userLabel returns a user's display label (or "" if unknown).
+func (db *DB) userLabel(id string) string {
+ var l string
+ db.sql.QueryRow("SELECT label FROM users WHERE id = ?", id).Scan(&l)
+ return l
+}
+
func (db *DB) userExists(id string) bool {
var x int
return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
@@ -449,37 +485,102 @@ func (db *DB) userExists(id string) bool {
// createKeyInvite stores a client-wrapped key under a client-derived id.
func (db *DB) createKeyInvite(id, userID, wrapped string, expiresAt int64) error {
- var exp any
+ return db.createKeyInviteWithThread(id, userID, wrapped, "", expiresAt)
+}
+
+// createKeyInviteWithThread is createKeyInvite that also links the invite to a
+// pending DM thread — used to re-send a user invite (a fresh link for the same
+// pending invitee), so a lost link can be replaced without spawning a new chat.
+func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID string, expiresAt int64) error {
+ var exp, tid any
if expiresAt > 0 {
exp = expiresAt
}
+ if threadID != "" {
+ tid = threadID
+ }
_, err := db.sql.Exec(
- "INSERT INTO key_invites(id, user_id, wrapped, created_at, expires_at) VALUES(?, ?, ?, ?, ?)",
- id, userID, wrapped, time.Now().Unix(), exp,
+ "INSERT INTO key_invites(id, user_id, wrapped, thread_id, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?)",
+ id, userID, wrapped, tid, time.Now().Unix(), exp,
)
return err
}
+// createUserInvite sets up an invite that drops the joiner straight into a DM with
+// the inviter. In one transaction it creates a placeholder user for the invitee
+// (label = the inviter's suggested name, no public key until they join), a `dm`
+// thread with the inviter (keyed) and the placeholder (keyless), records the DM
+// pair, and stores the key-invite whose wrapped payload carries the thread key
+// encrypted to the invite secret. inviterWrapped is the thread key wrapped to the
+// inviter's own public key; bootstrapWrapped is it encrypted to the invite secret
+// (so the joiner can recover it). The server sees only blobs it can't open.
+// Returns the new thread id and the placeholder user id.
+func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inviterWrapped string, expiresAt int64) (string, string, error) {
+ inviteeID, err := randToken()
+ if err != nil {
+ return "", "", err
+ }
+ threadID, err := randToken()
+ if err != nil {
+ return "", "", err
+ }
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return "", "", err
+ }
+ defer tx.Rollback()
+ now := time.Now().Unix()
+ lo, hi := inviterID, inviteeID
+ if lo > hi {
+ lo, hi = hi, lo
+ }
+ var exp any
+ if expiresAt > 0 {
+ exp = expiresAt
+ }
+ stmts := []struct {
+ q string
+ args []any
+ }{
+ {"INSERT INTO users(id, label, created_at) VALUES(?, ?, ?)", []any{inviteeID, label, now}},
+ {"INSERT INTO threads(id, kind, name_blob, created_at) VALUES(?, 'dm', NULL, ?)", []any{threadID, now}},
+ {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviterID, now}},
+ {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviteeID, now}},
+ {"INSERT INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", []any{threadID, inviterID, inviterWrapped}},
+ {"INSERT INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", []any{lo, hi, threadID}},
+ {"INSERT INTO key_invites(id, user_id, wrapped, thread_id, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?)", []any{inviteID, inviteeID, bootstrapWrapped, threadID, now, exp}},
+ }
+ for _, s := range stmts {
+ if _, err := tx.Exec(s.q, s.args...); err != nil {
+ return "", "", err
+ }
+ }
+ return threadID, inviteeID, tx.Commit()
+}
+
type KeyInvite struct {
- ID string
- UserID string
- Wrapped string
- Used bool
- Expired bool
+ ID string
+ UserID string
+ Wrapped string
+ ThreadID string // the pending DM this invite drops the joiner into ("" if none)
+ Used bool
+ Expired bool
}
func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) {
var ki KeyInvite
var used, exp sql.NullInt64
+ var threadID sql.NullString
err := db.sql.QueryRow(
- "SELECT id, user_id, wrapped, used_at, expires_at FROM key_invites WHERE id = ?", id,
- ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &used, &exp)
+ "SELECT id, user_id, wrapped, thread_id, used_at, expires_at FROM key_invites WHERE id = ?", id,
+ ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &used, &exp)
if err == sql.ErrNoRows {
return KeyInvite{}, false, nil
}
if err != nil {
return KeyInvite{}, false, err
}
+ ki.ThreadID = threadID.String
ki.Used = used.Valid
ki.Expired = exp.Valid && time.Now().Unix() > exp.Int64
return ki, true, nil
@@ -488,11 +589,24 @@ func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) {
// claimKeyInvite marks an unused, unexpired invite as used (single-use) and mints
// a device token for its user. Returns the plaintext token (shown once). ok=false
// (no error) if the invite is unknown, used, expired, or lost a claim race.
-func (db *DB) claimKeyInvite(id, deviceLabel string) (string, bool, error) {
+//
+// For a user invite (one that bootstraps a pending DM), wrappedSelf is the thread
+// key wrapped to the joiner's own public key — we store it as their thread_keys
+// row so they can decrypt the DM immediately. The joiner IS the invite's user
+// (the placeholder created at invite time now becomes a real, joined person).
+func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf string) (string, bool, error) {
ki, found, err := db.getKeyInvite(id)
if err != nil || !found || ki.Used || ki.Expired {
return "", false, err
}
+ // A user invite (one that bootstraps a pending DM) is for a not-yet-joined
+ // placeholder. Refuse once that user has joined (has a device token): otherwise a
+ // second, still-unused invite for them — e.g. a re-send whose earlier link was
+ // already used — could mint a token AS them and overwrite the DM key wrapped to
+ // their real keypair, taking over the account and bricking their chat.
+ if ki.ThreadID != "" && db.deviceCount(ki.UserID) > 0 {
+ return "", false, nil
+ }
res, err := db.sql.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), id)
if err != nil {
return "", false, err
@@ -511,9 +625,30 @@ func (db *DB) claimKeyInvite(id, deviceLabel string) (string, bool, error) {
if err != nil {
return "", false, err
}
+ if ki.ThreadID != "" && wrappedSelf != "" {
+ // They're already a member of the pending DM (added at invite time); this
+ // records the key wrapped to the fresh keypair they just generated.
+ if err := db.addThreadMember(ki.ThreadID, ki.UserID, wrappedSelf); err != nil {
+ return "", false, err
+ }
+ }
return tok, true, nil
}
+// deviceCount returns how many devices (tokens) a user currently has signed in — so
+// the client can warn before signing out the LAST one (which loses their keys).
+func (db *DB) deviceCount(userID string) int {
+ var n int
+ db.sql.QueryRow("SELECT COUNT(*) FROM device_tokens WHERE user_id = ?", userID).Scan(&n)
+ return n
+}
+
+// deleteDeviceToken revokes a single device token (signing out on that device).
+func (db *DB) deleteDeviceToken(token string) error {
+ _, err := db.sql.Exec("DELETE FROM device_tokens WHERE token_hash = ?", hashToken(token))
+ return err
+}
+
func (db *DB) deviceTokenValid(token string) bool {
var x int
return db.sql.QueryRow("SELECT 1 FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&x) == nil
@@ -546,6 +681,128 @@ func (db *DB) setUserPublicKey(userID, pub string) error {
return err
}
+// userPublicKey returns a user's registered ECDH public key ("" if not set yet).
+func (db *DB) userPublicKey(id string) string {
+ var pk sql.NullString
+ db.sql.QueryRow("SELECT public_key FROM users WHERE id = ?", id).Scan(&pk)
+ return pk.String
+}
+
+// removeUser deletes a user and all their traces (admin roster management): device
+// tokens, thread membership + wrapped keys, DM-pair records, pending invites, their
+// instance-key copy, reactions, and their own messages — then cleans up any thread
+// left with no members. Threads they merely shared (a DM/group with others) survive;
+// the other members keep them.
+func (db *DB) removeUser(id string) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // The threads the user is in — used to scope the orphan sweep and to find any
+ // pending DMs they set up. (Collect fully before issuing more statements: one
+ // SQLite connection can't interleave an open cursor with writes.)
+ rows, err := tx.Query("SELECT thread_id FROM thread_members WHERE user_id = ?", id)
+ if err != nil {
+ return err
+ }
+ var threadIDs []string
+ for rows.Next() {
+ var t string
+ if err := rows.Scan(&t); err != nil {
+ rows.Close()
+ return err
+ }
+ threadIDs = append(threadIDs, t)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+
+ teardown := func(tid string) error {
+ for _, q := range []string{
+ "DELETE FROM key_invites WHERE thread_id = ?",
+ "DELETE FROM thread_keys WHERE thread_id = ?",
+ "DELETE FROM thread_members WHERE thread_id = ?",
+ "DELETE FROM dm_pairs WHERE thread_id = ?",
+ "DELETE FROM messages WHERE thread_id = ?",
+ "DELETE FROM threads WHERE id = ?",
+ } {
+ if _, err := tx.Exec(q, tid); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ // Pending DMs this user set up whose invitee never joined: tear the whole thing
+ // down and delete the keyless placeholder, so the invitee's still-live link can't
+ // drop them into a dead, unreadable one-person chat.
+ for _, tid := range threadIDs {
+ var invitee string
+ err := tx.QueryRow("SELECT user_id FROM key_invites WHERE thread_id = ? LIMIT 1", tid).Scan(&invitee)
+ if err == sql.ErrNoRows {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ var joined int
+ if err := tx.QueryRow("SELECT COUNT(*) FROM device_tokens WHERE user_id = ?", invitee).Scan(&joined); err != nil {
+ return err
+ }
+ if joined > 0 {
+ continue // the invitee has joined; leave their real chat alone
+ }
+ if err := teardown(tid); err != nil {
+ return err
+ }
+ if invitee != id {
+ if _, err := tx.Exec("DELETE FROM users WHERE id = ? AND (public_key IS NULL OR public_key = '')", invitee); err != nil {
+ return err
+ }
+ }
+ }
+
+ // The user's own rows.
+ for _, s := range []struct {
+ q string
+ arg []any
+ }{
+ {"DELETE FROM thread_keys WHERE user_id = ?", []any{id}},
+ {"DELETE FROM thread_members WHERE user_id = ?", []any{id}},
+ {"DELETE FROM device_tokens WHERE user_id = ?", []any{id}},
+ {"DELETE FROM key_invites WHERE user_id = ?", []any{id}},
+ {"DELETE FROM instance_keys WHERE user_id = ?", []any{id}},
+ {"DELETE FROM dm_pairs WHERE user_a = ? OR user_b = ?", []any{id, id}},
+ {"DELETE FROM reactions WHERE token = ?", []any{id}},
+ {"DELETE FROM messages WHERE author_hash = ?", []any{id}},
+ {"DELETE FROM push_subscriptions WHERE user_id = ?", []any{id}},
+ {"DELETE FROM users WHERE id = ?", []any{id}},
+ } {
+ if _, err := tx.Exec(s.q, s.arg...); err != nil {
+ return err
+ }
+ }
+
+ // Sweep only the user's former threads that now have no members left (e.g. their
+ // "Notes" thread, or a DM whose other member was already gone).
+ for _, tid := range threadIDs {
+ var n int
+ if err := tx.QueryRow("SELECT COUNT(*) FROM thread_members WHERE thread_id = ?", tid).Scan(&n); err != nil {
+ return err
+ }
+ if n == 0 {
+ if err := teardown(tid); err != nil {
+ return err
+ }
+ }
+ }
+ return tx.Commit()
+}
+
// setUserAgent flags a user as an agent (bot) so the UI can badge it. Purely
// cosmetic — it confers no extra permission.
func (db *DB) setUserAgent(userID string) error {
@@ -562,8 +819,9 @@ func (db *DB) setAvatar(userID, blobID string) error {
return err
}
-// roster lists everyone who has joined (registered a public key). Open roster:
-// any member may read it to start a thread with anyone.
+// roster lists everyone who has joined (registered a public key) — the full
+// directory. Members no longer see this; they get connections() (self + people
+// they share a thread with). Kept for admin/back-end use and tests.
func (db *DB) roster() ([]RosterEntry, error) {
rows, err := db.sql.Query(
"SELECT id, label, public_key, COALESCE(avatar_blob, ''), COALESCE(is_agent, 0) FROM users WHERE public_key IS NOT NULL AND public_key <> '' ORDER BY label",
@@ -571,6 +829,33 @@ func (db *DB) roster() ([]RosterEntry, error) {
if err != nil {
return nil, err
}
+ return scanRoster(rows)
+}
+
+// connections is the roster a member actually sees: themselves plus everyone they
+// already share a thread with (a DM or a group). It intentionally does NOT expose
+// the whole instance — you can only start a new chat with someone you're already
+// connected to, or by inviting someone new. Keyless people (a pending invitee who
+// hasn't joined yet) are included so a pending DM can still show their name; the
+// client omits keyless entries from the "start a new chat" picker.
+func (db *DB) connections(userID string) ([]RosterEntry, error) {
+ rows, err := db.sql.Query(`
+ SELECT u.id, u.label, COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0)
+ FROM users u
+ WHERE u.id = ?
+ OR u.id IN (
+ SELECT m2.user_id FROM thread_members m1
+ JOIN thread_members m2 ON m2.thread_id = m1.thread_id
+ WHERE m1.user_id = ? AND m2.user_id <> ?
+ )
+ ORDER BY u.label`, userID, userID, userID)
+ if err != nil {
+ return nil, err
+ }
+ return scanRoster(rows)
+}
+
+func scanRoster(rows *sql.Rows) ([]RosterEntry, error) {
defer rows.Close()
out := []RosterEntry{}
for rows.Next() {
@@ -865,13 +1150,18 @@ func (db *DB) memberUserIDs(threadID string) ([]string, error) {
return out, rows.Err()
}
+// canonPair orders a user pair so there's exactly one dm_pairs row per pair.
+func canonPair(a, b string) (string, string) {
+ if a > b {
+ return b, a
+ }
+ return a, b
+}
+
// dmThreadFor returns the existing DM thread between two users, if any (so DMs
// are deduped — exactly one per pair).
func (db *DB) dmThreadFor(a, b string) (string, bool, error) {
- lo, hi := a, b
- if lo > hi {
- lo, hi = hi, lo
- }
+ lo, hi := canonPair(a, b)
var id string
err := db.sql.QueryRow("SELECT thread_id FROM dm_pairs WHERE user_a = ? AND user_b = ?", lo, hi).Scan(&id)
if err == sql.ErrNoRows {
@@ -884,14 +1174,156 @@ func (db *DB) dmThreadFor(a, b string) (string, bool, error) {
}
func (db *DB) recordDMPair(a, b, threadID string) error {
- lo, hi := a, b
- if lo > hi {
- lo, hi = hi, lo
- }
+ lo, hi := canonPair(a, b)
_, err := db.sql.Exec("INSERT INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", lo, hi, threadID)
return err
}
+// otherThreadMember returns the other member of a 2-person thread — the member
+// whose id isn't notUserID. Used to find the inviter of a pending DM.
+func (db *DB) otherThreadMember(threadID, notUserID string) (string, bool) {
+ var uid string
+ err := db.sql.QueryRow(
+ "SELECT user_id FROM thread_members WHERE thread_id = ? AND user_id <> ? LIMIT 1", threadID, notUserID,
+ ).Scan(&uid)
+ return uid, err == nil
+}
+
+// adoptUserInvite handles the case where the person opening a user invite is
+// ALREADY a member on this device: rather than the placeholder becoming them, we
+// wire their existing account into the DM. If they already share a DM with the
+// inviter we reuse it (and drop the pending one); otherwise we swap the placeholder
+// out of the pending thread for the real user. The placeholder — keyless, with no
+// messages of its own — is deleted either way. wrappedSelf is the thread key
+// wrapped to the existing user's public key. Returns the thread to open. ok=false
+// (no error) if the invite is unusable or lost the single-use race.
+func (db *DB) adoptUserInvite(inviteID, existingUserID, wrappedSelf string) (string, bool, error) {
+ ki, found, err := db.getKeyInvite(inviteID)
+ if err != nil || !found || ki.Used || ki.Expired || ki.ThreadID == "" {
+ return "", false, err
+ }
+ placeholder := ki.UserID
+ inviter, ok := db.otherThreadMember(ki.ThreadID, placeholder)
+ if !ok || inviter == existingUserID {
+ return "", false, nil // malformed, or you can't adopt your own invite
+ }
+ existing, haveDM, err := db.dmThreadFor(inviter, existingUserID)
+ if err != nil {
+ return "", false, err
+ }
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return "", false, err
+ }
+ defer tx.Rollback()
+ // Single-use guard: claim the invite; bail if someone beat us to it.
+ res, err := tx.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), inviteID)
+ if err != nil {
+ return "", false, err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return "", false, nil
+ }
+ oldLo, oldHi := canonPair(inviter, placeholder)
+ target := ki.ThreadID
+ if haveDM && existing != ki.ThreadID {
+ // Already have a DM together — discard the pending thread entirely.
+ target = existing
+ for _, q := range []string{
+ "DELETE FROM thread_keys WHERE thread_id = ?",
+ "DELETE FROM thread_members WHERE thread_id = ?",
+ "DELETE FROM threads WHERE id = ?",
+ } {
+ if _, err := tx.Exec(q, ki.ThreadID); err != nil {
+ return "", false, err
+ }
+ }
+ if _, err := tx.Exec("DELETE FROM dm_pairs WHERE user_a = ? AND user_b = ?", oldLo, oldHi); err != nil {
+ return "", false, err
+ }
+ } else {
+ // Swap the placeholder out of the pending thread for the real user.
+ now := time.Now().Unix()
+ if _, err := tx.Exec("DELETE FROM thread_members WHERE thread_id = ? AND user_id = ?", ki.ThreadID, placeholder); err != nil {
+ return "", false, err
+ }
+ if _, err := tx.Exec("DELETE FROM thread_keys WHERE thread_id = ? AND user_id = ?", ki.ThreadID, placeholder); err != nil {
+ return "", false, err
+ }
+ if _, err := tx.Exec("INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", ki.ThreadID, existingUserID, now); err != nil {
+ return "", false, err
+ }
+ if _, err := tx.Exec("INSERT OR REPLACE INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", ki.ThreadID, existingUserID, wrappedSelf); err != nil {
+ return "", false, err
+ }
+ // Re-key the dm_pair from (inviter, placeholder) to (inviter, existing).
+ newLo, newHi := canonPair(inviter, existingUserID)
+ if _, err := tx.Exec("DELETE FROM dm_pairs WHERE user_a = ? AND user_b = ?", oldLo, oldHi); err != nil {
+ return "", false, err
+ }
+ if _, err := tx.Exec("INSERT OR IGNORE INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", newLo, newHi, ki.ThreadID); err != nil {
+ return "", false, err
+ }
+ }
+ // The placeholder never joined (no key, no messages) — remove it.
+ if _, err := tx.Exec("DELETE FROM users WHERE id = ?", placeholder); err != nil {
+ return "", false, err
+ }
+ return target, true, tx.Commit()
+}
+
+// cancelUserInvite tears down an unclaimed pending DM: the thread, its keys and
+// members, the placeholder user, the DM pair, and the invite itself. Only the
+// inviter (a real, keyed member) may cancel, and only while the placeholder hasn't
+// joined (still keyless). ok=false (no error) if it doesn't qualify.
+func (db *DB) cancelUserInvite(threadID, inviterID string) (bool, error) {
+ // The invite for this pending thread, plus the placeholder it belongs to.
+ var inviteID, placeholder string
+ err := db.sql.QueryRow(
+ "SELECT id, user_id FROM key_invites WHERE thread_id = ? AND used_at IS NULL", threadID,
+ ).Scan(&inviteID, &placeholder)
+ if err == sql.ErrNoRows {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ // Guard: caller must be the (keyed) inviter, and the placeholder must not have
+ // joined (a joined member has a public key — cancelling then would delete a real
+ // person and their DM).
+ if !db.threadMember(threadID, inviterID) {
+ return false, nil
+ }
+ var pub sql.NullString
+ if err := db.sql.QueryRow("SELECT public_key FROM users WHERE id = ?", placeholder).Scan(&pub); err != nil {
+ return false, err
+ }
+ if pub.Valid && pub.String != "" {
+ return false, nil // they joined — not a pending invite anymore
+ }
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return false, err
+ }
+ defer tx.Rollback()
+ for _, s := range []struct {
+ q string
+ arg string
+ }{
+ {"DELETE FROM key_invites WHERE thread_id = ?", threadID},
+ {"DELETE FROM thread_keys WHERE thread_id = ?", threadID},
+ {"DELETE FROM thread_members WHERE thread_id = ?", threadID},
+ {"DELETE FROM dm_pairs WHERE thread_id = ?", threadID},
+ {"DELETE FROM threads WHERE id = ?", threadID},
+ {"DELETE FROM users WHERE id = ?", placeholder},
+ } {
+ if _, err := tx.Exec(s.q, s.arg); err != nil {
+ return false, err
+ }
+ }
+ return true, tx.Commit()
+}
+
// ---- messages -----------------------------------------------------------
type Message struct {
diff --git a/db_test.go b/db_test.go
index c881f0e..6de222c 100644
--- a/db_test.go
+++ b/db_test.go
@@ -201,7 +201,7 @@ func TestSelectRecipientsExcludesAllSenderDevices(t *testing.T) {
if err := db.createKeyInvite(id, user, "w", 0); err != nil {
t.Fatalf("createKeyInvite %s: %v", id, err)
}
- tok, ok, err := db.claimKeyInvite(id, "dev")
+ tok, ok, err := db.claimKeyInvite(id, "dev", "")
if err != nil || !ok {
t.Fatalf("claimKeyInvite %s: ok=%v err=%v", id, ok, err)
}
@@ -328,7 +328,7 @@ func TestUserStatus(t *testing.T) {
}
// Claim it: now joined on one device, no longer pending.
- if _, ok, err := db.claimKeyInvite("inv1", "phone"); err != nil || !ok {
+ if _, ok, err := db.claimKeyInvite("inv1", "phone", ""); err != nil || !ok {
t.Fatalf("claim inv1: ok=%v err=%v", ok, err)
}
users, _ = db.listUsers()
@@ -340,7 +340,7 @@ func TestUserStatus(t *testing.T) {
if err := db.createKeyInvite("inv2", uid, "wrapped", 0); err != nil {
t.Fatalf("createKeyInvite inv2: %v", err)
}
- if _, ok, err := db.claimKeyInvite("inv2", "laptop"); err != nil || !ok {
+ if _, ok, err := db.claimKeyInvite("inv2", "laptop", ""); err != nil || !ok {
t.Fatalf("claim inv2: ok=%v err=%v", ok, err)
}
users, _ = db.listUsers()
diff --git a/handlers_test.go b/handlers_test.go
index ba9ff5a..b56c7c9 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -496,7 +496,7 @@ func TestPostThreadDedupesDM(t *testing.T) {
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)
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "")
if err != nil || !ok {
t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
}
@@ -557,7 +557,7 @@ func TestThreadHiddenAndRemove(t *testing.T) {
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)
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "")
if err != nil || !ok {
t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
}
@@ -649,7 +649,7 @@ func joinedMember(t *testing.T, srv *Server, label string) (token, userID, threa
if err := srv.db.createKeyInvite("inv-"+label, uid, "wrap", 0); err != nil {
t.Fatalf("createKeyInvite: %v", err)
}
- tok, ok, err := srv.db.claimKeyInvite("inv-"+label, label)
+ tok, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "")
if err != nil || !ok {
t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err)
}
diff --git a/invites_test.go b/invites_test.go
new file mode 100644
index 0000000..5452753
--- /dev/null
+++ b/invites_test.go
@@ -0,0 +1,347 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestUserInviteSettingDefaultsOff(t *testing.T) {
+ db := newTestDB(t)
+ if db.usersCanInvite() {
+ t.Fatal("user invites should default off (safest posture)")
+ }
+ if err := db.setUsersCanInvite(true); err != nil {
+ t.Fatalf("setUsersCanInvite: %v", err)
+ }
+ if !db.usersCanInvite() {
+ t.Fatal("expected user invites on after enabling")
+ }
+}
+
+// connections is the per-member roster: self + anyone sharing a thread, never the
+// whole instance.
+func TestConnectionsRoster(t *testing.T) {
+ db := newTestDB(t)
+ mk := func(name string) string {
+ id, err := db.createUser(name)
+ if err != nil {
+ t.Fatalf("createUser %s: %v", name, err)
+ }
+ if err := db.setUserPublicKey(id, "pub-"+name); err != nil {
+ t.Fatalf("setUserPublicKey %s: %v", name, err)
+ }
+ return id
+ }
+ paul := mk("Paul")
+ wife := mk("Wife")
+ stranger := mk("Stranger") // shares no thread with Paul
+
+ if _, err := db.createThread("dm", "", []ThreadMemberKey{
+ {UserID: paul, Wrapped: "wp"}, {UserID: wife, Wrapped: "ww"},
+ }); err != nil {
+ t.Fatalf("createThread: %v", err)
+ }
+
+ conns, err := db.connections(paul)
+ if err != nil {
+ t.Fatalf("connections: %v", err)
+ }
+ got := map[string]bool{}
+ for _, e := range conns {
+ got[e.UserID] = true
+ }
+ if !got[paul] || !got[wife] {
+ t.Fatalf("connections should include self + Wife: %v", got)
+ }
+ if got[stranger] {
+ t.Fatal("connections must NOT include a stranger with no shared thread")
+ }
+ if len(conns) != 2 {
+ t.Fatalf("want 2 connections (self + Wife), got %d", len(conns))
+ }
+}
+
+// A user invite: a brand-new person is dropped into a pending DM, then joins it.
+func TestUserInviteFreshJoin(t *testing.T) {
+ db := newTestDB(t)
+ inviter, err := db.createUser("Paul")
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := db.setUserPublicKey(inviter, "pub-paul"); err != nil {
+ t.Fatalf("setUserPublicKey: %v", err)
+ }
+
+ threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot-wrapped", "self-wrapped", 0)
+ if err != nil {
+ t.Fatalf("createUserInvite: %v", err)
+ }
+
+ // Pending DM: inviter is keyed; invitee is a keyless member (no key until join).
+ if !db.threadMember(threadID, inviter) || !db.threadMember(threadID, invitee) {
+ t.Fatal("both should be members of the pending DM")
+ }
+ if w, ok, _ := db.wrappedKeyFor(threadID, inviter); !ok || w != "self-wrapped" {
+ t.Fatalf("inviter key: %q ok=%v", w, ok)
+ }
+ if _, ok, _ := db.wrappedKeyFor(threadID, invitee); ok {
+ t.Fatal("invitee should have no key until they join")
+ }
+
+ // The keyless invitee is a connection of the inviter (so the pending DM has a
+ // name), but has no public key yet.
+ conns, _ := db.connections(inviter)
+ var pending *RosterEntry
+ for i := range conns {
+ if conns[i].UserID == invitee {
+ pending = &conns[i]
+ }
+ }
+ if pending == nil {
+ t.Fatal("pending invitee should appear as a connection")
+ }
+ if pending.PublicKey != "" || pending.Label != "Grandma" {
+ t.Fatalf("pending invitee should be keyless with the given label: %+v", pending)
+ }
+
+ // Fresh join: the placeholder becomes them — token minted, DM key stored.
+ tok, ok, err := db.claimKeyInvite("inv1", "Grandma", "grandma-wrapped")
+ if err != nil || !ok || tok == "" {
+ t.Fatalf("claim: ok=%v tok=%q err=%v", ok, tok, err)
+ }
+ if uid, ok := db.deviceTokenUser(tok); !ok || uid != invitee {
+ t.Fatal("the minted token should belong to the invitee")
+ }
+ if w, ok, _ := db.wrappedKeyFor(threadID, invitee); !ok || w != "grandma-wrapped" {
+ t.Fatalf("invitee key after join: %q ok=%v", w, ok)
+ }
+ // DM dedupe holds for the now-joined pair.
+ if got, ok, _ := db.dmThreadFor(inviter, invitee); !ok || got != threadID {
+ t.Fatalf("dmThreadFor after join: %q ok=%v", got, ok)
+ }
+}
+
+// An existing member opening a user invite joins the pending DM as themselves; the
+// placeholder is swapped out and deleted.
+func TestUserInviteAdoptExisting(t *testing.T) {
+ db := newTestDB(t)
+ mk := func(name string) string {
+ id, _ := db.createUser(name)
+ db.setUserPublicKey(id, "pub-"+name)
+ return id
+ }
+ inviter := mk("Paul")
+ existing := mk("Bob")
+
+ threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", 0)
+ if err != nil {
+ t.Fatalf("createUserInvite: %v", err)
+ }
+
+ tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped")
+ if err != nil || !ok {
+ t.Fatalf("adopt: ok=%v err=%v", ok, err)
+ }
+ if tid != threadID {
+ t.Fatalf("adopt should reuse the pending thread, got %q want %q", tid, threadID)
+ }
+ if !db.threadMember(threadID, existing) {
+ t.Fatal("existing user should be a member")
+ }
+ if db.threadMember(threadID, placeholder) {
+ t.Fatal("placeholder should be removed from the thread")
+ }
+ if db.userExists(placeholder) {
+ t.Fatal("placeholder user should be deleted")
+ }
+ if w, ok, _ := db.wrappedKeyFor(threadID, existing); !ok || w != "bob-wrapped" {
+ t.Fatalf("existing user key: %q ok=%v", w, ok)
+ }
+ if got, ok, _ := db.dmThreadFor(inviter, existing); !ok || got != threadID {
+ t.Fatalf("dm pair should rekey to (inviter, existing): %q ok=%v", got, ok)
+ }
+ // Single-use: a second adopt fails.
+ if _, ok, _ := db.adoptUserInvite("inv1", existing, "x"); ok {
+ t.Fatal("invite should be single-use")
+ }
+}
+
+// If the adopting member already shares a DM with the inviter, reuse it and discard
+// the pending thread.
+func TestUserInviteAdoptDedupesExistingDM(t *testing.T) {
+ db := newTestDB(t)
+ mk := func(name string) string {
+ id, _ := db.createUser(name)
+ db.setUserPublicKey(id, "pub-"+name)
+ return id
+ }
+ inviter := mk("Paul")
+ existing := mk("Bob")
+ priorDM, err := db.createThread("dm", "", []ThreadMemberKey{
+ {UserID: inviter, Wrapped: "wp"}, {UserID: existing, Wrapped: "wb"},
+ })
+ if err != nil {
+ t.Fatalf("createThread: %v", err)
+ }
+ if err := db.recordDMPair(inviter, existing, priorDM); err != nil {
+ t.Fatalf("recordDMPair: %v", err)
+ }
+
+ pendingThread, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", 0)
+ if err != nil {
+ t.Fatalf("createUserInvite: %v", err)
+ }
+
+ tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped")
+ if err != nil || !ok {
+ t.Fatalf("adopt: ok=%v err=%v", ok, err)
+ }
+ if tid != priorDM {
+ t.Fatalf("adopt should reuse the existing DM, got %q want %q", tid, priorDM)
+ }
+ if db.threadKind(pendingThread) != "" {
+ t.Fatal("pending thread should be deleted")
+ }
+ if db.userExists(placeholder) {
+ t.Fatal("placeholder should be deleted")
+ }
+}
+
+func TestCancelUserInvite(t *testing.T) {
+ db := newTestDB(t)
+ inviter, _ := db.createUser("Paul")
+ db.setUserPublicKey(inviter, "pub-paul")
+ threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", 0)
+ if err != nil {
+ t.Fatalf("createUserInvite: %v", err)
+ }
+
+ // Only the inviter may cancel.
+ other, _ := db.createUser("Nosy")
+ if ok, _ := db.cancelUserInvite(threadID, other); ok {
+ t.Fatal("a non-member must not be able to cancel")
+ }
+ done, err := db.cancelUserInvite(threadID, inviter)
+ if err != nil || !done {
+ t.Fatalf("cancel: done=%v err=%v", done, err)
+ }
+ if db.threadKind(threadID) != "" {
+ t.Fatal("thread should be gone")
+ }
+ if db.userExists(placeholder) {
+ t.Fatal("placeholder user should be gone")
+ }
+ if _, found, _ := db.getKeyInvite("inv1"); found {
+ t.Fatal("invite should be gone")
+ }
+}
+
+// Security: once a user-invite's invitee has joined, a second still-unused invite
+// for them (e.g. a re-send whose earlier link was already used) must NOT be claimable
+// — otherwise it would mint a token as them and overwrite their DM key.
+func TestUserInviteClaimBlockedAfterJoin(t *testing.T) {
+ db := newTestDB(t)
+ inviter, _ := db.createUser("Paul")
+ db.setUserPublicKey(inviter, "pub-paul")
+ threadID, invitee, err := db.createUserInvite(inviter, "invA", "Grandma", "boot", "self", 0)
+ if err != nil {
+ t.Fatalf("createUserInvite: %v", err)
+ }
+ // A second, still-unused invite pointing at the same pending DM (a re-send).
+ if err := db.createKeyInviteWithThread("invB", invitee, "boot2", threadID, 0); err != nil {
+ t.Fatalf("createKeyInviteWithThread: %v", err)
+ }
+ // Invitee joins via invite A.
+ if _, ok, err := db.claimKeyInvite("invA", "Grandma", "grandma-key"); err != nil || !ok {
+ t.Fatalf("claim A: ok=%v err=%v", ok, err)
+ }
+ // Invite B must now be dead — the invitee already has a device token.
+ if tok, ok, _ := db.claimKeyInvite("invB", "Attacker", "attacker-key"); ok || tok != "" {
+ t.Fatal("a second user-invite must not be claimable after the invitee joined")
+ }
+}
+
+// Removing the inviter of a still-pending DM must tear the whole thing down (thread +
+// keyless placeholder + invite), so the invitee can't join a dead one-person chat.
+func TestRemoveUserTearsDownPendingInvite(t *testing.T) {
+ db := newTestDB(t)
+ inviter, _ := db.createUser("Paul")
+ db.setUserPublicKey(inviter, "pub-paul")
+ threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", 0)
+ if err != nil {
+ t.Fatalf("createUserInvite: %v", err)
+ }
+ if err := db.removeUser(inviter); err != nil {
+ t.Fatalf("removeUser: %v", err)
+ }
+ if db.threadKind(threadID) != "" {
+ t.Fatal("pending DM should be torn down")
+ }
+ if db.userExists(invitee) {
+ t.Fatal("keyless placeholder should be deleted")
+ }
+ if _, found, _ := db.getKeyInvite("inv1"); found {
+ t.Fatal("the pending invite should be gone")
+ }
+ if db.userExists(inviter) {
+ t.Fatal("the removed user should be gone")
+ }
+}
+
+// The /api/invites endpoint is gated by the admin setting, and the roster returned
+// to a member is their connections (self + the keyless invitee they just created).
+func TestUserInviteHTTPGateAndRoster(t *testing.T) {
+ ts, srv := newTestServer(t)
+ uid, _ := srv.db.createUser("Paul")
+ srv.db.createKeyInvite("inv-paul", uid, "", 0)
+ tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "")
+ srv.db.setUserPublicKey(uid, "pub-paul")
+
+ body := func(id string) string {
+ return `{"token":"` + tok + `","label":"Grandma","id":"` + id +
+ `","wrapped":"boot","self_wrapped":"self"}`
+ }
+
+ // Setting off (default) → forbidden.
+ resp, _ := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body("uinv1")))
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("invite while off: want 403, got %d", resp.StatusCode)
+ }
+
+ // Turn it on (admin), then invite succeeds.
+ if r := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true}`); r.StatusCode != http.StatusOK {
+ t.Fatalf("enable setting: got %d", r.StatusCode)
+ }
+ resp, _ = http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body("uinv2")))
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("invite while on: want 200, got %d", resp.StatusCode)
+ }
+ var out struct {
+ ThreadID string `json:"thread_id"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ if out.ThreadID == "" {
+ t.Fatal("expected a thread_id from the invite")
+ }
+
+ // Paul's roster is his connections: himself + the keyless pending invitee.
+ rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok)
+ var rout struct {
+ Roster []RosterEntry `json:"roster"`
+ }
+ json.NewDecoder(rr.Body).Decode(&rout)
+ var self, pending bool
+ for _, e := range rout.Roster {
+ if e.UserID == uid {
+ self = true
+ }
+ if e.Label == "Grandma" && e.PublicKey == "" {
+ pending = true
+ }
+ }
+ if !self || !pending {
+ t.Fatalf("roster should include self + keyless invitee: %+v", rout.Roster)
+ }
+}
diff --git a/main.go b/main.go
index 8d4669e..04e9002 100644
--- a/main.go
+++ b/main.go
@@ -115,6 +115,8 @@ func (s *Server) handler() http.Handler {
// directory; threads (DMs/groups) carry server-blind, per-member wrapped keys.
mux.HandleFunc("POST /api/pubkey", s.setPubkey)
mux.HandleFunc("GET /api/roster", s.getRoster)
+ mux.HandleFunc("GET /api/me", s.getMe)
+ mux.HandleFunc("POST /api/device/signout", s.signout)
// Instance key + profile photos: a shared key for all-members content (avatars).
mux.HandleFunc("GET /api/instance-key", s.getInstanceKey)
mux.HandleFunc("POST /api/instance-key", s.postInstanceKey)
@@ -126,6 +128,7 @@ 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}/cancel-invite", s.cancelInvite)
mux.HandleFunc("POST /api/threads/{id}/remove", s.postThreadRemove)
mux.HandleFunc("POST /api/threads/{id}/hidden", s.postThreadHidden)
@@ -146,10 +149,14 @@ func (s *Server) handler() http.Handler {
// open; claiming mints a device token. See docs/invite-redesign.md.
mux.HandleFunc("GET /api/key-invite/{id}", s.getKeyInvite)
mux.HandleFunc("POST /api/key-invite/{id}/claim", s.claimKeyInvite)
+ mux.HandleFunc("POST /api/key-invite/{id}/adopt", s.adoptKeyInvite)
mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite)
mux.HandleFunc("GET /api/admin/users", s.listUsers)
+ mux.HandleFunc("POST /api/admin/users/{id}/remove", s.removeUser)
// A member can mint a fresh invite for their own account (add a device).
mux.HandleFunc("POST /api/device-invites", s.createDeviceInvite)
+ // A member can invite a new person straight into a DM (gated by an admin setting).
+ mux.HandleFunc("POST /api/invites", s.postUserInvite)
// Group name (public read so the page/manifest can show it; admin write)
mux.HandleFunc("GET /api/group", s.getGroup)
@@ -162,6 +169,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/admin/invites", s.createInvite)
mux.HandleFunc("GET /api/admin/invites", s.listInvites)
mux.HandleFunc("POST /api/admin/invites/{token}/revoke", s.revokeInvite)
+ mux.HandleFunc("GET /api/admin/settings", s.getSettings)
+ mux.HandleFunc("POST /api/admin/settings", s.setSettings)
// Web Push (VAPID self-generated; pushed payload is ciphertext, decrypted in the SW)
mux.HandleFunc("GET /api/push/vapid", s.getVAPID)
@@ -202,6 +211,9 @@ func (s *Server) servePage(name string) http.HandlerFunc {
// build version (so the page knows which frontend it loaded — see /api/version).
page := strings.ReplaceAll(string(s.pages[name]), "__GROUP_NAME__", html.EscapeString(s.db.getGroupName()))
page = strings.ReplaceAll(page, "__APP_VERSION__", s.version)
+ // Whether ordinary members may invite people (gates the invite UI; the server
+ // enforces it regardless). Injected so the client needs no extra round trip.
+ page = strings.ReplaceAll(page, "__USER_INVITES__", boolStr(s.db.usersCanInvite()))
w.Write([]byte(page))
}
}
@@ -377,6 +389,14 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad public key")
return
}
+ // A user's public key is set once, on first join. Refuse to overwrite it with a
+ // DIFFERENT key — that would orphan every thread wrapped to the old key (e.g. an
+ // admin "device link" that made a new device generate its own keypair). Adding a
+ // device ships the SAME keypair, so re-registering the identical key is fine.
+ if cur := s.db.userPublicKey(uid); cur != "" && cur != body.PublicKey {
+ httpError(w, http.StatusConflict, "public key already registered")
+ return
+ }
if err := s.db.setUserPublicKey(uid, body.PublicKey); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -384,14 +404,22 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
-// getRoster returns the open directory of joined members (label + public key) so
-// any member can start a thread with anyone.
+// getRoster returns the caller's connections — themselves plus everyone they
+// already share a thread with (a DM or a group). It is deliberately NOT the whole
+// instance: you reach new people by inviting them, not by browsing a directory of
+// everyone on the server. (A legacy token with no user gets an empty roster.)
func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) {
- if !s.tokenOK(r.URL.Query().Get("token")) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
httpError(w, http.StatusForbidden, "not a member")
return
}
- entries, err := s.db.roster()
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"roster": []RosterEntry{}})
+ return
+ }
+ entries, err := s.db.connections(uid)
if err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -399,6 +427,44 @@ func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"roster": entries})
}
+// getMe reports facts about the caller's own account — currently just how many
+// devices they have signed in, so the client can warn before signing out the last
+// one (which loses their keys, and the chats encrypted to them, for good).
+func (s *Server) getMe(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"device_count": 0})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"user_id": uid, "device_count": s.db.deviceCount(uid)})
+}
+
+// signout revokes the caller's own device token (sign out on this device) and drops
+// its push subscriptions. Other devices keep working; the key survives on them.
+func (s *Server) signout(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ // Token-gate like every data endpoint; an unknown token has nothing to revoke, so
+ // treat it as already signed out (idempotent) rather than acting on it.
+ if !s.tokenOK(body.Token) {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ return
+ }
+ _ = s.db.deleteDeviceToken(body.Token)
+ _ = s.db.deleteSubscriptionsForToken(body.Token)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// getThreads returns the caller's threads, each with their wrapped key + members.
func (s *Server) getThreads(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
@@ -1025,18 +1091,35 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{"ok": ok}
if ok {
resp["wrapped"] = ki.Wrapped
+ // A user invite drops the joiner into a pending DM. The client uses this to
+ // (a) re-wrap the DM key to itself and (b) know an existing member can adopt.
+ resp["dm"] = ki.ThreadID != ""
+ // The name the inviter suggested, to pre-fill the joiner's screen name (they
+ // can still change it). Not secret — the id is only computable from the secret.
+ resp["label"] = s.db.userLabel(ki.UserID)
}
writeJSON(w, http.StatusOK, resp)
}
// claimKeyInvite marks the invite used (single-use) and mints a device token for
-// ongoing auth. The secret never reaches the server — only this claim does.
+// ongoing auth. The secret never reaches the server — only this claim does. For a
+// user invite, `wrapped` is the pending DM's key wrapped to the joiner's own new
+// public key; we store it so they can read the DM straight away, and return the
+// thread id so the client opens it first.
func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) {
var body struct {
- Label string `json:"label"`
+ Label string `json:"label"`
+ Wrapped string `json:"wrapped"`
}
json.NewDecoder(r.Body).Decode(&body)
- tok, ok, err := s.db.claimKeyInvite(r.PathValue("id"), strings.TrimSpace(body.Label))
+ if len(body.Wrapped) > 4096 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ id := r.PathValue("id")
+ // Grab the pending DM (if any) before the claim consumes the invite.
+ ki, _, _ := s.db.getKeyInvite(id)
+ tok, ok, err := s.db.claimKeyInvite(id, strings.TrimSpace(body.Label), body.Wrapped)
if err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -1045,7 +1128,11 @@ func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "invite unavailable")
return
}
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "token": tok})
+ resp := map[string]any{"ok": true, "token": tok}
+ if ki.ThreadID != "" {
+ resp["thread_id"] = ki.ThreadID
+ }
+ writeJSON(w, http.StatusOK, resp)
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
@@ -1060,6 +1147,18 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
+// removeUser deletes a person from the instance (admin roster management).
+func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ if err := s.db.removeUser(r.PathValue("id")); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// createDeviceInvite lets a member mint a fresh invite for their OWN account (to
// add another device), authenticated by their existing token. The browser wraps
// the key it already holds; the server stays blind. Ties to the device token's
@@ -1100,6 +1199,153 @@ func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// postUserInvite lets an ordinary member invite a brand-new person straight into a
+// DM — gated by the "users can invite" admin setting (off by default). The browser
+// generates the DM key and hands us only wrapped blobs: `wrapped` is the key
+// encrypted to the invite secret (so the joiner recovers it), `self_wrapped` is it
+// wrapped to the inviter's own public key. With no thread_id we create the pending
+// invitee + DM + invite in one go; with a thread_id we re-issue a fresh link for an
+// existing pending invitee (a lost/expired link, same chat). The server stays blind.
+func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Label string `json:"label"`
+ ID string `json:"id"`
+ Wrapped string `json:"wrapped"`
+ SelfWrapped string `json:"self_wrapped"`
+ ThreadID string `json:"thread_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
+ }
+ if !s.db.usersCanInvite() {
+ httpError(w, http.StatusForbidden, "inviting is turned off")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "no user for token")
+ return
+ }
+ if body.ID == "" || len(body.ID) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+
+ // Re-send: a fresh link for an existing pending invitee (same DM). Only the
+ // inviter (a member of that pending DM) may, and only while it's still pending.
+ if body.ThreadID != "" {
+ if !s.db.threadMember(body.ThreadID, uid) || s.db.threadKind(body.ThreadID) != "dm" {
+ httpError(w, http.StatusForbidden, "not your pending chat")
+ return
+ }
+ invitee, found := s.db.otherThreadMember(body.ThreadID, uid)
+ if !found {
+ httpError(w, http.StatusBadRequest, "no invitee")
+ return
+ }
+ // Only re-send while they're still a keyless placeholder. Once they've joined
+ // (registered a public key), a fresh claimable invite for them would be an
+ // account-takeover vector — and there's nothing to re-send anyway.
+ if s.db.userPublicKey(invitee) != "" {
+ httpError(w, http.StatusBadRequest, "they've already joined")
+ return
+ }
+ if err := s.db.createKeyInviteWithThread(body.ID, invitee, body.Wrapped, body.ThreadID, 0); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "thread_id": body.ThreadID, "user_id": invitee})
+ return
+ }
+
+ label := strings.TrimSpace(body.Label)
+ if label == "" || len(label) > 60 || body.SelfWrapped == "" || len(body.SelfWrapped) > 4096 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, label, body.Wrapped, body.SelfWrapped, 0)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "thread_id": threadID, "user_id": inviteeID})
+}
+
+// adoptKeyInvite handles a user invite opened by someone who is ALREADY a member on
+// this device: their existing account joins the pending DM (deduped if they already
+// share one), instead of the placeholder becoming a new user. `wrapped` is the DM
+// key wrapped to their existing public key. Auth is their own device token.
+func (s *Server) adoptKeyInvite(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Wrapped string `json:"wrapped"`
+ }
+ 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
+ }
+ if body.Wrapped == "" || len(body.Wrapped) > 4096 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ threadID, ok, err := s.db.adoptUserInvite(r.PathValue("id"), uid, body.Wrapped)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !ok {
+ httpError(w, http.StatusForbidden, "invite unavailable")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "thread_id": threadID})
+}
+
+// cancelInvite tears down a pending DM whose invitee never joined (thread, keys,
+// members, placeholder, invite). Only the inviter may, and only while it's pending.
+func (s *Server) cancelInvite(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ 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
+ }
+ done, err := s.db.cancelUserInvite(r.PathValue("id"), uid)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !done {
+ httpError(w, http.StatusBadRequest, "not a pending invite")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// ---- admin API ----------------------------------------------------------
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
@@ -1184,11 +1430,13 @@ func (s *Server) subscribePush(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "missing subscription")
return
}
+ uid, _ := s.db.deviceTokenUser(body.Token) // "" for a legacy (non-device) token
if err := s.db.addSubscription(PushSub{
Endpoint: body.Subscription.Endpoint,
Token: body.Token,
P256dh: body.Subscription.Keys.P256dh,
Auth: body.Subscription.Keys.Auth,
+ UserID: uid,
}); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -1273,6 +1521,38 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
+// ---- instance settings (admin) ------------------------------------------
+
+// getSettings returns the instance settings the admin console manages.
+func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"user_invites": s.db.usersCanInvite()})
+}
+
+// setSettings toggles instance settings. Fields are pointers so an omitted field
+// is left unchanged (a partial update).
+func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ var body struct {
+ UserInvites *bool `json:"user_invites"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if body.UserInvites != nil {
+ if err := s.db.setUsersCanInvite(*body.UserInvites); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_invites": s.db.usersCanInvite()})
+}
+
// ---- helpers ------------------------------------------------------------
// noCache makes browsers revalidate static assets, so deploys take effect without
@@ -1294,6 +1574,13 @@ func httpError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]any{"error": msg})
}
+func boolStr(b bool) string {
+ if b {
+ return "1"
+ }
+ return "0"
+}
+
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
diff --git a/push.go b/push.go
index 739894b..31ca476 100644
--- a/push.go
+++ b/push.go
@@ -18,15 +18,20 @@ type PushSub struct {
Token string
P256dh string
Auth string
+ UserID string // who this belongs to (so removing a user drops their subs)
}
func (db *DB) addSubscription(s PushSub) error {
+ var uid any
+ if s.UserID != "" {
+ uid = s.UserID
+ }
_, err := db.sql.Exec(
- `INSERT INTO push_subscriptions(endpoint, token, p256dh, auth, created_at)
- VALUES(?, ?, ?, ?, ?)
+ `INSERT INTO push_subscriptions(endpoint, token, p256dh, auth, user_id, created_at)
+ VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
- token=excluded.token, p256dh=excluded.p256dh, auth=excluded.auth`,
- s.Endpoint, s.Token, s.P256dh, s.Auth, time.Now().Unix(),
+ token=excluded.token, p256dh=excluded.p256dh, auth=excluded.auth, user_id=excluded.user_id`,
+ s.Endpoint, s.Token, s.P256dh, s.Auth, uid, time.Now().Unix(),
)
return err
}
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 8aff9b7..6bba372 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -50,12 +50,16 @@
</section>
<section>
- <h2>Invite someone</h2>
- <p class="sub" style="margin:0 0 8px">Creates a short, single-use link (and QR) for one person to join. They pick a screen name and their device generates its own keys — the server never sees any key.</p>
- <form id="createForm" class="inline-form" autocomplete="off">
- <input id="label" placeholder="Their name (e.g. Sophie)" maxlength="60" />
- <button type="submit">Create invite</button>
- </form>
+ <h2>Who can invite</h2>
+ <p class="sub" style="margin:0 0 8px">
+ By default only you (the admin) can invite people. Turn this on to let members
+ invite each other — each member can then start a chat with someone new and
+ share a join link themselves. The server never sees any key either way.
+ </p>
+ <label class="toggle-row">
+ <input type="checkbox" id="userInvites" />
+ <span>Let members invite other people</span>
+ </label>
</section>
<section>
@@ -75,8 +79,15 @@
<section>
<h2>People</h2>
- <p class="sub" style="margin:0 0 8px">Each person can have several devices. Re-issue a link to add a device or recover a lost one.</p>
- <div id="people"><p class="sub">Loading…</p></div>
+ <div id="bootstrapInvite">
+ <p class="sub" style="margin:0 0 10px">Invite the first person with a short, single-use link (and QR) — they pick a screen name and their device makes its own keys, so the server never sees any key. After someone joins, people are added from inside the chat.</p>
+ <form id="createForm" class="inline-form" autocomplete="off">
+ <input id="label" placeholder="Their name (e.g. Sophie)" maxlength="60" />
+ <button type="submit">Create invite</button>
+ </form>
+ </div>
+ <p id="inviteGuidance" class="sub hidden" style="margin:0 0 10px">People are added from inside the chat now — turn on “Let members invite other people” above, then use “Invite someone” when starting a new chat. That way the new person is connected the moment they join.</p>
+ <div id="people" style="margin-top:16px"><p class="sub">Loading…</p></div>
</section>
</div>
</div>
diff --git a/web/pages/index.html b/web/pages/index.html
index 7644d5b..a73e8ed 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -2,6 +2,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="theme-color" content="#5b6cff" />
<meta name="app-version" content="__APP_VERSION__" />
+<!-- Whether ordinary members may invite people (admin setting). Gates the invite
+ UI only; the server enforces it regardless. "1" = on, "0" = off. -->
+<meta name="user-invites" content="__USER_INVITES__" />
<title>__GROUP_NAME__</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="manifest" href="/manifest.webmanifest" />
@@ -60,7 +63,7 @@
<div class="menu hidden" id="menu" role="menu">
<button class="menu-item" id="menu-notif" role="menuitem">Notifications</button>
<button class="menu-item" id="menu-theme" role="menuitem">Theme</button>
- <button class="menu-item" id="menu-add-device" role="menuitem">Add a device</button>
+ <button class="menu-item" id="menu-add-device" role="menuitem">Add a device / back up</button>
<button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button>
<button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button>
</div>
diff --git a/web/static/admin.js b/web/static/admin.js
index 7ad6aef..a2e2d38 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -35,12 +35,31 @@ async function init() {
document.getElementById("copyMaster").onclick = () =>
copy(`${location.origin}/admin#token=${adminToken}`, "Admin link copied");
wireGroupName();
+ wireUserInvites();
wireTestPush();
wireCreate();
wireCreateAgent();
renderUsers((await res.json()).users || []);
}
+// ---- who can invite -----------------------------------------------------
+
+async function wireUserInvites() {
+ const box = document.getElementById("userInvites");
+ try {
+ const { user_invites } = await (await api("GET", "/api/admin/settings")).json();
+ box.checked = !!user_invites;
+ } catch {}
+ box.onchange = async () => {
+ const res = await api("POST", "/api/admin/settings", { user_invites: box.checked });
+ if (!res.ok) {
+ box.checked = !box.checked; // revert on failure
+ return toast("Couldn't save that");
+ }
+ toast(box.checked ? "Members can now invite people" : "Only you can invite now");
+ };
+}
+
// ---- group name ---------------------------------------------------------
async function wireGroupName() {
@@ -129,8 +148,14 @@ async function loadUsers() {
function renderUsers(users) {
const box = document.getElementById("people");
box.innerHTML = "";
+ // Bootstrap mode: the admin's bare invite exists only to create the FIRST member.
+ // Once anyone has joined, adding more happens in-app (members invite each other,
+ // which connects the newcomer via a DM) — so hide the bare invite and point there.
+ const anyJoined = users.some((u) => u.devices > 0);
+ document.getElementById("bootstrapInvite").classList.toggle("hidden", anyJoined);
+ document.getElementById("inviteGuidance").classList.toggle("hidden", !anyJoined);
if (!users.length) {
- box.innerHTML = `<p class="sub">No one yet. Invite someone above.</p>`;
+ box.innerHTML = `<p class="sub">No one yet. Invite the first person above.</p>`;
return;
}
for (const u of users) {
@@ -158,17 +183,41 @@ function renderUsers(users) {
meta.appendChild(status);
row.appendChild(meta);
- // Both actions mint a fresh single-use link for this same person; the wording
- // reflects intent (add a device vs. re-send an invite that hasn't landed yet).
- // Any earlier link still works — the server can't revoke what it can't unlock.
- const reissue = document.createElement("button");
- reissue.className = "secondary";
- reissue.textContent = joined ? "New device link" : "Resend link";
- reissue.onclick = async () => {
- const minted = await mintInvite(u.label, u.id);
- if (minted) showInviteModal(minted.link, u.label || (joined ? "New device" : "Invite"));
+ if (joined) {
+ // The admin CAN'T mint a working add-device link for someone already joined —
+ // only their existing device holds the private key needed to read their threads.
+ // A fresh admin link would make the new device generate a different keypair and
+ // orphan every thread wrapped to the old one. So point them at the in-app flow.
+ const note = document.createElement("div");
+ note.className = "device-note";
+ note.textContent = "To add a device, use “Add a device” in the app on a device they're already on.";
+ row.appendChild(note);
+ } else if (!u.pending_dm) {
+ // A bare (admin) pending invitee: a fresh single-use link is safe — they
+ // generate their keypair on join. (Invitees waiting on a member's in-app DM
+ // invite have pending_dm=true; that link is re-sent from inside the chat by the
+ // inviter, since a bare admin link would drop them into a keyless, dead DM.)
+ const reissue = document.createElement("button");
+ reissue.className = "secondary";
+ reissue.textContent = "Resend link";
+ reissue.onclick = async () => {
+ const minted = await mintInvite(u.label, u.id);
+ if (minted) showInviteModal(minted.link, u.label || "Invite");
+ };
+ row.appendChild(reissue);
+ }
+
+ // Remove a person from the instance (roster management).
+ const remove = document.createElement("button");
+ remove.className = "linkish danger";
+ remove.textContent = "Remove";
+ remove.onclick = async () => {
+ if (!confirm(`Remove ${u.label || "this person"} from the chat? This can't be undone.`)) return;
+ const res = await api("POST", `/api/admin/users/${encodeURIComponent(u.id)}/remove`);
+ toast(res.ok ? "Removed" : "Couldn't remove");
+ if (res.ok) loadUsers();
};
- row.appendChild(reissue);
+ row.appendChild(remove);
box.appendChild(row);
}
diff --git a/web/static/chat.js b/web/static/chat.js
index 855ffa8..8d76cc7 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -227,6 +227,12 @@ const ICON_TRASH =
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<polyline points="3 6 5 6 21 6"></polyline>' +
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
+// Overlapping pages, for the "Copy" action in the mobile long-press menu.
+const ICON_COPY =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<rect x="9" y="9" width="12" height="12" rx="2"></rect>' +
+ '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
// These five always ALIAS the open thread's feed-cache entry (see feedCache /
// bindThreadState). All the render/update code operates on "the active thread"
@@ -261,6 +267,10 @@ const threadKeys = {}; // thread id -> CryptoKey
const threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new members
const threadName = {}; // thread id -> display name
const threadPreview = {}; // thread id -> last-message snippet
+// Pending-invite links, keyed by the (pending DM) thread id. Only known in the
+// session that minted them — the secret never reaches the server, so it can't be
+// recovered after a reload (the pending chat offers "re-send" to mint a fresh one).
+const inviteLinks = {};
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)
@@ -290,6 +300,7 @@ async function boot() {
connectWS();
registerSW(session); // best-effort; subscribes if notifications already on
startHeartbeat();
+ setTimeout(maybeNudgeBackup, 2500); // once, nudge single-device users to back up
// Tapping a notification while a tab is already open posts us the thread to
// open (the SW can't navigate a live SPA) — jump straight to it.
if (navigator.serviceWorker) {
@@ -515,6 +526,10 @@ function syncThreadKeysToSW() {
// The frontend build this page loaded with (server-injected into the HTML).
const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).content || "";
+// Whether ordinary members may invite people (server-injected admin setting). Gates
+// the invite UI only; the server enforces it regardless.
+const USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1";
+
// On returning to the app, ask the server for the current build hash; if it shipped
// a newer frontend, reload to pick it up — unless that would clobber an unsent draft.
let updateChecking = false;
@@ -692,6 +707,16 @@ function renderApp() {
const text = document.getElementById("text");
const sendBtn = document.getElementById("send-btn");
const composer = document.getElementById("composer");
+ // Touch: while the keyboard is up the composer sits above it, so the home-indicator
+ // safe-area inset below would just be dead space — drop it while focused.
+ text.addEventListener("focus", () => composer.classList.add("editing"));
+ text.addEventListener("blur", () => composer.classList.remove("editing"));
+ // Tapping the conversation dismisses the keyboard (it otherwise stays up across sends).
+ const feedForBlur = document.getElementById("feed");
+ if (feedForBlur)
+ feedForBlur.addEventListener("pointerdown", () => {
+ if (document.activeElement === text) text.blur();
+ });
text.addEventListener("input", () => {
updateSendVisibility();
text.style.height = "auto";
@@ -736,6 +761,7 @@ function renderApp() {
text.style.height = "auto";
sendBtn.hidden = true;
closeTypeahead();
+ if (isCoarse) text.focus(); // keep the on-screen keyboard up after sending
};
// Attachments: pick / drag-drop / paste an image into the open thread.
@@ -773,7 +799,12 @@ function renderApp() {
// so boot() can drop the loading cover once the feed is in place (no jump).
const deep = initialThreadId();
let target = null;
- if (deep && threads.some((t) => t.id === deep)) {
+ // A fresh join (set by the join flow) wins — land straight in that new chat.
+ const justJoined = localStorage.getItem("lchat_open_thread");
+ if (justJoined) localStorage.removeItem("lchat_open_thread");
+ if (justJoined && threads.some((t) => t.id === justJoined)) {
+ target = justJoined;
+ } else if (deep && threads.some((t) => t.id === deep)) {
history.replaceState(null, "", location.pathname + location.search); // consume the deep link
target = deep;
} else {
@@ -1127,6 +1158,9 @@ async function openThread(id) {
document.getElementById("thread-empty").classList.add("hidden");
document.getElementById("thread-view").classList.remove("hidden");
paintThreadHeader(id);
+ // A pending DM (invitee not joined yet) shows a waiting-to-join banner with the
+ // share link; a normal chat clears any stale banner.
+ updatePendingBanner(threads.find((x) => x.id === id));
document.getElementById("tt").classList.add("show-thread");
renderThreadList();
@@ -1320,7 +1354,15 @@ async function appendMessage(threadId, m, scroll = true) {
feed.appendChild(wrap);
refreshSeparator(wrap); // centered time separator above, if the gap warrants
refreshSenderName(wrap); // hide a repeated "them" name within a run
- if (scroll) feed.scrollTop = feed.scrollHeight;
+ if (scroll) {
+ // A live send/receive glides the feed up so the new bubble eases into view.
+ // Reduced-motion falls back to an instant jump.
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ feed.scrollTop = feed.scrollHeight;
+ } else {
+ feed.scrollTo({ top: feed.scrollHeight, behavior: "smooth" });
+ }
+ }
}
// Fetch an OpenGraph preview for a URL and append a card below the bubble. The
@@ -1501,15 +1543,240 @@ function openReactionPicker(msgId, anchor) {
setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
}
-// Touch = double-tap; desktop = press-and-hold (≈450ms). Long-press on touch is
-// left free so text selection still works (see CLAUDE.md).
+// The actions in the mobile long-press menu, in a sensible order: Reply, Copy, then
+// your-own Edit (in-window) and Delete. Each: { label, icon, danger?, run }.
+function messageActions(msgId) {
+ const d = msgData[msgId];
+ if (!d) return [];
+ const acts = [];
+ if (d.replyMeta) acts.push({ label: "Reply", icon: ICON_REPLY, run: () => startReply(d.replyMeta) });
+ if (d.data && d.data.text) acts.push({ label: "Copy", icon: ICON_COPY, run: () => copyMessageText(d.data.text) });
+ if (d.mine && d.data && d.data.text && Date.now() - d.createdAt * 1000 < EDIT_WINDOW_MS)
+ acts.push({ label: "Edit", icon: ICON_EDIT, run: () => startEdit(msgId) });
+ if (d.mine)
+ acts.push({
+ label: "Delete",
+ icon: ICON_TRASH,
+ danger: true,
+ run: () => {
+ if (confirm("Delete this message for everyone? This can't be undone.")) deleteMessage(msgId);
+ },
+ });
+ return acts;
+}
+
+async function copyMessageText(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast("Copied");
+ } catch {
+ toast("Couldn't copy");
+ }
+}
+
+// A brief status pill (reuses the #toast element).
+let toastTimer;
+function toast(msg) {
+ const t = document.getElementById("toast");
+ if (!t) return;
+ t.textContent = msg;
+ t.classList.add("show");
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.classList.remove("show"), 1600);
+}
+
+// Mobile long-press menu (WhatsApp-style): a dim backdrop with the emoji reactions
+// floating above the message and a labelled action menu below it.
+function openMessageSheet(msgId, wrap) {
+ document.querySelector(".msg-sheet")?.remove();
+ const bubble = wrap.querySelector(".bubble") || wrap;
+ const brect = bubble.getBoundingClientRect(); // for horizontal alignment
+ const rect = wrap.getBoundingClientRect(); // the whole message (what we lift)
+ const mine = !!(msgData[msgId] && msgData[msgId].mine);
+
+ const backdrop = document.createElement("div");
+ backdrop.className = "msg-sheet";
+
+ // A non-interactive copy of the message, lifted above the dim so it stays legible
+ // while the reactions float above it and the menu below — the whole cluster is
+ // repositioned vertically so both fit even when the message is at the screen edge.
+ const clone = wrap.cloneNode(true);
+ clone.classList.add("msg-sheet-clone");
+ clone.classList.remove("appear");
+ clone.style.left = rect.left + "px";
+ clone.style.width = rect.width + "px";
+
+ const reactions = document.createElement("div");
+ reactions.className = "msg-sheet-reactions";
+ for (const emoji of REACTIONS) {
+ const b = document.createElement("button");
+ b.className = "sheet-emoji";
+ b.textContent = emoji;
+ b.onclick = () => {
+ toggleReaction(msgId, emoji);
+ close();
+ };
+ reactions.appendChild(b);
+ }
+
+ const menu = document.createElement("div");
+ menu.className = "msg-sheet-menu";
+ for (const a of messageActions(msgId)) {
+ const item = document.createElement("button");
+ item.className = "msg-sheet-item" + (a.danger ? " danger" : "");
+ const label = document.createElement("span");
+ label.textContent = a.label;
+ const icon = document.createElement("span");
+ icon.className = "msg-sheet-icon";
+ icon.innerHTML = a.icon; // static in-code SVG (safe)
+ item.appendChild(label);
+ item.appendChild(icon);
+ item.onclick = () => {
+ a.run();
+ close();
+ };
+ menu.appendChild(item);
+ }
+
+ backdrop.appendChild(clone);
+ backdrop.appendChild(reactions);
+ backdrop.appendChild(menu);
+ document.body.appendChild(backdrop);
+
+ const M = 8;
+ const GAP = 10;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const rH = reactions.offsetHeight;
+ const mH = menu.offsetHeight;
+ // Cap the lifted message so the reactions + menu always fit around it.
+ const maxMsgH = Math.max(60, vh - 2 * M - rH - mH - 2 * GAP);
+ const bH = Math.min(rect.height, maxMsgH);
+ if (rect.height > bH) {
+ clone.style.maxHeight = bH + "px";
+ clone.style.overflow = "hidden";
+ }
+ // Keep the message near where it was, but slide it just enough that the reactions
+ // fit above and the menu below.
+ const minTop = M + rH + GAP;
+ const maxTop = Math.max(minTop, vh - M - mH - GAP - bH);
+ const msgTop = Math.max(minTop, Math.min(rect.top, maxTop));
+ clone.style.top = msgTop + "px";
+
+ const placeX = (el) => {
+ const w = el.offsetWidth;
+ const left = mine ? brect.right - w : brect.left;
+ return Math.max(M, Math.min(left, vw - w - M));
+ };
+ reactions.style.top = msgTop - GAP - rH + "px";
+ reactions.style.left = placeX(reactions) + "px";
+ menu.style.top = msgTop + bH + GAP + "px";
+ menu.style.left = placeX(menu) + "px";
+
+ // Hide the original while its lifted copy is shown; restore on close.
+ wrap.style.visibility = "hidden";
+
+ function close() {
+ wrap.style.visibility = "";
+ backdrop.remove();
+ document.removeEventListener("pointerdown", onDoc, true);
+ }
+ const onDoc = (e) => {
+ if (!reactions.contains(e.target) && !menu.contains(e.target)) close();
+ };
+ // Deferred so the press that opened the sheet doesn't immediately close it.
+ setTimeout(() => document.addEventListener("pointerdown", onDoc, true), 0);
+}
+
+// Double-tap (touch) → a quick emoji reactions bar. Just the emojis: the actions
+// (reply/copy/edit/delete) live in the long-press menu. Reuses the compact picker.
+function openReactionBar(msgId, anchor) {
+ document.querySelector(".emoji-picker")?.remove();
+ const pop = document.createElement("div");
+ pop.className = "emoji-picker";
+ for (const emoji of REACTIONS) {
+ const b = document.createElement("button");
+ b.className = "emoji-opt";
+ b.textContent = emoji;
+ b.onclick = () => {
+ toggleReaction(msgId, emoji);
+ pop.remove();
+ };
+ pop.appendChild(b);
+ }
+ document.body.appendChild(pop);
+ const r = anchor.getBoundingClientRect();
+ pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
+ pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
+ const close = (e) => {
+ if (!pop.contains(e.target)) {
+ pop.remove();
+ document.removeEventListener("pointerdown", close, true);
+ }
+ };
+ setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
+}
+
+// Touch: long-press → the message menu (reactions + reply/copy/edit/delete); a quick
+// double-tap → the reactions bar. Desktop keeps press-and-hold (≈450ms) opening the
+// emoji/action picker + the hover strip.
function attachReactionGesture(wrap, msgId) {
if (isCoarse) {
- let last = 0;
+ let timer = null;
+ let sx = 0;
+ let sy = 0;
+ let lastTap = 0;
+ let longFired = false;
+ const cancel = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+ wrap.addEventListener(
+ "touchstart",
+ (e) => {
+ cancel();
+ longFired = false;
+ if (e.touches.length !== 1) return;
+ sx = e.touches[0].clientX;
+ sy = e.touches[0].clientY;
+ timer = setTimeout(() => {
+ timer = null;
+ longFired = true;
+ openMessageSheet(msgId, wrap);
+ }, 500);
+ },
+ { passive: true }
+ );
+ wrap.addEventListener(
+ "touchmove",
+ (e) => {
+ if (!timer) return;
+ const t = e.touches[0];
+ // A drag (swipe-to-reply or a scroll) isn't a long-press.
+ if (Math.abs(t.clientX - sx) > 10 || Math.abs(t.clientY - sy) > 10) cancel();
+ },
+ { passive: true }
+ );
wrap.addEventListener("touchend", () => {
+ const wasLong = longFired;
+ cancel();
+ if (wasLong) {
+ lastTap = 0; // the long-press already opened the menu — not a tap
+ return;
+ }
const now = Date.now();
- if (now - last < 300) openReactionPicker(msgId, wrap);
- last = now;
+ if (now - lastTap < 300) {
+ lastTap = 0;
+ openReactionBar(msgId, wrap); // second quick tap
+ } else {
+ lastTap = now;
+ }
+ });
+ wrap.addEventListener("touchcancel", () => {
+ cancel();
+ lastTap = 0;
});
} else {
let timer = null;
@@ -2033,6 +2300,7 @@ function openLightbox(url) {
back.className = "lightbox";
const img = document.createElement("img");
img.src = url;
+ img.draggable = false;
back.appendChild(img);
const close = () => {
back.remove();
@@ -2041,9 +2309,91 @@ function openLightbox(url) {
const onKey = (e) => {
if (e.key === "Escape") close();
};
- back.onclick = close;
+ // Tapping the backdrop (not the image) closes.
+ back.addEventListener("click", (e) => {
+ if (e.target === back) close();
+ });
document.addEventListener("keydown", onKey);
document.body.appendChild(back);
+
+ // Flick-to-dismiss with momentum: drag the image and a fast flick (or a big drag)
+ // flings it off in the release direction, carrying its velocity; otherwise it
+ // springs back. A plain tap closes. Velocity is px/ms, sampled between moves.
+ let dragging = false;
+ let moved = false;
+ let startX = 0;
+ let startY = 0;
+ let x = 0;
+ let y = 0;
+ let lastX = 0;
+ let lastY = 0;
+ let lastT = 0;
+ let vx = 0;
+ let vy = 0;
+ img.addEventListener("pointerdown", (e) => {
+ dragging = true;
+ moved = false;
+ startX = lastX = e.clientX;
+ startY = lastY = e.clientY;
+ lastT = e.timeStamp || performance.now();
+ vx = vy = x = y = 0;
+ img.style.transition = "none";
+ img.setPointerCapture?.(e.pointerId);
+ });
+ img.addEventListener("pointermove", (e) => {
+ if (!dragging) return;
+ x = e.clientX - startX;
+ y = e.clientY - startY;
+ if (Math.abs(x) > 6 || Math.abs(y) > 6) moved = true;
+ const now = e.timeStamp || performance.now();
+ const dt = now - lastT;
+ if (dt > 0) {
+ vx = (e.clientX - lastX) / dt;
+ vy = (e.clientY - lastY) / dt;
+ }
+ lastX = e.clientX;
+ lastY = e.clientY;
+ lastT = now;
+ // Fade the backdrop as the image is dragged away.
+ back.style.background = `rgba(0,0,0,${Math.max(0, 0.9 - Math.hypot(x, y) / 700)})`;
+ img.style.transform = `translate(${x}px, ${y}px) rotate(${x / 40}deg)`;
+ });
+ const release = (e) => {
+ if (!dragging) return;
+ dragging = false;
+ const speed = Math.hypot(vx, vy); // px/ms
+ const dist = Math.hypot(x, y);
+ if (speed < 0.55 && dist < 130) {
+ if (!moved) return close(); // a tap
+ // Not enough — spring back.
+ img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
+ img.style.transform = "translate(0,0)";
+ back.style.transition = "background 0.28s";
+ back.style.background = "";
+ return;
+ }
+ // Fling: continue in the release direction (fall back to the drag direction if it
+ // was a slow long drag), far enough to clear the screen, backdrop fading out.
+ let dx = vx;
+ let dy = vy;
+ if (speed < 0.05) {
+ dx = x;
+ dy = y;
+ }
+ const norm = Math.hypot(dx, dy) || 1;
+ const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25;
+ const tx = x + (dx / norm) * reach;
+ const ty = y + (dy / norm) * reach;
+ const spin = Math.max(-65, Math.min(65, tx / 20)); // tumbles more as it leaves
+ img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)";
+ img.style.transform = `translate(${tx}px, ${ty}px) rotate(${spin}deg)`;
+ back.style.transition = "opacity 0.45s, background 0.45s";
+ back.style.background = "rgba(0,0,0,0)";
+ back.style.opacity = "0";
+ setTimeout(close, 480);
+ };
+ img.addEventListener("pointerup", release);
+ img.addEventListener("pointercancel", release);
}
// ---- websocket ----------------------------------------------------------
@@ -2226,6 +2576,14 @@ function connectWS() {
}
if (msg.type !== "msg") return;
const tid = msg.thread_id;
+ // A message in a DM we thought was pending means the invited person has joined
+ // and started chatting — refresh roster + threads so their identity lands and the
+ // "waiting to join" state clears (only they can post here until they've joined).
+ const knownPending = threads.find((x) => x.id === tid);
+ if (knownPending && pendingInvitee(knownPending)) {
+ await loadRoster();
+ await loadThreads();
+ }
if (!threadKeys[tid]) {
// A thread we don't know yet (someone just added us / started a DM).
await loadThreads();
@@ -2249,6 +2607,9 @@ function connectWS() {
} catch {}
}
renderThreadList();
+ // If the just-joined invitee's message cleared the pending state, drop the
+ // waiting card and reveal the normal feed/composer.
+ if (tid === activeId) updatePendingBanner(threads.find((x) => x.id === tid));
};
ws.onclose = () => {
wsConnected = false;
@@ -2749,8 +3110,21 @@ function existingDM(userId) {
return threads.find((t) => t.kind === "dm" && (t.members || []).includes(userId));
}
+// Press Enter in a sheet field to fire its primary action (create / open the top
+// match). Ignores Shift+Enter and IME composition.
+function submitOnEnter(input, action) {
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
+ e.preventDefault();
+ action();
+ }
+ });
+}
+
function showNewChat() {
- const others = roster.filter((e) => e.user_id !== myUserId);
+ // Only joined connections can be picked for a new DM/group. Keyless entries are
+ // pending invitees who haven't joined yet — they already have their own chat.
+ const others = roster.filter((e) => e.user_id !== myUserId && e.public_key);
const backdrop = modal();
const card = backdrop.querySelector(".sheet");
@@ -2776,22 +3150,74 @@ function showNewChat() {
const head = document.createElement("div");
head.className = "sheet-head";
const h = document.createElement("h2");
- h.textContent = mode === "group" ? "New group" : "New chat";
+ h.textContent = mode === "group" ? "New group" : mode === "invite" ? "Invite someone" : "New chat";
head.appendChild(h);
- if (mode !== "group" && others.length) {
- const toggle = document.createElement("button");
- toggle.className = "linkish";
- toggle.textContent = "New group";
- toggle.onclick = () => render("group");
- head.appendChild(toggle);
+ // Secondary actions for the default "people" view: switch to a group, and — when
+ // the admin allows it — invite someone new to the server.
+ if (mode === "people") {
+ if (others.length) {
+ const toggle = document.createElement("button");
+ toggle.className = "linkish";
+ toggle.textContent = "New group";
+ toggle.onclick = () => render("group");
+ head.appendChild(toggle);
+ }
+ if (USER_INVITES) {
+ const inv = document.createElement("button");
+ inv.className = "linkish";
+ inv.textContent = "Invite someone";
+ inv.onclick = () => render("invite");
+ head.appendChild(inv);
+ }
}
card.appendChild(head);
+ // Invite a brand-new person: name them, mint a link, open the pending DM.
+ if (mode === "invite") {
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.style.padding = "0 4px 6px";
+ note.textContent =
+ "They'll get a private chat with you and a link to join. Anyone with the link can read that chat, so share it directly with them.";
+ card.appendChild(note);
+ const who = document.createElement("input");
+ who.placeholder = "Their name (e.g. Grandma)";
+ who.maxLength = 60;
+ who.className = "sheet-input";
+ card.appendChild(who);
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => render("people");
+ const create = document.createElement("button");
+ create.textContent = "Create invite";
+ create.disabled = true;
+ who.addEventListener("input", () => (create.disabled = !who.value.trim()));
+ create.onclick = () => go(inviteSomeone(who.value.trim()), create);
+ submitOnEnter(who, () => create.click());
+ foot.appendChild(cancel);
+ foot.appendChild(create);
+ card.appendChild(foot);
+ who.focus();
+ return;
+ }
+
if (!others.length) {
const p = document.createElement("p");
p.className = "sub";
- p.textContent = "No one else has joined yet. Invite people from the admin page.";
- card.appendChild(p);
+ if (USER_INVITES) {
+ p.textContent = "No connections yet — invite someone to start chatting.";
+ card.appendChild(p);
+ const btn = document.createElement("button");
+ btn.textContent = "Invite someone";
+ btn.onclick = () => render("invite");
+ card.appendChild(btn);
+ } else {
+ p.textContent = "No one else to chat with yet. Ask the admin to invite people.";
+ card.appendChild(p);
+ }
return;
}
@@ -2866,6 +3292,14 @@ function showNewChat() {
filter.addEventListener("input", renderList);
renderList();
+ // Enter submits: in a group, create it; in the picker, open the top match.
+ submitOnEnter(filter, () => {
+ if (mode === "group") return create.click();
+ const first = list.querySelector("button.list-row");
+ if (first) first.click();
+ });
+ if (mode === "group") submitOnEnter(nameInput, () => create.click());
+
if (mode === "group") {
const foot = document.createElement("div");
foot.className = "sheet-foot";
@@ -2918,6 +3352,190 @@ async function createThread(otherIds, opts = {}) {
return r.thread_id;
}
+// ---- inviting a new person straight into a DM ---------------------------
+
+// inviteSomeone creates a pending DM with a brand-new person plus a link they use
+// to join. The browser generates the DM key, wraps it to us (so we hold it) and
+// encrypts it to the invite secret (so the joiner recovers it) — the server only
+// stores blobs. Returns the pending thread id; the link lands in inviteLinks.
+async function inviteSomeone(name) {
+ const T = randomSecret();
+ const { id, wrapKey } = await deriveInvite(T);
+ const { key, b64 } = await generateKey();
+ const me = roster.find((e) => e.user_id === myUserId);
+ if (!me || !me.public_key) throw new Error("your account isn't loaded yet");
+ const selfWrapped = await wrapThreadKey(b64, me.public_key);
+ const wrapped = await encrypt(wrapKey, { threadKey: b64 });
+ const r = await (
+ await fetch("/api/invites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, label: name, id, wrapped, self_wrapped: selfWrapped }),
+ })
+ ).json();
+ if (!r.thread_id) throw new Error("invite failed");
+ threadKeys[r.thread_id] = key;
+ threadKeyB64[r.thread_id] = b64;
+ inviteLinks[r.thread_id] = inviteLink(name, T);
+ // Add the (keyless) invitee to our local roster now, so the pending DM titles by
+ // their name straight away — without waiting for the next connections reload.
+ if (r.user_id && !roster.some((e) => e.user_id === r.user_id)) {
+ roster.push({ user_id: r.user_id, label: name, public_key: "", avatar: "" });
+ }
+ return r.thread_id;
+}
+
+// resendInvite mints a fresh link for a pending invitee whose earlier link was lost
+// (the secret is never recoverable from the server). Re-encrypts the DM key we
+// still hold to a new secret, tied to the same pending chat. Returns the new link.
+async function resendInvite(threadId) {
+ const b64 = threadKeyB64[threadId];
+ if (!b64) throw new Error("no key");
+ const t = threads.find((x) => x.id === threadId);
+ const other = (t.members || []).find((uid) => uid !== myUserId);
+ const name = rosterLabel(other);
+ const T = randomSecret();
+ const { id, wrapKey } = await deriveInvite(T);
+ const wrapped = await encrypt(wrapKey, { threadKey: b64 });
+ const r = await (
+ await fetch("/api/invites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, id, wrapped, thread_id: threadId }),
+ })
+ ).json();
+ if (!r.ok) throw new Error("resend failed");
+ inviteLinks[threadId] = inviteLink(name, T);
+ return inviteLinks[threadId];
+}
+
+// cancelInvitedChat tears down a pending DM whose invitee never joined.
+async function cancelInvitedChat(threadId) {
+ if (!confirm("Cancel this invite and remove the chat?")) return;
+ try {
+ await fetch(`/api/threads/${encodeURIComponent(threadId)}/cancel-invite`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ } catch {}
+ delete inviteLinks[threadId];
+ delete threadKeys[threadId];
+ delete threadKeyB64[threadId];
+ delete feedCache[threadId];
+ activeId = null;
+ await loadThreads();
+ renderThreadList();
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ showList();
+}
+
+// pendingInvitee returns the roster entry of a DM's other member when they haven't
+// joined yet (keyless), so a caller can show their name — or null for a normal chat.
+function pendingInvitee(t) {
+ if (!t || t.kind !== "dm") return null;
+ const other = (t.members || []).find((uid) => uid !== myUserId);
+ const e = roster.find((x) => x.user_id === other);
+ return e && !e.public_key ? e : null;
+}
+
+// The waiting-to-join state for a pending DM: a centered card (the feed + composer
+// are hidden via the `.waiting` class while there's no one to talk to yet) showing
+// the join link with copy, or a re-send button, plus cancel. Clears — restoring the
+// normal feed/composer — once the invitee has joined.
+function updatePendingBanner(t) {
+ const view = document.getElementById("thread-view");
+ let banner = document.getElementById("pending-banner");
+ const invitee = pendingInvitee(t);
+ if (!invitee) {
+ view.classList.remove("waiting");
+ if (banner) banner.remove();
+ return;
+ }
+ view.classList.add("waiting");
+ if (!banner) {
+ banner = document.createElement("div");
+ banner.id = "pending-banner";
+ banner.className = "pending-banner";
+ view.appendChild(banner); // feed/composer are hidden by .waiting, so order is moot
+ }
+ banner.innerHTML = "";
+ const card = document.createElement("div");
+ card.className = "pending-card";
+
+ const msg = document.createElement("p");
+ msg.textContent = `${invitee.label || "They"} hasn't joined yet. Share this link so they can — anyone with it can read this chat.`;
+ card.appendChild(msg);
+
+ const link = inviteLinks[t.id];
+ if (link) {
+ const box = document.createElement("div");
+ box.className = "key-box mono";
+ box.textContent = link;
+ box.onclick = () => selectAllText(box);
+ card.appendChild(box);
+ }
+
+ const row = document.createElement("div");
+ row.className = "row";
+ if (link) {
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "secondary";
+ copyBtn.textContent = "Copy link";
+ copyBtn.onclick = () => copyToButton(link, copyBtn);
+ row.appendChild(copyBtn);
+ } else {
+ const get = document.createElement("button");
+ get.className = "secondary";
+ get.textContent = "Get invite link";
+ get.onclick = async () => {
+ get.disabled = true;
+ get.textContent = "Creating…";
+ try {
+ await resendInvite(t.id);
+ updatePendingBanner(t);
+ } catch {
+ get.disabled = false;
+ get.textContent = "Couldn't — try again";
+ }
+ };
+ row.appendChild(get);
+ }
+ const cancel = document.createElement("button");
+ cancel.className = "linkish danger";
+ cancel.textContent = "Cancel invite";
+ cancel.onclick = () => cancelInvitedChat(t.id);
+ row.appendChild(cancel);
+ card.appendChild(row);
+ banner.appendChild(card);
+}
+
+// /join/<slug>#<secret> — the slug is cosmetic; the secret is the whole key.
+function inviteLink(name, T) {
+ const slug = (name || "join").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "join";
+ return `${location.origin}/join/${encodeURIComponent(slug)}#${T}`;
+}
+
+function selectAllText(el) {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const sel = getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+}
+
+async function copyToButton(text, btn) {
+ const label = btn.textContent;
+ try {
+ await navigator.clipboard.writeText(text);
+ btn.textContent = "Link copied ✓";
+ } catch {
+ btn.textContent = "Select the link above to copy";
+ }
+ setTimeout(() => (btn.textContent = label), 2000);
+}
+
// ---- add a device -------------------------------------------------------
async function addDevice() {
@@ -2941,10 +3559,10 @@ async function addDevice() {
const backdrop = modal();
const card = backdrop.querySelector(".sheet");
const h = document.createElement("h2");
- h.textContent = "Add a device";
+ h.textContent = "Add a device or back up";
const note = document.createElement("p");
note.className = "sub";
- note.textContent = "Scan this on your other device, or open the link there. It works once — anyone who uses it signs in as you.";
+ note.textContent = "Open this on another device to sign in there — or save it somewhere safe (like a password manager) as your backup: it's the only way back in if you lose this device. It works once, and anyone who uses it signs in as you.";
const qr = document.createElement("div");
qr.className = "qr-wrap";
try {
@@ -3132,11 +3750,77 @@ async function uploadAvatar(file) {
if (me) me.avatar = id; // so avatarFor() picks it up immediately
}
-function leave() {
- if (confirm("Leave the chat on this device? You'll need an invite link to rejoin.")) {
- localStorage.removeItem(SESSION_KEY);
- location.reload();
+async function leave() {
+ // How many devices hold this account's key? Signing out the LAST one loses the key
+ // — and every chat encrypted to it — for good (there's no server-side copy).
+ let count = 1;
+ try {
+ const me = await (await fetch(`/api/me?token=${encodeURIComponent(session.token)}`)).json();
+ if (typeof me.device_count === "number") count = me.device_count;
+ } catch {}
+
+ if (count > 1) {
+ if (confirm("Sign out on this device? Your other devices stay signed in.")) doSignout();
+ return;
}
+
+ // Only device: warn hard and offer to save a backup link first.
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Sign out of your only device?";
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent =
+ "Your chats are encrypted to a key that lives only on this device. Sign out and you'll lose access to them for good — there's no way to recover it. Save a backup link first so you can get back in.";
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const out = document.createElement("button");
+ out.className = "secondary";
+ out.textContent = "Sign out anyway";
+ out.onclick = () => {
+ backdrop.remove();
+ doSignout();
+ };
+ const backup = document.createElement("button");
+ backup.textContent = "Save a backup first";
+ backup.onclick = () => {
+ backdrop.remove();
+ addDevice(); // save that link, then sign out later if you still want to
+ };
+ foot.appendChild(out);
+ foot.appendChild(backup);
+ card.appendChild(h);
+ card.appendChild(p);
+ card.appendChild(foot);
+}
+
+// Once, gently nudge a single-device user to save a backup, so a lost device doesn't
+// mean lost chats. Cheap: skips the network check entirely after the first nudge.
+async function maybeNudgeBackup() {
+ if (localStorage.getItem("lchat_backup_nudged")) return;
+ try {
+ const me = await (await fetch(`/api/me?token=${encodeURIComponent(session.token)}`)).json();
+ if (me && me.device_count === 1) {
+ localStorage.setItem("lchat_backup_nudged", "1");
+ toast("Tip: back up your account via the ⋮ menu → “Add a device / back up”, so you never lose your chats.");
+ }
+ } catch {}
+}
+
+// Revoke this device's token server-side (so it stops counting / receiving push) and
+// clear the local session.
+function doSignout() {
+ fetch("/api/device/signout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ })
+ .catch(() => {})
+ .finally(() => {
+ localStorage.removeItem(SESSION_KEY);
+ location.reload();
+ });
}
// ---- onboarding: paste-to-join + iOS install ----------------------------
@@ -3148,12 +3832,24 @@ async function claimInvite(T, name) {
const { id, wrapKey } = await deriveInvite(T);
const info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json();
if (!info.ok) throw new Error("invite unavailable");
- let pub, priv;
+ let pub, priv, wrappedSelf = "";
if (info.wrapped) {
- const kp = await decrypt(wrapKey, info.wrapped); // add-device: {pub, priv}
- pub = kp.pub;
- priv = kp.priv;
- } else {
+ const payload = await decrypt(wrapKey, info.wrapped);
+ if (payload.priv) {
+ // Add-device link: it carries this user's own keypair.
+ pub = payload.pub;
+ priv = payload.priv;
+ } else if (payload.threadKey) {
+ // User invite: a new person joining into a DM — fresh keypair, and re-wrap the
+ // DM key to it so we can read the chat straight away. (Paste-to-join only runs
+ // with no existing session, so this is always a brand-new person.)
+ const kp = await generateUserKeypair();
+ pub = kp.publicKey;
+ priv = kp.privateKey;
+ wrappedSelf = await wrapThreadKey(payload.threadKey, pub);
+ }
+ }
+ if (!pub) {
const kp = await generateUserKeypair();
pub = kp.publicKey;
priv = kp.privateKey;
@@ -3162,7 +3858,7 @@ async function claimInvite(T, name) {
await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ label: name }),
+ body: JSON.stringify({ label: name, wrapped: wrappedSelf }),
})
).json();
if (!claim.ok || !claim.token) throw new Error("claim failed");
@@ -3172,6 +3868,8 @@ async function claimInvite(T, name) {
body: JSON.stringify({ token: claim.token, public_key: pub }),
});
localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv }));
+ // Land straight in the DM this invite created (boot opens it).
+ if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id);
}
function parseLink(text) {
diff --git a/web/static/join.js b/web/static/join.js
index 49df1d9..54ec1c2 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -3,7 +3,7 @@
// the server. Joining sets up the user's keypair — generated fresh for a new
// person, or unwrapped from the link for an added device — registers the public
// key in the roster, claims a device token, and stores the session.
-import { decrypt, generateUserKeypair, deriveInvite } from "/static/crypto.js";
+import { decrypt, generateUserKeypair, deriveInvite, wrapThreadKey } from "/static/crypto.js";
const SESSION_KEY = "lchat_session";
const card = document.getElementById("card");
@@ -38,33 +38,54 @@ async function start() {
}
if (info.wrapped) {
- // Add-device link: it carries this user's keypair (and name) — just sign in.
- let kp;
+ let payload;
try {
- kp = await decrypt(wrapKey, info.wrapped);
+ payload = await decrypt(wrapKey, info.wrapped);
} catch {
return showError("Couldn't unlock this link (wrong or corrupt secret).");
}
- showStatus("Signing you in…");
- return finish(id, fragName || "Me", kp.pub, kp.priv);
+ if (payload.priv) {
+ // Add-device link: it carries this user's keypair (and name) — just sign in.
+ showStatus("Signing you in…");
+ return finish(id, fragName || "Me", payload.pub, payload.priv);
+ }
+ if (payload.threadKey) {
+ // User invite: it drops the joiner into a DM with the person who invited them.
+ const existing = loadSession();
+ if (existing && existing.token && existing.userPub && existing.userPriv) {
+ // Already a member on this device → join that DM as your existing self.
+ showStatus("Opening your chat…");
+ return adoptExisting(id, payload.threadKey, existing);
+ }
+ // New person: pick a screen name (pre-filled with the inviter's suggestion),
+ // generate a keypair, join into the DM.
+ return showForm(fragName || info.label, async (name) => {
+ showStatus("Setting up…");
+ const kp = await generateUserKeypair();
+ const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
+ finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf);
+ });
+ }
+ // Unknown payload shape — fall through and treat as a fresh join.
}
- // New person: pick a screen name, then generate a fresh keypair.
- showForm(fragName, async (name) => {
+ // New person: pick a screen name (pre-filled if the inviter suggested one), then
+ // generate a fresh keypair.
+ showForm(fragName || info.label, async (name) => {
showStatus("Setting up…");
const kp = await generateUserKeypair();
finish(id, name, kp.publicKey, kp.privateKey);
});
}
-async function finish(id, name, pub, priv) {
+async function finish(id, name, pub, priv, wrappedSelf = "") {
let claim;
try {
claim = await (
await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ label: name }),
+ body: JSON.stringify({ label: name, wrapped: wrappedSelf }),
})
).json();
} catch {
@@ -81,13 +102,46 @@ async function finish(id, name, pub, priv) {
});
} catch {}
localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv }));
+ // Land straight in the DM this invite created (the app opens it on boot).
+ if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id);
location.href = "/"; // drop the fragment from the URL bar
}
+// adoptExisting joins the invite's pending DM as the member already signed in on
+// this device (rather than creating a new person). We wrap the DM key to our
+// existing public key and let the server swap us in for the placeholder.
+async function adoptExisting(id, threadKeyB64, existing) {
+ try {
+ const wrapped = await wrapThreadKey(threadKeyB64, existing.userPub);
+ const r = await (
+ await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: existing.token, wrapped }),
+ })
+ ).json();
+ if (!r.ok) {
+ return showError("This invite has been used or is no longer valid. Ask for a new one.");
+ }
+ if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
+ location.href = "/";
+ } catch {
+ showError("Couldn't reach the server. Try again.");
+ }
+}
+
+function loadSession() {
+ try {
+ return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
+ } catch {
+ return null;
+ }
+}
+
// ---- views --------------------------------------------------------------
function showForm(defaultName, onSubmit) {
- card.classList.remove("onboard");
+ card.classList.remove("onboard", "status");
card.innerHTML = "";
card.appendChild(document.getElementById("form-tpl").content.cloneNode(true));
const input = card.querySelector("#name");
@@ -103,15 +157,19 @@ function showForm(defaultName, onSubmit) {
function showStatus(msg) {
card.classList.remove("onboard");
+ card.classList.add("status"); // centers the spinner + message in the card
card.innerHTML = "";
+ const spinner = document.createElement("div");
+ spinner.className = "status-spinner";
const p = document.createElement("p");
- p.className = "sub";
+ p.className = "status-msg";
p.textContent = msg;
+ card.appendChild(spinner);
card.appendChild(p);
}
function showError(msg) {
- card.classList.remove("onboard");
+ card.classList.remove("onboard", "status");
card.innerHTML = "";
card.appendChild(document.getElementById("error-tpl").content.cloneNode(true));
card.querySelector("#error-msg").textContent = msg;
diff --git a/web/static/style.css b/web/static/style.css
index 99eabfe..dcf3b17 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -214,6 +214,23 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.card h1 { margin: 0 0 6px; font-size: 24px; font-family: var(--font-display); }
.card p { margin: 0 0 18px; color: var(--muted); }
+
+/* Transient join states ("Setting up…", "Signing you in…") — a spinner + message
+ centered in the card, instead of a small line stuck top-left. */
+.card.status {
+ min-height: 180px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+.card.status .status-msg { margin: 0; font-size: 16px; color: var(--ink); }
+.status-spinner {
+ width: 30px; height: 30px; border-radius: 50%;
+ border: 3px solid var(--border); border-top-color: var(--accent);
+ animation: spin 0.8s linear infinite; margin-bottom: 14px; /* @keyframes spin defined above */
+}
.card .row { display: flex; gap: 10px; margin-top: 14px; }
@@ -405,7 +422,15 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
justify-content: center;
padding: 20px;
}
-.lightbox img { max-width: 100%; max-height: 100%; border-radius: 8px; }
+.lightbox img {
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: 8px;
+ touch-action: none; /* we own the drag: flick-to-dismiss, no browser pan/zoom */
+ user-select: none;
+ -webkit-user-drag: none;
+ will-change: transform;
+}
/* open animation: the backdrop fades, the image rises into place */
.lightbox { animation: fade-in 0.18s ease; }
@@ -584,6 +609,66 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.picker-action svg { width: 22px; height: 22px; }
.picker-action:hover { background: var(--bubble-them); }
+/* mobile long-press message menu (WhatsApp-style): a dim backdrop, the emoji
+ reactions floating above the message, and a labelled action menu below it
+ (see openMessageSheet in chat.js). */
+.msg-sheet {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ background: rgba(0, 0, 0, 0.38);
+ animation: fade-in 0.15s ease;
+}
+/* the lifted copy of the pressed message, above the dim (see openMessageSheet) */
+.msg-sheet-clone {
+ position: fixed;
+ margin: 0 !important;
+ pointer-events: none;
+}
+.msg-sheet-reactions,
+.msg-sheet-menu {
+ position: absolute;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ box-shadow: 0 12px 40px var(--shadow);
+}
+.msg-sheet-reactions {
+ display: flex;
+ gap: 2px;
+ padding: 5px 7px;
+ border-radius: 999px;
+}
+.sheet-emoji {
+ background: transparent;
+ font-size: 26px;
+ line-height: 1;
+ padding: 6px 8px;
+ border-radius: 50%;
+}
+.sheet-emoji:active { background: var(--bubble-them); }
+.msg-sheet-menu {
+ min-width: 200px;
+ border-radius: 14px;
+ overflow: hidden;
+}
+.msg-sheet-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ width: 100%;
+ padding: 13px 16px;
+ background: transparent;
+ font-size: 16px;
+ text-align: left;
+ color: var(--ink);
+}
+.msg-sheet-item:active { background: var(--bubble-them); }
+.msg-sheet-item.danger { color: var(--danger); }
+.msg-sheet-icon { display: inline-flex; color: var(--muted); flex: none; }
+.msg-sheet-item.danger .msg-sheet-icon { color: var(--danger); }
+.msg-sheet-icon svg { width: 20px; height: 20px; }
+
/* a reply: a tappable quote of the parent sits above the bubble (à la Messages),
and the parent gets a "N Replies" tag below it. */
.reply-quote {
@@ -756,6 +841,12 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
Scoped to coarse pointers so desktop stays at the bare inset (≈0). */
@media (pointer: coarse) {
.composer { padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
+ /* While typing, the keyboard covers the home-indicator inset, so drop it entirely
+ (the composer sits directly on the keyboard) — .editing is toggled on focus. */
+ .composer.editing { padding-bottom: 0; }
+ /* Long-press opens the message menu, so suppress the native selection/callout that
+ would otherwise fight it (Copy lives in the menu instead). */
+ .feed .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
/* A phone is held further from the eye than a laptop screen sits — bump the
core reading + typing text up a notch so messages are comfortable. Desktop
(fine pointer) keeps its sizes. Composer stays ≥16px so iOS doesn't
@@ -801,6 +892,17 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.send-btn[hidden] { display: none; }
.send-btn:disabled { opacity: 0.45; cursor: default; }
+/* Enlarge the send button's tap target — mostly to the right, into the composer's
+ padding — so a near-miss on touch triggers Send instead of falling through to the
+ field/feed and dismissing the keyboard. */
+.send-btn::after {
+ content: "";
+ position: absolute;
+ top: -10px;
+ right: -20px;
+ bottom: -10px;
+ left: -6px;
+}
/* left-side attach (+) button */
.attach-btn {
flex: none;
@@ -890,6 +992,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.invite .meta { flex: 1; min-width: 0; }
.invite .meta .label { font-weight: 600; }
.invite .meta .status { font-size: 12px; color: var(--muted); }
+.invite .device-note { flex: none; max-width: 220px; font-size: 12px; color: var(--muted); text-align: right; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.key-box {
background: var(--bubble-them);
@@ -923,6 +1026,42 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.steps li { margin-bottom: 6px; }
.steps strong { color: var(--ink); }
+/* admin: a labelled checkbox row (e.g. "let members invite people") */
+.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
+.toggle-row input { width: 18px; height: 18px; }
+
+/* pending DM: waiting for an invited person to join (web/static/chat.js). While
+ waiting there's no one to talk to, so the feed + composer are hidden and a single
+ centered card fills the pane. It clears once the invitee joins and chats. */
+.thread-view.waiting .feed,
+.thread-view.waiting .composer,
+.thread-view.waiting .composer-reply,
+.thread-view.waiting .composer-edit,
+.thread-view.waiting .composer-preview,
+.thread-view.waiting .typing { display: none !important; }
+.pending-banner {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ overflow-y: auto;
+}
+.pending-card {
+ width: 100%;
+ max-width: 480px;
+ text-align: center;
+ padding: 22px 20px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--bubble-them);
+}
+.pending-card p { margin: 0 0 14px; font-size: 15px; color: var(--muted); }
+.pending-card .key-box { margin: 0 0 14px; text-align: left; }
+.pending-card .row { justify-content: center; gap: 10px; flex-wrap: wrap; }
+.linkish.danger { color: var(--danger); }
+
/* ---- multi-thread layout (docs/threads-and-dms.md) ---- */
.tt { display: flex; height: 100%; width: 100%; background: var(--panel); }
/* floating connection-status pill (only shown during a sustained outage) */
@@ -997,6 +1136,25 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.thread-row-time { font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; }
.unread-dot { width: 8px; height: 8px; border-radius: 50%; background: transparent; flex: none; }
.unread-dot.on { background: var(--accent); }
+/* Mobile list treatment (à la Messages/WhatsApp): rows run edge-to-edge, the active
+ state is a full-width fill (no rounded bubble), and a hairline divider separates
+ rows starting just after the avatar (not under it). */
+@media (pointer: coarse) {
+ .thread-list { padding: 0; gap: 0; }
+ .thread-row { border-radius: 0; padding: 11px 16px; position: relative; }
+ .thread-row::after {
+ content: "";
+ position: absolute;
+ left: calc(16px + 52px + 12px); /* row padding-left + avatar width + gap */
+ right: 0;
+ bottom: 0;
+ height: 1px;
+ background: var(--border);
+ }
+ /* No divider under the last row, nor under the active row (its fill owns the edge). */
+ .thread-row:last-child::after,
+ .thread-row.active::after { display: none; }
+}
.thread-pane { flex: 1; display: flex; min-height: 0; min-width: 0; position: relative; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--muted); gap: 6px; text-align: center; padding: 24px; }
.empty-art { font-size: 42px; }