eleven / ocho Public

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>
Paul Campbell 692bd387540263e2c49f26ee8645e9681d4a6d2a parent 9e785d9
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 {