eleven / messenger Public

ios+mac: tap the group header's member counts to open the members list (#898)

The member-stats pill in a group chat header read as tappable but did
nothing; wire it to the same MembersSheet the ⋯ menu's "View Members"
opens (which stays as the labelled/fallback path).

- iOS: the name+stats capsule becomes `headerPill`, wrapped in a Button
  in the group case; the avatar is marked decorative and the pill is its
  own actionable a11y element ("Standup, 5 members, 3 online" + a
  "View members" hint). DM whole-header tap-to-profile is untouched
  (memberStats and dmPartner are mutually exclusive).
- Mac: the stats line becomes a plain-styled Button with a pointing-hand
  hover cursor and matching a11y label/hint.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Dermot Daly a8d295ac2781d6ab5e4b4ed590f386da883dcef7 parent 414bdeb
2 files changed, +65 −19
  • ios/LChat/Views/NativeChatScreen.swift +50 −15
  • ios/LChatMac/NativeChatView.swift +15 −4
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 37d8b8c..8919142 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -237,36 +237,71 @@ struct NativeChatScreen: View {
// 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.
+ // Decorative here — the title/pill below carries the name to
+ // VoiceOver, so the avatar needn't be its own element.
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) {
- Text(thread.title)
- .font(.headline)
- .lineLimit(1)
- .foregroundStyle(.primary)
- if let stats = memberStats {
- Text(stats)
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
+ .accessibilityHidden(true)
+ // In a group the pill is a fast path to the members list — the
+ // counts *read* as tappable, so make them so (View Members also
+ // stays in the ⋯ menu as the labelled/fallback path). The pill is
+ // already ~44pt tall, so no hit-area hacks, and it becomes its own
+ // actionable a11y element instead of a flattened, action-less one.
+ // (Groups own their tap here; the DM whole-header tap below never
+ // fires for them, since memberStats and dmPartner are exclusive.)
+ if memberStats != nil {
+ Button { showMembers = true } label: { headerPill }
+ .buttonStyle(.plain)
+ .accessibilityElement()
+ .accessibilityLabel(headerAccessibilityLabel)
+ .accessibilityHint("View members")
+ .accessibilityAddTraits(.isButton)
+ } else {
+ headerPill
}
- .padding(.horizontal, 12)
- .padding(.vertical, 4)
- .floatingGlass(in: Capsule())
}
.frame(maxWidth: .infinity)
.padding(.bottom, 6)
.offset(y: -52) // rise level with the back button / ⋯ menu
- .accessibilityElement(children: .combine)
+ // DMs: the whole header taps through to the partner's profile (#577).
+ // Groups handle their own tap on the pill above, so keep them separate
+ // a11y elements (.contain) rather than flattening the pill button away.
+ .accessibilityElement(children: dmPartner != nil ? .combine : .contain)
.contentShape(Rectangle())
.onTapGesture { if let uid = dmPartner { headerProfile = HeaderProfileTarget(id: uid) } }
.accessibilityAddTraits(dmPartner != nil ? .isButton : [])
.accessibilityHint(dmPartner != nil ? "Shows their profile" : "")
}
+ // The name + optional member-stats capsule; extracted so the group case can
+ // wrap it in a Button without duplicating the layout.
+ private var headerPill: some View {
+ VStack(spacing: 0) {
+ Text(thread.title)
+ .font(.headline)
+ .lineLimit(1)
+ .foregroundStyle(.primary)
+ if let stats = memberStats {
+ Text(stats)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 4)
+ .floatingGlass(in: Capsule())
+ }
+
+ // "Standup, 5 members, 2 agents, 3 online" — the pill's counts spoken
+ // cleanly (" · " reads awkwardly); the "View members" action rides the hint.
+ private var headerAccessibilityLabel: String {
+ guard let stats = memberStats else { return thread.title }
+ return thread.title + ", " + stats.replacingOccurrences(of: " · ", with: ", ")
+ }
+
// A group's shared-app surface: schema-described apps (To-do, Reminders)
// render the shared instance NATIVE, folded from the group's log; the
// rest keep the webview embed.
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 538364b..c61497d 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -586,10 +586,21 @@ struct NativeChatView: View {
}
}
if let stats = memberStats(t) {
- Text(stats)
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- .lineLimit(1)
+ // A fast path to the members list — the counts read as
+ // tappable, so wire them straight to the same MembersSheet
+ // the ⋯ menu's "View Members…" opens (which stays, as the
+ // labelled/fallback path). Pointing-hand cursor signals it.
+ Button { membersSheetThread = t } label: {
+ Text(stats)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() }
+ .accessibilityLabel(stats.replacingOccurrences(of: " · ", with: ", "))
+ .accessibilityHint("View members")
} else if t.kind == "dm" {
// Dot + Online/Away/Offline under the name (web parity).
PresenceStatusLine(t.presence)