eleven / messenger Public

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>
James Adam 47d61db87a59bfecbdc523787ada9854c97dddce parent 90f01d3
13 files changed, +599 −52
  • CLAUDE.md +11 −7
  • changelog/2026-07-07-space-and-profile-descriptions.md +8 −0
  • db.go +30 −3
  • descriptions_test.go +134 −0
  • main.go +69 −0
  • web/pages/admin.html +3 −1
  • web/pages/index.html +1 −0
  • web/pages/join.html +4 −0
  • web/static/admin.js +8 −3
  • web/static/apps/admin.js +43 −4
  • web/static/join.js +52 −27
  • web/static/shell.js +227 −7
  • web/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 &amp; 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 &amp; 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);