Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### 🐞 Fixed
- Fix show/hide message translation animation [#1426](https://github.com/GetStream/stream-chat-swiftui/pull/1426)
- Fix tapping a media attachment in the reactions overlay opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
- 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)
- Fix long-pressing a message with attachments occasionally opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
- Fix image attachments briefly showing a loading indicator when reopening a cached image [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)

# [5.0.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/5.0.0)
_April 16, 2026_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ struct MessageContainerView<Factory: ViewFactory>: View {
messageViewModel.failureIndicatorShown ? SendFailureIndicator() : nil
)
.frame(maxWidth: contentWidth, alignment: messageViewModel.isRightAligned ? .trailing : .leading)
.highPriorityGesture(
TapGesture().onEnded { /* Swallow taps on the bubble so attachments don't open and the overlay doesn't dismiss. */ },
including: shownAsPreview ? .all : .none
)
}

@ViewBuilder
Expand Down
60 changes: 48 additions & 12 deletions Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,11 @@ public struct MessageItemView<Factory: ViewFactory>: View {
}
)
.contentShape(Rectangle())
.allowsHitTesting(!shownAsPreview || (messageViewModel.usesScrollView))
.onTapGesture(count: 2) {
if messageViewModel.isDoubleTapOverlayEnabled {
handleGestureForMessage(showsMessageActions: true)
}
}
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.3)
.onEnded { _ in
handleGestureForMessage(showsMessageActions: true)
}
)
.modifier(MessageActionsGestureModifier(
shownAsPreview: shownAsPreview,
isDoubleTapEnabled: messageViewModel.isDoubleTapOverlayEnabled,
onActionsTriggered: { handleGestureForMessage(showsMessageActions: true) }
))
.modifier(SwipeToReplyModifier(
message: message,
channel: channel,
Expand Down Expand Up @@ -196,6 +189,49 @@ public struct MessageItemView<Factory: ViewFactory>: View {
}
}

// MARK: - Message Actions Gesture

/// Attaches the double-tap and long-press gestures that open the message actions
/// overlay on a `MessageItemView`.
///
/// When the message is rendered as a preview inside the reactions overlay
/// (`shownAsPreview == true`), both gestures are intentionally skipped. Keeping
/// them would force SwiftUI to wait for double-tap / long-press disambiguation
/// before delivering a single tap to the overlay's dismiss handler, introducing
/// a noticeable delay when the user taps the empty space around the message
/// bubble to dismiss.
struct MessageActionsGestureModifier: ViewModifier {
/// Whether the message is rendered inside the reactions overlay.
/// When `true`, no gestures are attached.
let shownAsPreview: Bool
/// Whether double-tap should trigger the message actions overlay.
let isDoubleTapEnabled: Bool
/// Invoked when either the double-tap or long-press is recognized.
let onActionsTriggered: () -> Void

private let longPressMinimumDuration: Double = 0.3

@ViewBuilder
func body(content: Content) -> some View {
if shownAsPreview {
content
} else {
content
.onTapGesture(count: 2) {
if isDoubleTapEnabled {
onActionsTriggered()
}
}
.highPriorityGesture(
LongPressGesture(minimumDuration: longPressMinimumDuration)
.onEnded { _ in
onActionsTriggered()
}
)
}
}
}

// MARK: - Swipe to Reply

/// Areas that should not trigger swipe-to-reply (e.g. waveform sliders).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import SwiftUI

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

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

@State private var image: UIImage?
@State private var error: Error?
/// Video preview image, loaded once on appear.
@State private var videoPreview: UIImage?
/// Error from the most recent video preview load.
@State private var videoPreviewError: Error?
/// Bumped to force ``StreamAsyncImage`` to retry a failed image load.
@State private var imageRetryToken = 0

public init(
factory: Factory,
Expand Down Expand Up @@ -65,35 +76,7 @@ public struct MessageMediaAttachmentContentView<Factory: ViewFactory>: View {

public var body: some View {
ZStack {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: width, height: height)
.clipped()
} else if error != nil {
placeholderBackground
} else {
placeholderGradient
}

if image == nil && error == nil && !isUploading {
LoadingSpinnerView(size: LoadingSpinnerSize.medium)
.allowsHitTesting(false)
}

if error != nil && source.uploadingState == nil {
retryOverlay { loadThumbnail() }
}

if let uploadingState = source.uploadingState {
uploadingOverlay(for: uploadingState)
}

if source.type == .video && width > 64 && source.uploadingState == nil && image != nil && error == nil {
VideoPlayIndicatorView(size: VideoPlayIndicatorSize.medium)
.allowsHitTesting(false)
}
thumbnail
}
.frame(width: width, height: height)
.clipShape(
Expand All @@ -102,30 +85,139 @@ public struct MessageMediaAttachmentContentView<Factory: ViewFactory>: View {
corners: corners ?? [.topLeft, .topRight, .bottomLeft, .bottomRight]
)
)
.accessibilityIdentifier("MessageMediaAttachmentContentView")
}

// MARK: - Thumbnail

@ViewBuilder
private var thumbnail: some View {
if source.type == .image {
imageThumbnail
} else if source.type == .video {
videoThumbnail
} else {
placeholderBackground
}
}

@ViewBuilder
private var imageThumbnail: some View {
StreamAsyncImage(
url: source.url,
resize: ImageResize(CGSize(width: width, height: height))
) { phase in
ZStack {
phaseBackground(for: phase)
imageOverlays(for: phase)
}
}
.id(imageRetryToken)
}

@ViewBuilder
private var videoThumbnail: some View {
ZStack {
videoBackground
videoOverlays
}
.onAppear {
guard image == nil else { return }
loadThumbnail()
guard videoPreview == nil else { return }
loadVideoThumbnail()
}
}

// MARK: - Phase Rendering (Image)

@ViewBuilder
private func phaseBackground(for phase: StreamAsyncImagePhase) -> some View {
switch phase {
case let .success(result):
Image(uiImage: result.image)
.resizable()
.scaledToFill()
.frame(width: width, height: height)
.clipped()
case .empty, .error:
placeholderBackground
case .loading:
placeholderGradient
}
}

@ViewBuilder
private func imageOverlays(for phase: StreamAsyncImagePhase) -> some View {
if case .loading = phase, !isUploading {
LoadingSpinnerView(size: LoadingSpinnerSize.medium)
.allowsHitTesting(false)
}

if case .error = phase, source.uploadingState == nil {
retryOverlay { imageRetryToken &+= 1 }
}

if let uploadingState = source.uploadingState {
uploadingOverlay(for: uploadingState)
}
}

// MARK: - Video Rendering

@ViewBuilder
private var videoBackground: some View {
if let videoPreview {
Image(uiImage: videoPreview)
.resizable()
.scaledToFill()
.frame(width: width, height: height)
.clipped()
} else if videoPreviewError != nil {
placeholderBackground
} else {
placeholderGradient
}
.accessibilityIdentifier("MessageMediaAttachmentContentView")
}

// MARK: - Private
@ViewBuilder
private var videoOverlays: some View {
if videoPreview == nil, videoPreviewError == nil, !isUploading {
LoadingSpinnerView(size: LoadingSpinnerSize.medium)
.allowsHitTesting(false)
}

if videoPreviewError != nil, source.uploadingState == nil {
retryOverlay { loadVideoThumbnail() }
}

private func loadThumbnail() {
error = nil
if let uploadingState = source.uploadingState {
uploadingOverlay(for: uploadingState)
}

if width > 64, source.uploadingState == nil, videoPreview != nil, videoPreviewError == nil {
VideoPlayIndicatorView(size: VideoPlayIndicatorSize.medium)
.allowsHitTesting(false)
}
}

// MARK: - Video Loading

private func loadVideoThumbnail() {
videoPreviewError = nil
source.generateThumbnail(
resize: true,
preferredSize: CGSize(width: width, height: height)
) { result in
switch result {
case .success(let loaded):
self.image = loaded
case .failure(let failure):
self.error = failure
case let .success(loaded):
self.videoPreview = loaded
case let .failure(failure):
self.videoPreviewError = failure
}
}
}

// MARK: - Shared Overlays

@ViewBuilder
private func uploadingOverlay(for uploadingState: AttachmentUploadingState) -> some View {
switch uploadingState.state {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ public struct ReactionsOverlayView<Factory: ViewFactory>: View {

GeometryReader { reader in
let height = reader.frame(in: .local).height
Color.clear.preference(key: HeightPreferenceKey.self, value: height)
Color.clear
.preference(key: HeightPreferenceKey.self, value: height)
.contentShape(Rectangle())
.onTapGesture {
dismissReactionsOverlay { /* No additional handling. */ }
}

VStack(alignment: isRightAligned ? .trailing : .leading, spacing: tokens.spacingXs) {
reactionsPickerView(reader: reader)
Expand All @@ -107,6 +112,9 @@ public struct ReactionsOverlayView<Factory: ViewFactory>: View {
.frame(maxHeight: messageDisplayInfo.frame.height)
.scaleEffect(popIn || willPopOut ? 1 : 0.95)
.animation(willPopOut ? .easeInOut : popInAnimation, value: popIn)
.onTapGesture {
dismissReactionsOverlay { /* No additional handling. */ }
}
messageActionsView(reader: reader)
}
.frame(width: overlayContentWidth, alignment: isRightAligned ? .trailing : .leading)
Expand Down
15 changes: 15 additions & 0 deletions Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ enum NukeImageLoader {
/// Returns `nil` when the image has never been loaded (no stored key)
/// or when Nuke has evicted it from memory.
static func cachedResult(url: URL, resize: ImageResize?) -> StreamAsyncImageResult? {
if let override = testSyncLookup?(url, resize) {
return override
}

let key = inputKey(url: url, resize: resize) as NSString
guard let storedKey = cachingKeyMap.object(forKey: key)?.value else { return nil }

Expand All @@ -39,6 +43,17 @@ enum NukeImageLoader {
)
}

// MARK: - Test Hook

/// Test-only hook that lets tests resolve image URLs synchronously.
///
/// Snapshot tests run entirely synchronously and cannot drive the async
/// ``MediaLoader`` pipeline before the view is captured. Installing a
/// resolver here makes ``StreamAsyncImage``'s `initialPhase` return a
/// `.success` immediately, so mock images render in the first layout
/// pass. In production this is always `nil`.
nonisolated(unsafe) static var testSyncLookup: ((URL, ImageResize?) -> StreamAsyncImageResult?)?

/// Stores a caching key for a given URL and resize combination.
///
/// Called by ``StreamAsyncImage`` after a successful load through
Expand Down
Loading
Loading