| 1 | import SwiftUI |
| 2 | |
| 3 | // Presence, shared by iOS + Mac. The pure rules mirror web/static/ui.js |
| 4 | // (presenceLabel / onlineFilterKeeps / chatCategory) so a member's status |
| 5 | // MEANS the same everywhere. Presentation splits by platform: web + Mac show |
| 6 | // the dot + word status line (there's room); iOS/mobile shows online as a |
| 7 | // green ring on the DM header avatar and nothing at all for away/offline |
| 8 | // (see ios/LChat/Views/Avatar.swift `onlineRing`). |
| 9 | |
| 10 | /// ui.js presenceLabel: "Online" / "Away" / "Offline"; nil when the state is |
| 11 | /// unknown (a keyless invitee, a server without presence yet) — show nothing. |
| 12 | public func presenceText(_ presence: String?) -> String? { |
| 13 | switch presence { |
| 14 | case "online": return "Online" |
| 15 | case "away": return "Away" |
| 16 | case "offline": return "Offline" |
| 17 | default: return nil |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | /// Roster facts about one member of a chat (the viewer excluded) — the input |
| 22 | /// to the filter rules below. Built by ThreadStore.memberPresence(of:). |
| 23 | public struct MemberPresence { |
| 24 | public let agent: Bool |
| 25 | public let presence: String? |
| 26 | public init(agent: Bool, presence: String?) { |
| 27 | self.agent = agent |
| 28 | self.presence = presence |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | /// ui.js onlineFilterKeeps: whether "Online only" keeps a chat. People decide |
| 33 | /// in any room with people (an always-on bot must not make every mixed room |
| 34 | /// read active, and Away doesn't count — the filter answers "who can I reach |
| 35 | /// right now"); a chat with only bots falls back to the bots' own status, |
| 36 | /// i.e. is the sidecar up. No other members (Notes, apps) → dropped. |
| 37 | public func onlineFilterKeeps(_ others: [MemberPresence]) -> Bool { |
| 38 | let people = others.filter { !$0.agent } |
| 39 | if !people.isEmpty { return people.contains { $0.presence == "online" } } |
| 40 | return others.contains { $0.agent && $0.presence == "online" } |
| 41 | } |
| 42 | |
| 43 | /// ui.js chatCategory: which filter-menu checkbox governs a chat — apps |
| 44 | /// (mini-app threads), bots (every other member is an agent), else people |
| 45 | /// (anything with another person in it, plus your own Notes). |
| 46 | public enum ChatFilterCategory { |
| 47 | case people, bots, apps |
| 48 | } |
| 49 | |
| 50 | public func chatCategory(isApp: Bool, others: [MemberPresence]) -> ChatFilterCategory { |
| 51 | if isApp { return .apps } |
| 52 | if !others.isEmpty, others.allSatisfy({ $0.agent }) { return .bots } |
| 53 | return .people |
| 54 | } |
| 55 | |
| 56 | /// The inline chip's words for an active funnel filter — the plain-language |
| 57 | /// answer to "why is a chat missing from my list?". Reads like a person would |
| 58 | /// say it ("Online only", "Hiding bots & apps", "Online only · hiding apps"), |
| 59 | /// not a flag dump; nil when nothing is filtering. Shared so the iOS and Mac |
| 60 | /// chips can't drift on copy. |
| 61 | public func chatFilterSummary(onlineOnly: Bool, people: Bool, bots: Bool, apps: Bool) -> String? { |
| 62 | var hidden: [String] = [] |
| 63 | if !people { hidden.append("people") } |
| 64 | if !bots { hidden.append("bots") } |
| 65 | if !apps { hidden.append("apps") } |
| 66 | // "a, b & c" — the & keeps the last pair conversational at any length. |
| 67 | let list = hidden.count > 1 |
| 68 | ? hidden.dropLast().joined(separator: ", ") + " & " + hidden[hidden.count - 1] |
| 69 | : hidden.first |
| 70 | switch (onlineOnly, list) { |
| 71 | case (true, nil): return "Online only" |
| 72 | case (true, let l?): return "Online only · hiding \(l)" |
| 73 | case (false, let l?): return "Hiding \(l)" |
| 74 | case (false, nil): return nil |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// The status line under a name: a coloured dot + the word, the native mirror |
| 79 | /// of the web's .presence-line (green online / yellow away / red offline). |
| 80 | /// Renders nothing when the state is unknown. Text font/colour follow the |
| 81 | /// caller's modifiers; only the dot's colour is fixed. Web + Mac only — |
| 82 | /// iOS/mobile has no room for it, so there online is a green ring on the DM |
| 83 | /// header avatar and away/offline show nothing. |
| 84 | public struct PresenceStatusLine: View { |
| 85 | let state: String? |
| 86 | |
| 87 | public init(_ state: String?) { |
| 88 | self.state = state |
| 89 | } |
| 90 | |
| 91 | public var body: some View { |
| 92 | if let label = presenceText(state) { |
| 93 | HStack(spacing: 5) { |
| 94 | Circle().fill(dotColor).frame(width: 7, height: 7) |
| 95 | Text(label) |
| 96 | } |
| 97 | .accessibilityElement(children: .combine) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | private var dotColor: Color { |
| 102 | switch state { |
| 103 | case "online": Color(red: 0x34 / 255, green: 0xC7 / 255, blue: 0x59 / 255) |
| 104 | case "away": Color(red: 0xF5 / 255, green: 0xB4 / 255, blue: 0x00 / 255) |
| 105 | default: Color(red: 0xFF / 255, green: 0x3B / 255, blue: 0x30 / 255) |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |