eleven / messenger Public

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>
Paul Campbell 3c35efeb46d3e9b4cc6743f2c562e0f0769205d8 parent c4bb7f2
12 files changed, +854 −19
  • changelog/2026-07-08-online-presence.md +7 −0
  • db.go +25 −3
  • handlers_test.go +13 −9
  • hub.go +11 −0
  • main.go +11 −0
  • presence.go +220 −0
  • presence_test.go +217 −0
  • test/ui.test.mjs +63 −0
  • web/pages/index.html +17 −0
  • web/static/shell.js +176 −5
  • web/static/style.css +64 −2
  • web/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