mac,ios: reaction chips perch on the bubble like iMessage Tapbacks (#331)
The native reaction pills sat below the bubble as flat, low-contrast blobs — an earlier overlap attempt "read as a collision", so they were parked in a row underneath. This gives them the actual iMessage Tapback seating: the cluster perches on the bubble's tail corner, overlapping its bottom edge. The missing ingredient the old attempt lacked is the cutout halo — each pill carries a 2pt ring of the feed backdrop (window/system background, since no container sets an explicit one), so where it overlaps the bubble a clean gap opens instead of two fills butting together. On top: an opaque fill (theirs = the bubble grey, mine = 16% accent over the panel, matching the web's color-mix), a hairline edge, and a whisper of shadow for lift. Your own reaction stays unmistakable via the accent fill + ring. Shared MessageBubble leaf, so it lands identically on Mac and iOS.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+67
−31
ios/LChatCore/Sources/LChatCore/MessageBubble.swift+67 −31
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index fa27c27..17a9ad1 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -296,40 +296,76 @@ public struct MessageBubble: View { |
| private var reactionChips: some View { |
| HStack(spacing: 4) { |
| ForEach(message.reactions, id: \.emoji) { r in |
| - HStack(spacing: 4) { |
| - Text(r.emoji).font(.system(size: 15)) |
| - if r.count > 1 { |
| - Text("\(r.count)") |
| - .font(.system(size: 12, weight: .semibold)) |
| - .foregroundStyle(r.isMine ? theme.accent.resolved(scheme) : .secondary) |
| - } |
| - } |
| - .padding(.horizontal, 9) |
| - .padding(.vertical, 3) |
| - // OPAQUE fills, layered — style.css .reaction-chip exactly: |
| - // every chip rides on --bubble-them, mine adds a 16% accent |
| - // wash over it plus the accent ring. The old translucent fill |
| - // picked up whatever sat underneath and went muddy. |
| - .background { |
| - ZStack { |
| - Capsule().fill(theme.bubbleThem.resolved(scheme)) |
| - if r.isMine { |
| - Capsule().fill(theme.accent.resolved(scheme).opacity(0.16)) |
| - } |
| - } |
| - } |
| - .overlay { |
| - if r.isMine { |
| - Capsule().strokeBorder(theme.accent.resolved(scheme), lineWidth: 1) |
| - } |
| - } |
| + reactionChip(r) |
| } |
| } |
| - // The web posture (.reactions margin 3px 2px 0): chips sit BELOW the |
| - // bubble with a small gap, hugging its tail edge — the old negative |
| - // overlap read as a collision, worst against accent bubbles. |
| + // iMessage seating: the cluster PERCHES on the bubble's tail corner, |
| + // overlapping its bottom edge rather than hanging below it. The negative |
| + // top padding lifts the row into the bubble (~8pt, landing inside its |
| + // 9pt bottom padding so it kisses the corner without covering text) and |
| + // reclaims the space so following tags tuck up under it; the VStack's |
| + // trailing/leading alignment already hugs the tail. Each chip's backdrop |
| + // halo (below) is what keeps the overlap from reading as a collision — |
| + // the ingredient the earlier attempt lacked. |
| + .padding(.top, -11) // -(≈8 overlap + the VStack's 3pt row spacing) |
| + .offset(x: message.mine ? 2 : -2) // lean the cluster off the corner |
| .padding(.horizontal, 2) |
| - .padding(.top, 3) |
| + } |
| + |
| + // One reaction pill, finished like an iMessage Tapback: an opaque fill |
| + // (style.css .reaction-chip — them-bubble, or mine = accent 16% over the |
| + // panel, matching the web's color-mix), a hairline ring for a crisp edge, |
| + // a whisper of shadow for lift, and — the seating trick — a ring of the |
| + // feed backdrop around it so where it overlaps the bubble a clean gap opens |
| + // instead of two fills butting together. |
| + @ViewBuilder private func reactionChip(_ r: Reaction) -> some View { |
| + let accent = theme.accent.resolved(scheme) |
| + // Ink for the count — the web colours the whole chip in --ink; mine |
| + // leans on the accent fill+ring for "this one's yours", not the text. |
| + let countInk = theme.bubbleThemInk.resolved(scheme).opacity(0.72) |
| + HStack(spacing: 3.5) { |
| + Text(r.emoji).font(.system(size: 14)) |
| + if r.count > 1 { |
| + Text("\(r.count)") |
| + .font(.system(size: 12, weight: .semibold)) |
| + .foregroundStyle(r.isMine ? accent : countInk) |
| + } |
| + } |
| + .padding(.horizontal, 8) |
| + .padding(.vertical, 3) |
| + .background { |
| + // Shape-only fill + hairline (kept off the glyphs). |
| + ZStack { |
| + // mine rides on --panel (web mixes accent INTO panel); theirs on |
| + // the them-bubble. Layering 16% accent over an opaque panel base |
| + // reproduces color-mix(accent 16%, panel) without a blend op. |
| + Capsule().fill(r.isMine ? theme.panel.resolved(scheme) |
| + : theme.bubbleThem.resolved(scheme)) |
| + if r.isMine { Capsule().fill(accent.opacity(0.16)) } |
| + } |
| + .overlay { |
| + Capsule().strokeBorder( |
| + r.isMine ? accent : theme.bubbleThemInk.resolved(scheme).opacity(0.12), |
| + lineWidth: r.isMine ? 1 : 0.75) |
| + } |
| + } |
| + // The cutout halo: a 2pt band of the feed backdrop, so the pill reads as |
| + // floating clear of the bubble it overlaps. Below the bubble (over the |
| + // same backdrop) it's invisible; a whisper of shadow rides it for lift. |
| + .padding(2) |
| + .background(Capsule().fill(feedBackground)) |
| + .shadow(color: .black.opacity(scheme == .dark ? 0.30 : 0.08), radius: 1.5, y: 0.5) |
| + } |
| + |
| + // The feed's backdrop colour — no container sets an explicit one, so the |
| + // effective surface is the window/system background. The reaction halo |
| + // matches it so the cutout disappears where a chip hangs below the bubble. |
| + private var feedBackground: Color { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + Color(nsColor: .windowBackgroundColor) |
| + #else |
| + Color(uiColor: .systemBackground) |
| + #endif |
| } |
| @ViewBuilder private var bubble: some View { |