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>
13 files changed,
+2159
−108
CLAUDE.md+5 −2db.go+456 −24db_test.go+3 −3handlers_test.go+3 −3invites_test.go+347 −0main.go+295 −8push.go+9 −4web/pages/admin.html+19 −8web/pages/index.html+4 −1web/static/admin.js+60 −11web/static/chat.js+728 −30web/static/join.js+71 −13web/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; } |