eleven / messenger Public

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>
Paul Campbell b7c3dbe80788dd001a034245d0941b6d7e37148e parent 1507971
17 files changed, +1365 −49
  • changelog/2026-07-10-default-groups.md +4 −0
  • changelog/2026-07-10-open-groups.md +5 −0
  • db.go +175 −2
  • handlers_test.go +290 −0
  • ios/LChat/Views/NewChatView.swift +27 −2
  • ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift +129 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +29 −2
  • ios/LChatCore/Sources/LChatCore/Models.swift +37 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +78 −9
  • ios/LChatCore/Tests/LChatCoreTests/OpenKeyRoundTripTests.swift +25 −0
  • ios/LChatMac/NativeChatView.swift +19 −0
  • ios/LChatMac/NewChatPopover.swift +19 −2
  • main.go +184 −6
  • test/format.test.mjs +10 −0
  • web/static/format.js +6 −1
  • web/static/shell.js +304 −25
  • web/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; }