diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d89b3fbb..b787aa43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_ diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift index cc9369bb3..f54928502 100644 --- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift @@ -145,6 +145,10 @@ struct MessageContainerView: 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 diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift index 469930fe7..d32b74797 100644 --- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift +++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift @@ -107,18 +107,11 @@ public struct MessageItemView: 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, @@ -196,6 +189,49 @@ public struct MessageItemView: 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). diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift index 87b82736a..1e5ad1953 100644 --- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift +++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift @@ -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: View { @Injected(\.colors) private var colors @@ -32,8 +39,12 @@ public struct MessageMediaAttachmentContentView: 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, @@ -65,35 +76,7 @@ public struct MessageMediaAttachmentContentView: 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( @@ -102,30 +85,139 @@ public struct MessageMediaAttachmentContentView: 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 { diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift index 56165921c..41158a5ef 100644 --- a/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift +++ b/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift @@ -83,7 +83,12 @@ public struct ReactionsOverlayView: 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) @@ -107,6 +112,9 @@ public struct ReactionsOverlayView: 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) diff --git a/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift b/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift index 3465ce41d..9dd7df4b2 100644 --- a/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift +++ b/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift @@ -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 } @@ -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 diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift index 6c6c24a6a..0ffab413b 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift @@ -30,12 +30,12 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable { options: ImageLoadOptions, completion: @escaping @MainActor (Result) -> Void ) { - loadImageCalled = true - loadImageCallCount += 1 - loadedURLs.append(url) - loadImageOptions.append(options) - let image = imageForURL(url) StreamConcurrency.onMain { + loadImageCalled = true + loadImageCallCount += 1 + loadedURLs.append(url) + loadImageOptions.append(options) + let image = imageForURL(url) completion(.success(MediaLoaderImage(image: image))) } } @@ -45,8 +45,8 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable { options: VideoLoadOptions, completion: @escaping @MainActor (Result) -> Void ) { - loadVideoAssetOptions.append(options) StreamConcurrency.onMain { + loadVideoAssetOptions.append(options) completion(.success(MediaLoaderVideoAsset(asset: AVURLAsset(url: url)))) } } @@ -56,9 +56,9 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable { options: VideoLoadOptions, completion: @escaping @MainActor (Result) -> Void ) { - loadVideoPreviewWithAttachmentCalled = true - loadVideoPreviewOptions.append(options) StreamConcurrency.onMain { + loadVideoPreviewWithAttachmentCalled = true + loadVideoPreviewOptions.append(options) completion(.success(MediaLoaderVideoPreview(image: Self.defaultLoadedImage))) } } @@ -68,9 +68,9 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable { options: VideoLoadOptions, completion: @escaping @MainActor (Result) -> Void ) { - loadVideoPreviewAtURLCalled = true - loadVideoPreviewOptions.append(options) StreamConcurrency.onMain { + loadVideoPreviewAtURLCalled = true + loadVideoPreviewOptions.append(options) completion(.success(MediaLoaderVideoPreview(image: Self.defaultLoadedImage))) } } @@ -85,7 +85,10 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable { } } - private func imageForURL(_ url: URL?) -> UIImage { + /// Synchronous URL-to-image mapping used both by the async `loadImage` + /// path and by the snapshot-test sync hook installed in + /// `StreamChatTestCase`. + func imageForURL(_ url: URL?) -> UIImage { guard let url else { return Self.defaultLoadedImage } let urlString = url.absoluteString if urlString.contains("chewbacca") { diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png index 465894818..19097b11a 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png index acbc1f2ad..e2cb78b14 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png index 024c69743..299169121 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png index 9fd216e59..64a228ebb 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png index 7e7ba2366..ab204ec89 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png index c230578fb..327befbef 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png index 145875be9..bd8c47235 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png differ diff --git a/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift b/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift index 86dd164f5..8c2f1d41f 100644 --- a/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift +++ b/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift @@ -9,6 +9,14 @@ import XCTest @MainActor final class NukeImageLoader_Tests: StreamChatTestCase { + override func setUp() { + super.setUp() + // These tests exercise the real Nuke cache path, so the default + // snapshot-test sync lookup installed by `StreamChatTestCase` must + // be disabled. + NukeImageLoader.testSyncLookup = nil + } + // MARK: - cachedResult func test_cachedResult_returnsNil_whenNoKeyStored() { diff --git a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift index 0cdceec2e..c998ce23a 100644 --- a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift +++ b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift @@ -29,13 +29,28 @@ import XCTest override open func setUp() { super.setUp() Appearance.bundle = Bundle(for: type(of: self)) + let mediaLoader = MediaLoader_Mock() streamChat = StreamChat( chatClient: chatClient, utils: Utils( - mediaLoader: MediaLoader_Mock(), + mediaLoader: mediaLoader, composerConfig: .init(isVoiceRecordingEnabled: true) ) ) + // Let StreamAsyncImage resolve mock image URLs synchronously so that + // snapshot tests capture the loaded image rather than the loading + // placeholder. `StreamAsyncImage` uses `.task` to load images, which + // completes after the snapshot is taken. + NukeImageLoader.testSyncLookup = { url, _ in + StreamAsyncImageResult(image: mediaLoader.imageForURL(url), animatedImageData: nil) + } + } + + override open func tearDown() { + NukeImageLoader.testSyncLookup = nil + testWindow?.isHidden = true + testWindow = nil + super.tearDown() } func adjustAppearance(_ block: (inout Appearance) -> Void) { @@ -70,12 +85,6 @@ import XCTest hostingController.view.layoutIfNeeded() return hostingController } - - override open func tearDown() { - testWindow?.isHidden = true - testWindow = nil - super.tearDown() - } } // Forces the solid primary button style regardless of platform,