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>
13 files changed,
+469
−10
ios/LChat/LChatApp.swift+3 −0ios/LChat/Views/NativeChatScreen.swift+17 −2ios/LChat/Views/ServerSettingsSheet.swift+38 −0ios/LChat/Views/ThreadListView.swift+40 −2ios/LChatCore/Sources/LChatCore/ChatSocket.swift+15 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+14 −0ios/LChatCore/Sources/LChatCore/Models.swift+6 −0ios/LChatCore/Sources/LChatCore/Presence.swift+82 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+73 −1ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift+78 −0ios/LChatMac/LChatMacApp.swift+3 −0ios/LChatMac/MacSettingsView.swift+46 −0ios/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 { |