eleven / messenger Public

iOS + Mac: presence dots on the chat list + Set-yourself-as-away (#416)

* ios,mac: presence dots on the chat list + a Set-yourself-as-away toggle

Native half of online presence (server + web: PR #413). DM/self rows in
both thread lists wear the member's dot — green online, yellow away,
nothing offline — via a shared PresenceDot view whose colours match the
web's .presence-dot.

LChatCore: RosterEntry + ChatThread carry `presence`; ChatSocket.Frame
decodes the new "presence" frames; ThreadStore seeds rows from the
roster stamp and folds live transitions in place (applyPresence — no
refetch), plus setAway/loadAway over POST /api/presence + /api/me.
Sockets now heartbeat {"type":"active"} on the keepalive tick while the
app is frontmost (ChatSocket.appIsActive, wired per platform), which
drives auto-idle presence and finally gives native the same push
suppression the web's heartbeat has.

The Away pin lives where the display name does: the iOS per-server cog
sheet and the Mac Settings Profile section. It applies immediately,
reverts inline on failure, and syncs across devices (server-stored).

No changelog entry — native entries wait for the minted release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios,mac: presence as text + filter menus — match the settled web design

Rework to the presentation that landed on web after canary feedback:
no dots on avatars. PresenceDot (the avatar pip) becomes
PresenceStatusLine — a coloured dot + Online/Away/Offline word (green/
yellow/red, the web's colours) under the name in the iOS chat header
and the Mac feed header for DMs. Group headers append "· N online"
(people only). Both thread lists gain a funnel filter menu — Online
only, then People / Bots / Apps — as a toolbar menu on iOS and a
trailing button in the Mac search field, filled while narrowing.

The pure rules (presenceText, onlineFilterKeeps, chatCategory) live in
LChatCore/Presence.swift mirroring web/static/ui.js case-for-case, with
the same unit tests on both sides so the platforms can't drift on what
"Online only" or a category means. ThreadStore gains memberPresence /
passesFilter / onlinePeopleCount for the views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2d62a2a75758f5b784a425bee61665dc130ba2bb parent 3c35efe
13 files changed, +469 −10
  • ios/LChat/LChatApp.swift +3 −0
  • ios/LChat/Views/NativeChatScreen.swift +17 −2
  • ios/LChat/Views/ServerSettingsSheet.swift +38 −0
  • ios/LChat/Views/ThreadListView.swift +40 −2
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +15 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +14 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +6 −0
  • ios/LChatCore/Sources/LChatCore/Presence.swift +82 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +73 −1
  • ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift +78 −0
  • ios/LChatMac/LChatMacApp.swift +3 −0
  • ios/LChatMac/MacSettingsView.swift +46 −0
  • ios/LChatMac/NativeChatView.swift +54 −5
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index de65544..579646e 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -11,6 +11,9 @@ struct LChatApp: App {
// Reading a chat clears its delivered notifications (PR #260) — wired
// here because the shared ThreadStore can't depend on the app target.
ThreadStore.onMarkRead = { PushManager.clearDelivered(thread: $0) }
+ // Sockets heartbeat "active" while the app is frontmost (presence +
+ // push suppression) — wired per platform because LChatCore can't see UIKit.
+ ChatSocket.appIsActive = { UIApplication.shared.applicationState == .active }
}
var body: some Scene {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 2f6d892..ea6cc21 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -110,7 +110,9 @@ struct NativeChatScreen: View {
AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot)
}
- // "2 members, 1 bot, 2 AI agents" — the web group header's subtitle.
+ // "2 members, 1 bot, 2 AI agents · 1 online" — the web group header's
+ // subtitle; the online count is people only (bots' always-on sockets would
+ // pin every room at its bot count).
private var memberStats: String? {
guard isGroup else { return nil }
var bots = 0, agents = 0
@@ -118,7 +120,15 @@ struct NativeChatScreen: View {
guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue }
if e.isAI { agents += 1 } else { bots += 1 }
}
- return memberCountLabel(people: thread.members.count - bots - agents, bots: bots, agents: agents)
+ let label = memberCountLabel(people: thread.members.count - bots - agents, bots: bots, agents: agents)
+ let online = store.onlinePeopleCount(of: thread)
+ return online > 0 ? label + " · \(online) online" : label
+ }
+
+ // The DM partner's live status — read from the store (kept current by
+ // presence frames), falling back to the snapshot this screen was built with.
+ private var dmPresence: String? {
+ store.threads.first { $0.id == thread.id }?.presence ?? thread.presence
}
@Environment(\.dismiss) private var dismiss
@@ -158,6 +168,11 @@ struct NativeChatScreen: View {
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
+ } else if thread.kind == "dm" {
+ // Dot + Online/Away/Offline under the name (web parity).
+ PresenceStatusLine(dmPresence)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
}
}
.padding(.horizontal, 12)
diff --git a/ios/LChat/Views/ServerSettingsSheet.swift b/ios/LChat/Views/ServerSettingsSheet.swift
index f4f7873..eb5094b 100644
--- a/ios/LChat/Views/ServerSettingsSheet.swift
+++ b/ios/LChat/Views/ServerSettingsSheet.swift
@@ -13,6 +13,10 @@ struct ServerSettingsSheet: View {
@State private var name = ""
@State private var saving = false
@State private var note: String?
+ @State private var away = false
+ @State private var savedAway = false // last server-confirmed value — makes onChange idempotent (initial load, failure revert)
+ @State private var awayLoaded = false // gate the toggle until /api/me answers
+ @State private var awayNote: String?
var body: some View {
NavigationStack {
@@ -22,6 +26,18 @@ struct ServerSettingsSheet: View {
.onSubmit { if dirty { save() } }
if let note { Text(note).font(.footnote).foregroundStyle(.secondary) }
}
+ Section {
+ Toggle("Set yourself as away", isOn: $away)
+ .disabled(!awayLoaded)
+ .onChange(of: away) { _, next in setAway(next) }
+ if let awayNote {
+ Text(awayNote).font(.footnote).foregroundStyle(.secondary)
+ }
+ } header: {
+ Text("Presence")
+ } footer: {
+ Text("Your dot stays yellow until you switch this off — being active won't flip you back to online.")
+ }
Section("Theme") {
Picker("Theme", selection: $themeName) {
ForEach(ChatTheme.presets, id: \.id) { preset in
@@ -53,6 +69,28 @@ struct ServerSettingsSheet: View {
}
}
.onAppear { name = store.instance.screenName }
+ .task {
+ if let current = await store.loadAway() {
+ savedAway = current
+ away = current
+ awayLoaded = true
+ } else {
+ awayNote = "Couldn't load your status."
+ }
+ }
+ }
+
+ private func setAway(_ next: Bool) {
+ guard awayLoaded, next != savedAway else { return } // skip programmatic sets (load, revert)
+ awayNote = nil
+ Task {
+ if await store.setAway(next) {
+ savedAway = next
+ } else {
+ away = savedAway // didn't stick — put the switch back, say so inline
+ awayNote = "Couldn't update — try again."
+ }
+ }
}
private var dirty: Bool {
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 0683700..bb69b9c 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -20,13 +20,29 @@ struct ThreadListView: View {
// Files waiting in the share-extension inbox (app-group container).
@State private var shareFiles: [URL] = []
+ // The funnel menu's filters (web parity: Online only + People/Bots/Apps).
+ @State private var filterOnline = false
+ @State private var filterPeople = true
+ @State private var filterBots = true
+ @State private var filterApps = true
+ private var filtering: Bool { filterOnline || !filterPeople || !filterBots || !filterApps }
+
// Mirrors renderThreadList in web/static/chat.js: a search reveals everything
// that matches (so a closed chat can be found and reopened), while the default
// list hides closed (hidden) chats. Case-insensitive match on the chat name.
+ // The funnel filters then narrow whatever the search/default produced.
private var visibleThreads: [ChatThread] {
let q = search.trimmingCharacters(in: .whitespaces)
- guard !q.isEmpty else { return store.threads.filter { !$0.hidden } }
- return store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) }
+ var base = q.isEmpty
+ ? store.threads.filter { !$0.hidden }
+ : store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) }
+ if filtering {
+ base = base.filter {
+ store.passesFilter($0, onlineOnly: filterOnline,
+ people: filterPeople, bots: filterBots, apps: filterApps)
+ }
+ }
+ return base
}
// The active server's name (falls back to the host until the group name loads).
@@ -84,6 +100,22 @@ struct ThreadListView: View {
Button { showNew = true } label: { Image(systemName: "square.and.pencil") }
.accessibilityLabel("New chat")
}
+ // List filters (web's funnel beside search): online-only + the
+ // People/Bots/Apps categories. Filled glyph while narrowing.
+ ToolbarItem(placement: .topBarTrailing) {
+ Menu {
+ Toggle("Online only", isOn: $filterOnline)
+ Divider()
+ Toggle("People", isOn: $filterPeople)
+ Toggle("Bots", isOn: $filterBots)
+ Toggle("Apps", isOn: $filterApps)
+ } label: {
+ Image(systemName: filtering
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease.circle")
+ }
+ .accessibilityLabel("Filter chats")
+ }
// Per-server settings (profile name, theme) — the cog beside search.
ToolbarItem(placement: .topBarTrailing) {
Button { showSettings = true } label: { Image(systemName: "gearshape") }
@@ -236,6 +268,12 @@ struct ThreadListView: View {
guard liveSocket == nil, let base = store.instance.baseURL else { return }
let socket = ChatSocket(baseURL: base, token: store.instance.deviceToken)
socket?.onFrame = { frame in
+ // Presence needs no refetch — fold the transition straight into the
+ // store (rows re-render off the published change).
+ if frame.type == "presence", let uid = frame.user_id, let state = frame.presence {
+ store.applyPresence(userID: uid, state: state)
+ return
+ }
guard frame.ephemeral != true,
["msg", "edit", "delete"].contains(frame.type) else { return }
guard !refreshPending else { return }
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index 74962c1..5ac6c5f 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -19,6 +19,8 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
public let ephemeral: Bool? // relay-only traffic (presence) — never stored
public let message_id: Int64? // "reaction" frames
public let reactions: [Reaction]? // full per-emoji tally after a (un)react
+ public let user_id: String? // "presence" / "label" frames: whose state changed
+ public let presence: String? // "presence" frames: "online" | "away" | "offline"
public let last_read_at: Int64? // "read" frames: our watermark, moved on another device
}
@@ -27,6 +29,14 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
/// Connection state changes ("connected" / "reconnecting…"), main thread.
public var onStatus: ((String) -> Void)?
+ /// Whether the app is frontmost — set once per platform at startup (iOS:
+ /// applicationState, Mac: NSApp.isActive). While true, every open socket
+ /// heartbeats an "active" frame (the same beat the web sends every 30s
+ /// while visible): the server uses it to suppress push to a person who's
+ /// reading, and to resolve online-vs-idle presence (presence.go). Read on
+ /// the main thread (the ping timer's), like everything else here.
+ public static var appIsActive: () -> Bool = { true }
+
private let url: URL
private let origin: String
private var task: URLSessionWebSocketTask?
@@ -113,9 +123,14 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
// Protocol-level pings keep NATs/proxies from reaping an idle socket
// (the server's websocket library answers them without surfacing a frame).
+ // While the app is frontmost, ride the same beat with an "active" frame —
+ // presence + push suppression (see appIsActive above).
pingTimer?.invalidate()
pingTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in
self?.task?.sendPing { _ in }
+ // No beat needed on connect itself — the server counts connecting
+ // as activity (hub.go handleWS).
+ if Self.appIsActive() { self?.send(#"{"type":"active"}"#) }
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 6d58f36..90d0668 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -226,6 +226,20 @@ public struct LChatAPI {
await postJSON("api/label", ["token": token, "label_blob": labelBlob])
}
+ /// The caller's pinned Away flag (GET /api/me) — what the settings toggle
+ /// shows. nil when the fetch failed.
+ public func away() async -> Bool? {
+ struct Resp: Codable { let away: Bool? }
+ return (try? await getJSON("/api/me", Resp.self))?.away
+ }
+
+ /// Pin (true) or unpin (false) your presence to Away — stored per user, so
+ /// it follows you across devices; everyone's dots update via the broadcast.
+ @discardableResult
+ public func setAway(_ away: Bool) async -> Bool {
+ await postJSON("api/presence", ["token": token, "away": away])
+ }
+
/// Add a member to a thread: their copy of the thread key, wrapped to
/// their public key (the server stores a blob it can't open).
@discardableResult
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 212a03d..6d20b25 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -76,6 +76,9 @@ public struct RosterEntry: Codable, Equatable {
public var avatar: String?
public var is_agent: Bool? // an agent/bot helper, not a person (BOT badge in pickers)
public var kind: String? = nil // "ai" = LLM-backed (AI badge); anything else = deterministic bot
+ // "online" | "away" | "offline" — the effective state the server stamps on
+ // the roster (presence.go); kept live between refreshes by "presence" frames.
+ public var presence: String? = nil
public var isAgent: Bool { is_agent ?? false }
public var isAI: Bool { kind == "ai" }
@@ -214,6 +217,9 @@ public struct ChatThread: Identifiable, Equatable {
public var appId: String? // the mini-app's id ("todo", "remind", …) when isApp
public var glyph: String? // the app's emoji face for lists/headers (apps have no member photo)
public var badge: String? // list chip: "AI" / "BOT" (a DM with a helper) or "APP" (mini-app thread)
+ // The row's presence dot ("online" | "away"; nil = none) — set for DM/self
+ // rows only, from the other member's (or your own) roster presence.
+ public var presence: String? = nil
}
// A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs —
diff --git a/ios/LChatCore/Sources/LChatCore/Presence.swift b/ios/LChatCore/Sources/LChatCore/Presence.swift
new file mode 100644
index 0000000..6838e9f
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/Presence.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+
+// Presence, shared by iOS + Mac. The pure rules mirror web/static/ui.js
+// (presenceLabel / onlineFilterKeeps / chatCategory) so a member's status
+// reads identically on every surface — status is a dot + WORD under a name,
+// never a pip on an avatar.
+
+/// ui.js presenceLabel: "Online" / "Away" / "Offline"; nil when the state is
+/// unknown (a keyless invitee, a server without presence yet) — show nothing.
+public func presenceText(_ presence: String?) -> String? {
+ switch presence {
+ case "online": return "Online"
+ case "away": return "Away"
+ case "offline": return "Offline"
+ default: return nil
+ }
+}
+
+/// Roster facts about one member of a chat (the viewer excluded) — the input
+/// to the filter rules below. Built by ThreadStore.memberPresence(of:).
+public struct MemberPresence {
+ public let agent: Bool
+ public let presence: String?
+ public init(agent: Bool, presence: String?) {
+ self.agent = agent
+ self.presence = presence
+ }
+}
+
+/// ui.js onlineFilterKeeps: whether "Online only" keeps a chat. People decide
+/// in any room with people (an always-on bot must not make every mixed room
+/// read active, 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. No other members (Notes, apps) → dropped.
+public func onlineFilterKeeps(_ others: [MemberPresence]) -> Bool {
+ let people = others.filter { !$0.agent }
+ if !people.isEmpty { return people.contains { $0.presence == "online" } }
+ return others.contains { $0.agent && $0.presence == "online" }
+}
+
+/// ui.js chatCategory: which filter-menu checkbox governs a chat — apps
+/// (mini-app threads), bots (every other member is an agent), else people
+/// (anything with another person in it, plus your own Notes).
+public enum ChatFilterCategory {
+ case people, bots, apps
+}
+
+public func chatCategory(isApp: Bool, others: [MemberPresence]) -> ChatFilterCategory {
+ if isApp { return .apps }
+ if !others.isEmpty, others.allSatisfy({ $0.agent }) { return .bots }
+ return .people
+}
+
+/// The status line under a name: a coloured dot + the word, the native mirror
+/// of the web's .presence-line (green online / yellow away / red offline).
+/// Renders nothing when the state is unknown. Text font/colour follow the
+/// caller's modifiers; only the dot's colour is fixed.
+public struct PresenceStatusLine: View {
+ let state: String?
+
+ public init(_ state: String?) {
+ self.state = state
+ }
+
+ public var body: some View {
+ if let label = presenceText(state) {
+ HStack(spacing: 5) {
+ Circle().fill(dotColor).frame(width: 7, height: 7)
+ Text(label)
+ }
+ .accessibilityElement(children: .combine)
+ }
+ }
+
+ private var dotColor: Color {
+ switch state {
+ case "online": Color(red: 0x34 / 255, green: 0xC7 / 255, blue: 0x59 / 255)
+ case "away": Color(red: 0xF5 / 255, green: 0xB4 / 255, blue: 0x00 / 255)
+ default: Color(red: 0xFF / 255, green: 0x3B / 255, blue: 0x30 / 255)
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index c023998..68d177d 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -272,7 +272,15 @@ public final class ThreadStore: ObservableObject {
isApp: app != nil,
appId: app,
glyph: app.map { appGlyphs[$0] ?? "▦" },
- badge: badge
+ badge: badge,
+ // The header's status line: yours on the Notes row, the other
+ // member's on a DM; groups/apps carry none (web parity).
+ presence: {
+ guard app == nil else { return nil }
+ let uid = t.kind == "self" ? myUserId : (t.kind == "dm" ? other : "")
+ guard !uid.isEmpty else { return nil }
+ return people.first { $0.user_id == uid }?.presence
+ }()
))
}
// Pinned chats first, then most-recent activity — mirrors the web list.
@@ -288,6 +296,70 @@ public final class ThreadStore: ObservableObject {
(a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt
}
+ // Whose presence a row's dot shows: yours on the Notes/self row, the other
+ // member's on a DM; groups (and app threads) carry none — matching the web
+ // list (shell.js renderThreadList).
+ private func presenceUserID(kind: String, members: [String]) -> String? {
+ switch kind {
+ case "self": return myUserId.isEmpty ? nil : myUserId
+ case "dm": return members.first { $0 != myUserId }
+ default: return nil
+ }
+ }
+
+ /// Roster facts for every member of a chat except the viewer — feeds the
+ /// filter rules and the "· N online" count (Presence.swift).
+ public func memberPresence(of t: ChatThread) -> [MemberPresence] {
+ t.members.filter { $0 != myUserId }.map { id in
+ let e = roster.first { $0.user_id == id }
+ return MemberPresence(agent: e?.isAgent ?? false, presence: e?.presence)
+ }
+ }
+
+ /// The filter menu's verdict for one chat: its category checkbox must be
+ /// on, then "Online only" (when set) must keep it.
+ public func passesFilter(_ t: ChatThread, onlineOnly: Bool, people: Bool, bots: Bool, apps: Bool) -> Bool {
+ let others = memberPresence(of: t)
+ let shown = switch chatCategory(isApp: t.isApp, others: others) {
+ case .people: people
+ case .bots: bots
+ case .apps: apps
+ }
+ return shown && (!onlineOnly || onlineFilterKeeps(others))
+ }
+
+ /// How many PEOPLE in a chat are online right now — the group header's
+ /// "· N online" (bots' always-on sockets would say nothing, so they don't
+ /// count; the web's paintThreadHeader applies the same rule).
+ public func onlinePeopleCount(of t: ChatThread) -> Int {
+ memberPresence(of: t).filter { !$0.agent && $0.presence == "online" }.count
+ }
+
+ /// Fold one live "presence" frame (uid → online/away/offline) into the
+ /// roster and any DM/self row showing that member — the between-refresh
+ /// counterpart of the stamp refresh() reads off /api/roster.
+ public func applyPresence(userID: String, state: String) {
+ if let i = roster.firstIndex(where: { $0.user_id == userID }) {
+ roster[i].presence = state
+ }
+ for i in threads.indices where !threads[i].isApp
+ && presenceUserID(kind: threads[i].kind, members: threads[i].members) == userID {
+ threads[i].presence = state
+ }
+ }
+
+ /// Pin/unpin yourself Away (POST /api/presence). The server broadcasts the
+ /// transition, so our own rows update via applyPresence like anyone else's.
+ @discardableResult
+ public func setAway(_ away: Bool) async -> Bool {
+ await api?.setAway(away) ?? false
+ }
+
+ /// The pinned Away flag for the settings toggle (nil = couldn't load).
+ public func loadAway() async -> Bool? {
+ await api?.away()
+ }
+
/// Pin or unpin a chat: update locally (re-sorting so it floats/settles), then
/// tell the server. The flag is per-user and syncs to the user's other devices.
public func setPinned(_ thread: ChatThread, _ pinned: Bool) {
diff --git a/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift b/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift
new file mode 100644
index 0000000..53dd4d2
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift
@@ -0,0 +1,78 @@
+import XCTest
+@testable import LChatCore
+
+// Presence wire contract: the "presence" hub frame and the roster's stamped
+// presence field decode as the server sends them (presence.go / PR #413), so
+// the native dots read the same states the web does.
+final class PresenceTests: XCTestCase {
+ func testPresenceFrameDecodes() throws {
+ let json = #"{"type":"presence","user_id":"u-alice","presence":"away"}"#
+ let frame = try JSONDecoder().decode(ChatSocket.Frame.self, from: Data(json.utf8))
+ XCTAssertEqual(frame.type, "presence")
+ XCTAssertEqual(frame.user_id, "u-alice")
+ XCTAssertEqual(frame.presence, "away")
+ XCTAssertNil(frame.thread_id) // presence is roster-wide, never thread-scoped
+ }
+
+ // Older frames (msg/typing/…) carry no presence fields — they must keep
+ // decoding with the new optionals absent.
+ func testFramesWithoutPresenceStillDecode() throws {
+ let json = #"{"type":"msg","thread_id":"t1","id":7,"created_at":1700000000,"blob":"x"}"#
+ let frame = try JSONDecoder().decode(ChatSocket.Frame.self, from: Data(json.utf8))
+ XCTAssertEqual(frame.type, "msg")
+ XCTAssertNil(frame.user_id)
+ XCTAssertNil(frame.presence)
+ }
+
+ // The pure filter rules mirror web/static/ui.js — same cases as ui.test.mjs,
+ // so web and native can't drift on what "Online only" or a category means.
+ func testOnlineFilterKeeps() {
+ // People decide: online keeps, away/offline drop.
+ XCTAssertTrue(onlineFilterKeeps([.init(agent: false, presence: "online")]))
+ XCTAssertFalse(onlineFilterKeeps([.init(agent: false, presence: "away")]))
+ XCTAssertFalse(onlineFilterKeeps([.init(agent: false, presence: "offline")]))
+ // A chat with ONLY bots falls back to the bot's own status (sidecar health).
+ XCTAssertTrue(onlineFilterKeeps([.init(agent: true, presence: "online")]))
+ XCTAssertFalse(onlineFilterKeeps([.init(agent: true, presence: "offline")]))
+ // In a mixed room only the people count — an always-on bot must not make
+ // every room it sits in read "someone's online".
+ XCTAssertFalse(onlineFilterKeeps([
+ .init(agent: true, presence: "online"), .init(agent: false, presence: "offline"),
+ ]))
+ XCTAssertTrue(onlineFilterKeeps([
+ .init(agent: true, presence: "offline"), .init(agent: false, presence: "online"),
+ ]))
+ // Notes/app threads (no other members) never match.
+ XCTAssertFalse(onlineFilterKeeps([]))
+ }
+
+ func testChatCategory() {
+ XCTAssertEqual(chatCategory(isApp: true, others: []), .apps)
+ XCTAssertEqual(chatCategory(isApp: true, others: [.init(agent: true, presence: "online")]), .apps)
+ XCTAssertEqual(chatCategory(isApp: false, others: [.init(agent: true, presence: "offline")]), .bots)
+ // Any person in the room makes it a people chat, bots present or not.
+ XCTAssertEqual(chatCategory(isApp: false, others: [
+ .init(agent: true, presence: "online"), .init(agent: false, presence: "offline"),
+ ]), .people)
+ XCTAssertEqual(chatCategory(isApp: false, others: []), .people) // your own Notes
+ }
+
+ func testPresenceText() {
+ XCTAssertEqual(presenceText("online"), "Online")
+ XCTAssertEqual(presenceText("away"), "Away")
+ XCTAssertEqual(presenceText("offline"), "Offline")
+ XCTAssertNil(presenceText(""))
+ XCTAssertNil(presenceText(nil))
+ }
+
+ func testRosterEntryCarriesPresence() throws {
+ let json = #"{"user_id":"u1","label":"Alice","public_key":"pk","presence":"online"}"#
+ let entry = try JSONDecoder().decode(RosterEntry.self, from: Data(json.utf8))
+ XCTAssertEqual(entry.presence, "online")
+
+ // The field is omitempty on the server — absent must decode too.
+ let legacy = #"{"user_id":"u2","label":"Bob","public_key":"pk2"}"#
+ let bob = try JSONDecoder().decode(RosterEntry.self, from: Data(legacy.utf8))
+ XCTAssertNil(bob.presence)
+ }
+}
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 0c57831..a25cea1 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -34,6 +34,9 @@ struct LChatMacApp: App {
// SF glyphs render visibly heavier in native text than in the
// webview. This is the app-wide equivalent of that CSS.
UserDefaults.standard.set(0, forKey: "AppleFontSmoothing")
+ // Sockets heartbeat "active" while the app is frontmost (presence +
+ // push suppression) — wired per platform because LChatCore can't see AppKit.
+ ChatSocket.appIsActive = { NSApp.isActive }
}
var body: some Scene {
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index e8e6493..e80b58c 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -14,11 +14,20 @@ struct MacSettingsView: View {
@State private var note: String?
@State private var selectedInstanceID: String?
@FocusState private var nameFocused: Bool
+ @State private var away = false
+ @State private var savedAway = false // last server-confirmed value — makes onChange idempotent (load, revert)
+ @State private var awayLoaded = false // gate the toggle until /api/me answers
+ @State private var awayNote: String?
private var instance: SavedInstance? {
app.instances.first { $0.id == selectedInstanceID } ?? app.instances.first
}
+ private var api: LChatAPI? {
+ guard let inst = instance, let base = inst.baseURL else { return nil }
+ return LChatAPI(baseURL: base, token: inst.deviceToken)
+ }
+
@StateObject private var homeSignIn = MacHomeSignIn()
var body: some View {
@@ -67,6 +76,14 @@ struct MacSettingsView: View {
}
}
}
+ // Presence: pin your dot to away (activity won't flip it back).
+ // Applies immediately, per server, synced across your devices.
+ Toggle("Set yourself as away", isOn: $away)
+ .disabled(!awayLoaded)
+ .onChange(of: away) { _, next in setAway(next) }
+ if let awayNote {
+ Text(awayNote).font(.footnote).foregroundStyle(.secondary)
+ }
}
}
.formStyle(.grouped)
@@ -75,10 +92,39 @@ struct MacSettingsView: View {
.onAppear {
selectedInstanceID = instance?.id
name = instance?.screenName ?? ""
+ loadAway()
}
.onChange(of: selectedInstanceID) { _, _ in
name = instance?.screenName ?? ""
note = nil
+ loadAway()
+ }
+ }
+
+ private func loadAway() {
+ awayLoaded = false
+ awayNote = nil
+ Task {
+ if let current = await api?.away() {
+ savedAway = current
+ away = current
+ awayLoaded = true
+ } else {
+ awayNote = "Couldn't load your status."
+ }
+ }
+ }
+
+ private func setAway(_ next: Bool) {
+ guard awayLoaded, next != savedAway else { return } // skip programmatic sets (load, revert)
+ awayNote = nil
+ Task {
+ if await api?.setAway(next) == true {
+ savedAway = next
+ } else {
+ away = savedAway // didn't stick — put the switch back, say so inline
+ awayNote = "Couldn't update — try again."
+ }
}
}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 0c89f7c..5f32e68 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -241,8 +241,9 @@ struct NativeChatView: View {
}
}
- // "2 members, 1 bot, 2 AI agents" — the web header's subtitle, from the
- // roster's helper kinds (shell.js's count + ui.js memberCountLabel).
+ // "2 members, 1 bot, 2 AI agents · 1 online" — the web header's subtitle,
+ // from the roster's helper kinds (shell.js's count + ui.js
+ // memberCountLabel); the online count is people only.
private func memberStats(_ t: ChatThread) -> String? {
guard t.kind == "group" else { return nil }
var bots = 0, agents = 0
@@ -250,7 +251,9 @@ struct NativeChatView: View {
guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue }
if e.isAI { agents += 1 } else { bots += 1 }
}
- return memberCountLabel(people: t.members.count - bots - agents, bots: bots, agents: agents)
+ let label = memberCountLabel(people: t.members.count - bots - agents, bots: bots, agents: agents)
+ let online = store.onlinePeopleCount(of: t)
+ return online > 0 ? label + " · \(online) online" : label
}
// Chat = the native feed; View = the app's webview; Both = chat with the
@@ -372,6 +375,11 @@ struct NativeChatView: View {
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
+ } else if t.kind == "dm" {
+ // Dot + Online/Away/Offline under the name (web parity).
+ PresenceStatusLine(t.presence)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
}
}
Spacer()
@@ -567,6 +575,26 @@ struct NativeChatView: View {
search = ""
return .handled
}
+ // List filters (web's funnel beside search): online-only +
+ // the People/Bots/Apps categories. Filled while narrowing.
+ Menu {
+ Toggle("Online only", isOn: $filterOnline)
+ Divider()
+ Toggle("People", isOn: $filterPeople)
+ Toggle("Bots", isOn: $filterBots)
+ Toggle("Apps", isOn: $filterApps)
+ } label: {
+ Image(systemName: filtering
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease.circle")
+ .font(.system(size: 12))
+ .foregroundStyle(filtering ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
+ .contentShape(Rectangle())
+ }
+ .menuStyle(.button)
+ .buttonStyle(.plain)
+ .menuIndicator(.hidden)
+ .help("Filter chats")
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
@@ -743,10 +771,25 @@ struct NativeChatView: View {
// ---- the thread list ---------------------------------------------------
+ // The funnel menu's filters (web parity: Online only + People/Bots/Apps).
+ @State private var filterOnline = false
+ @State private var filterPeople = true
+ @State private var filterBots = true
+ @State private var filterApps = true
+ private var filtering: Bool { filterOnline || !filterPeople || !filterBots || !filterApps }
+
private var visibleThreads: [ChatThread] {
let q = search.trimmingCharacters(in: .whitespaces).lowercased()
- if q.isEmpty { return store.threads.filter { !$0.hidden } }
- return store.threads.filter { $0.title.lowercased().contains(q) }
+ var base = q.isEmpty
+ ? store.threads.filter { !$0.hidden }
+ : store.threads.filter { $0.title.lowercased().contains(q) }
+ if filtering {
+ base = base.filter {
+ store.passesFilter($0, onlineOnly: filterOnline,
+ people: filterPeople, bots: filterBots, apps: filterApps)
+ }
+ }
+ return base
}
private var threadList: some View {
@@ -887,6 +930,12 @@ struct NativeChatView: View {
guard liveSocket == nil, let base = instance.baseURL else { return }
let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
socket?.onFrame = { frame in
+ // Presence needs no refetch — fold the transition straight into the
+ // store (rows re-render off the published change).
+ if frame.type == "presence", let uid = frame.user_id, let state = frame.presence {
+ store.applyPresence(userID: uid, state: state)
+ return
+ }
// Read on another of this user's devices: clear the dot in place —
// no fetch needed, the frame carries the whole change.
if frame.type == "read", let tid = frame.thread_id, let at = frame.last_read_at {