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}) }