eleven / ocho Public

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>
Paul Campbell 706c558f8db88c4c8b82fb2a9df94e2b8d4cac15 parent 9c9d25f
4 files changed, +36 −9
  • ios/LChat/Views/Avatar.swift +19 −0
  • ios/LChat/Views/NativeChatScreen.swift +6 −6
  • ios/LChatCore/Sources/LChatCore/Presence.swift +7 −3
  • web/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" : "";
}