Online presence: green/away dots + a manual Away pin (server + web) (#413)
* server,web: online presence — green/away dots + a manual Away pin
Members see who's around: a green dot on DM/self chat-list avatars when
someone's online, yellow when they're away, nothing when offline.
Server (presence.go): effective state = offline (no sockets) / away
(pinned by the member, or a person idle >10 min) / online. Socket counts
ride handleWS's connect/defer; transitions broadcast a "presence" frame
scoped to the subject's connections (roster visibility). Idle detection
reuses the existing "active" heartbeat; agents never idle. Pinned Away is
a per-user users.presence column ('' | 'away') set via POST /api/presence
— connection metadata like role, never content. /api/roster stamps each
entry's effective presence, so the roster doubles as the snapshot.
Web: roster entries carry .presence (live via the new WS frame; roster
re-pulled on reconnect); DM/self thread rows wear the dot; the profile
sheet gains "Set yourself as away" (applies immediately, synced across
devices via /api/me's pinned flag; inline error, no toast).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: presence as text + an Online filter — no more avatar pip
Rework from canary feedback: presence never rides the avatar. Instead —
a DM header shows Online/Away under the person's name (blank when
offline; a bot's line doubles as sidecar health), a group's subtitle
appends "· N online" counting people only (bots would pin every room at
its bot count), and the Members sheet notes Online/Away beside each
name — the per-member view for mixed people/bot/AI rooms.
An Online pill beside chat search filters the list to chats where a
person is online right now (bots excluded for the same reason; composes
with text search). "Set yourself as away" becomes a slider switch
(input.switch — a styled checkbox, stock behaviour).
onlineFilterKeeps/presenceLabel live in ui.js, unit-tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: funnel filter menu + coloured status dots beside the text
The Online pill becomes a small funnel icon beside search, opening a
checkbox menu: "Show online only", then People / Bots / Apps categories
(all on by default; the funnel highlights while anything is narrowing
the list). Category = chatCategory (apps → all-bot chats → people);
online-only = onlineFilterKeeps, now two-tier: people decide in any
room with people, and a pure-bot chat falls back to whether its sidecar
is up — so filtering to online bots is meaningful rather than noise.
Status lines gain the coloured dot Paul asked for: green Online, yellow
Away, red Offline — and Offline now reads out (dot + word) in the DM
header and member sheet instead of a blank.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: filter menu — real checkboxes, aligned funnel, "Online only"
The app-wide input rule (width:100%, panel fill, field padding) was
mangling the menu's native checkboxes into blank grey pills that never
showed a tick. Draw the control entirely in scoped CSS instead
(appearance:none + accent fill + white tick — the switch's approach),
lay rows out as menu items (box, label, hover wash), stretch the funnel
to the search field's height, and shorten the copy to "Online only".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: centre the status dot — inline-flex line instead of vertical-align
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+854
−19
changelog/2026-07-08-online-presence.md+7 −0db.go+25 −3handlers_test.go+13 −9hub.go+11 −0main.go+11 −0presence.go+220 −0presence_test.go+217 −0test/ui.test.mjs+63 −0web/pages/index.html+17 −0web/static/shell.js+176 −5web/static/style.css+64 −2web/static/ui.js+30 −0
diff --git a/changelog/2026-07-08-online-presence.md b/changelog/2026-07-08-online-presence.md| new file mode 100644 |
| index 0000000..a5619bb |
| --- /dev/null |
| +++ b/changelog/2026-07-08-online-presence.md |
| @@ -0,0 +1,7 @@ |
| +🤖 See who's around: open a chat with someone and their status — a green |
| +Online, yellow Away, or red Offline dot — now shows right under their name. |
| +Group chats add "· 2 online" to the member count, and the member list shows |
| +who's about. The new filter button beside search can narrow your chats to |
| +people who are online right now, or to just people, bots, or apps. You go |
| +away automatically after ten quiet minutes, or flip "Set yourself as away" in |
| +your profile to stay that way until you switch back. |
diff --git a/db.go b/db.go| index 7d69baf..a0cb914 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -498,6 +498,10 @@ CREATE TABLE IF NOT EXISTS member_app_versions ( |
| "ALTER TABLE member_apps ADD COLUMN owner_id TEXT", |
| // The version a reverted deploy was based on ("based on vN"), or NULL. |
| "ALTER TABLE member_app_versions ADD COLUMN based_on INTEGER", |
| + // Pinned presence: '' (automatic) or 'away' (the member set themselves |
| + // 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 ''", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -1088,6 +1092,10 @@ type RosterEntry struct { |
| // Kind is "ai" (LLM-backed) or "bot" (deterministic) for agents; "" for |
| // people. Drives the AI/BOT badge — the honest "where do my words go?" cue. |
| Kind string `json:"kind,omitempty"` |
| + // Presence: "online" | "away" | "offline". Scanned as the raw pinned flag |
| + // ('' or 'away'); the roster handler overwrites it with the effective state |
| + // (stampPresence, presence.go) before it leaves the server. |
| + Presence string `json:"presence,omitempty"` |
| } |
| func (db *DB) setUserPublicKey(userID, pub string) error { |
| @@ -1102,6 +1110,20 @@ func (db *DB) userPublicKey(id string) string { |
| return pk.String |
| } |
| +// setUserPresence pins ('away') or unpins ('') a user's presence. |
| +func (db *DB) setUserPresence(userID, presence string) error { |
| + _, err := db.sql.Exec("UPDATE users SET presence = ? WHERE id = ?", presence, userID) |
| + return err |
| +} |
| + |
| +// userPresenceInfo returns the pinned presence flag plus whether the user is |
| +// an agent — the two per-user inputs to presenceState (presence.go). |
| +func (db *DB) userPresenceInfo(userID string) (pinned string, isAgent bool) { |
| + var agent int |
| + db.sql.QueryRow("SELECT COALESCE(presence, ''), COALESCE(is_agent, 0) FROM users WHERE id = ?", userID).Scan(&pinned, &agent) |
| + return pinned, agent != 0 |
| +} |
| + |
| // removeUser deletes a user and all their traces (admin roster management): device |
| // tokens, thread membership + wrapped keys, DM-pair records, pending invites, their |
| // instance-key copy, reactions, and their own messages — then cleans up any thread |
| @@ -1363,7 +1385,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') |
| + `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, '') |
| 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`, |
| ) |
| @@ -1386,7 +1408,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') |
| + 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, '') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| @@ -1410,7 +1432,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); err != nil { |
| + 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 { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
diff --git a/handlers_test.go b/handlers_test.go| index 7e9982c..e2f4bec 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -1715,16 +1715,20 @@ func TestCustomAppsBroadcastsAppsChanged(t *testing.T) { |
| t.Fatalf("warmup read: %v", err) |
| } |
| + // Skip unrelated traffic (our own presence broadcast and the warmup echo, |
| + // whose order isn't fixed) — the assertion is that apps_changed arrives. |
| expectAppsChanged := func(stage string) { |
| - var msg struct { |
| - Type string `json:"type"` |
| - } |
| - _, data, err := conn.Read(ctx) |
| - if err != nil { |
| - t.Fatalf("%s read: %v", stage, err) |
| - } |
| - if json.Unmarshal(data, &msg) != nil || msg.Type != "apps_changed" { |
| - t.Fatalf("%s: want apps_changed, got %s", stage, data) |
| + for { |
| + var msg struct { |
| + Type string `json:"type"` |
| + } |
| + _, data, err := conn.Read(ctx) |
| + if err != nil { |
| + t.Fatalf("%s read: %v (no apps_changed)", stage, err) |
| + } |
| + if json.Unmarshal(data, &msg) == nil && msg.Type == "apps_changed" { |
| + return |
| + } |
| } |
| } |
diff --git a/hub.go b/hub.go| index 4d846e8..39f1be4 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -154,6 +154,9 @@ func (s *Server) markActive(token string) { |
| // Roll the signal up to the owner so suppression is per-person, not per-device. |
| if uid, ok := s.db.deviceTokenUser(token); ok && uid != "" { |
| s.activeUsers.Store(uid, now) |
| + // Fresh activity may flip an idle-away member back online (presence.go); |
| + // a pinned-away one stays away — presenceChanged resolves that. |
| + s.presenceChanged(uid) |
| } |
| } |
| @@ -225,6 +228,10 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| userID, _ := s.db.deviceTokenUser(token) |
| cl := &client{userID: userID, send: make(chan []byte, 32)} |
| s.hub.register <- cl |
| + // Presence: connecting counts as activity (a device that never sends |
| + // "active" frames — native, today — still reads online, then idles out). |
| + s.presence.addConn(userID) |
| + s.markActive(token) |
| ctx := r.Context() |
| @@ -242,9 +249,13 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| }() |
| // Reader: accepts new ciphertext, stores it, and broadcasts to everyone. |
| + // However the socket dies — clean close, error, or the hub dropping a |
| + // backed-up client — this defer runs, so the presence count stays true. |
| defer func() { |
| s.hub.unregister <- cl |
| conn.CloseNow() |
| + s.presence.dropConn(userID) |
| + s.presenceChanged(userID) |
| }() |
| for { |
| _, data, err := conn.Read(ctx) |
diff --git a/main.go b/main.go| index d7a7af3..21f3e98 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -96,6 +96,9 @@ type Server struct { |
| // reading on any one of them — not just the exact device they're on. |
| active sync.Map |
| activeUsers sync.Map |
| + // presence tracks open sockets + last broadcast state per user, so members |
| + // see who's online/away/offline (presence.go). |
| + presence presenceTracker |
| // upd caches the last verified release-manifest fetch, so the admin console |
| // can say whether the tracked channel has something newer (updatecheck.go). |
| upd updateState |
| @@ -238,6 +241,8 @@ func main() { |
| // call sweepDueScheduled directly, so only the real server runs the ticker. |
| go s.runScheduler() |
| go s.runUpdateChecker() |
| + // Presence idle sweep (presence.go). Tests call sweepPresence directly. |
| + go s.runPresenceSweeper() |
| ln, where, err := listen(*addr, *socket) |
| if err != nil { |
| @@ -351,6 +356,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/instance-key", s.postInstanceKey) |
| mux.HandleFunc("POST /api/instance-key/nudge", s.nudgeInstanceKey) |
| mux.HandleFunc("POST /api/label", s.postLabel) |
| + mux.HandleFunc("POST /api/presence", s.postPresence) |
| mux.HandleFunc("POST /api/avatar", s.postAvatar) |
| mux.HandleFunc("GET /api/threads", s.getThreads) |
| mux.HandleFunc("POST /api/threads", s.postThread) |
| @@ -880,6 +886,7 @@ func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + s.stampPresence(entries) |
| writeJSON(w, http.StatusOK, map[string]any{"roster": entries}) |
| } |
| @@ -897,10 +904,14 @@ func (s *Server) getMe(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"device_count": 0}) |
| return |
| } |
| + pinned, _ := s.db.userPresenceInfo(uid) |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "user_id": uid, |
| "device_count": s.db.deviceCount(uid), |
| "role": s.db.userRole(uid), |
| + // The caller's pinned Away flag (not the effective state) — what the |
| + // profile sheet's toggle reflects. |
| + "away": pinned == presenceAway, |
| // setup_complete gates the first-run setup conversation. It's instance |
| // state (not a secret), but only an admin's client acts on it. |
| "setup_complete": s.db.setupComplete(), |
diff --git a/presence.go b/presence.go| new file mode 100644 |
| index 0000000..3b06485 |
| --- /dev/null |
| +++ b/presence.go |
| @@ -0,0 +1,220 @@ |
| +package main |
| + |
| +// Presence: who's around right now. Effective state is computed server-side |
| +// from three inputs — open sockets, the member's pinned "away" flag, and the |
| +// existing per-user activity signal (the "active" frames that already drive |
| +// push suppression, hub.go): |
| +// |
| +// offline — no open sockets |
| +// away — connected, but pinned away by the member OR idle >10 min |
| +// online — connected and recently active |
| +// |
| +// Privacy: presence is connection metadata the server already possesses (it |
| +// holds the sockets and the activity timestamps); no content and no key rides |
| +// on it. The pinned flag is a per-user enum like `role`. Fan-out is scoped to |
| +// the subject's connections — the same people who can see them in the roster — |
| +// so nobody learns the presence of a member they can't already see. |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +const ( |
| + presenceOnline = "online" |
| + presenceAway = "away" |
| + presenceOffline = "offline" |
| +) |
| + |
| +// presenceIdleAfter: a connected person with no "active" signal for this long |
| +// counts as away (auto-idle). A var so tests can shrink it. |
| +var presenceIdleAfter = 10 * time.Minute |
| + |
| +// presenceTracker owns the socket counts and the last state broadcast per |
| +// user, so only *transitions* emit frames. Sockets are counted here (not in |
| +// the hub) by handleWS's connect/defer, which avoids racing the hub goroutine: |
| +// however a socket dies — clean close, network error, or the hub dropping a |
| +// backed-up client — the reader's defer runs and decrements. |
| +type presenceTracker struct { |
| + mu sync.Mutex |
| + conns map[string]int // user id → open sockets |
| + last map[string]string // user id → last broadcast state |
| +} |
| + |
| +// addConn counts a new socket for uid; reports whether it's their first. |
| +func (p *presenceTracker) addConn(uid string) { |
| + if uid == "" { |
| + return |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.conns == nil { |
| + p.conns = map[string]int{} |
| + } |
| + p.conns[uid]++ |
| +} |
| + |
| +func (p *presenceTracker) dropConn(uid string) { |
| + if uid == "" { |
| + return |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.conns[uid] > 1 { |
| + p.conns[uid]-- |
| + } else { |
| + delete(p.conns, uid) |
| + } |
| +} |
| + |
| +func (p *presenceTracker) connected(uid string) bool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return p.conns[uid] > 0 |
| +} |
| + |
| +// connectedUsers snapshots who holds at least one socket (for the idle sweep |
| +// and for stamping a roster response in one pass). |
| +func (p *presenceTracker) connectedUsers() map[string]bool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + out := make(map[string]bool, len(p.conns)) |
| + for uid := range p.conns { |
| + out[uid] = true |
| + } |
| + return out |
| +} |
| + |
| +// setLast records the state about to be broadcast; reports whether it changed. |
| +func (p *presenceTracker) setLast(uid, state string) bool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.last == nil { |
| + p.last = map[string]string{} |
| + } |
| + // A user nobody has seen transition yet starts implicitly offline. |
| + prev := p.last[uid] |
| + if prev == "" { |
| + prev = presenceOffline |
| + } |
| + if prev == state { |
| + return false |
| + } |
| + p.last[uid] = state |
| + return true |
| +} |
| + |
| +// presenceState resolves one user's effective state from its inputs. Agents |
| +// never idle: a bot sidecar holds a socket but sends no "active" frames, and a |
| +// connected bot is simply online. lastActive is unix seconds (0 = never). |
| +func presenceState(connected bool, pinned string, isAgent bool, lastActive int64) string { |
| + switch { |
| + case !connected: |
| + return presenceOffline |
| + case pinned == presenceAway: |
| + return presenceAway |
| + case !isAgent && time.Since(time.Unix(lastActive, 0)) > presenceIdleAfter: |
| + return presenceAway |
| + default: |
| + return presenceOnline |
| + } |
| +} |
| + |
| +func (s *Server) lastActive(uid string) int64 { |
| + if v, ok := s.activeUsers.Load(uid); ok { |
| + return v.(int64) |
| + } |
| + return 0 |
| +} |
| + |
| +func (s *Server) effectivePresence(uid string) string { |
| + if uid == "" { |
| + return presenceOffline |
| + } |
| + pinned, isAgent := s.db.userPresenceInfo(uid) |
| + return presenceState(s.presence.connected(uid), pinned, isAgent, s.lastActive(uid)) |
| +} |
| + |
| +// presenceChanged recomputes uid's state and, if it differs from the last one |
| +// broadcast, fans a presence frame out to their connections (self included — |
| +// their other devices keep the toggle in sync). |
| +func (s *Server) presenceChanged(uid string) { |
| + if uid == "" { |
| + return |
| + } |
| + state := s.effectivePresence(uid) |
| + if !s.presence.setLast(uid, state) { |
| + return |
| + } |
| + entries, err := s.db.connections(uid) |
| + if err != nil { |
| + return |
| + } |
| + set := make(map[string]bool, len(entries)) |
| + for _, e := range entries { |
| + set[e.UserID] = true |
| + } |
| + out, _ := json.Marshal(map[string]any{"type": "presence", "user_id": uid, "presence": state}) |
| + s.hub.broadcast <- outbound{members: set, data: out} |
| +} |
| + |
| +// runPresenceSweeper catches the transitions nothing else triggers — a |
| +// connected member going idle (their "active" beats just stop). Tests call |
| +// sweepPresence directly; only the real server runs the ticker. |
| +func (s *Server) runPresenceSweeper() { |
| + for range time.Tick(30 * time.Second) { |
| + s.sweepPresence() |
| + } |
| +} |
| + |
| +func (s *Server) sweepPresence() { |
| + for uid := range s.presence.connectedUsers() { |
| + s.presenceChanged(uid) |
| + } |
| +} |
| + |
| +// stampPresence overwrites each roster entry's presence (the raw pinned flag |
| +// as scanned) with the caller-visible effective state, so the roster clients |
| +// already load doubles as the presence snapshot. |
| +func (s *Server) stampPresence(entries []RosterEntry) { |
| + connected := s.presence.connectedUsers() |
| + for i := range entries { |
| + e := &entries[i] |
| + e.Presence = presenceState(connected[e.UserID], e.Presence, e.IsAgent, s.lastActive(e.UserID)) |
| + } |
| +} |
| + |
| +// postPresence pins or unpins the caller's Away status. Stored per user (not |
| +// per device), so it follows them across devices; the transition broadcasts |
| +// like any other. |
| +func (s *Server) postPresence(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Away bool `json:"away"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + pinned := "" |
| + if body.Away { |
| + pinned = presenceAway |
| + } |
| + if err := s.db.setUserPresence(uid, pinned); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + s.presenceChanged(uid) |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "away": body.Away}) |
| +} |
diff --git a/presence_test.go b/presence_test.go| new file mode 100644 |
| index 0000000..c2a39ad |
| --- /dev/null |
| +++ b/presence_test.go |
| @@ -0,0 +1,217 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/coder/websocket" |
| +) |
| + |
| +// presenceState resolves the three states from its inputs; pinned away beats |
| +// activity, idleness only applies to people, and offline beats everything. |
| +func TestPresenceState(t *testing.T) { |
| + now := time.Now().Unix() |
| + stale := time.Now().Add(-presenceIdleAfter - time.Minute).Unix() |
| + cases := []struct { |
| + name string |
| + connected bool |
| + pinned string |
| + isAgent bool |
| + last int64 |
| + want string |
| + }{ |
| + {"disconnected", false, "", false, now, presenceOffline}, |
| + {"disconnected pinned away", false, presenceAway, false, now, presenceOffline}, |
| + {"connected + active", true, "", false, now, presenceOnline}, |
| + {"pinned away while active", true, presenceAway, false, now, presenceAway}, |
| + {"idle", true, "", false, stale, presenceAway}, |
| + {"never active", true, "", false, 0, presenceAway}, |
| + {"agent never idles", true, "", true, 0, presenceOnline}, |
| + } |
| + for _, c := range cases { |
| + if got := presenceState(c.connected, c.pinned, c.isAgent, c.last); got != c.want { |
| + t.Errorf("%s: got %s, want %s", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// dialWS opens a member socket and proves its registration landed with a |
| +// stored-message round-trip (Dial returns before the server's register does). |
| +func dialWS(t *testing.T, ctx context.Context, tsURL, tok, tid string) *websocket.Conn { |
| + t.Helper() |
| + wsURL := "ws" + strings.TrimPrefix(tsURL, "http") + "/ws?token=" + tok |
| + conn, _, err := websocket.Dial(ctx, wsURL, nil) |
| + if err != nil { |
| + t.Fatalf("ws dial: %v", err) |
| + } |
| + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil { |
| + t.Fatalf("warmup write: %v", err) |
| + } |
| + if _, _, err := conn.Read(ctx); err != nil { |
| + t.Fatalf("warmup read: %v", err) |
| + } |
| + return conn |
| +} |
| + |
| +// readPresenceFor drains frames until a presence frame for uid arrives. |
| +func readPresenceFor(t *testing.T, ctx context.Context, conn *websocket.Conn, uid, stage string) string { |
| + t.Helper() |
| + for { |
| + _, data, err := conn.Read(ctx) |
| + if err != nil { |
| + t.Fatalf("%s: read: %v", stage, err) |
| + } |
| + var msg struct { |
| + Type string `json:"type"` |
| + UserID string `json:"user_id"` |
| + Presence string `json:"presence"` |
| + } |
| + if json.Unmarshal(data, &msg) == nil && msg.Type == "presence" && msg.UserID == uid { |
| + return msg.Presence |
| + } |
| + } |
| +} |
| + |
| +// Presence end to end: connecting broadcasts online to a connection, pinning |
| +// Away flips it (and survives activity), unpinning restores online, and the |
| +// last socket closing broadcasts offline. The roster carries the same states. |
| +func TestPresenceLifecycle(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + aliceTok, aliceUID, _ := joinedMember(t, srv, "Alice") |
| + bobTok, bobUID, _ := joinedMember(t, srv, "Bob") |
| + // A shared thread makes them connections — the visibility gate presence |
| + // fan-out rides on. |
| + shared, err := srv.db.createThread("group", "", []ThreadMemberKey{ |
| + {UserID: aliceUID, Wrapped: "wa"}, {UserID: bobUID, Wrapped: "wb"}, |
| + }) |
| + if err != nil { |
| + t.Fatalf("createThread: %v", err) |
| + } |
| + |
| + rosterPresence := func(viewerTok, uid string) string { |
| + resp, err := http.Get(ts.URL + "/api/roster?token=" + viewerTok) |
| + if err != nil { |
| + t.Fatalf("roster: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + var out struct{ Roster []RosterEntry } |
| + json.NewDecoder(resp.Body).Decode(&out) |
| + for _, e := range out.Roster { |
| + if e.UserID == uid { |
| + return e.Presence |
| + } |
| + } |
| + return "(absent)" |
| + } |
| + |
| + // Nobody connected: the roster reads offline. |
| + if p := rosterPresence(bobTok, aliceUID); p != presenceOffline { |
| + t.Fatalf("roster before connect: got %s, want offline", p) |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + bobConn := dialWS(t, ctx, ts.URL, bobTok, shared) |
| + defer bobConn.CloseNow() |
| + |
| + // Alice connects → Bob sees her come online, live and in the roster. |
| + aliceConn := dialWS(t, ctx, ts.URL, aliceTok, shared) |
| + defer aliceConn.CloseNow() |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "connect"); p != presenceOnline { |
| + t.Fatalf("on connect: got %s, want online", p) |
| + } |
| + if p := rosterPresence(bobTok, aliceUID); p != presenceOnline { |
| + t.Fatalf("roster while connected: got %s, want online", p) |
| + } |
| + |
| + // Alice pins herself Away; activity no longer flips her back. |
| + resp, err := http.Post(ts.URL+"/api/presence", "application/json", |
| + strings.NewReader(`{"token":"`+aliceTok+`","away":true}`)) |
| + if err != nil || resp.StatusCode != http.StatusOK { |
| + t.Fatalf("post presence: %v status %d", err, resp.StatusCode) |
| + } |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "away"); p != presenceAway { |
| + t.Fatalf("on pin: got %s, want away", p) |
| + } |
| + srv.markActive(aliceTok) // fresh activity while pinned — must stay away |
| + if p := rosterPresence(bobTok, aliceUID); p != presenceAway { |
| + t.Fatalf("roster pinned away: got %s, want away", p) |
| + } |
| + |
| + // getMe reports the pinned flag (what the toggle shows). |
| + meResp, _ := http.Get(ts.URL + "/api/me?token=" + aliceTok) |
| + var me struct { |
| + Away bool `json:"away"` |
| + } |
| + json.NewDecoder(meResp.Body).Decode(&me) |
| + if !me.Away { |
| + t.Fatal("getMe: away should be true while pinned") |
| + } |
| + |
| + // Unpin → online again. |
| + http.Post(ts.URL+"/api/presence", "application/json", |
| + strings.NewReader(`{"token":"`+aliceTok+`","away":false}`)) |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "unpin"); p != presenceOnline { |
| + t.Fatalf("on unpin: got %s, want online", p) |
| + } |
| + |
| + // Last socket closes → offline. |
| + aliceConn.CloseNow() |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "disconnect"); p != presenceOffline { |
| + t.Fatalf("on disconnect: got %s, want offline", p) |
| + } |
| +} |
| + |
| +// The sweep catches a connected member whose "active" beats stopped: they |
| +// drift to away without any request triggering it. |
| +func TestPresenceIdleSweep(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + aliceTok, aliceUID, _ := joinedMember(t, srv, "Alice") |
| + bobTok, bobUID, _ := joinedMember(t, srv, "Bob") |
| + shared, err := srv.db.createThread("group", "", []ThreadMemberKey{ |
| + {UserID: aliceUID, Wrapped: "wa"}, {UserID: bobUID, Wrapped: "wb"}, |
| + }) |
| + if err != nil { |
| + t.Fatalf("createThread: %v", err) |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + bobConn := dialWS(t, ctx, ts.URL, bobTok, shared) |
| + defer bobConn.CloseNow() |
| + aliceConn := dialWS(t, ctx, ts.URL, aliceTok, shared) |
| + defer aliceConn.CloseNow() |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "connect"); p != presenceOnline { |
| + t.Fatalf("on connect: got %s, want online", p) |
| + } |
| + |
| + // Age Alice's last activity past the idle window, then sweep. |
| + srv.activeUsers.Store(aliceUID, time.Now().Add(-presenceIdleAfter-time.Minute).Unix()) |
| + srv.sweepPresence() |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "idle"); p != presenceAway { |
| + t.Fatalf("after sweep: got %s, want away", p) |
| + } |
| + |
| + // Fresh activity brings them straight back. |
| + srv.markActive(aliceTok) |
| + if p := readPresenceFor(t, ctx, bobConn, aliceUID, "return"); p != presenceOnline { |
| + t.Fatalf("after activity: got %s, want online", p) |
| + } |
| +} |
| + |
| +// The endpoint is token-gated like every data endpoint. |
| +func TestPresenceRequiresToken(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + resp, err := http.Post(ts.URL+"/api/presence", "application/json", |
| + strings.NewReader(`{"token":"nope","away":true}`)) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusForbidden { |
| + t.Fatalf("want 403, got %d", resp.StatusCode) |
| + } |
| +} |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index ec0b418..bd40068 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -13,6 +13,9 @@ import { |
| pinnedFirst, |
| ownMessage, |
| memberCountLabel, |
| + onlineFilterKeeps, |
| + chatCategory, |
| + presenceLabel, |
| DEFAULT_THEME, |
| DEFAULT_APPEARANCE, |
| sanitizeActions, |
| @@ -268,5 +271,65 @@ await test("memberCountLabel: all helpers → no '0 members'", () => { |
| assert.equal(memberCountLabel(0, 1, 1), "1 bot, 1 AI agent"); |
| }); |
| +await test("onlineFilterKeeps: people decide; pure-bot chats fall back to the bot", () => { |
| + // A DM with an online person stays; away or offline drops. |
| + assert.equal(onlineFilterKeeps([{ agent: false, presence: "online" }]), true); |
| + assert.equal(onlineFilterKeeps([{ agent: false, presence: "away" }]), false); |
| + assert.equal(onlineFilterKeeps([{ agent: false, presence: "offline" }]), false); |
| + // A chat with ONLY bots keeps iff the bot is online (sidecar health). |
| + assert.equal(onlineFilterKeeps([{ agent: true, presence: "online" }]), true); |
| + assert.equal(onlineFilterKeeps([{ agent: true, presence: "offline" }]), false); |
| + // In a mixed room only the PEOPLE count — an always-on bot must not make |
| + // every room it sits in read "someone's online". |
| + assert.equal( |
| + onlineFilterKeeps([ |
| + { agent: true, presence: "online" }, |
| + { agent: false, presence: "offline" }, |
| + ]), |
| + false, |
| + ); |
| + assert.equal( |
| + onlineFilterKeeps([ |
| + { agent: true, presence: "offline" }, |
| + { agent: false, presence: "online" }, |
| + ]), |
| + true, |
| + ); |
| + // Notes/app threads (no other members) never match; missing entries are safe. |
| + assert.equal(onlineFilterKeeps([]), false); |
| + assert.equal(onlineFilterKeeps([null, { agent: false, presence: "" }]), false); |
| +}); |
| + |
| +await test("chatCategory: apps, then all-bot chats, else people", () => { |
| + assert.equal(chatCategory(true, []), "apps"); |
| + assert.equal(chatCategory(true, [{ agent: true, presence: "online" }]), "apps"); // app wins |
| + assert.equal(chatCategory(false, [{ agent: true, presence: "offline" }]), "bots"); |
| + assert.equal( |
| + chatCategory(false, [ |
| + { agent: true, presence: "online" }, |
| + { agent: true, presence: "online" }, |
| + ]), |
| + "bots", |
| + ); |
| + // Any person in the room makes it a people chat, bots present or not. |
| + assert.equal( |
| + chatCategory(false, [ |
| + { agent: true, presence: "online" }, |
| + { agent: false, presence: "offline" }, |
| + ]), |
| + "people", |
| + ); |
| + assert.equal(chatCategory(false, [{ agent: false, presence: "online" }]), "people"); |
| + assert.equal(chatCategory(false, []), "people"); // your own Notes |
| +}); |
| + |
| +await test("presenceLabel: all three states read out; unknown reads blank", () => { |
| + assert.equal(presenceLabel("online"), "Online"); |
| + assert.equal(presenceLabel("away"), "Away"); |
| + assert.equal(presenceLabel("offline"), "Offline"); |
| + assert.equal(presenceLabel(""), ""); |
| + assert.equal(presenceLabel(undefined), ""); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/pages/index.html b/web/pages/index.html| index 84772f6..25540a6 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -104,6 +104,23 @@ |
| </header> |
| <div class="chat-search hidden" id="chat-search"> |
| <input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off" /> |
| + <!-- List filters: online-only + People/Bots/Apps categories (shell.js). --> |
| + <div class="menu-wrap"> |
| + <button type="button" id="filter-btn" class="icon-btn filter-btn" aria-label="Filter chats" title="Filter chats" |
| + aria-haspopup="true" aria-expanded="false"> |
| + <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" |
| + stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> |
| + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon> |
| + </svg> |
| + </button> |
| + <div class="menu filter-menu hidden" id="filter-menu"> |
| + <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-online" /><span>Online only</span></label> |
| + <div class="filter-sep" role="separator"></div> |
| + <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-people" checked /><span>People</span></label> |
| + <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-bots" checked /><span>Bots</span></label> |
| + <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-apps" checked /><span>Apps</span></label> |
| + </div> |
| + </div> |
| </div> |
| <!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout |
| space in the sidebar — never a floating toast. --> |
diff --git a/web/static/shell.js b/web/static/shell.js| index 6f6d480..170cab5 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -39,6 +39,9 @@ import { |
| ownMessage, |
| sanitizeActions, |
| memberCountLabel, |
| + onlineFilterKeeps, |
| + chatCategory, |
| + presenceLabel, |
| } from "/static/ui.js"; |
| import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| @@ -537,6 +540,9 @@ const readMarks = {}; |
| const hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server |
| const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server |
| let chatFilter = ""; // lowercased substring filtering the thread list (search box) |
| +// The funnel menu beside search: online-only plus category checkboxes. All |
| +// categories on + online off = the unfiltered default (funnel unhighlighted). |
| +const chatFilters = { online: false, people: true, bots: true, apps: true }; |
| let activeId = null; |
| let pendingJoinThread = null; // the DM a fresh invite created — the welcome conversation offers it at the end |
| let ws = null; |
| @@ -1105,6 +1111,30 @@ function rosterLabel(userId) { |
| return e && e.label ? e.label : "Someone"; |
| } |
| +// Presence ("online" | "away" | "offline" | "") rides on roster entries: seeded |
| +// by /api/roster, kept live by "presence" WS frames. "" = unknown (no dot). |
| +function rosterPresence(userId) { |
| + const e = roster.find((x) => x.user_id === userId); |
| + return (e && e.presence) || ""; |
| +} |
| + |
| +// Fill `el` with a status line — a coloured dot (green/yellow/red) + the |
| +// Online/Away/Offline text, on an inline-flex wrapper so the dot centres |
| +// against the text. Empties the element when the state is unknown. |
| +function setPresenceLine(el, presence) { |
| + el.textContent = ""; |
| + const label = presenceLabel(presence); |
| + if (!label) return; |
| + const line = document.createElement("span"); |
| + line.className = "presence-line"; |
| + const dot = document.createElement("span"); |
| + dot.className = "p-dot " + presence; |
| + dot.setAttribute("aria-hidden", "true"); |
| + line.appendChild(dot); |
| + line.appendChild(document.createTextNode(label)); |
| + el.appendChild(line); |
| +} |
| + |
| // A small "BOT" tag to mark an agent in the roster picker, so it's clear before |
| // you add it that it isn't a person. Self-contained styling (no CSS dependency). |
| // Accent by default (a discovery cue in pickers/lists); pass muted=true inline |
| @@ -1649,6 +1679,39 @@ function renderApp() { |
| renderThreadList(); |
| }; |
| + // The funnel beside search: a small menu of list filters ("Show online |
| + // only" + People/Bots/Apps). Checkboxes apply live and the panel stays open |
| + // while ticking; it closes on outside click like the kebab. The funnel |
| + // highlights whenever any filter deviates from the show-everything default. |
| + const filterBtn = document.getElementById("filter-btn"); |
| + const filterMenu = document.getElementById("filter-menu"); |
| + if (filterBtn && filterMenu) { |
| + filterBtn.onclick = (e) => { |
| + e.stopPropagation(); |
| + const open = filterMenu.classList.toggle("hidden"); |
| + filterBtn.setAttribute("aria-expanded", String(!open)); |
| + }; |
| + document.addEventListener("click", (e) => { |
| + if (e.target !== filterBtn && !filterBtn.contains(e.target) && !filterMenu.contains(e.target)) { |
| + filterMenu.classList.add("hidden"); |
| + filterBtn.setAttribute("aria-expanded", "false"); |
| + } |
| + }); |
| + for (const key of ["online", "people", "bots", "apps"]) { |
| + const cb = document.getElementById(`flt-${key}`); |
| + if (!cb) continue; |
| + cb.checked = chatFilters[key]; |
| + cb.oninput = () => { |
| + chatFilters[key] = cb.checked; |
| + filterBtn.classList.toggle( |
| + "active", |
| + chatFilters.online || !chatFilters.people || !chatFilters.bots || !chatFilters.apps, |
| + ); |
| + renderThreadList(); |
| + }; |
| + } |
| + } |
| + |
| // Kebab menu (beside +): add device / rename / leave. |
| const menu = document.getElementById("menu"); |
| const menuBtn = document.getElementById("menu-btn"); |
| @@ -1802,14 +1865,32 @@ function renderThreadList() { |
| // (mirrors the server's ORDER BY). This is what floats a chat to the top the |
| // moment it has new activity — the active chat and your own sends included. |
| const recencyOf = (t) => t.last_at || t.created_at || 0; |
| - const base = chatFilter |
| + let base = chatFilter |
| ? threads.filter((t) => (threadName[t.id] || "").toLowerCase().includes(chatFilter)) |
| : threads.filter((t) => !hidden[t.id]); |
| + // The funnel menu: category checkboxes (People/Bots/Apps), then online-only. |
| + const filtering = chatFilters.online || !chatFilters.people || !chatFilters.bots || !chatFilters.apps; |
| + if (filtering) { |
| + const others = (t) => |
| + (t.members || []) |
| + .filter((id) => id !== myUserId) |
| + .map((id) => roster.find((x) => x.user_id === id)) |
| + .map((e) => ({ agent: !!(e && e.is_agent), presence: (e && e.presence) || "" })); |
| + base = base.filter((t) => { |
| + const o = others(t); |
| + if (!chatFilters[chatCategory(!!threadApp[t.id], o)]) return false; |
| + return !chatFilters.online || onlineFilterKeeps(o); |
| + }); |
| + } |
| const shown = pinnedFirst(base, (t) => pinned[t.id], recencyOf); |
| if (!shown.length) { |
| const empty = document.createElement("p"); |
| empty.className = "thread-empty-note"; |
| - empty.textContent = "No chats match your search."; |
| + empty.textContent = chatFilters.online |
| + ? "No one's online right now." |
| + : filtering |
| + ? "No chats match your filters." |
| + : "No chats match your search."; |
| list.appendChild(empty); |
| return; |
| } |
| @@ -2178,7 +2259,26 @@ function paintThreadHeader(id) { |
| if (e.kind === "ai") agents++; |
| else bots++; |
| } |
| - sub.textContent = isGroup ? memberCountLabel(members.length - bots - agents, bots, agents) : ""; |
| + if (isGroup) { |
| + // "· N online" counts people only — bots hold sockets around the clock, so |
| + // counting them would pin every room at its bot count and say nothing. |
| + let online = 0; |
| + for (const id of members) { |
| + if (id === myUserId) continue; |
| + const e = roster.find((x) => x.user_id === id); |
| + if (e && !e.is_agent && e.presence === "online") online++; |
| + } |
| + sub.textContent = |
| + memberCountLabel(members.length - bots - agents, bots, agents) + (online ? ` · ${online} online` : ""); |
| + } else if (t && t.kind === "dm") { |
| + // The status line under the person's name: dot + Online/Away/Offline. |
| + // Bots read Online while their sidecar is connected — an honest health |
| + // light. Blank only when the state is unknown (keyless invitee). |
| + const other = members.find((id) => id !== myUserId); |
| + setPresenceLine(sub, rosterPresence(other)); |
| + } else { |
| + sub.textContent = ""; |
| + } |
| sub.onclick = isGroup ? () => showMembers() : null; // tap "N members" → member list |
| sub.style.cursor = isGroup ? "pointer" : ""; |
| // The kebab is always available (every chat can be closed); the group-only |
| @@ -4767,8 +4867,12 @@ function connectWS() { |
| }, 25000); |
| // A reconnect (not the first connect) means we were offline — pull anything |
| // the socket missed while it was down. The initial connect follows boot()'s |
| - // fresh load, so there's nothing to catch up on then. |
| - if (everConnected) resyncActive(); |
| + // fresh load, so there's nothing to catch up on then. Presence transitions |
| + // don't resync (they're ephemeral), so re-pull the roster snapshot too. |
| + if (everConnected) { |
| + resyncActive(); |
| + loadRoster().then(renderThreadList).catch(() => {}); |
| + } |
| everConnected = true; |
| // If we just came back from an OAuth consent redirect, finish it now that the |
| // socket's open and roster/threads are loaded. |
| @@ -4844,6 +4948,18 @@ function connectWS() { |
| } |
| return; |
| } |
| + if (msg.type === "presence") { |
| + // Someone came online / went away / left. Roster is the store; repaint |
| + // the surfaces that read it — the (possibly filtered) chat list and the |
| + // open thread's header status line / online count. |
| + const e = roster.find((x) => x.user_id === msg.user_id); |
| + if (e && e.presence !== msg.presence) { |
| + e.presence = msg.presence; |
| + renderThreadList(); |
| + if (activeId && document.getElementById("thread-title")) paintThreadHeader(activeId); |
| + } |
| + return; |
| + } |
| if (msg.type === "avatar") { |
| const e = roster.find((x) => x.user_id === msg.user_id); |
| if (e) { |
| @@ -5388,6 +5504,14 @@ function showMembers() { |
| // Transparency: a bot in the room stays visibly a bot, not just in the picker. |
| const entry = roster.find((x) => x.user_id === uid); |
| if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind)); |
| + // Presence beside the name — this sheet is where a mixed room's status |
| + // lives (the header shows only the people-online count). |
| + if (entry && presenceLabel(entry.presence)) { |
| + const st = document.createElement("span"); |
| + st.className = "member-presence"; |
| + setPresenceLine(st, entry.presence); |
| + lbl.appendChild(st); |
| + } |
| main.appendChild(lbl); |
| if (!me) |
| main.onclick = async () => { |
| @@ -6449,6 +6573,53 @@ function editProfile() { |
| card.appendChild(label); |
| card.appendChild(input); |
| + // 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. |
| + const awayRow = document.createElement("label"); |
| + awayRow.className = "toggle-row away-row"; |
| + const awayCb = document.createElement("input"); |
| + awayCb.type = "checkbox"; |
| + awayCb.className = "switch"; |
| + awayCb.setAttribute("role", "switch"); |
| + awayCb.disabled = true; // until /api/me reports the current value |
| + const awayText = document.createElement("span"); |
| + awayText.textContent = "Set yourself as away"; |
| + awayRow.appendChild(awayText); // text left, switch right (settings idiom) |
| + awayRow.appendChild(awayCb); |
| + const awayNote = document.createElement("p"); |
| + awayNote.className = "away-note"; |
| + fetch(`/api/me?token=${encodeURIComponent(session.token)}`) |
| + .then((r) => r.json()) |
| + .then((me) => { |
| + awayCb.checked = !!me.away; |
| + awayCb.disabled = false; |
| + }) |
| + .catch(() => { |
| + awayNote.textContent = "Couldn't load your status."; |
| + }); |
| + awayCb.onchange = async () => { |
| + const away = awayCb.checked; |
| + awayCb.disabled = true; |
| + awayNote.textContent = ""; |
| + try { |
| + const r = await fetch("/api/presence", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, away }), |
| + }); |
| + if (!r.ok) throw new Error(); |
| + // Our own dot updates via the presence frame the server echoes back. |
| + } catch { |
| + awayCb.checked = !away; // didn't stick — say so inline, no toast |
| + awayNote.textContent = "Couldn't update — try again."; |
| + } finally { |
| + awayCb.disabled = false; |
| + } |
| + }; |
| + card.appendChild(awayRow); |
| + card.appendChild(awayNote); |
| + |
| const foot = document.createElement("div"); |
| foot.className = "sheet-foot"; |
| const cancel = document.createElement("button"); |
diff --git a/web/static/style.css b/web/static/style.css| index c9786f8..514aac8 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1556,12 +1556,31 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| /* admin: a labelled checkbox row (e.g. "let members invite people") */ |
| .toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; } |
| +/* profile sheet: the Away toggle + its inline error line (never a toast) */ |
| +.away-row { margin-top: 14px; font-size: 14px; justify-content: space-between; } |
| +.away-note { color: var(--muted); font-size: 13px; min-height: 1em; margin: 4px 0 0; } |
| +/* A slider switch (settings idiom) — a styled checkbox, so behaviour/forms |
| + stay stock. input.switch outranks the generic .toggle-row input sizing. */ |
| +input.switch { |
| + -webkit-appearance: none; appearance: none; margin: 0; flex: none; |
| + width: 42px; height: 26px; border-radius: 13px; position: relative; |
| + background: var(--bubble-them); cursor: pointer; transition: background 0.15s; |
| +} |
| +input.switch::after { |
| + content: ""; position: absolute; top: 2px; left: 2px; width: 22px; height: 22px; |
| + border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); |
| + transition: left 0.15s; |
| +} |
| +input.switch:checked { background: var(--accent); } |
| +input.switch:checked::after { left: 18px; } |
| +input.switch:disabled { opacity: 0.5; cursor: default; } |
| /* BOT badge on admin People rows (shell.js has its own inline-styled agentTag) */ |
| .bot-tag { |
| margin-left: 6px; font-size: 0.7em; font-weight: 600; letter-spacing: 0.03em; |
| padding: 1px 5px; border-radius: 6px; background: var(--accent, #888); color: #fff; vertical-align: middle; |
| } |
| .toggle-row input { width: 18px; height: 18px; } |
| +.toggle-row input.switch { width: 42px; height: 26px; } /* the switch keeps its own size */ |
| /* pending DM: waiting for an invited person to join (web/static/shell.js). While |
| waiting there's no one to talk to, so the feed + composer are hidden and a single |
| @@ -1701,12 +1720,45 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .field-label { display: block; font-size: 13px; color: var(--muted); margin: 2px 0 4px; } |
| /* Filter the chat list. Sits between the header and the list; hidden when there |
| are no chats (renderThreadList toggles .hidden). */ |
| -.chat-search { flex: none; padding: 4px 12px 6px; } |
| +.chat-search { flex: none; padding: 4px 12px 6px; display: flex; gap: 8px; } |
| .chat-search input { |
| - width: 100%; box-sizing: border-box; font: inherit; font-size: 14px; |
| + flex: 1; min-width: 0; box-sizing: border-box; font: inherit; font-size: 14px; |
| padding: 8px 12px; border: 1px solid transparent; border-radius: 12px; |
| background: var(--bubble-them); color: var(--ink); -webkit-appearance: none; appearance: none; |
| } |
| +/* The funnel beside search: opens the list-filter menu (online-only + |
| + People/Bots/Apps). Highlights while any filter is narrowing the list. |
| + Stretched to the search field's height so the pair reads as one control. */ |
| +.chat-search .menu-wrap { display: flex; align-items: stretch; } /* so the funnel can match the field's height */ |
| +.filter-btn { color: var(--muted); padding: 0; width: 38px; align-self: stretch; justify-content: center; } |
| +.filter-btn.active { color: var(--accent); } |
| +.filter-menu { display: flex; flex-direction: column; gap: 2px; min-width: 180px; padding: 6px; } |
| +/* Rows read like menu items: box, label, hover wash — mirroring .menu-item. */ |
| +.filter-row { |
| + display: flex; align-items: center; gap: 10px; cursor: pointer; |
| + font-size: 14px; font-weight: 500; padding: 8px 10px; border-radius: 8px; |
| +} |
| +.filter-row:hover { background: var(--bubble-them); } |
| +.filter-sep { height: 1px; background: var(--border); margin: 4px 8px; } |
| +/* The checkbox is drawn entirely here: the app-wide `input` rule (width:100%, |
| + panel fill, field padding) mangles a native checkbox into a blank pill, so |
| + appearance:none and take over — accent fill + a white tick when checked. */ |
| +input.filter-check { |
| + -webkit-appearance: none; appearance: none; margin: 0; padding: 0; flex: none; |
| + width: 18px; height: 18px; border-radius: 6px; cursor: pointer; |
| + border: 1.5px solid var(--border); background: var(--panel); |
| + display: inline-grid; place-content: center; |
| +} |
| +input.filter-check::before { |
| + content: ""; width: 10px; height: 10px; background: #fff; |
| + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); |
| + transform: scale(0); transition: transform 0.1s; |
| +} |
| +input.filter-check:checked { background: var(--accent); border-color: var(--accent); } |
| +input.filter-check:checked::before { transform: scale(1); } |
| +/* Keyboard focus keeps the app's accent ring; pointer clicks stay quiet. */ |
| +input.filter-check:focus { outline: none; } |
| +input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } |
| .chat-search input::placeholder { color: var(--muted); } |
| /* Keep the app's standard focus ring (global input:focus); just lift the fill. */ |
| .chat-search input:focus { background: var(--panel); } |
| @@ -1721,6 +1773,16 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| /* Bigger avatar in the chat list (à la WhatsApp/iMessage); scoped to the list so |
| header/profile/reaction avatars keep their sizes. */ |
| .thread-row .avatar { width: 52px; height: 52px; font-size: 21px; } |
| +/* Presence: a coloured dot + status text (the DM header line, the member |
| + sheet's note) — never a pip on an avatar. Green/yellow/red per Paul's call. */ |
| +:root { --online: #34c759; --away: #f5b400; --offline: #ff3b30; } |
| +.presence-line { display: inline-flex; align-items: center; gap: 6px; } /* dot truly centred on the text */ |
| +.p-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } |
| +.p-dot.online { background: var(--online); } |
| +.p-dot.away { background: var(--away); } |
| +.p-dot.offline { background: var(--offline); } |
| +/* Member sheet: the muted status note beside a member's name. */ |
| +.member-presence { margin-left: 8px; font-size: 12px; color: var(--muted); flex: none; } |
| .thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } |
| .thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; } |
| .thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
diff --git a/web/static/ui.js b/web/static/ui.js| index 099d7b3..64a0d8b 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -118,6 +118,36 @@ export function memberCountLabel(people, bots, agents = 0) { |
| return parts.join(", "); |
| } |
| +// ---- presence -------------------------------------------------------------- |
| +// Which chats the filter menu's "Show online only" keeps. `others` is roster |
| +// info for every member except the viewer: [{ agent, presence }]. People |
| +// decide when a chat has any: at least one person online right now (bots' |
| +// always-on sockets would otherwise match every mixed room forever, and Away |
| +// doesn't count — the filter answers "who can I reach right now"). A chat |
| +// with only bots falls back to the bots' own status, i.e. is the sidecar up. |
| +// A Notes/app thread has no others → filtered out. |
| +export function onlineFilterKeeps(others) { |
| + const people = others.filter((o) => o && !o.agent); |
| + if (people.length) return people.some((o) => o.presence === "online"); |
| + return others.some((o) => o && o.agent && o.presence === "online"); |
| +} |
| + |
| +// Which filter-menu category a chat belongs to: "apps" (mini-app threads), |
| +// "bots" (every other member is an agent — a bot DM, an all-bot room), else |
| +// "people" (anything with another person in it, plus your own Notes). |
| +export function chatCategory(isApp, others) { |
| + if (isApp) return "apps"; |
| + if (others.length && others.every((o) => o && o.agent)) return "bots"; |
| + return "people"; |
| +} |
| + |
| +// The status line under a DM partner's name (and beside members in the member |
| +// sheet). All three states read out — each wears a coloured dot in the UI |
| +// (green/yellow/red). "" = unknown (a keyless invitee, an old server): no line. |
| +export function presenceLabel(presence) { |
| + return presence === "online" ? "Online" : presence === "away" ? "Away" : presence === "offline" ? "Offline" : ""; |
| +} |
| + |
| // ---- chat list ordering -------------------------------------------------- |
| // Pinned-first ordering of the thread list: pinned chats float to the top, and |
| // WITHIN each band chats are ordered most-recent-first by `lastOf(t)` (a |