diff --git a/CHANGELOG.md b/CHANGELOG.md index 6135f62e2..3287dc09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add the `makeAttachmentTextView` method to ViewFactory [#1013](https://github.com/GetStream/stream-chat-swiftui/pull/1013) - +- Allow dismissing commands overlay when tapping the message list [#1024](https://github.com/GetStream/stream-chat-swiftui/pull/1024) +- Allows dismissing the keyboard attachments picker when tapping the message list [#1024](https://github.com/GetStream/stream-chat-swiftui/pull/1024) ### 🐞 Fixed - Fix composer not being locked after the channel was frozen [#1015](https://github.com/GetStream/stream-chat-swiftui/pull/1015) -### 🔄 Changed - # [4.90.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.90.0) _October 08, 2025_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift index 5a6e50b34..47ff42773 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift @@ -73,6 +73,9 @@ public struct ChatChannelView: View, KeyboardReadable { }, onJumpToMessage: viewModel.jumpToMessage(messageId:) ) + .dismissKeyboardOnTap(enabled: true) { + hideComposerCommandsAndAttachmentsPicker() + } .overlay( viewModel.currentDateString != nil ? factory.makeDateIndicatorView(dateString: viewModel.currentDateString!) @@ -81,7 +84,9 @@ public struct ChatChannelView: View, KeyboardReadable { } else { ZStack { factory.makeEmptyMessagesView(for: channel, colors: colors) - .dismissKeyboardOnTap(enabled: keyboardShown) + .dismissKeyboardOnTap(enabled: keyboardShown) { + hideComposerCommandsAndAttachmentsPicker() + } if viewModel.shouldShowTypingIndicator { factory.makeTypingIndicatorBottomView( channel: channel, @@ -213,4 +218,13 @@ public struct ChatChannelView: View, KeyboardReadable { let bottomPadding = topVC()?.view.safeAreaInsets.bottom ?? 0 return bottomPadding } + + private func hideComposerCommandsAndAttachmentsPicker() { + NotificationCenter.default.post( + name: .attachmentPickerHiddenNotification, object: nil + ) + NotificationCenter.default.post( + name: .commandsOverlayHiddenNotification, object: nil + ) + } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift index 1d5a6addb..c4712a21d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift @@ -9,6 +9,7 @@ import SwiftUI public struct MessageComposerView: View, KeyboardReadable { @Injected(\.colors) private var colors @Injected(\.fonts) private var fonts + @Injected(\.utils) private var utils // Initial popup size, before the keyboard is shown. @State private var popupSize: CGFloat = 350 @@ -228,6 +229,18 @@ public struct MessageComposerView: View, KeyboardReadable viewModel.updateDraftMessage(quotedMessage: quotedMessage) } }) + .onReceive(NotificationCenter.default.publisher(for: .commandsOverlayHiddenNotification)) { _ in + guard utils.messageListConfig.hidesCommandsOverlayOnMessageListTap else { + return + } + viewModel.composerCommand = nil + } + .onReceive(NotificationCenter.default.publisher(for: .attachmentPickerHiddenNotification)) { _ in + guard utils.messageListConfig.hidesAttachmentsPickersOnMessageListTap else { + return + } + viewModel.pickerTypeState = .expanded(.none) + } .accessibilityElement(children: .contain) } } @@ -444,3 +457,13 @@ public struct ComposerInputView: View, KeyboardReadable { isInCooldown || isChannelFrozen } } + +// MARK: - Notification Names + +extension Notification.Name { + /// Notification sent when the attachments picker should be hidden. + static let attachmentPickerHiddenNotification = Notification.Name("attachmentPickerHiddenNotification") + + /// Notification sent when the commands overlay should be hidden. + static let commandsOverlayHiddenNotification = Notification.Name("commandsOverlayHiddenNotification") +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift index e0dba4bcf..b03d85500 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift @@ -36,7 +36,9 @@ public struct MessageListConfig { bouncedMessagesAlertActionsEnabled: Bool = true, skipEditedMessageLabel: @escaping (ChatMessage) -> Bool = { _ in false }, draftMessagesEnabled: Bool = false, - downloadFileAttachmentsEnabled: Bool = false + downloadFileAttachmentsEnabled: Bool = false, + hidesCommandsOverlayOnMessageListTap: Bool = true, + hidesAttachmentsPickersOnMessageListTap: Bool = true ) { self.messageListType = messageListType self.typingIndicatorPlacement = typingIndicatorPlacement @@ -66,6 +68,8 @@ public struct MessageListConfig { self.skipEditedMessageLabel = skipEditedMessageLabel self.draftMessagesEnabled = draftMessagesEnabled self.downloadFileAttachmentsEnabled = downloadFileAttachmentsEnabled + self.hidesCommandsOverlayOnMessageListTap = hidesCommandsOverlayOnMessageListTap + self.hidesAttachmentsPickersOnMessageListTap = hidesAttachmentsPickersOnMessageListTap } public let messageListType: MessageListType @@ -93,6 +97,16 @@ public struct MessageListConfig { public let markdownSupportEnabled: Bool public let userBlockingEnabled: Bool + /// A boolean to enable hiding the commands overlay when tapping the message list. + /// + /// It is enabled by default. + public let hidesCommandsOverlayOnMessageListTap: Bool + + /// A boolean to enable hiding the attachments keyboard picker when tapping the message list. + /// + /// It is enabled by default. + public let hidesAttachmentsPickersOnMessageListTap: Bool + /// A boolean to enable the alert actions for bounced messages. /// /// By default it is true and the bounced actions are displayed as an alert instead of a context menu. diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 0696eeb7e..2bfe8a96b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -313,7 +313,6 @@ public struct MessageListView: View, KeyboardReadable { ) : nil ) .modifier(factory.makeMessageListContainerModifier()) - .dismissKeyboardOnTap(enabled: keyboardShown) .onDisappear { messageRenderingUtil.update(previousTopMessage: nil) } diff --git a/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift b/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift index ec483dbff..5a21e9dc8 100644 --- a/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift +++ b/Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift @@ -63,7 +63,7 @@ extension View { /// - enabled: If true, tapping on the view dismisses the view, otherwise keyboard stays visible. /// - onTapped: A closure which is triggered when keyboard is dismissed after tapping the view. func dismissKeyboardOnTap(enabled: Bool, onKeyboardDismissed: (() -> Void)? = nil) -> some View { - modifier(HideKeyboardOnTapGesture(shouldAdd: enabled)) + modifier(HideKeyboardOnTapGesture(shouldAdd: enabled, onTapped: onKeyboardDismissed)) } } diff --git a/StreamChatSwiftUITests/Infrastructure/TestTools/ViewFrameUtils.swift b/StreamChatSwiftUITests/Infrastructure/TestTools/ViewFrameUtils.swift index c9186bbe6..77a7130d6 100644 --- a/StreamChatSwiftUITests/Infrastructure/TestTools/ViewFrameUtils.swift +++ b/StreamChatSwiftUITests/Infrastructure/TestTools/ViewFrameUtils.swift @@ -15,4 +15,15 @@ extension View { func applySize(_ size: CGSize) -> some View { frame(width: size.width, height: size.height) } + + @discardableResult + /// Add SwiftUI View to a fake hierarchy so that it can receive UI events. + func addToViewHierarchy() -> some View { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 400)) + let hostingController = UIHostingController(rootView: self) + window.rootViewController = hostingController + window.makeKeyAndVisible() + hostingController.view.layoutIfNeeded() + return self + } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift index 7fe459f51..34663602e 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift @@ -894,6 +894,166 @@ class MessageComposerView_Tests: StreamChatTestCase { onMessageSent: {} ) } + + // MARK: - Notification Tests + + func test_commandsOverlayHiddenNotification_hidesCommandsOverlay() { + // Given + let utils = Utils( + messageListConfig: MessageListConfig( + hidesCommandsOverlayOnMessageListTap: true + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + let factory = DefaultViewFactory.shared + let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient) + let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + + // Set up a command to be shown + viewModel.composerCommand = ComposerCommand( + id: "testCommand", + typingSuggestion: TypingSuggestion.empty, + displayInfo: nil + ) + + let view = MessageComposerView( + viewFactory: factory, + viewModel: viewModel, + channelController: channelController, + messageController: nil, + quotedMessage: .constant(nil), + editedMessage: .constant(nil), + onMessageSent: {} + ) + view.addToViewHierarchy() + + // When + NotificationCenter.default.post( + name: .commandsOverlayHiddenNotification, + object: nil + ) + + // Then + XCTAssertNil(viewModel.composerCommand) + } + + func test_commandsOverlayHiddenNotification_respectsConfigSetting() { + // Given + let utils = Utils( + messageListConfig: MessageListConfig( + hidesCommandsOverlayOnMessageListTap: false + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + let factory = DefaultViewFactory.shared + let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient) + let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + + // Set up a command to be shown + let testCommand = ComposerCommand( + id: "testCommand", + typingSuggestion: TypingSuggestion.empty, + displayInfo: nil + ) + viewModel.composerCommand = testCommand + + let view = MessageComposerView( + viewFactory: factory, + viewModel: viewModel, + channelController: channelController, + messageController: nil, + quotedMessage: .constant(nil), + editedMessage: .constant(nil), + onMessageSent: {} + ) + view.addToViewHierarchy() + + // When + NotificationCenter.default.post( + name: .commandsOverlayHiddenNotification, + object: nil + ) + + // Then + XCTAssertNotNil(viewModel.composerCommand) + XCTAssertEqual(viewModel.composerCommand?.id, testCommand.id) + } + + func test_attachmentPickerHiddenNotification_hidesAttachmentPicker() { + // Given + let utils = Utils( + messageListConfig: MessageListConfig( + hidesAttachmentsPickersOnMessageListTap: true + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + let factory = DefaultViewFactory.shared + let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient) + let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + + // Set up attachment picker to be shown + viewModel.pickerTypeState = .expanded(.media) + + let view = MessageComposerView( + viewFactory: factory, + viewModel: viewModel, + channelController: channelController, + messageController: nil, + quotedMessage: .constant(nil), + editedMessage: .constant(nil), + onMessageSent: {} + ) + view.addToViewHierarchy() + + // When + NotificationCenter.default.post( + name: .attachmentPickerHiddenNotification, + object: nil + ) + + // Then + XCTAssertEqual(viewModel.pickerTypeState, .expanded(.none)) + } + + func test_attachmentPickerHiddenNotification_respectsConfigSetting() { + // Given + let utils = Utils( + messageListConfig: MessageListConfig( + hidesAttachmentsPickersOnMessageListTap: false + ) + ) + streamChat = StreamChat(chatClient: chatClient, utils: utils) + + let factory = DefaultViewFactory.shared + let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient) + let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + + // Set up attachment picker to be shown + viewModel.pickerTypeState = .expanded(.media) + + let view = MessageComposerView( + viewFactory: factory, + viewModel: viewModel, + channelController: channelController, + messageController: nil, + quotedMessage: .constant(nil), + editedMessage: .constant(nil), + onMessageSent: {} + ) + view.addToViewHierarchy() + + // When + NotificationCenter.default.post( + name: .attachmentPickerHiddenNotification, + object: nil + ) + + // Then + XCTAssertEqual(viewModel.pickerTypeState, .expanded(.media)) + } } class SynchronousAttachmentsConverter: MessageAttachmentsConverter {