Skip to content
5 changes: 2 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_

Expand Down
16 changes: 15 additions & 1 deletion Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
},
onJumpToMessage: viewModel.jumpToMessage(messageId:)
)
.dismissKeyboardOnTap(enabled: true) {
hideComposerCommandsAndAttachmentsPicker()
}
.overlay(
viewModel.currentDateString != nil ?
factory.makeDateIndicatorView(dateString: viewModel.currentDateString!)
Expand All @@ -81,7 +84,9 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
} else {
ZStack {
factory.makeEmptyMessagesView(for: channel, colors: colors)
.dismissKeyboardOnTap(enabled: keyboardShown)
.dismissKeyboardOnTap(enabled: keyboardShown) {
hideComposerCommandsAndAttachmentsPicker()
}
if viewModel.shouldShowTypingIndicator {
factory.makeTypingIndicatorBottomView(
channel: channel,
Expand Down Expand Up @@ -213,4 +218,13 @@ public struct ChatChannelView<Factory: ViewFactory>: 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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI
public struct MessageComposerView<Factory: ViewFactory>: 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
Expand Down Expand Up @@ -228,6 +229,18 @@ public struct MessageComposerView<Factory: ViewFactory>: 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)
}
}
Expand Down Expand Up @@ -444,3 +457,13 @@ public struct ComposerInputView<Factory: ViewFactory>: 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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
) : nil
)
.modifier(factory.makeMessageListContainerModifier())
.dismissKeyboardOnTap(enabled: keyboardShown)
.onDisappear {
messageRenderingUtil.update(previousTopMessage: nil)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading