Skip to content

Commit d854020

Browse files
authored
Fix message reactions overlay gesture handling (#1424)
* Fix tapping images on messages, showing the gallery screen * Make it so that only message bubble sallows the dismiss of reactions overlay view * Fix opening gallery sometimes when long pressing the message view hovering attachments * Simplify long-press gallery fix using highPriorityGesture Replace the environment-based coordination with a single-modifier change: switching the message long-press from `simultaneousGesture` to `highPriorityGesture` cancels pending attachment tap recognizers when the long-press is recognized, preventing the gallery from opening on finger release. * Update CHANGELOG * Fix changelog * Fix gesture taking a bit longer is the message spacing * Use StreamAsyncImage in Media Content View * Fix snapshot tests * Update changelog
1 parent 5b1e9de commit d854020

16 files changed

Lines changed: 255 additions & 76 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88

99
### 🐞 Fixed
1010
- Fix show/hide message translation animation [#1426](https://github.com/GetStream/stream-chat-swiftui/pull/1426)
11+
- Fix tapping a media attachment in the reactions overlay opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
12+
- Fix empty space around the previewed message in the reactions overlay not dismissing the overlay [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
13+
- Fix long-pressing a message with attachments occasionally opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
14+
- Fix image attachments briefly showing a loading indicator when reopening a cached image [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
1115

1216
# [5.0.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/5.0.0)
1317
_April 16, 2026_

Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ struct MessageContainerView<Factory: ViewFactory>: View {
145145
messageViewModel.failureIndicatorShown ? SendFailureIndicator() : nil
146146
)
147147
.frame(maxWidth: contentWidth, alignment: messageViewModel.isRightAligned ? .trailing : .leading)
148+
.highPriorityGesture(
149+
TapGesture().onEnded { /* Swallow taps on the bubble so attachments don't open and the overlay doesn't dismiss. */ },
150+
including: shownAsPreview ? .all : .none
151+
)
148152
}
149153

150154
@ViewBuilder

Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,11 @@ public struct MessageItemView<Factory: ViewFactory>: View {
107107
}
108108
)
109109
.contentShape(Rectangle())
110-
.allowsHitTesting(!shownAsPreview || (messageViewModel.usesScrollView))
111-
.onTapGesture(count: 2) {
112-
if messageViewModel.isDoubleTapOverlayEnabled {
113-
handleGestureForMessage(showsMessageActions: true)
114-
}
115-
}
116-
.simultaneousGesture(
117-
LongPressGesture(minimumDuration: 0.3)
118-
.onEnded { _ in
119-
handleGestureForMessage(showsMessageActions: true)
120-
}
121-
)
110+
.modifier(MessageActionsGestureModifier(
111+
shownAsPreview: shownAsPreview,
112+
isDoubleTapEnabled: messageViewModel.isDoubleTapOverlayEnabled,
113+
onActionsTriggered: { handleGestureForMessage(showsMessageActions: true) }
114+
))
122115
.modifier(SwipeToReplyModifier(
123116
message: message,
124117
channel: channel,
@@ -196,6 +189,49 @@ public struct MessageItemView<Factory: ViewFactory>: View {
196189
}
197190
}
198191

192+
// MARK: - Message Actions Gesture
193+
194+
/// Attaches the double-tap and long-press gestures that open the message actions
195+
/// overlay on a `MessageItemView`.
196+
///
197+
/// When the message is rendered as a preview inside the reactions overlay
198+
/// (`shownAsPreview == true`), both gestures are intentionally skipped. Keeping
199+
/// them would force SwiftUI to wait for double-tap / long-press disambiguation
200+
/// before delivering a single tap to the overlay's dismiss handler, introducing
201+
/// a noticeable delay when the user taps the empty space around the message
202+
/// bubble to dismiss.
203+
struct MessageActionsGestureModifier: ViewModifier {
204+
/// Whether the message is rendered inside the reactions overlay.
205+
/// When `true`, no gestures are attached.
206+
let shownAsPreview: Bool
207+
/// Whether double-tap should trigger the message actions overlay.
208+
let isDoubleTapEnabled: Bool
209+
/// Invoked when either the double-tap or long-press is recognized.
210+
let onActionsTriggered: () -> Void
211+
212+
private let longPressMinimumDuration: Double = 0.3
213+
214+
@ViewBuilder
215+
func body(content: Content) -> some View {
216+
if shownAsPreview {
217+
content
218+
} else {
219+
content
220+
.onTapGesture(count: 2) {
221+
if isDoubleTapEnabled {
222+
onActionsTriggered()
223+
}
224+
}
225+
.highPriorityGesture(
226+
LongPressGesture(minimumDuration: longPressMinimumDuration)
227+
.onEnded { _ in
228+
onActionsTriggered()
229+
}
230+
)
231+
}
232+
}
233+
}
234+
199235
// MARK: - Swipe to Reply
200236

201237
/// Areas that should not trigger swipe-to-reply (e.g. waveform sliders).

Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift

Lines changed: 137 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import SwiftUI
77

88
/// A view that renders a single media attachment (image or video) thumbnail.
99
///
10-
/// Uses ``MediaAttachment/generateThumbnail(resize:preferredSize:completion:)``
11-
/// to load and display thumbnails.
12-
/// Shows a gradient placeholder while the thumbnail is loading.
13-
/// For video attachments, a play icon is overlaid on the thumbnail.
10+
/// Images are loaded through ``StreamAsyncImage``, which consults Nuke's
11+
/// in-memory cache synchronously and avoids a loading flash when the
12+
/// thumbnail was rendered earlier (e.g. when the message is shown again
13+
/// inside the reactions overlay). Video thumbnails go through
14+
/// ``MediaAttachment/generateThumbnail(resize:preferredSize:completion:)``
15+
/// because they can originate from ``AVAssetImageGenerator`` and are not
16+
/// pure image URLs.
17+
///
18+
/// Shows a gradient placeholder and a spinner while the thumbnail is
19+
/// loading. For video attachments, a play icon is overlaid on the
20+
/// successfully loaded thumbnail.
1421
public struct MessageMediaAttachmentContentView<Factory: ViewFactory>: View {
1522
@Injected(\.colors) private var colors
1623

@@ -32,8 +39,12 @@ public struct MessageMediaAttachmentContentView<Factory: ViewFactory>: View {
3239
/// Called when the user taps the retry badge on an upload-failed attachment.
3340
let onUploadRetry: (() -> Void)?
3441

35-
@State private var image: UIImage?
36-
@State private var error: Error?
42+
/// Video preview image, loaded once on appear.
43+
@State private var videoPreview: UIImage?
44+
/// Error from the most recent video preview load.
45+
@State private var videoPreviewError: Error?
46+
/// Bumped to force ``StreamAsyncImage`` to retry a failed image load.
47+
@State private var imageRetryToken = 0
3748

3849
public init(
3950
factory: Factory,
@@ -65,35 +76,7 @@ public struct MessageMediaAttachmentContentView<Factory: ViewFactory>: View {
6576

6677
public var body: some View {
6778
ZStack {
68-
if let image {
69-
Image(uiImage: image)
70-
.resizable()
71-
.scaledToFill()
72-
.frame(width: width, height: height)
73-
.clipped()
74-
} else if error != nil {
75-
placeholderBackground
76-
} else {
77-
placeholderGradient
78-
}
79-
80-
if image == nil && error == nil && !isUploading {
81-
LoadingSpinnerView(size: LoadingSpinnerSize.medium)
82-
.allowsHitTesting(false)
83-
}
84-
85-
if error != nil && source.uploadingState == nil {
86-
retryOverlay { loadThumbnail() }
87-
}
88-
89-
if let uploadingState = source.uploadingState {
90-
uploadingOverlay(for: uploadingState)
91-
}
92-
93-
if source.type == .video && width > 64 && source.uploadingState == nil && image != nil && error == nil {
94-
VideoPlayIndicatorView(size: VideoPlayIndicatorSize.medium)
95-
.allowsHitTesting(false)
96-
}
79+
thumbnail
9780
}
9881
.frame(width: width, height: height)
9982
.clipShape(
@@ -102,30 +85,139 @@ public struct MessageMediaAttachmentContentView<Factory: ViewFactory>: View {
10285
corners: corners ?? [.topLeft, .topRight, .bottomLeft, .bottomRight]
10386
)
10487
)
88+
.accessibilityIdentifier("MessageMediaAttachmentContentView")
89+
}
90+
91+
// MARK: - Thumbnail
92+
93+
@ViewBuilder
94+
private var thumbnail: some View {
95+
if source.type == .image {
96+
imageThumbnail
97+
} else if source.type == .video {
98+
videoThumbnail
99+
} else {
100+
placeholderBackground
101+
}
102+
}
103+
104+
@ViewBuilder
105+
private var imageThumbnail: some View {
106+
StreamAsyncImage(
107+
url: source.url,
108+
resize: ImageResize(CGSize(width: width, height: height))
109+
) { phase in
110+
ZStack {
111+
phaseBackground(for: phase)
112+
imageOverlays(for: phase)
113+
}
114+
}
115+
.id(imageRetryToken)
116+
}
117+
118+
@ViewBuilder
119+
private var videoThumbnail: some View {
120+
ZStack {
121+
videoBackground
122+
videoOverlays
123+
}
105124
.onAppear {
106-
guard image == nil else { return }
107-
loadThumbnail()
125+
guard videoPreview == nil else { return }
126+
loadVideoThumbnail()
127+
}
128+
}
129+
130+
// MARK: - Phase Rendering (Image)
131+
132+
@ViewBuilder
133+
private func phaseBackground(for phase: StreamAsyncImagePhase) -> some View {
134+
switch phase {
135+
case let .success(result):
136+
Image(uiImage: result.image)
137+
.resizable()
138+
.scaledToFill()
139+
.frame(width: width, height: height)
140+
.clipped()
141+
case .empty, .error:
142+
placeholderBackground
143+
case .loading:
144+
placeholderGradient
145+
}
146+
}
147+
148+
@ViewBuilder
149+
private func imageOverlays(for phase: StreamAsyncImagePhase) -> some View {
150+
if case .loading = phase, !isUploading {
151+
LoadingSpinnerView(size: LoadingSpinnerSize.medium)
152+
.allowsHitTesting(false)
153+
}
154+
155+
if case .error = phase, source.uploadingState == nil {
156+
retryOverlay { imageRetryToken &+= 1 }
157+
}
158+
159+
if let uploadingState = source.uploadingState {
160+
uploadingOverlay(for: uploadingState)
161+
}
162+
}
163+
164+
// MARK: - Video Rendering
165+
166+
@ViewBuilder
167+
private var videoBackground: some View {
168+
if let videoPreview {
169+
Image(uiImage: videoPreview)
170+
.resizable()
171+
.scaledToFill()
172+
.frame(width: width, height: height)
173+
.clipped()
174+
} else if videoPreviewError != nil {
175+
placeholderBackground
176+
} else {
177+
placeholderGradient
108178
}
109-
.accessibilityIdentifier("MessageMediaAttachmentContentView")
110179
}
111180

112-
// MARK: - Private
181+
@ViewBuilder
182+
private var videoOverlays: some View {
183+
if videoPreview == nil, videoPreviewError == nil, !isUploading {
184+
LoadingSpinnerView(size: LoadingSpinnerSize.medium)
185+
.allowsHitTesting(false)
186+
}
187+
188+
if videoPreviewError != nil, source.uploadingState == nil {
189+
retryOverlay { loadVideoThumbnail() }
190+
}
113191

114-
private func loadThumbnail() {
115-
error = nil
192+
if let uploadingState = source.uploadingState {
193+
uploadingOverlay(for: uploadingState)
194+
}
195+
196+
if width > 64, source.uploadingState == nil, videoPreview != nil, videoPreviewError == nil {
197+
VideoPlayIndicatorView(size: VideoPlayIndicatorSize.medium)
198+
.allowsHitTesting(false)
199+
}
200+
}
201+
202+
// MARK: - Video Loading
203+
204+
private func loadVideoThumbnail() {
205+
videoPreviewError = nil
116206
source.generateThumbnail(
117207
resize: true,
118208
preferredSize: CGSize(width: width, height: height)
119209
) { result in
120210
switch result {
121-
case .success(let loaded):
122-
self.image = loaded
123-
case .failure(let failure):
124-
self.error = failure
211+
case let .success(loaded):
212+
self.videoPreview = loaded
213+
case let .failure(failure):
214+
self.videoPreviewError = failure
125215
}
126216
}
127217
}
128218

219+
// MARK: - Shared Overlays
220+
129221
@ViewBuilder
130222
private func uploadingOverlay(for uploadingState: AttachmentUploadingState) -> some View {
131223
switch uploadingState.state {

Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ public struct ReactionsOverlayView<Factory: ViewFactory>: View {
8383

8484
GeometryReader { reader in
8585
let height = reader.frame(in: .local).height
86-
Color.clear.preference(key: HeightPreferenceKey.self, value: height)
86+
Color.clear
87+
.preference(key: HeightPreferenceKey.self, value: height)
88+
.contentShape(Rectangle())
89+
.onTapGesture {
90+
dismissReactionsOverlay { /* No additional handling. */ }
91+
}
8792

8893
VStack(alignment: isRightAligned ? .trailing : .leading, spacing: tokens.spacingXs) {
8994
reactionsPickerView(reader: reader)
@@ -107,6 +112,9 @@ public struct ReactionsOverlayView<Factory: ViewFactory>: View {
107112
.frame(maxHeight: messageDisplayInfo.frame.height)
108113
.scaleEffect(popIn || willPopOut ? 1 : 0.95)
109114
.animation(willPopOut ? .easeInOut : popInAnimation, value: popIn)
115+
.onTapGesture {
116+
dismissReactionsOverlay { /* No additional handling. */ }
117+
}
110118
messageActionsView(reader: reader)
111119
}
112120
.frame(width: overlayContentWidth, alignment: isRightAligned ? .trailing : .leading)

Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ enum NukeImageLoader {
2424
/// Returns `nil` when the image has never been loaded (no stored key)
2525
/// or when Nuke has evicted it from memory.
2626
static func cachedResult(url: URL, resize: ImageResize?) -> StreamAsyncImageResult? {
27+
if let override = testSyncLookup?(url, resize) {
28+
return override
29+
}
30+
2731
let key = inputKey(url: url, resize: resize) as NSString
2832
guard let storedKey = cachingKeyMap.object(forKey: key)?.value else { return nil }
2933

@@ -39,6 +43,17 @@ enum NukeImageLoader {
3943
)
4044
}
4145

46+
// MARK: - Test Hook
47+
48+
/// Test-only hook that lets tests resolve image URLs synchronously.
49+
///
50+
/// Snapshot tests run entirely synchronously and cannot drive the async
51+
/// ``MediaLoader`` pipeline before the view is captured. Installing a
52+
/// resolver here makes ``StreamAsyncImage``'s `initialPhase` return a
53+
/// `.success` immediately, so mock images render in the first layout
54+
/// pass. In production this is always `nil`.
55+
nonisolated(unsafe) static var testSyncLookup: ((URL, ImageResize?) -> StreamAsyncImageResult?)?
56+
4257
/// Stores a caching key for a given URL and resize combination.
4358
///
4459
/// Called by ``StreamAsyncImage`` after a successful load through

0 commit comments

Comments
 (0)