Encrypted descriptions for people and the space (#325)
* server+web: encrypted descriptions for people and the space
People get a free-text "About you" (users.description_blob) encrypted
under the instance key like their name and photo alt, served only to
their connections and shown under their name in pickers and member
lists. The space gets a description too (config group_description_blob,
admin-role gated): members read it in a new "About this space" sheet,
and member-minted invite links carry a sealed snapshot so the welcome
card can show what you're joining before any key exists — the server
only ever stores ciphertext for both.
Co-Authored-By: Claude <noreply@anthropic.com>
* server+web+admin: space description follows the admin-email pattern (plaintext)
The space description moves from an instance-key blob to plaintext config
(group_description), classified like the admin name/email: admin-authored
operator metadata, not user content. Write is POST /api/group-description
(requireAdmin, trimmed + length-validated server-side — now possible since
the server can see it); reads are member-gated on /api/group and, for
invitees, served on GET /api/key-invite/{id} to valid link-holders only —
never the fully-public reads. That replaces the sealed-payload snapshot:
admin-console-minted invites now carry it too, it never goes stale, and the
keyless admin console gains a Description field in the This-space form.
People's "About you" stays end-to-end encrypted, unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
* web: profile card on DM header tap + breathing room in Edit profile
Tapping the name at the top of a one-to-one chat opens the other
person's profile card — photo, name (BOT/AI badge for agents), and
their "about me" (agents show their catalog blurb) — the DM
counterpart of a group's tappable member count. Also puts air between
the Screen name field and the new About-you box in Edit profile.
Co-Authored-By: Claude <noreply@anthropic.com>
* web: edit the space description from the in-app Admin app too
The Admin app's "This space" section gains a Description textarea
(between Name and Contact email, mirroring /admin), saved with the
section's Save via the same /api/group-description endpoint. The
invitation preview mirrors it live under the sub line — exactly where
the real join card renders it. Bridge: groupInfo() returns
description; saveSpace() forwards it (omitted = unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
* web: the join card's space description reads as content, not chrome
The description was set in the same muted centered style as the
"Private, end-to-end encrypted chat." tagline, so it disappeared into
the card's boilerplate. Now it's a soft accent-tinted panel with
full-strength, left-aligned text — the admin's note to the person
joining. The Admin app's invitation miniature mirrors the same look.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
13 files changed,
+599
−52
CLAUDE.md+11 −7changelog/2026-07-07-space-and-profile-descriptions.md+8 −0db.go+30 −3descriptions_test.go+134 −0main.go+69 −0web/pages/admin.html+3 −1web/pages/index.html+1 −0web/pages/join.html+4 −0web/static/admin.js+8 −3web/static/apps/admin.js+43 −4web/static/join.js+52 −27web/static/shell.js+227 −7web/static/style.css+9 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 4ca584d..776fb0c 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -35,13 +35,17 @@ message text). This was an explicit, accepted trade-off and is easy to gate |
| behind a setting later. |
| Instance **branding** is plaintext by design and doesn't count against the rule — |
| -it's admin-set, never user content. Two things: the **group name** (`config` |
| -`group_name`), and the optional **server avatar** (`instance_avatar` table, served |
| -in the clear at `GET /api/server-avatar`). Both must render where no key exists |
| -yet — the join/onboarding cards, the native add-chat screens, and the home |
| -Save-passkey card — so they can't be E2E-encrypted like profile/group photos. |
| -Keep branding to this narrow set; anything that is *user* content still gets |
| -encrypted client-side. |
| +it's admin-set, never user content. Three things: the **group name** (`config` |
| +`group_name`), the optional **server avatar** (`instance_avatar` table, served |
| +in the clear at `GET /api/server-avatar`), and the optional **space description** |
| +(`config` `group_description`). All must render where no key exists yet — the |
| +join/onboarding cards, the native add-chat screens, and the home Save-passkey |
| +card — so they can't be E2E-encrypted like profile/group photos. The description |
| +(like the admin name/email) is gated a notch tighter than the other two: served |
| +to members and to valid invite-link holders (`GET /api/key-invite/{id}`), never |
| +on the fully-public reads. Keep branding to this narrow set; anything that is |
| +*user* content still gets encrypted client-side (e.g. a member's own "about me", |
| +`users.description_blob`, under the instance key). |
| **Bots** are the rule's scoped, disclosed exception-by-membership: a bot is an |
| ordinary E2EE member whose "device" is a machine the operator runs (`agent/`), |
diff --git a/changelog/2026-07-07-space-and-profile-descriptions.md b/changelog/2026-07-07-space-and-profile-descriptions.md| new file mode 100644 |
| index 0000000..0fd523b |
| --- /dev/null |
| +++ b/changelog/2026-07-07-space-and-profile-descriptions.md |
| @@ -0,0 +1,8 @@ |
| +🤖 You can now say a little about yourself: Edit profile has a new "About you" |
| +box, and what you write shows under your name in member lists and pickers — and |
| +tapping a name at the top of a one-to-one chat opens that person's profile |
| +card. It's end-to-end encrypted like your name and photos, so the server never |
| +sees it. |
| +Your space can have a description too: admins write it (on the admin page or |
| +under ⋮ → About this space), members read it there, and it greets new people |
| +right on their invite's welcome card. |
diff --git a/db.go b/db.go| index a0cb914..79eb4d2 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -52,6 +52,19 @@ func (db *DB) getAdminEmail() string { v, _, _ := db.getConfig("admin_email"); r |
| func (db *DB) setAdminName(v string) error { return db.setConfig("admin_name", v) } |
| func (db *DB) setAdminEmail(v string) error { return db.setConfig("admin_email", v) } |
| +// getGroupDescription / setGroupDescription hold the space's free-text |
| +// description. Plaintext like the admin name/email above — admin-authored |
| +// operator metadata, never user content — so the keyless admin console can |
| +// edit it and the join card can show an invitee what they're joining. Served |
| +// to members and to valid invite holders, not on the public onboarding read. |
| +func (db *DB) getGroupDescription() string { |
| + v, _, _ := db.getConfig("group_description") |
| + return v |
| +} |
| +func (db *DB) setGroupDescription(v string) error { |
| + return db.setConfig("group_description", v) |
| +} |
| + |
| // setupComplete gates the first-run Eleven setup conversation: false until the |
| // admin finishes (or dismisses) it. See docs/conversational-onboarding.md. |
| func (db *DB) setupComplete() bool { |
| @@ -502,6 +515,9 @@ CREATE TABLE IF NOT EXISTS member_app_versions ( |
| // Away, so activity doesn't flip them back online). Connection metadata |
| // like role — the server already holds the sockets — never content. |
| "ALTER TABLE users ADD COLUMN presence TEXT NOT NULL DEFAULT ''", |
| + // A person's free-text "about me", encrypted under the instance key like |
| + // their name and photo alt — user content, never readable by the server. |
| + "ALTER TABLE users ADD COLUMN description_blob TEXT", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -793,6 +809,14 @@ func (db *DB) setUserLabelBlob(userID, blob string) error { |
| return err |
| } |
| +// setUserDescriptionBlob stores a person's "about me" text encrypted under the |
| +// instance key ("" clears it). Like the label, it's set by the member themselves; |
| +// an agent's blurb is the plaintext bot_configs.description instead. |
| +func (db *DB) setUserDescriptionBlob(userID, blob string) error { |
| + _, err := db.sql.Exec("UPDATE users SET description_blob = ? WHERE id = ? AND COALESCE(is_agent, 0) = 0", blob, userID) |
| + return err |
| +} |
| + |
| func (db *DB) userExists(id string) bool { |
| var x int |
| return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil |
| @@ -1081,6 +1105,9 @@ type RosterEntry struct { |
| Avatar string `json:"avatar,omitempty"` |
| // The photo's alt text, encrypted under the instance key (client decrypts). |
| AvatarAltBlob string `json:"avatar_alt_blob,omitempty"` |
| + // A person's free-text "about me", encrypted under the instance key (client |
| + // decrypts). Served only to their connections, like the rest of the entry. |
| + DescriptionBlob string `json:"description_blob,omitempty"` |
| // admin | moderator | member — lets clients say who runs the space (the |
| // welcome conversation's "run by <admin>"). Server-known metadata, not |
| // content; it already gates the admin API. |
| @@ -1385,7 +1412,7 @@ func (db *DB) setAvatar(userID, blobID, altBlob string) error { |
| // they share a thread with). Kept for admin/back-end use and tests. |
| func (db *DB) roster() ([]RosterEntry, error) { |
| rows, err := db.sql.Query( |
| - `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, '') |
| + `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, '') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`, |
| ) |
| @@ -1408,7 +1435,7 @@ func (db *DB) roster() ([]RosterEntry, error) { |
| // connections-only; only vetted, clearly-badged helpers are instance-visible. |
| func (db *DB) connections(userID string) ([]RosterEntry, error) { |
| rows, err := db.sql.Query(` |
| - SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, '') |
| + SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, '') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| @@ -1432,7 +1459,7 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) { |
| var e RosterEntry |
| var isAgent int |
| var kind string |
| - if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.Role, &isAgent, &e.Description, &kind, &e.Presence); err != nil { |
| + if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.DescriptionBlob, &e.Role, &isAgent, &e.Description, &kind, &e.Presence); err != nil { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
diff --git a/descriptions_test.go b/descriptions_test.go| new file mode 100644 |
| index 0000000..81477ed |
| --- /dev/null |
| +++ b/descriptions_test.go |
| @@ -0,0 +1,134 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// joinTestMember creates a user, claims a device token for them, and registers a |
| +// public key so they appear on the roster (joined members only). |
| +func joinTestMember(t *testing.T, srv *Server, inviteID string) (uid, tok string) { |
| + t.Helper() |
| + uid, _ = srv.db.createUser("") |
| + srv.db.createKeyInvite(inviteID, uid, "", testClaimHash, 0) |
| + tok, _, _ = srv.db.claimKeyInvite(inviteID, "", "", testClaimSecret) |
| + srv.db.sql.Exec("UPDATE users SET public_key = 'pk' WHERE id = ?", uid) |
| + return uid, tok |
| +} |
| + |
| +func TestPersonDescriptionBlob(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + uid, tok := joinTestMember(t, srv, "desc") |
| + |
| + post := func(body string) *http.Response { |
| + resp, err := http.Post(ts.URL+"/api/description", "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post description: %v", err) |
| + } |
| + return resp |
| + } |
| + |
| + // Set: the roster carries the opaque blob. |
| + if resp := post(`{"token":"` + tok + `","description_blob":"enc-about"}`); resp.StatusCode != 200 { |
| + t.Fatalf("set: want 200, got %d", resp.StatusCode) |
| + } |
| + roster, _ := srv.db.roster() |
| + var e *RosterEntry |
| + for i := range roster { |
| + if roster[i].UserID == uid { |
| + e = &roster[i] |
| + } |
| + } |
| + if e == nil || e.DescriptionBlob != "enc-about" { |
| + t.Fatalf("roster entry after set: %+v", e) |
| + } |
| + |
| + // Clear: "" empties it again. |
| + if resp := post(`{"token":"` + tok + `","description_blob":""}`); resp.StatusCode != 200 { |
| + t.Fatalf("clear: want 200, got %d", resp.StatusCode) |
| + } |
| + roster, _ = srv.db.roster() |
| + for i := range roster { |
| + if roster[i].UserID == uid && roster[i].DescriptionBlob != "" { |
| + t.Fatalf("clear left residue: %+v", roster[i]) |
| + } |
| + } |
| + |
| + // Oversized blobs and non-members are rejected. |
| + if resp := post(`{"token":"` + tok + `","description_blob":"` + strings.Repeat("x", 5000) + `"}`); resp.StatusCode != 400 { |
| + t.Fatalf("oversize: want 400, got %d", resp.StatusCode) |
| + } |
| + if resp := post(`{"token":"nope","description_blob":"enc"}`); resp.StatusCode != 403 { |
| + t.Fatalf("bad token: want 403, got %d", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestGroupDescription(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + _, memberTok := joinTestMember(t, srv, "gd-member") |
| + adminUID, adminTok := joinTestMember(t, srv, "gd-admin") |
| + srv.db.sql.Exec("UPDATE users SET role = 'admin' WHERE id = ?", adminUID) |
| + |
| + post := func(bearer, body string) *http.Response { |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/group-description", strings.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("post group description: %v", err) |
| + } |
| + return resp |
| + } |
| + getJSON := func(url string) map[string]any { |
| + resp, err := http.Get(url) |
| + if err != nil { |
| + t.Fatalf("get %s: %v", url, err) |
| + } |
| + var out map[string]any |
| + json.NewDecoder(resp.Body).Decode(&out) |
| + return out |
| + } |
| + |
| + // An ordinary member can't set it; a role-admin's own device token can. |
| + if resp := post(memberTok, `{"description":"We talk about cheese."}`); resp.StatusCode != 401 { |
| + t.Fatalf("member set: want 401, got %d", resp.StatusCode) |
| + } |
| + if resp := post(adminTok, `{"description":" We talk about cheese. "}`); resp.StatusCode != 200 { |
| + t.Fatalf("role-admin set: want 200, got %d", resp.StatusCode) |
| + } |
| + |
| + // Members read it (trimmed) from /api/group; the unauthenticated onboarding |
| + // read never includes it — invited people get it via the invite lookup below. |
| + if g := getJSON(ts.URL + "/api/group?token=" + memberTok); g["description"] != "We talk about cheese." { |
| + t.Fatalf("member read: %v", g["description"]) |
| + } |
| + if g := getJSON(ts.URL + "/api/group"); g["description"] != nil { |
| + t.Fatalf("public read leaked the description: %v", g["description"]) |
| + } |
| + |
| + // A valid (unclaimed) invite serves it, so the join card can greet with it; |
| + // a bogus invite id gets nothing. |
| + pendingUID, _ := srv.db.createUser("") |
| + srv.db.createKeyInvite("gd-pending", pendingUID, "", testClaimHash, 0) |
| + if ki := getJSON(ts.URL + "/api/key-invite/gd-pending"); ki["ok"] != true || ki["description"] != "We talk about cheese." { |
| + t.Fatalf("invite read: %v", ki) |
| + } |
| + if ki := getJSON(ts.URL + "/api/key-invite/no-such-invite"); ki["description"] != nil { |
| + t.Fatalf("bogus invite got the description: %v", ki) |
| + } |
| + |
| + // The break-glass admin bearer token also passes, and "" clears it. |
| + if resp := adminPost(t, ts.URL+"/api/group-description", `{"description":""}`); resp.StatusCode != 200 { |
| + t.Fatalf("admin clear: want 200, got %d", resp.StatusCode) |
| + } |
| + if g := getJSON(ts.URL + "/api/group?token=" + memberTok); g["description"] != "" { |
| + t.Fatalf("clear left residue: %v", g["description"]) |
| + } |
| + |
| + // Oversized descriptions are rejected. |
| + if resp := post(adminTok, `{"description":"`+strings.Repeat("x", 1500)+`"}`); resp.StatusCode != 400 { |
| + t.Fatalf("oversize: want 400, got %d", resp.StatusCode) |
| + } |
| +} |
diff --git a/main.go b/main.go| index 21f3e98..2993e11 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -358,6 +358,8 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/label", s.postLabel) |
| mux.HandleFunc("POST /api/presence", s.postPresence) |
| mux.HandleFunc("POST /api/avatar", s.postAvatar) |
| + mux.HandleFunc("POST /api/description", s.postDescription) |
| + mux.HandleFunc("POST /api/group-description", s.postGroupDescription) |
| mux.HandleFunc("GET /api/threads", s.getThreads) |
| mux.HandleFunc("POST /api/threads", s.postThread) |
| mux.HandleFunc("POST /api/threads/{id}/members", s.postThreadMember) |
| @@ -1275,6 +1277,62 @@ func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// postDescription sets the caller's own "about me" text, encrypted under the |
| +// instance key by their client ("" clears it). Opaque here, like the label blob. |
| +func (s *Server) postDescription(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + DescriptionBlob string `json:"description_blob"` |
| + } |
| + 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 || len(body.DescriptionBlob) > 4096 { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if err := s.db.setUserDescriptionBlob(uid, body.DescriptionBlob); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + out, _ := json.Marshal(map[string]any{"type": "description", "user_id": uid, "description_blob": body.DescriptionBlob}) |
| + s.hub.broadcast <- outbound{data: out} |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// postGroupDescription sets the space's description (admin only — the space's |
| +// self-portrait is a branding decision, like its name). Plaintext, like the |
| +// admin name/email: it must be editable from the keyless admin console and |
| +// readable on the join card before an invitee holds any key. "" clears it. |
| +func (s *Server) postGroupDescription(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + var body struct { |
| + Description string `json:"description"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + desc := strings.TrimSpace(body.Description) |
| + if len(desc) > 1000 { // ~500 chars even in multi-byte scripts |
| + httpError(w, http.StatusBadRequest, "too long") |
| + return |
| + } |
| + if err := s.db.setGroupDescription(desc); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "description": desc}) |
| +} |
| + |
| // postThreadAvatar sets a group's photo (an encrypted blob id). Any member may. |
| func (s *Server) postThreadAvatar(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| @@ -1820,6 +1878,12 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // setup conversation asks instead). Only link-holders can derive the id, so |
| // this flags nothing to anyone who couldn't claim it anyway. |
| resp["grants_admin"] = ki.GrantsAdmin |
| + // The space's description, so the welcome card can say what this place is |
| + // before the joiner holds any key. Only link-holders can derive the invite |
| + // id, so this stays "invited people only" — it's never on the public read. |
| + if d := s.db.getGroupDescription(); d != "" { |
| + resp["description"] = d |
| + } |
| // The suggested pre-fill name is NOT returned here — it rides in the invite |
| // link's #fragment (never sent to the server), so the server never sees it. |
| } |
| @@ -2354,6 +2418,10 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| if s.tokenOK(r.URL.Query().Get("token")) { |
| out["admin_name"] = s.db.getAdminName() |
| out["admin_email"] = s.db.getAdminEmail() |
| + // The space description is member-gated like the admin identity: this |
| + // endpoint is also the public onboarding read, and invitees get the |
| + // description via their (unguessable) invite id instead — see getKeyInvite. |
| + out["description"] = s.db.getGroupDescription() |
| } |
| writeJSON(w, http.StatusOK, out) |
| } |
| @@ -2549,6 +2617,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| "user_invites": s.db.usersCanInvite(), |
| "admin_name": s.db.getAdminName(), |
| "admin_email": s.db.getAdminEmail(), |
| + "description": s.db.getGroupDescription(), |
| "setup_complete": s.db.setupComplete(), |
| "update_auto": s.db.updateAuto(), |
| "update_download": s.db.updateDownload(), |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index db742f3..3f68fd3 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -42,10 +42,12 @@ |
| <section> |
| <h2>This space</h2> |
| - <p class="sub" style="margin:0 0 8px">The name is shown as the app name on a Home Screen and as the chat's title. The administrator name & contact email are optional — they're shown to members (e.g. by Eleven's welcome tour) so people know who runs the space and how to reach you.</p> |
| + <p class="sub" style="margin:0 0 8px">The name is shown as the app name on a Home Screen and as the chat's title. The description greets people on their invite's welcome card and lives under ⋮ → About this space. The administrator name & contact email are optional — they're shown to members (e.g. by Eleven's welcome tour) so people know who runs the space and how to reach you.</p> |
| <form id="nameForm" autocomplete="off"> |
| <label class="field-label" for="groupName">Group name</label> |
| <input id="groupName" placeholder="e.g. Family Chat" maxlength="60" style="display:block;width:100%;margin:0 0 8px" /> |
| + <label class="field-label" for="groupDesc">Description (optional)</label> |
| + <textarea id="groupDesc" placeholder="What is this space? Members and the people they invite can read this." maxlength="500" rows="3" style="display:block;width:100%;margin:0 0 8px"></textarea> |
| <label class="field-label" for="adminName">Administrator (optional)</label> |
| <input id="adminName" placeholder="e.g. Paul Campbell" maxlength="60" style="display:block;width:100%;margin:0 0 8px" /> |
| <label class="field-label" for="adminEmail">Contact email (optional)</label> |
diff --git a/web/pages/index.html b/web/pages/index.html| index 0d6bdd6..64998e4 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -94,6 +94,7 @@ |
| <div class="menu-wrap"> |
| <button class="icon-btn" id="menu-btn" aria-label="Menu" aria-haspopup="true">⋮</button> |
| <div class="menu hidden" id="menu" role="menu"> |
| + <button class="menu-item" id="menu-about" role="menuitem">About this space</button> |
| <button class="menu-item" id="menu-notif" role="menuitem">Notifications</button> |
| <button class="menu-item" id="menu-theme" role="menuitem">Theme</button> |
| <button class="menu-item" id="menu-add-device" role="menuitem">Add a device / back up</button> |
diff --git a/web/pages/join.html b/web/pages/join.html| index 020a373..aed5036 100644 |
| --- a/web/pages/join.html |
| +++ b/web/pages/join.html |
| @@ -110,6 +110,10 @@ |
| <div class="onboard-icon" id="welcome-icon">💬</div> |
| <h1 id="welcome-title">You're invited to __GROUP_NAME__</h1> |
| <p id="welcome-sub">Private, end-to-end encrypted chat.</p> |
| + <!-- The space's admin-set description, when one is set: served on the invite |
| + lookup (valid link-holders only, never the public read — see getKeyInvite), |
| + so it can render here before the joiner holds any key. --> |
| + <p id="welcome-desc" class="welcome-desc hidden"></p> |
| <div class="row"><button id="welcome-app">Open in the app</button></div> |
| <div class="row"><button id="welcome-store" class="secondary">Get the app</button></div> |
| <div class="row"><button id="welcome-web" class="secondary">Continue in the browser</button></div> |
diff --git a/web/static/admin.js b/web/static/admin.js| index 7996e72..fb8fef2 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -159,14 +159,16 @@ async function wireUpdates() { |
| async function wireGroupName() { |
| const input = document.getElementById("groupName"); |
| + const desc = document.getElementById("groupDesc"); |
| const adminName = document.getElementById("adminName"); |
| const adminEmail = document.getElementById("adminEmail"); |
| try { |
| const { name } = await (await fetch("/api/group")).json(); |
| if (name) input.value = name; |
| - // The admin identity is member-gated on /api/group, so read it back via the |
| - // admin-authed settings endpoint instead. |
| + // The description + admin identity are member-gated on /api/group, so read |
| + // them back via the admin-authed settings endpoint instead. |
| const settings = await (await api("GET", "/api/admin/settings")).json(); |
| + if (settings.description) desc.value = settings.description; |
| if (settings.admin_name) adminName.value = settings.admin_name; |
| if (settings.admin_email) adminEmail.value = settings.admin_email; |
| } catch {} |
| @@ -179,7 +181,10 @@ async function wireGroupName() { |
| admin_name: adminName.value.trim(), |
| admin_email: adminEmail.value.trim(), |
| }); |
| - toast(res.ok ? "Saved" : "Couldn't save"); |
| + // The description has its own endpoint (it's also editable in-app from the |
| + // About-this-space sheet); save it with the same button. |
| + const dres = await api("POST", "/api/group-description", { description: desc.value.trim() }); |
| + toast(res.ok && dres.ok ? "Saved" : "Couldn't save"); |
| }; |
| } |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index a938e54..9e6c186 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -52,6 +52,14 @@ export const app = defineApp({ |
| .admin-preview-glyph { font-size: 44px; line-height: 64px; margin-bottom: 12px; } |
| .admin-preview-title { font-size: 18px; font-weight: 700; color: var(--ink); overflow-wrap: anywhere; } |
| .admin-preview-sub { font-size: 13px; color: var(--muted); margin-top: 4px; } |
| +/* mirrors .welcome-desc on the real join card: the description is content to |
| + read, not chrome — a tinted panel with full-strength text. */ |
| +.admin-preview-desc { |
| + font-size: 12px; color: var(--ink); text-align: left; |
| + background: color-mix(in srgb, var(--accent) 8%, transparent); |
| + border-radius: 10px; padding: 8px 10px; margin-top: 8px; |
| + white-space: pre-wrap; overflow-wrap: anywhere; |
| +} |
| .admin-preview-caption { |
| text-align: center; font-size: 12px; color: var(--muted); margin: 10px 0 6px; |
| } |
| @@ -65,11 +73,14 @@ export const app = defineApp({ |
| } |
| .admin-row { display: flex; gap: 10px; align-items: center; margin: 10px 0; flex-wrap: wrap; } |
| .admin-row label { flex: 0 0 104px; font-size: 14px; color: var(--muted); } |
| -.admin-row input[type="text"], .admin-row input[type="email"], .admin-row select { |
| +.admin-row input[type="text"], .admin-row input[type="email"], .admin-row select, .admin-row textarea { |
| flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px; |
| border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink); |
| } |
| -.admin-row input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } |
| +.admin-row textarea { resize: vertical; } |
| +.admin-row-top { align-items: flex-start; } /* textarea rows: label hugs the top */ |
| +.admin-row-top label { padding-top: 8px; } |
| +.admin-row input:focus-visible, .admin-row textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } |
| .admin-row button { flex: none; } |
| .admin-status { font-size: 13px; color: var(--muted); min-height: 1.2em; } |
| .admin-photo { width: 56px; height: 56px; border-radius: 12px; object-fit: cover; border: 1px solid var(--border); } |
| @@ -167,7 +178,12 @@ export const app = defineApp({ |
| const previewSub = document.createElement("div"); |
| previewSub.className = "admin-preview-sub"; |
| previewSub.textContent = "Private, end-to-end encrypted chat."; |
| - preview.append(previewGlyph, previewPhoto, previewTitle, previewSub); |
| + // The space description renders on the real join card (below the sub line), |
| + // so the miniature mirrors it — live as the admin types, hidden when empty. |
| + const previewDesc = document.createElement("div"); |
| + previewDesc.className = "admin-preview-desc"; |
| + previewDesc.style.display = "none"; |
| + preview.append(previewGlyph, previewPhoto, previewTitle, previewSub, previewDesc); |
| root.appendChild(preview); |
| const previewCaption = document.createElement("div"); |
| previewCaption.className = "admin-preview-caption"; |
| @@ -198,6 +214,23 @@ export const app = defineApp({ |
| const photoState = { has: false, alt: "" }; |
| nameInput.addEventListener("input", () => paintPreview(nameInput.value.trim(), photoState.has, photoState.alt)); |
| + const descRow = document.createElement("div"); |
| + descRow.className = "admin-row admin-row-top"; |
| + const descLabel = document.createElement("label"); |
| + descLabel.textContent = "Description"; |
| + const descInput = document.createElement("textarea"); |
| + descInput.rows = 3; |
| + descInput.maxLength = 500; |
| + descInput.placeholder = "What is this space? Members and the people they invite can read this."; |
| + descRow.append(descLabel, descInput); |
| + space.appendChild(descRow); |
| + const paintPreviewDesc = () => { |
| + const d = descInput.value.trim(); |
| + previewDesc.textContent = d; |
| + previewDesc.style.display = d ? "" : "none"; |
| + }; |
| + descInput.addEventListener("input", paintPreviewDesc); |
| + |
| const emailRow = document.createElement("div"); |
| emailRow.className = "admin-row"; |
| const emailLabel = document.createElement("label"); |
| @@ -216,7 +249,11 @@ export const app = defineApp({ |
| const spaceStatus = statusLine(space); |
| save.onclick = async () => { |
| - const result = await bridge.saveSpace({ name: nameInput.value.trim(), email: emailInput.value.trim() }); |
| + const result = await bridge.saveSpace({ |
| + name: nameInput.value.trim(), |
| + email: emailInput.value.trim(), |
| + description: descInput.value.trim(), |
| + }); |
| flash( |
| spaceStatus, |
| result === "ok" |
| @@ -402,6 +439,8 @@ export const app = defineApp({ |
| bridge.groupInfo().then((info) => { |
| if (!info) return flash(spaceStatus, "Couldn't load settings — are you an admin?"); |
| nameInput.value = info.name || ""; |
| + descInput.value = info.description || ""; |
| + paintPreviewDesc(); |
| emailInput.value = info.email || ""; |
| invCheck.checked = !!info.userInvites; |
| autoCheck.checked = !!info.updateAuto; |
diff --git a/web/static/join.js b/web/static/join.js| index 76b4f4e..403e09d 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -80,6 +80,11 @@ async function start() { |
| return showError("This invite has been used or is no longer valid. Ask for a new one."); |
| } |
| + // The space's description rides on the invite lookup (holders of a valid link |
| + // only — it's never on the public read), so the welcome card can say what |
| + // this place is before the joiner holds any key. |
| + const spaceDesc = typeof info.description === "string" ? info.description : ""; |
| + |
| // Note whether this instance has a branding photo (a public flag), so the |
| // onboarding cards below can show it instead of the placeholder emoji. |
| try { |
| @@ -126,22 +131,29 @@ async function start() { |
| // in the pending marker). The native add-chat webview keeps the form: it |
| // hands a NAMED identity straight back to the shell. |
| if (window.__lchatEmbed) { |
| - return showWelcome("join", () => |
| - showForm(fragName, async (name) => { |
| - showStatus("Setting up…"); |
| - const kp = await generateUserKeypair(); |
| - const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| - finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf); |
| - }), |
| + return showWelcome( |
| + "join", |
| + () => |
| + showForm(fragName, async (name) => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| + finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf); |
| + }), |
| + spaceDesc, |
| ); |
| } |
| - return showWelcome("join", async () => { |
| - showStatus("Setting up…"); |
| - const kp = await generateUserKeypair(); |
| - const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| - markWelcomePending(fragName); |
| - finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf); |
| - }); |
| + return showWelcome( |
| + "join", |
| + async () => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| + markWelcomePending(fragName); |
| + finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf); |
| + }, |
| + spaceDesc, |
| + ); |
| } |
| // Setup invite (admin-granting): if already logged in, promote the existing |
| @@ -184,20 +196,27 @@ async function start() { |
| // New person: same chat-first dance — the name is Eleven's first question, |
| // not a form (native add-chat keeps its form). |
| if (window.__lchatEmbed) { |
| - return showWelcome("join", () => |
| - showForm(fragName, async (name) => { |
| - showStatus("Setting up…"); |
| - const kp = await generateUserKeypair(); |
| - finish(id, name, kp.publicKey, kp.privateKey); |
| - }), |
| + return showWelcome( |
| + "join", |
| + () => |
| + showForm(fragName, async (name) => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + finish(id, name, kp.publicKey, kp.privateKey); |
| + }), |
| + spaceDesc, |
| ); |
| } |
| - showWelcome("join", async () => { |
| - showStatus("Setting up…"); |
| - const kp = await generateUserKeypair(); |
| - markWelcomePending(fragName); |
| - finish(id, "", kp.publicKey, kp.privateKey); |
| - }); |
| + showWelcome( |
| + "join", |
| + async () => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + markWelcomePending(fragName); |
| + finish(id, "", kp.publicKey, kp.privateKey); |
| + }, |
| + spaceDesc, |
| + ); |
| } |
| // The pending marker hands the joiner to Eleven's welcome conversation on the |
| @@ -217,7 +236,8 @@ function markWelcomePending(suggested) { |
| // The welcome card: the invite is inspected (a non-consuming GET) but nothing is |
| // claimed until a button is pressed. In the native add-chat flow (__lchatEmbed) |
| // the person already chose explicitly in the app, so continue straight on. |
| -function showWelcome(kind, onContinue) { |
| +// `spaceDesc` is the space's admin-set description (from the invite lookup). |
| +function showWelcome(kind, onContinue, spaceDesc = "") { |
| if (window.__lchatEmbed) return onContinue(); |
| card.classList.add("onboard"); |
| card.classList.remove("status"); |
| @@ -226,6 +246,11 @@ function showWelcome(kind, onContinue) { |
| const tpl = document.getElementById("welcome-tpl"); |
| card.appendChild(tpl.content.cloneNode(true)); |
| applyServerPhoto(card); |
| + if (spaceDesc) { |
| + const d = card.querySelector("#welcome-desc"); |
| + d.textContent = spaceDesc.slice(0, 600); |
| + d.classList.remove("hidden"); |
| + } |
| if (kind === "device") { |
| // document.title carries the substituted group name. |
| card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 76872aa..8e596b2 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -911,6 +911,11 @@ async function decryptRosterLabels() { |
| e.avatarAlt = await decrypt(instanceKey, e.avatar_alt_blob); |
| } catch {} |
| } |
| + if (e.description_blob) { |
| + try { |
| + e.about = await decrypt(instanceKey, e.description_blob); |
| + } catch {} |
| + } |
| }), |
| ); |
| } |
| @@ -936,6 +941,26 @@ async function publishMyLabel() { |
| } catch {} |
| } |
| +// Publish our "about me" text, encrypted under the instance key ("" clears it). |
| +// The server stores only the blob and serves it to our connections, who decrypt |
| +// it like the display name. |
| +async function publishMyDescription(text) { |
| + if (!instanceKey || !myUserId) return; |
| + try { |
| + const blob = text ? await encrypt(instanceKey, text) : ""; |
| + await fetch("/api/description", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, description_blob: blob }), |
| + }); |
| + const me = roster.find((e) => e.user_id === myUserId); |
| + if (me) { |
| + me.about = text; |
| + me.description_blob = blob; |
| + } |
| + } catch {} |
| +} |
| + |
| function rosterLabel(userId) { |
| const e = roster.find((x) => x.user_id === userId); |
| return e && e.label ? e.label : "Someone"; |
| @@ -986,7 +1011,8 @@ function agentTag(muted, kind) { |
| } |
| // A picker row's name: the label, plus for agents the BOT tag and the admin's |
| -// one-line description (the Helpers catalog blurb — the "what am I adding?" hint). |
| +// one-line description (the Helpers catalog blurb — the "what am I adding?" |
| +// hint), or for a person their own "about me" line (decrypted from the roster). |
| function pickerName(e) { |
| const nm = document.createElement("span"); |
| // Name + badge on a centered flex line; the description (if any) sits below. |
| @@ -997,10 +1023,11 @@ function pickerName(e) { |
| line.appendChild(txt); |
| if (e.is_agent) line.appendChild(agentTag(false, e.kind)); |
| nm.appendChild(line); |
| - if (e.is_agent && e.description) { |
| + const blurb = e.is_agent ? e.description : e.about; |
| + if (blurb) { |
| const d = document.createElement("span"); |
| d.className = "helper-desc"; |
| - d.textContent = e.description; |
| + d.textContent = blurb; |
| nm.appendChild(d); |
| } |
| return nm; |
| @@ -1092,6 +1119,14 @@ function refreshProfilePhotoBtn() { |
| b.disabled = false; |
| b.textContent = "Change photo"; |
| } |
| + // The "About you" field in the same sheet waits on the key too. |
| + const a = document.getElementById("me-about"); |
| + if (a && instanceKey && a.disabled) { |
| + a.disabled = false; |
| + a.placeholder = "A line or two about yourself"; |
| + const e = roster.find((x) => x.user_id === myUserId); |
| + if (!a.value) a.value = (e && e.about) || ""; |
| + } |
| } |
| // Wrap the instance key to every roster member not already in `have`. |
| @@ -1559,6 +1594,7 @@ function renderApp() { |
| closeMenu(); |
| fn(); |
| }); |
| + wireItem("menu-about", showSpaceInfo); |
| wireItem("menu-theme", showThemePicker); |
| wireItem("menu-add-device", addDevice); |
| wireItem("menu-rename", editProfile); |
| @@ -2075,7 +2111,13 @@ function stashAppPane(id) { |
| function paintThreadHeader(id) { |
| const t = threads.find((x) => x.id === id); |
| const isGroup = t && t.kind === "group"; |
| - document.getElementById("thread-title").textContent = threadName[id] || "Chat"; |
| + const title = document.getElementById("thread-title"); |
| + title.textContent = threadName[id] || "Chat"; |
| + // In a DM, the name in the header opens the other person's profile card |
| + // (photo + "about me") — the counterpart of a group's tappable member count. |
| + const other = !isGroup && t ? (t.members || []).find((uid) => uid !== myUserId) : null; |
| + title.onclick = other ? () => viewProfile(other) : null; |
| + title.style.cursor = other ? "pointer" : ""; |
| const sub = document.getElementById("thread-sub"); |
| // Count people, deterministic bots, and AI agents separately (roster is_agent |
| // + kind), so the subtitle reads "2 members, 2 bots, 1 AI agent" — mirroring |
| @@ -3136,8 +3178,24 @@ setSetupBridge({ |
| }, |
| setGroupName: (name) => saveGroupSettings({ name }), |
| setContactEmail: (email) => saveGroupSettings({ admin_email: email }), |
| - // The admin panel saves name + contact together (one Save for the section). |
| - saveSpace: ({ name, email }) => saveGroupSettings({ name, admin_email: email }), |
| + // The admin panel saves name + contact together (one Save for the section); |
| + // the description rides along to its own endpoint (also fed by the /admin |
| + // console and the About-this-space sheet). Omitting it leaves it unchanged. |
| + saveSpace: async ({ name, email, description }) => { |
| + const r = await saveGroupSettings({ name, admin_email: email }); |
| + if (description === undefined) return r; |
| + try { |
| + const d = await fetch("/api/group-description", { |
| + method: "POST", |
| + headers: adminAuth(), |
| + body: JSON.stringify({ description }), |
| + }); |
| + if (r !== "ok") return r; // the trio's failure is the headline |
| + return d.ok ? "ok" : d.status === 400 ? "invalid" : "error"; |
| + } catch { |
| + return "error"; |
| + } |
| + }, |
| // Adopt a photo the admin just SENT in the chat as the server photo: fetch + |
| // decrypt the attachment (it's an ordinary E2E blob) straight to bytes — NOT |
| // via an object URL; the page CSP's connect-src 'self' blocks fetch(blob:) — |
| @@ -3252,6 +3310,7 @@ setSetupBridge({ |
| name: g.name || "", |
| nameSet: !!g.name_set, |
| email: g.admin_email || "", |
| + description: g.description || "", |
| avatar: !!g.avatar, |
| avatarAlt: g.avatar_alt || "", |
| userInvites: !!s.user_invites, |
| @@ -4790,6 +4849,19 @@ function connectWS() { |
| } |
| return; |
| } |
| + if (msg.type === "description") { |
| + const e = roster.find((x) => x.user_id === msg.user_id); |
| + if (e) { |
| + e.description_blob = msg.description_blob; |
| + e.about = ""; |
| + if (msg.description_blob && instanceKey) { |
| + try { |
| + e.about = await decrypt(instanceKey, msg.description_blob); |
| + } catch {} |
| + } |
| + } |
| + return; |
| + } |
| if (msg.type === "avatar") { |
| const e = roster.find((x) => x.user_id === msg.user_id); |
| if (e) { |
| @@ -5342,7 +5414,17 @@ function showMembers() { |
| setPresenceLine(st, entry.presence); |
| lbl.appendChild(st); |
| } |
| - main.appendChild(lbl); |
| + // Name (+badge +presence) with their "about me" line stacked beneath. |
| + const idcol = document.createElement("span"); |
| + idcol.style.minWidth = "0"; |
| + idcol.appendChild(lbl); |
| + if (entry && !entry.is_agent && entry.about) { |
| + const d = document.createElement("span"); |
| + d.className = "helper-desc"; |
| + d.textContent = entry.about; |
| + idcol.appendChild(d); |
| + } |
| + main.appendChild(idcol); |
| if (!me) |
| main.onclick = async () => { |
| backdrop.remove(); |
| @@ -5376,6 +5458,122 @@ function showMembers() { |
| card.appendChild(list); |
| } |
| +// A member's profile card: photo, name (with the BOT/AI badge for agents), and |
| +// their "about me" — for agents, the admin's Helpers-catalog blurb. Opened by |
| +// tapping a DM's header title; groups show the same lines in the member list. |
| +function viewProfile(uid) { |
| + const e = roster.find((x) => x.user_id === uid); |
| + if (!e) return; |
| + const backdrop = modal(); |
| + const card = backdrop.querySelector(".sheet"); |
| + const av = avatarEl(e.label || "?", () => avatarFor(uid)); |
| + av.classList.add("avatar-lg"); |
| + const avWrap = document.createElement("div"); |
| + avWrap.style.cssText = "text-align:center;margin:6px 0 12px"; |
| + avWrap.appendChild(av); |
| + card.appendChild(avWrap); |
| + const name = document.createElement("h2"); |
| + name.style.cssText = "display:flex;align-items:center;justify-content:center"; |
| + const nm = document.createElement("span"); |
| + nm.textContent = e.label || "Someone"; |
| + name.appendChild(nm); |
| + if (e.is_agent) name.appendChild(agentTag(false, e.kind)); |
| + card.appendChild(name); |
| + const blurb = e.is_agent ? e.description : e.about; |
| + if (blurb) { |
| + const p = document.createElement("p"); |
| + p.className = "sub"; |
| + p.style.cssText = "text-align:center;white-space:pre-wrap;margin:8px 0 4px"; |
| + p.textContent = blurb; |
| + card.appendChild(p); |
| + } |
| +} |
| + |
| +// The group info incl. the space description — plaintext branding-adjacent |
| +// metadata like the admin name/email, member-gated on /api/group. |
| +async function fetchSpaceInfo() { |
| + const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + return { g, about: g.description || "" }; |
| +} |
| + |
| +// "About this space": the group name, its free-text description, and who runs |
| +// it. The description is admin-authored, plaintext like the group name — it |
| +// also greets invitees on the join card. Role-admins edit it here (their |
| +// device token passes the admin API); it's editable from /admin too. |
| +async function showSpaceInfo() { |
| + const backdrop = modal(); |
| + const card = backdrop.querySelector(".sheet"); |
| + const h = document.createElement("h2"); |
| + h.textContent = document.title; // carries the substituted group name |
| + card.appendChild(h); |
| + |
| + let info = { g: {}, about: "" }; |
| + try { |
| + info = await fetchSpaceInfo(); |
| + } catch {} |
| + |
| + const meEntry = roster.find((x) => x.user_id === myUserId); |
| + const canEdit = !!(meEntry && meEntry.role === "admin"); |
| + |
| + if (canEdit) { |
| + const lbl = document.createElement("label"); |
| + lbl.className = "field-label"; |
| + lbl.textContent = "Description"; |
| + const ta = document.createElement("textarea"); |
| + ta.rows = 4; |
| + ta.maxLength = 500; |
| + ta.placeholder = "What is this space? Members and the people they invite can read this."; |
| + ta.value = info.about; |
| + card.appendChild(lbl); |
| + card.appendChild(ta); |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + card.appendChild(note); |
| + const foot = document.createElement("div"); |
| + foot.className = "sheet-foot"; |
| + const cancel = document.createElement("button"); |
| + cancel.className = "secondary"; |
| + cancel.textContent = "Cancel"; |
| + cancel.onclick = () => backdrop.remove(); |
| + const save = document.createElement("button"); |
| + save.textContent = "Save"; |
| + save.onclick = async () => { |
| + save.disabled = true; |
| + try { |
| + const r = await fetch("/api/group-description", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify({ description: ta.value.trim().slice(0, 500) }), |
| + }); |
| + if (!r.ok) throw new Error("save failed"); |
| + backdrop.remove(); |
| + } catch { |
| + note.textContent = "Couldn't save — try again."; |
| + save.disabled = false; |
| + } |
| + }; |
| + foot.appendChild(cancel); |
| + foot.appendChild(save); |
| + card.appendChild(foot); |
| + } else { |
| + const p = document.createElement("p"); |
| + p.className = "sub"; |
| + p.style.whiteSpace = "pre-wrap"; // the description keeps its line breaks |
| + p.textContent = info.about || "No description yet."; |
| + card.appendChild(p); |
| + } |
| + |
| + // Who runs the space (member-gated fields; empty until the admin sets them). |
| + const who = info.g.admin_name || ""; |
| + const mail = info.g.admin_email || ""; |
| + if (who || mail) { |
| + const run = document.createElement("p"); |
| + run.className = "sub"; |
| + run.textContent = "Run by " + (who || mail) + (who && mail ? ` (${mail})` : ""); |
| + card.appendChild(run); |
| + } |
| +} |
| + |
| // Remove another member from the current group (any member may; no key rotation — |
| // the server just stops delivering to them, like leaving). Confirms first. |
| async function removeMember(uid, name, backdrop) { |
| @@ -6403,6 +6601,25 @@ function editProfile() { |
| card.appendChild(label); |
| card.appendChild(input); |
| + // "About you": free text encrypted under the instance key, shown under your |
| + // name to the people you chat with. Needs the key to encrypt — until it |
| + // arrives the field waits, like the photo button (which already nudges). |
| + const meEntry = () => roster.find((x) => x.user_id === myUserId); |
| + const aboutLabel = document.createElement("label"); |
| + aboutLabel.className = "field-label"; |
| + aboutLabel.style.marginTop = "14px"; // air between the name field and this one |
| + aboutLabel.textContent = "About you"; |
| + const about = document.createElement("textarea"); |
| + about.id = "me-about"; // so refreshProfilePhotoBtn() can enable it live |
| + about.rows = 3; |
| + about.maxLength = 280; |
| + about.placeholder = instanceKey ? "A line or two about yourself" : "Syncing keys…"; |
| + about.disabled = !instanceKey; |
| + const e = meEntry(); |
| + about.value = (e && e.about) || ""; |
| + card.appendChild(aboutLabel); |
| + card.appendChild(about); |
| + |
| // Away: pins your presence dot to away, so activity doesn't flip it back to |
| // online. Applies immediately (server-stored, follows you across devices) — |
| // Save is only for the name. |
| @@ -6471,6 +6688,9 @@ function editProfile() { |
| // republish it. The welcome/setup bridge already does the same. |
| publishMyLabel(); |
| } |
| + const nextAbout = about.value.trim().slice(0, 280); |
| + const cur = meEntry(); |
| + if (!about.disabled && nextAbout !== ((cur && cur.about) || "")) publishMyDescription(nextAbout); |
| backdrop.remove(); |
| }; |
| foot.appendChild(cancel); |
diff --git a/web/static/style.css b/web/static/style.css| index 06bb7b7..3acd467 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1594,6 +1594,15 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| air between it and the button it annotates. (.card selectors so this outranks |
| the generic .card p margin rule.) */ |
| .card .welcome-caveat { font-size: 13px; color: var(--muted); margin: 20px auto 4px; max-width: 42ch; } |
| +/* the space's own description on the welcome card: the admin's note to the |
| + person joining — content to read, not chrome. A tinted panel with |
| + full-strength text sets it apart from the muted boilerplate around it. */ |
| +.card .welcome-desc { |
| + font-size: 15px; color: var(--ink); text-align: left; line-height: 1.5; |
| + background: color-mix(in srgb, var(--accent) 8%, transparent); |
| + border-radius: 12px; padding: 12px 16px; margin: 2px auto 18px; max-width: 46ch; |
| + white-space: pre-wrap; overflow-wrap: anywhere; |
| +} |
| .steps { |
| text-align: left; |
| color: var(--muted); |