ios: swipe-right to reply on mobile message rows (#11)
Mobile (touch) had no swipe affordance for replying — only the long-press context menu — while pointer devices get the hover button. Add a WhatsApp/Messages-style swipe-right-to-reply on the message row, mirroring the web (attachSwipeReply in chat.js): 56pt trigger, 80pt cap, a reply arrow that fades in on the leading edge, and a medium haptic on fire. Gated out of the Mac Catalyst build with #if !targetEnvironment(macCatalyst). Uses .simultaneousGesture plus a horizontal-dominance lock so it coexists with the feed's vertical scroll and the left-edge back-swipe rather than fighting them.
1 file changed,
+62
−5
ios/LChat/Views/MessageRow.swift+62 −5
diff --git a/ios/LChat/Views/MessageRow.swift b/ios/LChat/Views/MessageRow.swift| index 0b9cce4..63c9582 100644 |
| --- a/ios/LChat/Views/MessageRow.swift |
| +++ b/ios/LChat/Views/MessageRow.swift |
| @@ -12,17 +12,74 @@ struct MessageRow: View { |
| @State private var hovering = false |
| var body: some View { |
| - HStack(spacing: 6) { |
| - if msg.mine { Spacer(minLength: 28) } |
| - column.contextMenu { reactionMenu } |
| - if !msg.mine { Spacer(minLength: 28) } |
| - } |
| + swipeToReply( |
| + HStack(spacing: 6) { |
| + if msg.mine { Spacer(minLength: 28) } |
| + column.contextMenu { reactionMenu } |
| + if !msg.mine { Spacer(minLength: 28) } |
| + } |
| + ) |
| .padding(flashing ? 6 : 0) |
| .background(flashing ? Color.yellow.opacity(0.3) : .clear, in: RoundedRectangle(cornerRadius: 12)) |
| .animation(.easeOut(duration: 0.3), value: flashing) |
| .onHover { hovering = $0 } |
| } |
| + // MARK: Swipe to reply (touch) |
| + |
| + // Mobile (touch) gets the WhatsApp/Messages affordance: drag a message to the |
| + // right to reply. Pointer devices use the hover button above and Mac has no |
| + // touch, so this is gated out of the Mac Catalyst build entirely. Mirrors the |
| + // web (attachSwipeReply in chat.js): a 56pt trigger, 80pt cap, and a |
| + // horizontal-dominant lock so it never steals the feed's vertical scroll. |
| +#if targetEnvironment(macCatalyst) |
| + private func swipeToReply<Content: View>(_ content: Content) -> some View { content } |
| +#else |
| + @State private var dragOffset: CGFloat = 0 |
| + @State private var swipeEngaged: Bool? |
| + |
| + private static let swipeTrigger: CGFloat = 56 |
| + private static let swipeMax: CGFloat = 80 |
| + |
| + private func swipeToReply<Content: View>(_ content: Content) -> some View { |
| + // Offset first, then lay the reply arrow in the *background*: the arrow |
| + // stays pinned to the leading edge while the message slides off it. |
| + content |
| + .offset(x: dragOffset) |
| + .background(alignment: .leading) { |
| + Image(systemName: "arrowshape.turn.up.left.fill") |
| + .foregroundStyle(.tint) |
| + .padding(.leading, 12) |
| + .opacity(Double(min(1, dragOffset / Self.swipeTrigger))) |
| + } |
| + .simultaneousGesture( |
| + // Simultaneous (not exclusive) so the ScrollView still owns |
| + // vertical drags; we only act on ones that read as a rightward swipe. |
| + DragGesture(minimumDistance: 10) |
| + .onChanged { value in |
| + let dx = value.translation.width |
| + let dy = value.translation.height |
| + if swipeEngaged == nil { |
| + swipeEngaged = dx > 0 && abs(dx) > abs(dy) * 1.3 |
| + } |
| + guard swipeEngaged == true else { return } |
| + dragOffset = min(max(0, dx), Self.swipeMax) |
| + } |
| + .onEnded { _ in |
| + let fire = dragOffset >= Self.swipeTrigger |
| + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { |
| + dragOffset = 0 |
| + } |
| + swipeEngaged = nil |
| + if fire { |
| + UIImpactFeedbackGenerator(style: .medium).impactOccurred() |
| + onReply(msg) |
| + } |
| + } |
| + ) |
| + } |
| +#endif |
| + |
| private var column: some View { |
| VStack(alignment: msg.mine ? .trailing : .leading, spacing: 3) { |
| if !msg.mine { |