Open community groups: discover + self-serve join, key under the instance key (#623)
* server: open community groups — visibility flag, instance-key open_key, directory + self-join
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: open groups — create-open toggle, Discover sheet, instance-key unwrap path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios+mac: unwrap open-group thread keys via the instance key in ThreadStore.fold
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios+mac: unwrap open-group thread keys via the instance key; changelog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: announce self-joins in the room ("Paul joined")
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review fixes: open_key as true fallback on bad wraps (web+native), joined-member add is a no-op (junk-wrap guard), self-join renders 'Paul joined', Discover error states, boot-race re-fold
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Default groups: new members auto-join admin-marked open groups (#635)
* server+web: default groups — admins mark open groups auto-joined by new members
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: design pass on open-group surfaces — roster-style Discover rows, opt-rows with consequences, inviting empty state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Native Discover + join: open groups land on iOS and Mac (#640)
* ios+mac: native Discover + join, create-open toggles
Shared DiscoverGroupsView in LChatCore (monogram rows, one loud Join, quiet
Open, key-pending + empty states); iOS: New chat gains a Discover row,
New group gains an Open group toggle; Mac: listHeader menu gains Discover
Groups… sheet, NewChatPopover gains the checkbox. createChat mints open_key
({k} under the instance key) off-main, matching the web wire shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: pin the open_key wire shape with a round-trip test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
17 files changed,
+1365
−49
changelog/2026-07-10-default-groups.md+4 −0changelog/2026-07-10-open-groups.md+5 −0db.go+175 −2handlers_test.go+290 −0ios/LChat/Views/NewChatView.swift+27 −2ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift+129 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+29 −2ios/LChatCore/Sources/LChatCore/Models.swift+37 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+78 −9ios/LChatCore/Tests/LChatCoreTests/OpenKeyRoundTripTests.swift+25 −0ios/LChatMac/NativeChatView.swift+19 −0ios/LChatMac/NewChatPopover.swift+19 −2main.go+184 −6test/format.test.mjs+10 −0web/static/format.js+6 −1web/static/shell.js+304 −25web/static/style.css+24 −0
diff --git a/changelog/2026-07-10-default-groups.md b/changelog/2026-07-10-default-groups.md| new file mode 100644 |
| index 0000000..cc17a77 |
| --- /dev/null |
| +++ b/changelog/2026-07-10-default-groups.md |
| @@ -0,0 +1,4 @@ |
| +🤖 **Default groups.** Admins can mark an open group so that everyone who |
| +joins the space lands in it automatically — your space's own #general. Flip |
| +it in Edit group ("New members join automatically"). Only new members are |
| +added; nobody already here gets moved into anything, and leaving sticks. |
diff --git a/changelog/2026-07-10-open-groups.md b/changelog/2026-07-10-open-groups.md| new file mode 100644 |
| index 0000000..b11cb8c |
| --- /dev/null |
| +++ b/changelog/2026-07-10-open-groups.md |
| @@ -0,0 +1,5 @@ |
| +🤖 **Open groups.** A group can now be open to your whole space: tick "Open |
| +group" when you create one (or open an existing group from Edit group), and |
| +anyone can find it under **Discover** and join with one tap — no invite |
| +needed. Everything in an open group stays end-to-end encrypted, same as |
| +every other chat; "open" only means open to the members of your space. |
diff --git a/db.go b/db.go| index ee1dcae..0797417 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -536,6 +536,18 @@ CREATE TABLE IF NOT EXISTS member_bots ( |
| // blobs above, not even other members can open it; the server holds |
| // ciphertext either way. |
| "ALTER TABLE users ADD COLUMN nicknames_blob TEXT", |
| + // Open "community" groups (docs/community-and-tiers.md): a group any |
| + // member can discover and self-join. visibility is 'invite' (default) |
| + // or 'open'; open_key is the thread's AES key encrypted under the |
| + // shared instance key — every member can unwrap it client-side, while |
| + // the server (which never holds the instance key) cannot. |
| + "ALTER TABLE threads ADD COLUMN visibility TEXT NOT NULL DEFAULT 'invite'", |
| + "ALTER TABLE threads ADD COLUMN open_key TEXT", |
| + // Default groups (Slack's #general): open groups every NEW member is |
| + // auto-joined to on their first device claim (existing members are |
| + // never force-joined). Admin-set, meaningful only while visibility is |
| + // 'open' — joining still needs no key material beyond open_key. |
| + "ALTER TABLE threads ADD COLUMN default_join INTEGER NOT NULL DEFAULT 0", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -1040,6 +1052,9 @@ func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) ( |
| if n, _ := res.RowsAffected(); n == 0 { |
| return "", false, nil // already claimed |
| } |
| + // First device = this person is entering the space (an add-device claim |
| + // has devices already). Checked before the token insert below creates one. |
| + firstDevice := db.deviceCount(ki.UserID) == 0 |
| tok, err := randToken() |
| if err != nil { |
| return "", false, err |
| @@ -1051,6 +1066,13 @@ func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) ( |
| if err != nil { |
| return "", false, err |
| } |
| + if firstDevice { |
| + // Land the newcomer in every default open group. Best-effort ON |
| + // PURPOSE: used_at is already burned above, so failing the claim here |
| + // would brick the invite — and a missed default join is recoverable |
| + // (the group is one Discover tap away). |
| + _ = db.joinDefaultThreads(ki.UserID) |
| + } |
| 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. |
| @@ -1605,6 +1627,15 @@ type Thread struct { |
| // Pinned is true when the requesting user keeps this chat at the top of |
| // their list. |
| Pinned bool `json:"pinned,omitempty"` |
| + // Visibility is "open" for a community group any member can discover and |
| + // join (see openThreads); omitted for ordinary invite-only threads. |
| + Visibility string `json:"visibility,omitempty"` |
| + // OpenKey is the thread key encrypted under the instance key (open groups |
| + // only) — the key source for members who joined self-serve and so have no |
| + // per-member wrapped copy in thread_keys. |
| + OpenKey string `json:"open_key,omitempty"` |
| + // DefaultJoin marks an open group new members are auto-joined to. |
| + DefaultJoin bool `json:"default_join,omitempty"` |
| } |
| // ThreadMemberKey pairs a member with the thread key wrapped to their pubkey. |
| @@ -1616,6 +1647,14 @@ type ThreadMemberKey struct { |
| // createThread creates a thread with its members and their wrapped key blobs in |
| // one transaction. nameBlob is the client-encrypted group name ("" for dm/self). |
| func (db *DB) createThread(kind, nameBlob string, members []ThreadMemberKey) (string, error) { |
| + return db.createThreadVis(kind, nameBlob, members, "", "") |
| +} |
| + |
| +// createThreadVis also stamps visibility + open_key for open community groups |
| +// (see openThreads). "" visibility means the default 'invite'. Members with an |
| +// empty Wrapped get no thread_keys row — an open group's key reaches them via |
| +// open_key instead. |
| +func (db *DB) createThreadVis(kind, nameBlob string, members []ThreadMemberKey, visibility, openKey string) (string, error) { |
| id, err := randToken() |
| if err != nil { |
| return "", err |
| @@ -1630,13 +1669,23 @@ func (db *DB) createThread(kind, nameBlob string, members []ThreadMemberKey) (st |
| if nameBlob != "" { |
| nb = nameBlob |
| } |
| - if _, err := tx.Exec("INSERT INTO threads(id, kind, name_blob, created_at) VALUES(?, ?, ?, ?)", id, kind, nb, now); err != nil { |
| + if visibility == "" { |
| + visibility = "invite" |
| + } |
| + var ok any |
| + if openKey != "" { |
| + ok = openKey |
| + } |
| + if _, err := tx.Exec("INSERT INTO threads(id, kind, name_blob, created_at, visibility, open_key) VALUES(?, ?, ?, ?, ?, ?)", id, kind, nb, now, visibility, ok); err != nil { |
| return "", err |
| } |
| for _, m := range members { |
| if _, err := tx.Exec("INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", id, m.UserID, now); err != nil { |
| return "", err |
| } |
| + if m.Wrapped == "" { |
| + continue |
| + } |
| if _, err := tx.Exec("INSERT INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", id, m.UserID, m.Wrapped); err != nil { |
| return "", err |
| } |
| @@ -1733,6 +1782,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) { |
| COALESCE((SELECT blob FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), ''), |
| COALESCE((SELECT created_at FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), 0), |
| COALESCE(t.avatar_blob, ''), |
| + COALESCE(t.visibility, 'invite'), COALESCE(t.open_key, ''), COALESCE(t.default_join, 0), |
| EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id), |
| EXISTS(SELECT 1 FROM pinned_threads p WHERE p.thread_id = t.id AND p.user_id = m.user_id) AS pinned, |
| COALESCE((SELECT last_read_at FROM read_marks r WHERE r.thread_id = t.id AND r.user_id = m.user_id), 0) |
| @@ -1753,9 +1803,12 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) { |
| out := []Thread{} |
| for rows.Next() { |
| var t Thread |
| - if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Hidden, &t.Pinned, &t.LastReadAt); err != nil { |
| + if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Visibility, &t.OpenKey, &t.DefaultJoin, &t.Hidden, &t.Pinned, &t.LastReadAt); err != nil { |
| return nil, err |
| } |
| + if t.Visibility == "invite" { |
| + t.Visibility = "" // the default; keep the JSON row lean |
| + } |
| out = append(out, t) |
| } |
| if err := rows.Err(); err != nil { |
| @@ -1769,6 +1822,126 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) { |
| return out, nil |
| } |
| +// OpenThread is a community-directory row: enough for any member to render |
| +// and self-join an open group. The name/avatar blobs decrypt with the thread |
| +// key, which every member can reach via open_key + the instance key — so the |
| +// whole directory is readable by any member and by nobody else. |
| +type OpenThread struct { |
| + ID string `json:"id"` |
| + NameBlob string `json:"name_blob,omitempty"` |
| + CreatedAt int64 `json:"created_at"` |
| + Avatar string `json:"avatar,omitempty"` |
| + OpenKey string `json:"open_key"` |
| + MemberCount int `json:"member_count"` |
| + Joined bool `json:"joined"` |
| + DefaultJoin bool `json:"default_join,omitempty"` |
| +} |
| + |
| +// openThreads lists every open group (joined or not) for the community |
| +// directory, busiest first. |
| +func (db *DB) openThreads(userID string) ([]OpenThread, error) { |
| + rows, err := db.sql.Query(` |
| + SELECT t.id, COALESCE(t.name_blob, ''), t.created_at, COALESCE(t.avatar_blob, ''), COALESCE(t.open_key, ''), |
| + (SELECT COUNT(*) FROM thread_members m WHERE m.thread_id = t.id) AS n, |
| + EXISTS(SELECT 1 FROM thread_members m WHERE m.thread_id = t.id AND m.user_id = ?), |
| + COALESCE(t.default_join, 0) |
| + FROM threads t |
| + WHERE t.visibility = 'open' |
| + ORDER BY n DESC, t.created_at DESC`, userID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []OpenThread{} |
| + for rows.Next() { |
| + var t OpenThread |
| + if err := rows.Scan(&t.ID, &t.NameBlob, &t.CreatedAt, &t.Avatar, &t.OpenKey, &t.MemberCount, &t.Joined, &t.DefaultJoin); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, t) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// threadIsOpen reports whether a thread is an open (discoverable) group. |
| +func (db *DB) threadIsOpen(threadID string) bool { |
| + var v string |
| + if db.sql.QueryRow("SELECT COALESCE(visibility, 'invite') FROM threads WHERE id = ?", threadID).Scan(&v) != nil { |
| + return false |
| + } |
| + return v == "open" |
| +} |
| + |
| +// setThreadDefault flags (or unflags) an open group as joined-by-default for |
| +// new members. Policy, not key material — flipping it moves no keys. |
| +func (db *DB) setThreadDefault(threadID string, def bool) error { |
| + v := 0 |
| + if def { |
| + v = 1 |
| + } |
| + _, err := db.sql.Exec("UPDATE threads SET default_join = ? WHERE id = ?", v, threadID) |
| + return err |
| +} |
| + |
| +// joinDefaultThreads adds a brand-new member to every default open group — |
| +// membership rows only, like any self-serve join (open_key carries the key). |
| +// Closed groups stop defaulting even if their flag is still set. Agents are |
| +// skipped: auto-adding a bot would silently widen what its sidecar can read, |
| +// and bot membership must stay a deliberate, disclosed choice (docs/bots.md). |
| +func (db *DB) joinDefaultThreads(userID string) error { |
| + _, err := db.sql.Exec(` |
| + INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) |
| + SELECT t.id, ?, ? |
| + FROM threads t |
| + WHERE t.visibility = 'open' AND COALESCE(t.default_join, 0) = 1 |
| + AND (SELECT COALESCE(is_agent, 0) FROM users WHERE id = ?) = 0`, |
| + userID, time.Now().Unix(), userID) |
| + return err |
| +} |
| + |
| +var errNotOpen = errors.New("not an open thread") |
| + |
| +// joinOpenThread self-serve-joins a member to an open group: a membership row |
| +// only, no thread_keys row — the group's key reaches them via open_key, which |
| +// they can already unwrap with the instance key. Errors errNotOpen for |
| +// anything that isn't an open group (including unknown threads). |
| +func (db *DB) joinOpenThread(threadID, userID string) error { |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + var v string |
| + if err := tx.QueryRow("SELECT COALESCE(visibility, 'invite') FROM threads WHERE id = ?", threadID).Scan(&v); err != nil { |
| + if err == sql.ErrNoRows { |
| + return errNotOpen |
| + } |
| + return err |
| + } |
| + if v != "open" { |
| + return errNotOpen |
| + } |
| + if _, err := tx.Exec("INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", threadID, userID, time.Now().Unix()); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +// setThreadVisibility opens or closes a group for discovery. Opening stores |
| +// open_key only when none exists yet (COALESCE, the grantInstanceKey |
| +// anti-brick rule: any member may call this, so an overwrite could replace a |
| +// good key blob with junk and cut off every self-serve joiner). Closing keeps |
| +// open_key — members who joined self-serve still decrypt through it; 'invite' |
| +// only stops discovery and new joins. |
| +func (db *DB) setThreadVisibility(threadID, visibility, openKey string) error { |
| + if visibility == "open" { |
| + _, err := db.sql.Exec("UPDATE threads SET visibility = 'open', open_key = COALESCE(open_key, ?) WHERE id = ?", openKey, threadID) |
| + return err |
| + } |
| + _, err := db.sql.Exec("UPDATE threads SET visibility = ? WHERE id = ?", visibility, threadID) |
| + return err |
| +} |
| + |
| // removeThreadMember drops a user from a thread (their membership + key copy). |
| // Note: the thread key isn't rotated, so this stops the server delivering future |
| // messages to them, but isn't forward-secret (rekey-on-leave is deferred). |
diff --git a/handlers_test.go b/handlers_test.go| index b791f37..4a3e55b 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -1761,6 +1761,180 @@ func TestNicknamesRoundTrip(t *testing.T) { |
| } |
| } |
| +// TestOpenGroups covers the community directory: creating an open group, |
| +// discovering it, self-serve joining, and flipping visibility — including the |
| +// open_key overwrite guard. |
| +func TestOpenGroups(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + mkUser := func(label string) (tok, uid string) { |
| + u, err := srv.db.createUser(label) |
| + if err != nil { |
| + t.Fatalf("createUser %s: %v", label, err) |
| + } |
| + if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatalf("createKeyInvite %s: %v", label, err) |
| + } |
| + tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| + if err != nil || !ok { |
| + t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| + } |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + t.Fatalf("setUserPublicKey %s: %v", label, err) |
| + } |
| + return tk, u |
| + } |
| + tokA, uidA := mkUser("Alice") |
| + tokB, uidB := mkUser("Bob") |
| + |
| + post := func(path, body string) (int, map[string]any) { |
| + resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post %s: %v", path, err) |
| + } |
| + defer resp.Body.Close() |
| + var out map[string]any |
| + _ = json.NewDecoder(resp.Body).Decode(&out) |
| + return resp.StatusCode, out |
| + } |
| + |
| + // Open must be group-only, and open requires open_key (and only open may carry one). |
| + if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"self","visibility":"open","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 { |
| + t.Fatalf("open self thread: code=%d, want 400", code) |
| + } |
| + if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","visibility":"open","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 { |
| + t.Fatalf("open group without open_key: code=%d, want 400", code) |
| + } |
| + if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 { |
| + t.Fatalf("invite group with open_key: code=%d, want 400", code) |
| + } |
| + // An invite-only group still requires per-member wraps. |
| + if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","members":[{"user_id":"`+uidA+`","wrapped":""}]}`); code != 400 { |
| + t.Fatalf("invite group with empty wrap: code=%d, want 400", code) |
| + } |
| + |
| + // Create an open group; the creator may omit their wrap (open_key covers them). |
| + code, out := post("/api/threads", `{"token":"`+tokA+`","kind":"group","name_blob":"nb","visibility":"open","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":""}]}`) |
| + if code != 200 { |
| + t.Fatalf("create open group: code=%d", code) |
| + } |
| + gid, _ := out["thread_id"].(string) |
| + if gid == "" { |
| + t.Fatalf("create open group: no thread_id in %v", out) |
| + } |
| + // And an ordinary invite-only group to check the directory filters it out. |
| + code, out = post("/api/threads", `{"token":"`+tokA+`","kind":"group","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`) |
| + if code != 200 { |
| + t.Fatalf("create invite group: code=%d", code) |
| + } |
| + inviteGid, _ := out["thread_id"].(string) |
| + |
| + directory := func(tok string) []OpenThread { |
| + resp, err := http.Get(ts.URL + "/api/threads/open?token=" + tok) |
| + if err != nil { |
| + t.Fatalf("directory: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("directory: code=%d", resp.StatusCode) |
| + } |
| + var out struct { |
| + Threads []OpenThread `json:"threads"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + t.Fatalf("directory decode: %v", err) |
| + } |
| + return out.Threads |
| + } |
| + |
| + // The directory is token-gated. |
| + if resp, _ := http.Get(ts.URL + "/api/threads/open?token=bogus"); resp.StatusCode != 403 { |
| + t.Fatalf("directory with bad token: code=%d, want 403", resp.StatusCode) |
| + } |
| + // Bob (not a member of the group) sees exactly the open group, with its key blob. |
| + dir := directory(tokB) |
| + if len(dir) != 1 || dir[0].ID != gid { |
| + t.Fatalf("directory: %+v, want just %s", dir, gid) |
| + } |
| + if dir[0].OpenKey != "ok1" || dir[0].Joined || dir[0].MemberCount != 1 || dir[0].NameBlob != "nb" { |
| + t.Fatalf("directory row: %+v", dir[0]) |
| + } |
| + |
| + // Self-serve join; joining twice is idempotent; invite-only groups refuse. |
| + if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 { |
| + t.Fatalf("join: code=%d", code) |
| + } |
| + if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 { |
| + t.Fatalf("re-join: code=%d", code) |
| + } |
| + if code, _ := post("/api/threads/"+inviteGid+"/join", `{"token":"`+tokB+`"}`); code != 403 { |
| + t.Fatalf("join invite-only: code=%d, want 403", code) |
| + } |
| + if code, _ := post("/api/threads/nope/join", `{"token":"`+tokB+`"}`); code != 403 { |
| + t.Fatalf("join unknown: code=%d, want 403", code) |
| + } |
| + if !srv.db.threadMember(gid, uidB) { |
| + t.Fatalf("Bob should be a member after joining") |
| + } |
| + dir = directory(tokB) |
| + if !dir[0].Joined || dir[0].MemberCount != 2 { |
| + t.Fatalf("directory after join: %+v", dir[0]) |
| + } |
| + |
| + // Bob's thread list carries the open group with open_key and no per-member wrap. |
| + resp, err := http.Get(ts.URL + "/api/threads?token=" + tokB) |
| + if err != nil { |
| + t.Fatalf("threads: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + var tl struct { |
| + Threads []Thread `json:"threads"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil { |
| + t.Fatalf("threads decode: %v", err) |
| + } |
| + if len(tl.Threads) != 1 { |
| + t.Fatalf("Bob's threads: %+v", tl.Threads) |
| + } |
| + if got := tl.Threads[0]; got.ID != gid || got.Visibility != "open" || got.OpenKey != "ok1" || got.Wrapped != "" { |
| + t.Fatalf("Bob's open-group row: %+v", got) |
| + } |
| + |
| + // Closing hides it from discovery but keeps open_key (Bob's key source). |
| + if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokA+`","visibility":"invite"}`); code != 200 { |
| + t.Fatalf("close: code=%d", code) |
| + } |
| + if dir = directory(tokB); len(dir) != 0 { |
| + t.Fatalf("directory after close: %+v", dir) |
| + } |
| + tokC, _ := mkUser("Carol") |
| + if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokC+`"}`); code != 403 { |
| + t.Fatalf("join closed group: code=%d, want 403", code) |
| + } |
| + |
| + // Reopening never overwrites the stored open_key (anti-brick rule), and |
| + // non-members / non-groups can't flip visibility. |
| + if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokC+`","visibility":"open","open_key":"junk"}`); code != 403 { |
| + t.Fatalf("non-member flip: code=%d, want 403", code) |
| + } |
| + if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokA+`","visibility":"open","open_key":"junk"}`); code != 200 { |
| + t.Fatalf("reopen: code=%d", code) |
| + } |
| + if dir = directory(tokC); len(dir) != 1 || dir[0].OpenKey != "ok1" { |
| + t.Fatalf("open_key must survive a reopen: %+v", dir) |
| + } |
| + |
| + // A self-serve joiner has an EMPTY thread_keys slot; another member must |
| + // not be able to fill it with an arbitrary blob (add-member is a no-op for |
| + // existing members — the junk-wrap lockout the DO NOTHING rule exists for). |
| + if code, _ := post("/api/threads/"+gid+"/members", `{"token":"`+tokA+`","user_id":"`+uidB+`","wrapped":"junk-wrap"}`); code != 200 { |
| + t.Fatalf("re-add existing member: code=%d", code) |
| + } |
| + if w, ok, _ := srv.db.wrappedKeyFor(gid, uidB); ok || w != "" { |
| + t.Fatalf("joiner's empty key slot was filled: %q", w) |
| + } |
| +} |
| + |
| func TestAdminCanAlwaysInvite(t *testing.T) { |
| ts, srv := newTestServer(t) |
| adminTok, adminUID, _ := joinedMember(t, srv, "boss") |
| @@ -1797,3 +1971,119 @@ func TestAdminCanAlwaysInvite(t *testing.T) { |
| t.Fatalf("member with invites on: want 200, got %d", r.StatusCode) |
| } |
| } |
| + |
| +// TestDefaultGroups: admins flag open groups as joined-by-default; brand-new |
| +// members land in them on their first device claim, add-device claims and |
| +// closed groups don't auto-join. |
| +func TestDefaultGroups(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + mkUser := func(label string) (tok, uid string) { |
| + u, err := srv.db.createUser(label) |
| + if err != nil { |
| + t.Fatalf("createUser %s: %v", label, err) |
| + } |
| + if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatalf("createKeyInvite %s: %v", label, err) |
| + } |
| + tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| + if err != nil || !ok { |
| + t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| + } |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + t.Fatalf("setUserPublicKey %s: %v", label, err) |
| + } |
| + return tk, u |
| + } |
| + post := func(path, body string) int { |
| + resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post %s: %v", path, err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + |
| + tokAdmin, uidAdmin := mkUser("Root") |
| + if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { |
| + t.Fatalf("setUserRole: %v", err) |
| + } |
| + tokPeer, uidPeer := mkUser("Peer") |
| + |
| + // One open group (will be default), one open non-default, one closed+flagged. |
| + mkGroup := func(vis, openKey string) string { |
| + body := `{"token":"` + tokAdmin + `","kind":"group","members":[{"user_id":"` + uidAdmin + `","wrapped":"w"}]}` |
| + if vis == "open" { |
| + body = `{"token":"` + tokAdmin + `","kind":"group","visibility":"open","open_key":"` + openKey + `","members":[{"user_id":"` + uidAdmin + `","wrapped":""}]}` |
| + } |
| + resp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("create group: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + var out struct { |
| + ThreadID string `json:"thread_id"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" { |
| + t.Fatalf("create group: id=%q err=%v", out.ThreadID, err) |
| + } |
| + return out.ThreadID |
| + } |
| + defGid := mkGroup("open", "ok-def") |
| + plainGid := mkGroup("open", "ok-plain") |
| + closedGid := mkGroup("invite", "") |
| + |
| + // Gating: members can't set it; a closed group can't be default. |
| + if code := post("/api/threads/"+defGid+"/default", `{"token":"`+tokPeer+`","default":true}`); code != 403 { |
| + t.Fatalf("non-admin default: code=%d, want 403", code) |
| + } |
| + if code := post("/api/threads/"+closedGid+"/default", `{"token":"`+tokAdmin+`","default":true}`); code != 400 { |
| + t.Fatalf("default on closed group: code=%d, want 400", code) |
| + } |
| + if code := post("/api/threads/"+defGid+"/default", `{"token":"`+tokAdmin+`","default":true}`); code != 200 { |
| + t.Fatalf("set default: code=%d", code) |
| + } |
| + // Flag the closed group directly in the DB: even if flagged, a closed |
| + // group must never auto-join (the sweep filters on visibility). |
| + if err := srv.db.setThreadDefault(closedGid, true); err != nil { |
| + t.Fatalf("setThreadDefault: %v", err) |
| + } |
| + |
| + // Existing members are never force-joined. |
| + if srv.db.threadMember(defGid, uidPeer) { |
| + t.Fatalf("existing member force-joined") |
| + } |
| + |
| + // A brand-new member lands in the default group only. |
| + _, uidNew := mkUser("Newbie") |
| + if !srv.db.threadMember(defGid, uidNew) { |
| + t.Fatalf("new member not auto-joined to default group") |
| + } |
| + if srv.db.threadMember(plainGid, uidNew) || srv.db.threadMember(closedGid, uidNew) { |
| + t.Fatalf("new member auto-joined to a non-default or closed group") |
| + } |
| + if w, ok, _ := srv.db.wrappedKeyFor(defGid, uidNew); ok || w != "" { |
| + t.Fatalf("auto-join must not mint a thread_keys row: %q", w) |
| + } |
| + |
| + // Leaving sticks: an add-device claim later must not re-join. |
| + if code := post("/api/threads/"+defGid+"/leave", `{"token":"`+tokPeer+`"}`); code != 200 { |
| + // tokPeer isn't a member (never joined) — use the newbie's leave instead. |
| + t.Logf("peer leave (not a member): code=%d", code) |
| + } |
| + newTok := func(uid, label string) { |
| + if err := srv.db.createKeyInvite("dev-"+label, uid, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatalf("device invite: %v", err) |
| + } |
| + if _, ok, err := srv.db.claimKeyInvite("dev-"+label, label, "", testClaimSecret); !ok || err != nil { |
| + t.Fatalf("device claim: ok=%v err=%v", ok, err) |
| + } |
| + } |
| + if err := srv.db.removeThreadMember(defGid, uidNew); err != nil { |
| + t.Fatalf("leave: %v", err) |
| + } |
| + newTok(uidNew, "second-device") |
| + if srv.db.threadMember(defGid, uidNew) { |
| + t.Fatalf("add-device claim re-joined a left default group") |
| + } |
| +} |
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift| index 002c7d8..c04a2b6 100644 |
| --- a/ios/LChat/Views/NewChatView.swift |
| +++ b/ios/LChat/Views/NewChatView.swift |
| @@ -44,6 +44,17 @@ struct NewChatView: View { |
| .foregroundStyle(Color.accentColor) |
| } |
| } |
| + // The community directory: open groups anyone can join. |
| + NavigationLink { |
| + DiscoverGroupsView(store: store, onOpen: finish) |
| + } label: { |
| + Label { |
| + Text("Discover groups").font(.body.weight(.medium)) |
| + } icon: { |
| + Image(systemName: "safari") |
| + .foregroundStyle(Color.accentColor) |
| + } |
| + } |
| } |
| if others.isEmpty { |
| @@ -115,6 +126,7 @@ struct NewGroupView: View { |
| @State private var selected: Set<String> = [] |
| @State private var selectedApps: Set<String> = [] |
| @State private var groupName = "" |
| + @State private var openGroup = false |
| @State private var busy = false |
| @State private var error: String? |
| @@ -126,6 +138,16 @@ struct NewGroupView: View { |
| TextField("Optional", text: $groupName) |
| .autocorrectionDisabled() |
| } |
| + // Open community group — offered only once the instance key is |
| + // here (it carries the group key to future joiners), matching the |
| + // web sheet and how photos wait on the same key. |
| + if store.instanceKey() != nil { |
| + Section { |
| + Toggle("Open group", isOn: $openGroup) |
| + } footer: { |
| + Text("Anyone in this space can find it under Discover and join.") |
| + } |
| + } |
| Section(header: Text(selected.isEmpty ? "Add people" : "\(selected.count) selected")) { |
| ForEach(others, id: \.user_id) { person in |
| Button { toggle(person.user_id) } label: { |
| @@ -186,8 +208,10 @@ struct NewGroupView: View { |
| ToolbarItem(placement: .confirmationAction) { |
| if busy { ProgressView() } |
| else { |
| + // An open group may start with just its creator — others |
| + // find it in Discover; a closed group still needs people. |
| Button("Create") { create() } |
| - .disabled(selected.isEmpty) |
| + .disabled(selected.isEmpty && !openGroup) |
| } |
| } |
| } |
| @@ -206,7 +230,8 @@ struct NewGroupView: View { |
| error = nil |
| Task { |
| let id = await store.createChat(memberIDs: Array(selected), groupName: groupName, |
| - group: true, apps: Array(selectedApps).sorted()) |
| + group: true, apps: Array(selectedApps).sorted(), |
| + openGroup: openGroup) |
| busy = false |
| if let id { onCreated(id) } else { error = store.loadError ?? "Couldn't create the group." } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift b/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift| new file mode 100644 |
| index 0000000..99662eb |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift |
| @@ -0,0 +1,129 @@ |
| +import SwiftUI |
| + |
| +// The community directory (the web's showDiscover in shell.js): every open |
| +// group in the space, joined or not. Join is the surface's one loud control — |
| +// groups you're already in get the quiet variant. Shared by the iOS push |
| +// (from New chat) and the Mac sheet so the two surfaces can't drift; the |
| +// platform supplies navigation via onOpen. |
| +public struct DiscoverGroupsView: View { |
| + @ObservedObject var store: ThreadStore |
| + let onOpen: (String) -> Void |
| + |
| + private enum Phase: Equatable { |
| + case loading |
| + case keyPending // instance key hasn't reached this device yet |
| + case loaded([DiscoverGroup]) |
| + } |
| + @State private var phase: Phase = .loading |
| + @State private var joining: String? |
| + @State private var failedJoin: String? |
| + |
| + public init(store: ThreadStore, onOpen: @escaping (String) -> Void) { |
| + self.store = store |
| + self.onOpen = onOpen |
| + } |
| + |
| + public var body: some View { |
| + List { |
| + switch phase { |
| + case .loading: |
| + HStack { Spacer(); ProgressView(); Spacer() } |
| + .listRowBackground(Color.clear) |
| + case .keyPending: |
| + Text("Waiting for your key to arrive — try again in a moment.") |
| + .foregroundStyle(.secondary) |
| + case .loaded(let groups) where groups.isEmpty: |
| + Text("No open groups here yet. Make a group open and it shows up for everyone in this space.") |
| + .foregroundStyle(.secondary) |
| + case .loaded(let groups): |
| + Section { |
| + ForEach(groups) { row($0) } |
| + } footer: { |
| + Text("Groups anyone in this space can join — no invite needed.") |
| + } |
| + } |
| + } |
| + .navigationTitle("Discover") |
| + #if os(iOS) |
| + .navigationBarTitleDisplayMode(.inline) |
| + #endif |
| + .task { await load() } |
| + .refreshable { await load() } |
| + } |
| + |
| + private func row(_ g: DiscoverGroup) -> some View { |
| + HStack(spacing: 12) { |
| + GroupMonogram(name: g.title) |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text(g.title).font(.body.weight(.medium)).lineLimit(1) |
| + Text(subtitle(g)) |
| + .font(.footnote) |
| + .foregroundStyle(failedJoin == g.id ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) |
| + } |
| + Spacer(minLength: 8) |
| + if g.joined { |
| + Button("Open") { onOpen(g.id) } |
| + .buttonStyle(.bordered) |
| + .buttonBorderShape(.capsule) |
| + .accessibilityLabel("Open \(g.title)") |
| + } else { |
| + Button { join(g) } label: { |
| + if joining == g.id { ProgressView().controlSize(.small) } else { Text("Join") } |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .buttonBorderShape(.capsule) |
| + .disabled(joining != nil) |
| + .accessibilityLabel("Join \(g.title)") |
| + } |
| + } |
| + .padding(.vertical, 2) |
| + } |
| + |
| + private func subtitle(_ g: DiscoverGroup) -> String { |
| + if failedJoin == g.id { return "Couldn't join — try again." } |
| + let count = g.memberCount == 1 ? "1 member" : "\(g.memberCount) members" |
| + return g.defaultJoin ? "\(count) · everyone starts here" : count |
| + } |
| + |
| + private func load() async { |
| + guard let groups = await store.discoverGroups() else { |
| + phase = .keyPending |
| + return |
| + } |
| + phase = .loaded(groups) |
| + } |
| + |
| + private func join(_ g: DiscoverGroup) { |
| + joining = g.id |
| + failedJoin = nil |
| + Task { |
| + let ok = await store.joinOpenGroup(g.id) |
| + joining = nil |
| + if ok { |
| + onOpen(g.id) |
| + } else { |
| + failedJoin = g.id |
| + await load() // it may have closed while the sheet was up |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The same face the app draws everywhere for a photo-less name: first initial |
| +// over a colour hashed from the name (MonogramImage's shared rules, so a |
| +// group's Discover face matches its sidebar face once joined). |
| +private struct GroupMonogram: View { |
| + let name: String |
| + private static let palette: [Color] = [.blue, .purple, .pink, .orange, .green, .teal, .indigo, .red] |
| + |
| + var body: some View { |
| + ZStack { |
| + Circle().fill(Self.palette[MonogramImage.paletteIndex(for: name)]) |
| + Text(MonogramImage.initial(for: name)) |
| + .font(.system(size: 16, weight: .semibold)) |
| + .foregroundStyle(.white) |
| + } |
| + .frame(width: 38, height: 38) |
| + .accessibilityHidden(true) |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index c267c64..0b43a5f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -33,6 +33,26 @@ public struct LChatAPI { |
| return (try? await getJSON("/api/threads", Resp.self))?.threads ?? [] |
| } |
| + /// The community directory: every open group, joined or not, each with its |
| + /// instance-key-encrypted open_key (mirrors the web's showDiscover fetch). |
| + public func openThreads() async -> [OpenThreadRow] { |
| + struct Resp: Codable { let threads: [OpenThreadRow]? } |
| + return (try? await getJSON("/api/threads/open", Resp.self))?.threads ?? [] |
| + } |
| + |
| + /// Self-serve join an open group. True only on a real 200 — a 403 (the |
| + /// group closed while the sheet was up) must not read as joined. |
| + public func joinThread(_ thread: String) async -> Bool { |
| + guard let url = URL(string: "api/threads/\(thread)/join", relativeTo: baseURL), |
| + let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false } |
| + var req = URLRequest(url: url) |
| + req.httpMethod = "POST" |
| + req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + req.httpBody = data |
| + guard let (_, resp) = try? await session.data(for: req) else { return false } |
| + return (resp as? HTTPURLResponse)?.statusCode == 200 |
| + } |
| + |
| /// The caller's wrapped instance key (for member avatars), or nil if not granted. |
| public func instanceKeyWrapped() async -> String? { |
| struct Resp: Codable { let wrapped: String? } |
| @@ -93,9 +113,16 @@ public struct LChatAPI { |
| /// Creates a thread (self/dm/group) with the per-member wrapped keys. Returns the |
| /// new (or, for a deduped DM, existing) thread id. `members` is [{user_id, wrapped}]. |
| - public func createThread(kind: String, nameBlob: String, members: [[String: String]]) async -> String? { |
| + public func createThread(kind: String, nameBlob: String, members: [[String: String]], |
| + openKey: String = "") async -> String? { |
| struct Resp: Codable { let thread_id: String? } |
| - let body: [String: Any] = ["token": token, "kind": kind, "name_blob": nameBlob, "members": members] |
| + var body: [String: Any] = ["token": token, "kind": kind, "name_blob": nameBlob, "members": members] |
| + if !openKey.isEmpty { |
| + // An open community group: the thread key encrypted under the |
| + // instance key, so any member can discover + self-join later. |
| + body["visibility"] = "open" |
| + body["open_key"] = openKey |
| + } |
| guard let url = URL(string: "/api/threads", relativeTo: baseURL), |
| let data = try? JSONSerialization.data(withJSONObject: body) else { return nil } |
| var req = URLRequest(url: url) |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index a29b5df..2431bdc 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -83,6 +83,8 @@ public struct ServerThread: Codable { |
| public var pinned: Bool? |
| public var hidden: Bool? // this user "closed" the chat (per-user, server-synced) |
| public var last_read_at: Int64? // this user's read watermark (per-user, server-synced) |
| + public var visibility: String? // "open" for a community group anyone can join |
| + public var open_key: String? // the thread key, encrypted under the instance key |
| } |
| public struct RosterEntry: Codable, Equatable { |
| @@ -201,6 +203,41 @@ public struct GroupNamePayload: Codable { public var name: String? } |
| // no native change to be labelled correctly. (web: createAppThread in shell.js.) |
| public struct AppThreadPayload: Codable { public var app: String?; public var title: String? } |
| +// An open group's open_key decrypts (under the instance key) to {k: <the |
| +// thread key, base64url>} — written by createThread in web/static/shell.js. |
| +public struct OpenKeyPayload: Codable { public var k: String } |
| + |
| +// A community-directory row (GET /api/threads/open): enough to render and |
| +// self-join an open group. name_blob decrypts with the thread key carried |
| +// inside open_key; the server can open neither blob. |
| +public struct OpenThreadRow: Codable { |
| + public let id: String |
| + public var name_blob: String? |
| + public var created_at: Int64? |
| + public var avatar: String? |
| + public var open_key: String |
| + public var member_count: Int? |
| + public var joined: Bool? |
| + public var default_join: Bool? |
| +} |
| + |
| +// A decrypted directory row, ready for the Discover list on either platform. |
| +public struct DiscoverGroup: Identifiable, Equatable { |
| + public let id: String |
| + public let title: String |
| + public let memberCount: Int |
| + public let joined: Bool |
| + public let defaultJoin: Bool |
| + |
| + public init(id: String, title: String, memberCount: Int, joined: Bool, defaultJoin: Bool) { |
| + self.id = id |
| + self.title = title |
| + self.memberCount = memberCount |
| + self.joined = joined |
| + self.defaultJoin = defaultJoin |
| + } |
| +} |
| + |
| // An unfurled OpenGraph card for a posted URL (GET /api/unfurl — the one |
| // deliberate E2E exception: the server sees the URL, never message text). |
| public struct LinkPreview: Equatable { |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 140fbdf..793b119 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -184,11 +184,23 @@ public final class ThreadStore: ObservableObject { |
| var rows: [ChatThread] = [] |
| var readMarks: [String: Int64] = [:] |
| for t in serverThreads { |
| - // No usable key → can't decrypt anything about this thread; skip it. |
| - guard let wrapped = t.wrapped, !wrapped.isEmpty, |
| - let raw = try? ECIES.unwrap(wrappedB64: wrapped, |
| - privPkcs8B64: instance.userPriv) |
| - else { continue } |
| + // Two key sources: the normal per-member ECIES wrap, or — for an |
| + // open community group joined self-serve, which has no per-member |
| + // copy — open_key, the thread key encrypted under the instance key |
| + // (mirrors loadThreads in web/static/shell.js). No usable key → |
| + // can't decrypt anything about this thread; skip it. |
| + var rawKey: Data? |
| + if let wrapped = t.wrapped, !wrapped.isEmpty { |
| + rawKey = try? ECIES.unwrap(wrappedB64: wrapped, |
| + privPkcs8B64: instance.userPriv) |
| + } |
| + // A true fallback, not an else: a bad wrapped blob must not hide |
| + // an open group the instance key can decrypt. |
| + if rawKey == nil, let openKey = t.open_key, !openKey.isEmpty, let ik = instanceGroupKey, |
| + let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: openKey) { |
| + rawKey = Data(base64urlEncoded: payload.k) |
| + } |
| + guard let raw = rawKey else { continue } |
| let key = try? GroupKey(rawKey: raw) |
| let members = t.members ?? [] |
| let other = members.first { $0 != myUserId } ?? "" |
| @@ -433,6 +445,47 @@ public final class ThreadStore: ObservableObject { |
| // Start a chat with the given other members (empty = a "Notes" self thread, one = |
| // DM, more = group). Generates a fresh thread key, wraps it to every member |
| + // MARK: Open groups (community) |
| + |
| + /// The community directory, decrypted: every open group with its name and |
| + /// whether we're already in it. nil means the instance key hasn't reached |
| + /// this device yet (the same key-pending state the web's Discover shows); |
| + /// rows whose open_key won't decrypt are dropped — they'd be unjoinable. |
| + /// Mirrors showDiscover in web/static/shell.js. |
| + public func discoverGroups() async -> [DiscoverGroup]? { |
| + guard let api else { return [] } |
| + let rows = await api.openThreads() |
| + if rows.isEmpty { return [] } |
| + guard let ikB64 = instanceKeyB64, let ik = try? GroupKey(base64url: ikB64) else { return nil } |
| + var out: [DiscoverGroup] = [] |
| + for row in rows { |
| + guard let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: row.open_key), |
| + let raw = Data(base64urlEncoded: payload.k), |
| + let key = try? GroupKey(rawKey: raw) else { continue } |
| + var title = "Open group" |
| + if let nb = row.name_blob, !nb.isEmpty, |
| + let n = (try? key.decryptJSON(GroupNamePayload.self, blob: nb))?.name, !n.isEmpty { |
| + title = n |
| + } |
| + out.append(DiscoverGroup(id: row.id, title: title, |
| + memberCount: row.member_count ?? 0, |
| + joined: row.joined ?? false, |
| + defaultJoin: row.default_join ?? false)) |
| + } |
| + return out |
| + } |
| + |
| + /// Self-serve join an open group, then refresh so the thread appears in |
| + /// the list (its key rides open_key — the fold unwraps it via the instance |
| + /// key; no key material moves at join time). False on refusal, e.g. the |
| + /// group was closed while the sheet was up. |
| + public func joinOpenGroup(_ id: String) async -> Bool { |
| + guard let api else { return false } |
| + guard await api.joinThread(id) else { return false } |
| + await refresh() |
| + return true |
| + } |
| + |
| // (including us) via ECIES, encrypts the group name, and creates the thread; the |
| // server dedupes DMs. Returns the thread id (refreshing the list), or nil on error. |
| // `group` = the caller's explicit intent (the group flow was used) — the |
| @@ -441,12 +494,20 @@ public final class ThreadStore: ObservableObject { |
| // `apps` (groups only) seeds the new group's shared apps: one encrypted |
| // groupapps.add event each, posted into the fresh log exactly as the web's |
| // ⋮ → Add apps emits them — so every member (web included) folds the same list. |
| + // `openGroup` makes a community group: the thread key is additionally |
| + // encrypted under the instance key (open_key) so any member can discover |
| + // and self-join — requires the instance key to have reached this device. |
| public func createChat(memberIDs others: [String], groupName: String?, |
| - group: Bool = false, apps: [String] = []) async -> String? { |
| + group: Bool = false, apps: [String] = [], |
| + openGroup: Bool = false) async -> String? { |
| guard let api, !myUserId.isEmpty else { |
| loadError = "Couldn't determine your identity yet — try again in a moment." |
| return nil |
| } |
| + if openGroup && instanceKeyB64 == nil { |
| + loadError = "Your key hasn't arrived yet — try again in a moment." |
| + return nil |
| + } |
| var ids = Set(others) |
| ids.insert(myUserId) |
| @@ -465,10 +526,11 @@ public final class ThreadStore: ObservableObject { |
| let seedApps = kind == "group" ? apps : [] |
| let screenName = instance.screenName |
| let uid = myUserId |
| + let openIKB64 = (openGroup && kind == "group") ? instanceKeyB64 : nil |
| // Key generation, the name encrypt, one ECDH wrap PER MEMBER and the |
| // seed-app event encrypts — a big group is many milliseconds; all of |
| // it happens off main. |
| - let built: (nameBlob: String, members: [[String: String]], appBlobs: [String])? = |
| + let built: (nameBlob: String, members: [[String: String]], appBlobs: [String], openKey: String)? = |
| await Task.detached(priority: .userInitiated) { |
| let raw = ECIES.randomAESKey() |
| let key = try? GroupKey(rawKey: raw) |
| @@ -476,6 +538,13 @@ public final class ThreadStore: ObservableObject { |
| if kind == "group", let n = name, !n.isEmpty, let key { |
| nameBlob = (try? key.encryptJSON(["name": n])) ?? "" |
| } |
| + // The open_key blob: {k: <thread key>} under the instance key — |
| + // the same shape the web's createThread writes. |
| + var openKey = "" |
| + if let ikB64 = openIKB64, let ik = try? GroupKey(base64url: ikB64) { |
| + openKey = (try? ik.encryptJSON(["k": raw.base64urlEncodedString()])) ?? "" |
| + } |
| + if openIKB64 != nil && openKey.isEmpty { return nil } |
| var members: [[String: String]] = [] |
| for id in memberIDs { |
| guard let pub = memberPubs[id], |
| @@ -499,7 +568,7 @@ public final class ThreadStore: ObservableObject { |
| } |
| } |
| } |
| - return (nameBlob, members, appBlobs) |
| + return (nameBlob, members, appBlobs, openKey) |
| }.value |
| guard let built else { |
| loadError = "Couldn't wrap the chat key." |
| @@ -507,7 +576,7 @@ public final class ThreadStore: ObservableObject { |
| } |
| guard let tid = await api.createThread(kind: kind, nameBlob: built.nameBlob, |
| - members: built.members) else { |
| + members: built.members, openKey: built.openKey) else { |
| loadError = "Couldn't start the chat. Try again." |
| return nil |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/OpenKeyRoundTripTests.swift b/ios/LChatCore/Tests/LChatCoreTests/OpenKeyRoundTripTests.swift| new file mode 100644 |
| index 0000000..9e4b216 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/OpenKeyRoundTripTests.swift |
| @@ -0,0 +1,25 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// Pins the open-group key wire shape shared with the web (shell.js |
| +// createThread / loadThreads): open_key = {k: <thread key, base64url>} |
| +// encrypted under the instance key; the group name decrypts with the carried |
| +// thread key. If either side changes the envelope, this breaks here rather |
| +// than as a silently-undecryptable directory in production. |
| +final class OpenKeyRoundTripTests: XCTestCase { |
| + func testOpenKeyWireShapeRoundTrips() throws { |
| + let ik = try GroupKey(rawKey: ECIES.randomAESKey()) |
| + let threadRaw = ECIES.randomAESKey() |
| + |
| + // What createChat(openGroup:) writes… |
| + let openKey = try ik.encryptJSON(["k": threadRaw.base64urlEncodedString()]) |
| + |
| + // …and what the fold / discoverGroups read back. |
| + let payload = try ik.decryptJSON(OpenKeyPayload.self, blob: openKey) |
| + XCTAssertEqual(Data(base64urlEncoded: payload.k), threadRaw) |
| + |
| + let key = try GroupKey(rawKey: Data(base64urlEncoded: payload.k)!) |
| + let nameBlob = try key.encryptJSON(["name": "Fáilte 🏠"]) |
| + XCTAssertEqual(try key.decryptJSON(GroupNamePayload.self, blob: nameBlob).name, "Fáilte 🏠") |
| + } |
| +} |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index ff7d7d5..4a475d7 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -14,6 +14,7 @@ struct NativeChatView: View { |
| var isActive = true |
| @StateObject private var store: ThreadStore |
| @State private var selectedID: String? |
| + @State private var showDiscover = false |
| // One instance-wide socket keeps the SIDEBAR live: the hub fans every |
| // thread's frames to us anyway, so any stored-message activity just |
| // schedules a (debounced) roster/thread refresh for previews + unread. |
| @@ -766,6 +767,7 @@ struct NativeChatView: View { |
| } |
| Divider() |
| Button("Invite Someone…") { showInvite = true } |
| + Button("Discover Groups…") { showDiscover = true } |
| Button("Refresh") { Task { await store.refresh() } } |
| } label: { |
| Image(systemName: "ellipsis") |
| @@ -794,6 +796,23 @@ struct NativeChatView: View { |
| selectedID = threadID |
| } |
| } |
| + .sheet(isPresented: $showDiscover) { |
| + // The community directory (shared view; web: showDiscover). |
| + VStack(spacing: 0) { |
| + DiscoverGroupsView(store: store) { threadID in |
| + showDiscover = false |
| + selectedID = threadID |
| + } |
| + Divider() |
| + HStack { |
| + Spacer() |
| + Button("Done") { showDiscover = false } |
| + .keyboardShortcut(.cancelAction) |
| + } |
| + .padding(12) |
| + } |
| + .frame(width: 380, height: 420) |
| + } |
| } |
| .padding(.horizontal, 12) |
| .frame(height: Self.headerHeight) |
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift| index b94d4c9..5567fd3 100644 |
| --- a/ios/LChatMac/NewChatPopover.swift |
| +++ b/ios/LChatMac/NewChatPopover.swift |
| @@ -14,6 +14,7 @@ struct NewChatPopover: View { |
| @State private var selected: Set<String> = [] |
| @State private var selectedApps: Set<String> = [] |
| @State private var groupName = "" |
| + @State private var openGroup = false |
| @State private var busy = false |
| @State private var error: String? |
| // Invite-someone mode (the iOS InviteSomeoneView, popover-sized): type a |
| @@ -65,6 +66,19 @@ struct NewChatPopover: View { |
| TextField("Group name (optional)", text: $groupName) |
| .textFieldStyle(.roundedBorder) |
| .font(.system(size: 13)) |
| + // Open community group — offered only once the instance key |
| + // is here (it carries the group key to future joiners). |
| + if store.instanceKey() != nil { |
| + Toggle(isOn: $openGroup) { |
| + VStack(alignment: .leading, spacing: 1) { |
| + Text("Open group").font(.system(size: 13)) |
| + Text("Anyone in this space can find it under Discover and join.") |
| + .font(.system(size: 11)) |
| + .foregroundStyle(.secondary) |
| + } |
| + } |
| + .toggleStyle(.checkbox) |
| + } |
| } |
| Divider() |
| ScrollView { |
| @@ -118,7 +132,9 @@ struct NewChatPopover: View { |
| } |
| } |
| .buttonStyle(.borderedProminent) |
| - .disabled(selected.isEmpty || busy) |
| + // An open group may start with just its creator — others find |
| + // it in Discover; a closed group still needs people picked. |
| + .disabled((selected.isEmpty && !openGroup) || busy) |
| } |
| } |
| .padding(12) |
| @@ -352,7 +368,8 @@ struct NewChatPopover: View { |
| let apps = groupMode ? Array(selectedApps).sorted() : [] |
| Task { |
| let threadID = await store.createChat(memberIDs: memberIDs, groupName: name, |
| - group: groupMode, apps: apps) |
| + group: groupMode, apps: apps, |
| + openGroup: groupMode && openGroup) |
| busy = false |
| if let threadID { |
| onCreated(threadID) |
diff --git a/main.go b/main.go| index 3462e8b..c4a5634 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -8,6 +8,7 @@ import ( |
| "embed" |
| "encoding/base64" |
| "encoding/json" |
| + "errors" |
| "flag" |
| "fmt" |
| "html" |
| @@ -375,6 +376,10 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/group-description", s.postGroupDescription) |
| mux.HandleFunc("GET /api/threads", s.getThreads) |
| mux.HandleFunc("POST /api/threads", s.postThread) |
| + mux.HandleFunc("GET /api/threads/open", s.getOpenThreads) |
| + mux.HandleFunc("POST /api/threads/{id}/join", s.postThreadJoin) |
| + mux.HandleFunc("POST /api/threads/{id}/visibility", s.postThreadVisibility) |
| + mux.HandleFunc("POST /api/threads/{id}/default", s.postThreadDefault) |
| mux.HandleFunc("POST /api/threads/{id}/members", s.postThreadMember) |
| mux.HandleFunc("POST /api/threads/{id}/name", s.postThreadName) |
| mux.HandleFunc("POST /api/threads/{id}/avatar", s.postThreadAvatar) |
| @@ -988,10 +993,12 @@ func (s *Server) getThreads(w http.ResponseWriter, r *http.Request) { |
| // deduped to one per pair. |
| func (s *Server) postThread(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| - Token string `json:"token"` |
| - Kind string `json:"kind"` |
| - NameBlob string `json:"name_blob"` |
| - Members []ThreadMemberKey `json:"members"` |
| + Token string `json:"token"` |
| + Kind string `json:"kind"` |
| + NameBlob string `json:"name_blob"` |
| + Members []ThreadMemberKey `json:"members"` |
| + Visibility string `json:"visibility"` |
| + OpenKey string `json:"open_key"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1016,9 +1023,27 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad member count") |
| return |
| } |
| + // Open community groups: group-only, and the client must supply open_key — |
| + // the thread key encrypted under the instance key (opaque to the server) — |
| + // so any member can later discover, decrypt, and self-join. |
| + if body.Visibility != "" && body.Visibility != "invite" && body.Visibility != "open" { |
| + httpError(w, http.StatusBadRequest, "bad visibility") |
| + return |
| + } |
| + open := body.Visibility == "open" |
| + if open && body.Kind != "group" { |
| + httpError(w, http.StatusBadRequest, "only groups can be open") |
| + return |
| + } |
| + if (open && (body.OpenKey == "" || len(body.OpenKey) > 4096)) || (!open && body.OpenKey != "") { |
| + httpError(w, http.StatusBadRequest, "bad open key") |
| + return |
| + } |
| inSet := false |
| for _, m := range body.Members { |
| - if m.Wrapped == "" || len(m.Wrapped) > 4096 || !s.db.userExists(m.UserID) { |
| + // An open group's members may omit their per-member wrap — the key |
| + // reaches everyone via open_key instead. |
| + if (m.Wrapped == "" && !open) || len(m.Wrapped) > 4096 || !s.db.userExists(m.UserID) { |
| httpError(w, http.StatusBadRequest, "bad member") |
| return |
| } |
| @@ -1041,7 +1066,7 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| - id, err := s.db.createThread(body.Kind, body.NameBlob, body.Members) |
| + id, err := s.db.createThreadVis(body.Kind, body.NameBlob, body.Members, body.Visibility, body.OpenKey) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1067,6 +1092,149 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"thread_id": id}) |
| } |
| +// getOpenThreads lists the community directory: every open group, joined or |
| +// not, each carrying its instance-key-wrapped open_key so any member's client |
| +// can decrypt names/photos and self-join. Members only — never public: someone |
| +// outside the space sees nothing. |
| +func (s *Server) getOpenThreads(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{"threads": []OpenThread{}}) |
| + return |
| + } |
| + ts, err := s.db.openThreads(uid) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"threads": ts}) |
| +} |
| + |
| +// postThreadJoin self-serve-joins the caller to an open group. No key material |
| +// changes hands: the joiner already holds the instance key, and the group's |
| +// open_key rides their next thread-list fetch. |
| +func (s *Server) postThreadJoin(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 |
| + } |
| + if err := s.db.joinOpenThread(r.PathValue("id"), uid); err != nil { |
| + if errors.Is(err, errNotOpen) { |
| + httpError(w, http.StatusForbidden, "not an open group") |
| + return |
| + } |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// postThreadVisibility opens or closes a group for discovery. Any member may |
| +// flip it (groups are flat, like rename). Opening requires open_key — the |
| +// thread key encrypted under the instance key by the caller's browser; the |
| +// server can open neither. An existing open_key is never overwritten (see |
| +// setThreadVisibility), and closing keeps it so self-serve joiners keep their |
| +// key source — 'invite' only stops discovery and new joins. |
| +func (s *Server) postThreadVisibility(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Visibility string `json:"visibility"` |
| + OpenKey string `json:"open_key"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok { |
| + httpError(w, http.StatusForbidden, "no user for token") |
| + return |
| + } |
| + threadID := r.PathValue("id") |
| + if !s.db.threadMember(threadID, uid) { |
| + httpError(w, http.StatusForbidden, "not a thread member") |
| + return |
| + } |
| + if s.db.threadKind(threadID) != "group" { |
| + httpError(w, http.StatusBadRequest, "only groups can be open") |
| + return |
| + } |
| + open := body.Visibility == "open" |
| + if body.Visibility != "invite" && !open { |
| + httpError(w, http.StatusBadRequest, "bad visibility") |
| + return |
| + } |
| + if (open && (body.OpenKey == "" || len(body.OpenKey) > 4096)) || (!open && body.OpenKey != "") { |
| + httpError(w, http.StatusBadRequest, "bad open key") |
| + return |
| + } |
| + if err := s.db.setThreadVisibility(threadID, body.Visibility, body.OpenKey); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// postThreadDefault flags an open group as joined-by-default for new members |
| +// (Slack's #general). Space policy rather than group housekeeping, so unlike |
| +// rename/visibility it's admin-role-gated, not any-member. Only open groups |
| +// can default (a default group must be self-joinable); unflagging is always |
| +// allowed. Existing members are never force-joined. |
| +func (s *Server) postThreadDefault(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Default bool `json:"default"` |
| + } |
| + 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 || s.db.userRole(uid) != "admin" { |
| + httpError(w, http.StatusForbidden, "admins only") |
| + return |
| + } |
| + threadID := r.PathValue("id") |
| + if !s.db.threadMember(threadID, uid) { |
| + httpError(w, http.StatusForbidden, "not a thread member") |
| + return |
| + } |
| + if body.Default && !s.db.threadIsOpen(threadID) { |
| + httpError(w, http.StatusBadRequest, "only open groups can be default") |
| + return |
| + } |
| + if err := s.db.setThreadDefault(threadID, body.Default); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // postThreadMember adds an existing instance member to a thread the caller is in. |
| // The caller's browser wraps the thread key to the new member's public key. |
| func (s *Server) postThreadMember(w http.ResponseWriter, r *http.Request) { |
| @@ -1103,6 +1271,16 @@ func (s *Server) postThreadMember(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad member") |
| return |
| } |
| + // Adding an existing member is a no-op, checked here on purpose: a |
| + // self-serve joiner of an open group has membership but no thread_keys |
| + // row, and that empty slot must not be fillable with another member's |
| + // arbitrary blob (addThreadMember's DO NOTHING only protects rows that |
| + // already exist). The one sanctioned fill-an-existing-member's-slot path |
| + // is invite claiming, which writes the joiner's OWN wrap (claimKeyInvite). |
| + if s.db.threadMember(threadID, body.UserID) { |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| + return |
| + } |
| if err := s.db.addThreadMember(threadID, body.UserID, body.Wrapped); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
diff --git a/test/format.test.mjs b/test/format.test.mjs| index 1d04d9e..3109b85 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -357,6 +357,16 @@ await test("membershipLine renders joins from the viewer's point of view", () => |
| assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u1", label), "You added Alice and Bob"); |
| }); |
| +await test("membershipLine renders a self-serve join as 'joined', not 'added themselves'", () => { |
| + const names = { u1: "Paul" }; |
| + const label = (id) => names[id] || "Someone"; |
| + // Joining an open group via Discover: join = [the actor themselves]. |
| + assert.equal(membershipLine({ uid: "u1", join: ["u1"] }, "u9", label), "Paul joined"); |
| + assert.equal(membershipLine({ uid: "u1", join: ["u1"] }, "u1", label), "You joined"); |
| + // A uid-less legacy payload keeps the added-list rendering. |
| + assert.equal(membershipLine({ name: "Paul", join: ["u2"] }, "u9", label), "Paul added Someone"); |
| +}); |
| + |
| await test("membershipLine renders removals from the viewer's point of view", () => { |
| const names = { u1: "Paul", u2: "Alice", u3: "Bob" }; |
| const label = (id) => names[id] || "Someone"; |
diff --git a/web/static/format.js b/web/static/format.js| index 0ff6dcd..50f5267 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -317,7 +317,12 @@ export function membershipLine(payload, meId, labelOf) { |
| const actor = payload.uid; |
| const actorName = actor ? (actor === meId ? "You" : labelOf(actor)) : payload.name || "Someone"; |
| const names = (ids) => nameList(ids.map((uid) => (uid === meId ? "you" : labelOf(uid)))); |
| - if (Array.isArray(payload.join) && payload.join.length) return `${actorName} added ${names(payload.join)}`; |
| + if (Array.isArray(payload.join) && payload.join.length) { |
| + // A self-serve join (an open group's Discover): the joiner announces |
| + // themselves — "Paul joined" / "You joined", never "Paul added Paul". |
| + if (actor && payload.join.length === 1 && payload.join[0] === actor) return `${actorName} joined`; |
| + return `${actorName} added ${names(payload.join)}`; |
| + } |
| if (Array.isArray(payload.removed) && payload.removed.length) return `${actorName} removed ${names(payload.removed)}`; |
| if (payload.left) return `${actorName} left`; |
| return null; |
diff --git a/web/static/shell.js b/web/static/shell.js| index c3e90cb..ae16ccf 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -711,6 +711,9 @@ async function boot() { |
| ]); |
| await loadRoster(); |
| await Promise.all([setupInstanceKey(), loadThreads()]); |
| + // The two loads race: when the thread list landed first, any open group |
| + // (key under the instance key) was skipped — fold it in now the key's here. |
| + if (instanceKey && threads.some((t) => t.open_key && !threadKeys[t.id])) await loadThreads(); |
| // Member-built apps are sealed under the instance key, so they can only |
| // register now (a late-arriving key retries from onInstanceKeyReady). |
| // Before renderApp + connectWS on purpose: their threads and live events |
| @@ -1337,15 +1340,30 @@ async function loadThreads() { |
| const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json(); |
| threads = r.threads || []; |
| for (const t of threads) { |
| - if (!threadKeys[t.id] && t.wrapped) { |
| - try { |
| - const b64 = await unwrapThreadKey(t.wrapped, session.userPriv); |
| - threadKeys[t.id] = await importKey(b64); |
| - threadKeyB64[t.id] = b64; |
| - } catch (e) { |
| - console.error("unwrap thread", t.id, e); |
| - continue; |
| + if (!threadKeys[t.id] && (t.wrapped || t.open_key)) { |
| + // Two key sources: the per-member wrapped copy, then — for open groups — |
| + // open_key (the thread key encrypted under the instance key). open_key is |
| + // a true fallback, not an else: a bad wrapped blob must not hide an open |
| + // group the instance key can decrypt. If the instance key hasn't arrived |
| + // yet, skip for now; boot / onInstanceKeyReady re-fold the list. |
| + let b64 = null; |
| + if (t.wrapped) { |
| + try { |
| + b64 = await unwrapThreadKey(t.wrapped, session.userPriv); |
| + } catch (e) { |
| + console.error("unwrap thread", t.id, e); |
| + } |
| } |
| + if (!b64 && t.open_key && instanceKey) { |
| + try { |
| + b64 = (await decrypt(instanceKey, t.open_key)).k; |
| + } catch (e) { |
| + console.error("unwrap open thread", t.id, e); |
| + } |
| + } |
| + if (!b64) continue; |
| + threadKeys[t.id] = await importKey(b64); |
| + threadKeyB64[t.id] = b64; |
| } |
| threadName[t.id] = await displayName(t); |
| hidden[t.id] = !!t.hidden; // closed chats (server-synced) are dropped from the list |
| @@ -1573,6 +1591,7 @@ window.lchatPaletteItems = () => { |
| const others = roster.some((e) => e.user_id !== myUserId && e.public_key); |
| const actions = [{ id: "new-chat", label: "New chat" }]; |
| if (others) actions.push({ id: "new-group", label: "New group" }); |
| +actions.push({ id: "discover", label: "Discover groups" }); |
| if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" }); |
| if (personalApps().length) actions.push({ id: "apps", label: "Open apps" }); |
| actions.push( |
| @@ -1588,6 +1607,7 @@ window.lchatPaletteRun = (kind, id) => { |
| const run = { |
| "new-chat": () => showNewChat(), |
| "new-group": () => showNewChat("group"), |
| + discover: showDiscover, |
| invite: () => showNewChat("invite"), |
| apps: showApps, |
| "add-device": addDevice, |
| @@ -1692,6 +1712,10 @@ async function refreshNamesUI() { |
| async function onInstanceKeyReady() { |
| if (!instanceKey) return; |
| await decryptRosterLabels(); |
| + // Open groups joined on another device were skipped at boot (their key |
| + // travels under the instance key) — re-fold the list now the key is here. |
| + await loadThreads(); |
| + renderThreadList(); |
| // Member apps couldn't decrypt at boot without the key — register them now. |
| await loadMemberAppsNow(); |
| await publishMyLabel(); |
| @@ -5858,6 +5882,49 @@ async function editGroup() { |
| card.appendChild(label); |
| card.appendChild(input); |
| + // Open/close for discovery: any member may flip it (groups are flat, like |
| + // rename). Opening ships the group key encrypted under the instance key — |
| + // once; the server never overwrites a stored open_key — so the choice is |
| + // only offered when this device holds both keys it needs to mint the blob. |
| + let openCb = null; |
| + let defaultCb = null; |
| + if (instanceKey && threadKeyB64[id]) { |
| + const optRow = (label, descText) => { |
| + const row = document.createElement("label"); |
| + row.className = "opt-row"; |
| + const cb = document.createElement("input"); |
| + cb.type = "checkbox"; |
| + const txt = document.createElement("span"); |
| + txt.textContent = label; |
| + const desc = document.createElement("span"); |
| + desc.className = "opt-desc"; |
| + desc.textContent = descText; |
| + txt.appendChild(desc); |
| + row.appendChild(cb); |
| + row.appendChild(txt); |
| + card.appendChild(row); |
| + return cb; |
| + }; |
| + openCb = optRow("Open group", "Anyone in this space can find it under Discover and join."); |
| + openCb.checked = t.visibility === "open"; |
| + // Default groups are space policy (who lands where on arrival), so the |
| + // choice is admin-only — and only meaningful while the group is open. |
| + const meEntry = roster.find((e) => e.user_id === myUserId); |
| + if (meEntry && meEntry.role === "admin") { |
| + defaultCb = optRow( |
| + "New members join automatically", |
| + "Everyone who joins the space starts in this group. Nobody already here is added.", |
| + ); |
| + defaultCb.checked = !!t.default_join; |
| + const syncDefault = () => { |
| + defaultCb.disabled = !openCb.checked; |
| + if (!openCb.checked) defaultCb.checked = false; |
| + }; |
| + syncDefault(); |
| + openCb.addEventListener("change", syncDefault); |
| + } |
| + } |
| + |
| const foot = document.createElement("div"); |
| foot.className = "sheet-foot"; |
| const cancel = document.createElement("button"); |
| @@ -5868,21 +5935,48 @@ async function editGroup() { |
| save.textContent = "Save"; |
| save.onclick = async () => { |
| const next = input.value.trim(); |
| - if (next !== stored) { |
| + const flipOpen = openCb && openCb.checked !== (t.visibility === "open"); |
| + const flipDefault = defaultCb && defaultCb.checked !== !!t.default_join; |
| + if (next !== stored || flipOpen || flipDefault) { |
| save.disabled = true; |
| try { |
| - // A blank name clears it (name_blob=""), so the group reverts to its |
| - // auto-generated member-name title. |
| - const name_blob = next ? await encrypt(threadKeys[id], { name: next }) : ""; |
| - const r = await fetch(`/api/threads/${encodeURIComponent(id)}/name`, { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, name_blob }), |
| - }); |
| - if (!r.ok) throw new Error("server"); |
| - t.name_blob = name_blob; |
| - threadName[id] = next || autoName; |
| - if (activeId === id) document.getElementById("thread-title").textContent = threadName[id]; |
| + if (next !== stored) { |
| + // A blank name clears it (name_blob=""), so the group reverts to its |
| + // auto-generated member-name title. |
| + const name_blob = next ? await encrypt(threadKeys[id], { name: next }) : ""; |
| + const r = await fetch(`/api/threads/${encodeURIComponent(id)}/name`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, name_blob }), |
| + }); |
| + if (!r.ok) throw new Error("server"); |
| + t.name_blob = name_blob; |
| + threadName[id] = next || autoName; |
| + if (activeId === id) document.getElementById("thread-title").textContent = threadName[id]; |
| + } |
| + if (flipOpen) { |
| + const body = { token: session.token, visibility: openCb.checked ? "open" : "invite" }; |
| + if (openCb.checked) body.open_key = await encrypt(instanceKey, { k: threadKeyB64[id] }); |
| + const r = await fetch(`/api/threads/${encodeURIComponent(id)}/visibility`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify(body), |
| + }); |
| + if (!r.ok) throw new Error("server"); |
| + t.visibility = openCb.checked ? "open" : ""; |
| + } |
| + if (flipDefault) { |
| + // After the visibility flip on purpose: making a group open AND |
| + // default in one save must open it first (the server refuses a |
| + // default flag on a closed group). |
| + const r = await fetch(`/api/threads/${encodeURIComponent(id)}/default`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, default: defaultCb.checked }), |
| + }); |
| + if (!r.ok) throw new Error("server"); |
| + t.default_join = defaultCb.checked; |
| + } |
| renderThreadList(); |
| } catch { |
| save.disabled = false; |
| @@ -6478,6 +6572,14 @@ function showNewChat(startMode) { |
| toggle.onclick = () => render("group"); |
| head.appendChild(toggle); |
| } |
| +const disc = document.createElement("button"); |
| + disc.className = "linkish"; |
| + disc.textContent = "Discover"; |
| + disc.onclick = () => { |
| + backdrop.remove(); |
| + showDiscover(); |
| + }; |
| + head.appendChild(disc); |
| if (inviteAllowed()) { |
| const inv = document.createElement("button"); |
| inv.className = "linkish"; |
| @@ -6545,13 +6647,33 @@ function showNewChat(startMode) { |
| return; |
| } |
| - let nameInput; |
| + let nameInput, openCb; |
| if (mode === "group") { |
| nameInput = document.createElement("input"); |
| nameInput.placeholder = "Group name (optional)"; |
| nameInput.maxLength = 60; |
| nameInput.className = "sheet-input"; |
| card.appendChild(nameInput); |
| + // Open community group: any member can find it in Discover and join |
| + // without an invite. Needs the instance key (it carries the group key to |
| + // future joiners) — until that arrives the choice simply isn't offered, |
| + // matching how photos wait for the same key. |
| + if (instanceKey) { |
| + const row = document.createElement("label"); |
| + row.className = "opt-row"; |
| + openCb = document.createElement("input"); |
| + openCb.type = "checkbox"; |
| + if (startMode === "open-group") openCb.checked = true; |
| + const txt = document.createElement("span"); |
| + txt.textContent = "Open group"; |
| + const desc = document.createElement("span"); |
| + desc.className = "opt-desc"; |
| + desc.textContent = "Anyone in this space can find it under Discover and join."; |
| + txt.appendChild(desc); |
| + row.appendChild(openCb); |
| + row.appendChild(txt); |
| + card.appendChild(row); |
| + } |
| } |
| const filter = document.createElement("input"); |
| @@ -6565,6 +6687,12 @@ function showNewChat(startMode) { |
| const selected = new Set(); |
| const create = document.createElement("button"); |
| + // An open group may start with just its creator (others join via |
| + // Discover); a closed group still needs at least one other member. |
| + const updateCreate = () => { |
| + create.disabled = selected.size === 0 && !(openCb && openCb.checked); |
| + }; |
| + if (openCb) openCb.onchange = updateCreate; |
| const renderList = () => { |
| const q = filter.value.trim().toLowerCase(); |
| @@ -6578,7 +6706,7 @@ function showNewChat(startMode) { |
| cb.checked = selected.has(e.user_id); |
| cb.onchange = () => { |
| cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id); |
| - create.disabled = selected.size === 0; |
| + updateCreate(); |
| }; |
| row.appendChild(cb); |
| row.appendChild(pickerName(e)); |
| @@ -6641,9 +6769,16 @@ function showNewChat(startMode) { |
| cancel.textContent = "Cancel"; |
| cancel.onclick = () => render("people"); |
| create.textContent = "Create group"; |
| - create.disabled = true; |
| + updateCreate(); // pre-ticked Open (Discover's empty state) starts enabled |
| create.onclick = () => |
| - go(createThread([...selected], { group: true, name: nameInput.value.trim() }), create); |
| + go( |
| + createThread([...selected], { |
| + group: true, |
| + name: nameInput.value.trim(), |
| + open: !!(openCb && openCb.checked), |
| + }), |
| + create, |
| + ); |
| foot.appendChild(cancel); |
| foot.appendChild(create); |
| card.appendChild(foot); |
| @@ -6652,10 +6787,146 @@ function showNewChat(startMode) { |
| // Honour a requested start view only when it's usable, else fall back. |
| let start = "people"; |
| if (startMode === "group" && others.length) start = "group"; |
| + // "open-group" (Discover's empty state) works even with nobody else joined |
| + // yet — an open group may start with just its creator. |
| + if (startMode === "open-group") start = "group"; |
| if (startMode === "invite" && inviteAllowed()) start = "invite"; |
| render(start); |
| } |
| +// Discover (⌘K → "Discover groups", or the new-chat sheet): the community |
| +// directory of open groups — join any of them on the spot. Each row's name |
| +// decrypts via its open_key + the instance key, so the whole directory is |
| +// readable by any member and by nobody else (the server sees only blobs). |
| +async function showDiscover() { |
| + const backdrop = modal(); |
| + const card = backdrop.querySelector(".sheet"); |
| + const head = document.createElement("div"); |
| + head.className = "sheet-head"; |
| + const h = document.createElement("h2"); |
| + h.textContent = "Discover groups"; |
| + head.appendChild(h); |
| + card.appendChild(head); |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + note.style.padding = "0 4px 6px"; |
| + note.textContent = "Groups anyone in this space can join — no invite needed."; |
| + card.appendChild(note); |
| + |
| + let rows = []; |
| + try { |
| + const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json(); |
| + rows = r.threads || []; |
| + } catch (e) { |
| + console.error(e); |
| + note.textContent = "Couldn't load open groups — try again."; |
| + return; |
| + } |
| + if (!rows.length) { |
| + // An empty directory is an invitation: hand over the one action that |
| + // fills it (the button lands in the new-group sheet with Open pre-ticked). |
| + note.textContent = "No open groups here yet. Start one and it shows up for everyone in this space."; |
| + const start = document.createElement("button"); |
| + start.textContent = "Start an open group"; |
| + start.onclick = () => { |
| + backdrop.remove(); |
| + showNewChat("open-group"); |
| + }; |
| + card.appendChild(start); |
| + return; |
| + } |
| + if (!instanceKey) { |
| + // Same key-pending state as photos: the instance key hasn't reached this |
| + // device yet, so the directory can't be decrypted (or joined usefully). |
| + note.textContent = "Waiting for your key to arrive — try again in a moment."; |
| + return; |
| + } |
| + |
| + const list = document.createElement("div"); |
| + list.className = "pick-list"; |
| + card.appendChild(list); |
| + for (const row of rows) { |
| + let name = "Open group"; |
| + try { |
| + const key = await importKey((await decrypt(instanceKey, row.open_key)).k); |
| + if (row.name_blob) name = (await decrypt(key, row.name_blob)).name || name; |
| + } catch (e) { |
| + console.error("discover row", row.id, e); |
| + continue; // an undecryptable row is unjoinable — don't offer it |
| + } |
| + const item = document.createElement("div"); |
| + item.className = "list-row static"; |
| + item.appendChild(avatarEl(name)); |
| + const info = document.createElement("div"); |
| + info.className = "discover-main"; |
| + const title = document.createElement("div"); |
| + title.textContent = name; |
| + const sub = document.createElement("span"); |
| + sub.className = "helper-desc"; |
| + const count = row.member_count === 1 ? "1 member" : `${row.member_count} members`; |
| + sub.textContent = row.default_join ? `${count} · everyone starts here` : count; |
| + info.appendChild(title); |
| + info.appendChild(sub); |
| + item.appendChild(info); |
| + const btn = document.createElement("button"); |
| + btn.className = "pill-btn"; |
| + if (row.joined) { |
| + // Already yours — the quiet variant keeps Join the sheet's one loud act. |
| + btn.classList.add("quiet"); |
| + btn.textContent = "Open"; |
| + btn.onclick = () => { |
| + backdrop.remove(); |
| + openThread(row.id); |
| + }; |
| + } else { |
| + btn.textContent = "Join"; |
| + btn.onclick = async () => { |
| + btn.disabled = true; |
| + try { |
| + const r = await fetch(`/api/threads/${encodeURIComponent(row.id)}/join`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token }), |
| + }); |
| + if (!r.ok) throw new Error("join failed"); |
| + await loadThreads(); |
| + renderThreadList(); |
| + // Tell the room — the same action-line shape announceJoin sends, |
| + // but in the joiner's own voice ("Paul joined", not "… added Paul"). |
| + // The join field nudges other clients to refresh their member list. |
| + const key = threadKeys[row.id]; |
| + if (key && ws && ws.readyState === WebSocket.OPEN) { |
| + const me = session.name || "New member"; |
| + const payload = { name: me, text: `${me} joined`, join: myUserId ? [myUserId] : [], ts: Date.now(), action: true }; |
| + if (myUserId) payload.uid = myUserId; |
| + ws.send(JSON.stringify({ type: "msg", thread_id: row.id, blob: await encrypt(key, payload) })); |
| + } |
| + // The sheet stays up until we're actually in: a failure above lands |
| + // in the catch with the retry button still visible (re-running the |
| + // join is safe — the server treats it as idempotent). |
| + backdrop.remove(); |
| + openThread(row.id); |
| + } catch (e) { |
| + console.error(e); |
| + btn.disabled = false; |
| + btn.textContent = "Couldn't join — try again"; |
| + } |
| + }; |
| + } |
| + item.appendChild(btn); |
| + list.appendChild(item); |
| + } |
| + if (!list.childElementCount) { |
| + // Every row failed to decrypt (e.g. open_keys minted against a different |
| + // instance key) — say so rather than presenting a silently empty sheet. |
| + const p = document.createElement("p"); |
| + p.className = "sub"; |
| + p.style.padding = "0 4px 6px"; |
| + p.textContent = "Couldn't read the group directory on this device — try again in a moment."; |
| + card.appendChild(p); |
| + } |
| +} |
| + |
| // Apps directory (⌘K → "Open apps"): the personal apps as a plain pick list — |
| // tap one to open (or create on first use) its app-thread. Same rows the |
| // New-chat sheet shows under "Apps", including the connected-service gate: a |
| @@ -6720,6 +6991,14 @@ async function createThread(otherIds, opts = {}) { |
| // Only store a name when one was actually given — a nameless group falls back |
| // to its member names for display (see displayName), rather than "Group". |
| if (group && opts.name) body.name_blob = await encrypt(key, { name: opts.name }); |
| + // An open community group also ships its key encrypted under the instance |
| + // key, so any member can discover it and self-join (the server can open |
| + // neither blob). Picked members still get their normal wrapped copy above. |
| + if (group && opts.open) { |
| + if (!instanceKey) throw new Error("no instance key yet"); |
| + body.visibility = "open"; |
| + body.open_key = await encrypt(instanceKey, { k: b64 }); |
| + } |
| const r = await ( |
| await fetch("/api/threads", { |
| method: "POST", |
diff --git a/web/static/style.css b/web/static/style.css| index f6b2303..4f2ee4c 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -2099,6 +2099,30 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off |
| .member-remove { flex: none; background: transparent; color: var(--danger); font-weight: 600; font-size: 14px; padding: 4px 8px; } |
| .sheet-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; } |
| +/* Open groups. Two pieces of vocabulary, both built from the sheet's existing |
| + language so community UI reads as native, not bolted on: |
| + - .opt-row: a choice with its consequence spelled out underneath (label sets |
| + the what, .opt-desc the so-what) — checkboxes stop needing run-on labels. |
| + - Discover rows: roster-style avatar + name + quiet facts, action on the |
| + right. Join is the sheet's ONE loud control (accent pill); everything a |
| + member already has (Open) stays quiet. */ |
| +.opt-row { |
| + display: flex; align-items: flex-start; gap: 12px; |
| + padding: 10px 2px; cursor: pointer; font-weight: 550; |
| +} |
| +.opt-row input { width: auto; flex: none; margin-top: 4px; accent-color: var(--accent); } |
| +.opt-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 1px; } |
| +/* A dependent option that can't apply right now (default needs open) dims as a |
| + unit — state is the row's opacity AND the disabled input, never color alone. */ |
| +.opt-row:has(input:disabled) { opacity: 0.45; cursor: default; } |
| +.pill-btn { |
| + flex: none; background: var(--accent); color: var(--accent-ink); |
| + border-radius: 999px; padding: 7px 16px; font-weight: 650; font-size: 14px; |
| +} |
| +.pill-btn.quiet { background: var(--bubble-them); color: var(--ink); } |
| +.discover-main { flex: 1; min-width: 0; } |
| +.discover-main > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| + |
| /* theme picker (kebab → Theme) */ |
| .appearance-seg { display: flex; gap: 6px; background: var(--bubble-them); border-radius: 12px; padding: 4px; } |
| .appearance-seg button { flex: 1; background: transparent; color: var(--muted); border-radius: 9px; padding: 9px; font-weight: 600; } |