ios: online is a green ring on the DM avatar; the dot+word line stays on web and Mac (#556)
* ios: online is a green ring on the DM header avatar, not a status line Mobile has no room for the dot+word presence pill under the name, so the iOS DM header drops PresenceStatusLine entirely: online draws a subtle green (#34C759) halo just outside the header avatar (2pt stroke, ~2pt gap, riding above the glass capsule via the avatar's zIndex); away, offline and unknown show nothing — the header reads like any other. Avatar gains an opt-in onlineRing parameter (default off) so the ring is one implementation, not call-site overlay code; only the DM header turns it on. Web + Mac keep the dot+word line (they have the room) — the platform split is now codified in Presence.swift's comments. This deliberately reverses the earlier "never a pip on an avatar" rule for mobile; the shared presence LOGIC is untouched (PresenceTests pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: presenceLabel comment codifies the per-platform presentation split Logic unchanged — only the note above presenceLabel now records that the dot+word line is web + Mac presentation, while iOS shows online as a green ring on the DM header avatar (and nothing for away/offline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+36
−9
ios/LChat/Views/Avatar.swift+19 −0ios/LChat/Views/NativeChatScreen.swift+6 −6ios/LChatCore/Sources/LChatCore/Presence.swift+7 −3web/static/ui.js+4 −0
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift| index d21cdc0..0b03b1c 100644 |
| --- a/ios/LChat/Views/Avatar.swift |
| +++ b/ios/LChat/Views/Avatar.swift |
| @@ -16,6 +16,12 @@ struct Avatar: View { |
| var photo: UIImage? = nil |
| // An app-thread's emoji glyph (⏰, ✓, …) — replaces the initial. |
| var glyph: String? = nil |
| + // Presence, iOS-style: a subtle green ring just outside the avatar edge. |
| + // Mobile has no room for the web/Mac dot+word status line, so online is |
| + // signalled here — and ONLY online; away/offline draw nothing (see |
| + // LChatCore/Presence.swift). Opt-in per call site; only the DM chat |
| + // header turns it on today. |
| + var onlineRing: Bool = false |
| @State private var image: UIImage? |
| @@ -35,9 +41,22 @@ struct Avatar: View { |
| } |
| .frame(width: size, height: size) |
| .clipShape(Circle()) |
| + // The ring rides OUTSIDE the clip (overlay after clipShape isn't |
| + // clipped): a 2pt stroke inset -3 leaves a ~2pt breathing gap between |
| + // the avatar edge and the ring, so it reads as a halo, not a border. |
| + .overlay { |
| + if onlineRing { |
| + Circle() |
| + .inset(by: -3) |
| + .stroke(Self.onlineGreen, lineWidth: 2) |
| + } |
| + } |
| .task(id: source) { await load() } |
| } |
| + // The presence green (#34C759) — same as PresenceStatusLine's online dot. |
| + static let onlineGreen = Color(red: 0x34 / 255, green: 0xC7 / 255, blue: 0x59 / 255) |
| + |
| private func load() async { |
| guard let source else { image = nil; return } |
| // A decoded photo shows instantly; otherwise fall to initials and fetch. |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 0329a3a..80ff5d5 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -194,7 +194,12 @@ struct NativeChatScreen: View { |
| // bar buttons, the name in a glass pill tucked under it. |
| private var chatHeader: some View { |
| VStack(spacing: -5) { |
| - Avatar(name: thread.title, size: 54, source: thread.avatar) |
| + // Presence on mobile is the avatar ring, not a status line: online |
| + // wears a green halo; away/offline/unknown look like any other |
| + // header. (Web + Mac keep the dot+word line — they have the room.) |
| + // zIndex(1) keeps the ring above the glass capsule below. |
| + Avatar(name: thread.title, size: 54, source: thread.avatar, |
| + onlineRing: thread.kind == "dm" && dmPresence == "online") |
| .scaleEffect(avatarPop ? 1 : 0.7) |
| .zIndex(1) |
| VStack(spacing: 0) { |
| @@ -207,11 +212,6 @@ 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/LChatCore/Sources/LChatCore/Presence.swift b/ios/LChatCore/Sources/LChatCore/Presence.swift| index 6838e9f..cabbd8f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Presence.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Presence.swift |
| @@ -2,8 +2,10 @@ 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. |
| +// MEANS the same everywhere. Presentation splits by platform: web + Mac show |
| +// the dot + word status line (there's room); iOS/mobile shows online as a |
| +// green ring on the DM header avatar and nothing at all for away/offline |
| +// (see ios/LChat/Views/Avatar.swift `onlineRing`). |
| /// ui.js presenceLabel: "Online" / "Away" / "Offline"; nil when the state is |
| /// unknown (a keyless invitee, a server without presence yet) — show nothing. |
| @@ -54,7 +56,9 @@ public func chatCategory(isApp: Bool, others: [MemberPresence]) -> ChatFilterCat |
| /// 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. |
| +/// caller's modifiers; only the dot's colour is fixed. Web + Mac only — |
| +/// iOS/mobile has no room for it, so there online is a green ring on the DM |
| +/// header avatar and away/offline show nothing. |
| public struct PresenceStatusLine: View { |
| let state: String? |
diff --git a/web/static/ui.js b/web/static/ui.js| index 90a7598..0ab15b4 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -144,6 +144,10 @@ export function chatCategory(isApp, others) { |
| // 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. |
| +// Presentation is web + Mac only (there's room for a dot + word); the iOS app |
| +// instead shows online as a green ring on the DM header avatar and nothing for |
| +// away/offline. The LOGIC here stays the single source, mirrored by |
| +// ios/LChatCore Presence.swift. |
| export function presenceLabel(presence) { |
| return presence === "online" ? "Online" : presence === "away" ? "Away" : presence === "offline" ? "Offline" : ""; |
| } |