diff --git a/DemoAppSwiftUI/AppleMessageComposerView.swift b/DemoAppSwiftUI/AppleMessageComposerView.swift index 80354b7e1..c200677a8 100644 --- a/DemoAppSwiftUI/AppleMessageComposerView.swift +++ b/DemoAppSwiftUI/AppleMessageComposerView.swift @@ -91,7 +91,8 @@ struct AppleMessageComposerView: View, KeyboardReadable { maxMessageLength: channelConfig?.maxMessageLength, cooldownDuration: viewModel.cooldownDuration, onCustomAttachmentTap: viewModel.customAttachmentTapped(_:), - removeAttachmentWithId: viewModel.removeAttachment(with:) + removeAttachmentWithId: viewModel.removeAttachment(with:), + sendMessage: {} ) .overlay( viewModel.sendButtonEnabled ? sendButton : nil @@ -115,7 +116,11 @@ struct AppleMessageComposerView: View, KeyboardReadable { askForAssetsAccessPermissions: viewModel.askForPhotosPermission, isDisplayed: viewModel.overlayShown, height: viewModel.overlayShown ? popupSize : 0, - popupHeight: popupSize + popupHeight: popupSize, + selectedAssetIds: viewModel.addedAssets.map(\.id), + channelController: viewModel.channelController, + messageController: viewModel.messageController, + canSendPoll: viewModel.canSendPoll ) ) } @@ -168,7 +173,7 @@ struct AppleMessageComposerView: View, KeyboardReadable { .animation(.none, value: viewModel.showCommandsOverlay) : nil, alignment: .bottom ) - .modifier(factory.makeComposerViewModifier(options: ComposerViewModifierOptions())) + .modifier(factory.styles.makeComposerViewModifier(options: ComposerViewModifierOptions())) .onChange(of: editedMessage) { _ in viewModel.text = editedMessage?.text ?? "" if editedMessage != nil { diff --git a/DemoAppSwiftUI/CustomComposerAttachmentView.swift b/DemoAppSwiftUI/CustomComposerAttachmentView.swift index 78b9fb9c6..78727fbb0 100644 --- a/DemoAppSwiftUI/CustomComposerAttachmentView.swift +++ b/DemoAppSwiftUI/CustomComposerAttachmentView.swift @@ -26,6 +26,8 @@ extension ContactAttachmentPayload: Identifiable { class CustomAttachmentsFactory: ViewFactory { @Injected(\.chatClient) var chatClient: ChatClient + + public var styles = LiquidGlassStyles() private let mockContacts = [ CustomAttachment( diff --git a/DemoAppSwiftUI/ViewFactoryExamples.swift b/DemoAppSwiftUI/ViewFactoryExamples.swift index 994d7e687..c0458bfbe 100644 --- a/DemoAppSwiftUI/ViewFactoryExamples.swift +++ b/DemoAppSwiftUI/ViewFactoryExamples.swift @@ -14,6 +14,8 @@ class DemoAppFactory: ViewFactory { private var mentionsHandler = MentionsHandler() public static let shared = DemoAppFactory() + + public var styles = LiquidGlassStyles() func makeChannelListHeaderViewModifier(options: ChannelListHeaderViewModifierOptions) -> some ChannelListHeaderViewModifier { CustomChannelModifier(title: options.title) @@ -162,7 +164,7 @@ struct ShowProfileModifier: ViewModifier { func body(content: Content) -> some View { content .modifier( - DefaultViewFactory.shared.makeMessageViewModifier(for: messageModifierInfo) + DefaultViewFactory.shared.styles.makeMessageViewModifier(for: messageModifierInfo) ) .modifier( ProfileURLModifier( @@ -230,6 +232,8 @@ struct CustomChannelDestination: View { class CustomFactory: ViewFactory { @Injected(\.chatClient) public var chatClient + public var styles = LiquidGlassStyles() + private init() {} public static let shared = CustomFactory() diff --git a/DemoAppSwiftUI/iMessagePocView.swift b/DemoAppSwiftUI/iMessagePocView.swift index 66d73e598..06260da0a 100644 --- a/DemoAppSwiftUI/iMessagePocView.swift +++ b/DemoAppSwiftUI/iMessagePocView.swift @@ -122,6 +122,8 @@ class iMessageViewFactory: ViewFactory { static let shared = iMessageViewFactory() + public var styles = LiquidGlassStyles() + private init() {} func makeLeadingSwipeActionsView( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift index 1383b0a8f..ded33c034 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift @@ -18,7 +18,8 @@ public struct ChatChannelView: View, KeyboardReadable { @State private var messageDisplayInfo: MessageDisplayInfo? @State private var keyboardShown = false @State private var tabBarAvailable: Bool = false - + @State private var floatingComposerHeight: CGFloat + private var factory: Factory public init( @@ -26,8 +27,10 @@ public struct ChatChannelView: View, KeyboardReadable { viewModel: ChatChannelViewModel? = nil, channelController: ChatChannelController, messageController: ChatMessageController? = nil, - scrollToMessage: ChatMessage? = nil + scrollToMessage: ChatMessage? = nil, + composerPlacement: ComposerPlacement = .floating ) { + _floatingComposerHeight = State(initialValue: Self.defaultFloatingComposerHeight()) _viewModel = StateObject( wrappedValue: viewModel ?? ViewModelsFactory.makeChannelViewModel( with: channelController, @@ -55,6 +58,7 @@ public struct ChatChannelView: View, KeyboardReadable { listId: viewModel.listId, isMessageThread: viewModel.isMessageThread, shouldShowTypingIndicator: viewModel.shouldShowTypingIndicator, + bottomInset: composerPlacement == .floating ? floatingComposerHeight : 0, scrollPosition: $viewModel.scrollPosition, loadingNextMessages: viewModel.loadingNextMessages, firstUnreadMessageId: $viewModel.firstUnreadMessageId, @@ -73,6 +77,7 @@ public struct ChatChannelView: View, KeyboardReadable { }, onJumpToMessage: viewModel.jumpToMessage(messageId:) ) + .edgesIgnoringSafeArea(.bottom) .environment(\.highlightedMessageId, viewModel.highlightedMessageId) .dismissKeyboardOnTap(enabled: true) { hideComposerCommandsAndAttachmentsPicker() @@ -100,6 +105,7 @@ public struct ChatChannelView: View, KeyboardReadable { } Divider() + .opacity(composerPlacement == .docked ? 1 : 0) .navigationBarBackButtonHidden(viewModel.reactionsShown) .if(viewModel.reactionsShown, transform: { view in view.modifier(factory.makeChannelBarsVisibilityViewModifier(options: ChannelBarsVisibilityViewModifierOptions(shouldShow: false))) @@ -118,21 +124,13 @@ public struct ChatChannelView: View, KeyboardReadable { } .animation(nil) - factory.makeMessageComposerViewType( - options: MessageComposerViewTypeOptions( - channelController: viewModel.channelController, - messageController: viewModel.messageController, - quotedMessage: $viewModel.quotedMessage, - editedMessage: $viewModel.editedMessage, - onMessageSent: { - viewModel.messageSentTapped() - } - ) - ) - .opacity(( - utils.messageListConfig.messagePopoverEnabled && messageDisplayInfo != nil && !viewModel - .reactionsShown && viewModel.channel?.isFrozen == false - ) ? 0 : 1) + if composerPlacement == .docked { + composerView + .opacity(( + utils.messageListConfig.messagePopoverEnabled && messageDisplayInfo != nil && !viewModel + .reactionsShown && viewModel.channel?.isFrozen == false + ) ? 0 : 1) + } NavigationLink( isActive: $viewModel.threadMessageShown @@ -171,10 +169,27 @@ public struct ChatChannelView: View, KeyboardReadable { .edgesIgnoringSafeArea(.all) : nil ) + .modifier(FloatingComposerContainer( + composerPlacement: composerPlacement, + composer: { + composerView + .opacity(viewModel.reactionsShown ? 0 : 1) + } + )) } else { factory.makeChannelLoadingView(options: ChannelLoadingViewOptions()) } } + .onPreferenceChange(FloatingComposerHeightPreferenceKey.self) { value in + guard composerPlacement == .floating, value > 0 else { return } + let defaultHeight = Self.defaultFloatingComposerHeight() + let newHeight = max(value, defaultHeight) + if abs(newHeight - floatingComposerHeight) > 0.5 { + withAnimation { + floatingComposerHeight = newHeight + } + } + } .navigationBarTitleDisplayMode(factory.navigationBarDisplayMode()) .onReceive(keyboardWillChangePublisher, perform: { visible in keyboardShown = visible @@ -209,9 +224,27 @@ public struct ChatChannelView: View, KeyboardReadable { .alertBanner(isPresented: $viewModel.showAlertBanner) .accessibilityElement(children: .contain) .accessibilityIdentifier("ChatChannelView") - .modifier(factory.makeBouncedMessageActionsModifier(viewModel: viewModel)) + .modifier(factory.styles.makeBouncedMessageActionsModifier(viewModel: viewModel)) .accentColor(colors.tintColor) } + + private var composerView: some View { + factory.makeMessageComposerViewType( + options: MessageComposerViewTypeOptions( + channelController: viewModel.channelController, + messageController: viewModel.messageController, + quotedMessage: $viewModel.quotedMessage, + editedMessage: $viewModel.editedMessage, + onMessageSent: { + viewModel.messageSentTapped() + } + ) + ) + } + + private var composerPlacement: ComposerPlacement { + factory.styles.composerPlacement + } private var generatingSnapshot: Bool { if #available(iOS 26, *) { @@ -235,3 +268,43 @@ public struct ChatChannelView: View, KeyboardReadable { ) } } + +public enum ComposerPlacement { + case docked + case floating +} + +private extension ChatChannelView { + static func defaultFloatingComposerHeight() -> CGFloat { + let utils = InjectedValues[\.utils] + let baseHeight = utils.composerConfig.inputViewMinHeight + let outerPadding: CGFloat = 16 // HStack padding (.all, 8) + return baseHeight + outerPadding + 10 + } +} + +private struct FloatingComposerContainer: ViewModifier { + let composerPlacement: ComposerPlacement + let composer: () -> Composer + + func body(content: Content) -> some View { + if composerPlacement == .docked { + content + } else { + if #available(iOS 15.0, *) { + content + .overlay(alignment: .bottom) { + composer() + } + } else { + content + .overlay( + VStack { + Spacer() + composer() + } + ) + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index 06527c443..1fe85d8d6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -37,6 +37,7 @@ import SwiftUI private var canMarkRead = false private var hasSetInitialCanMarkRead = false private var currentUserSentNewMessage = false + private var skipScrollUpdates = true private let messageListDateOverlay: DateFormatter = DateFormatter.messageListDateOverlay @@ -230,6 +231,11 @@ import SwiftUI object: nil ) } + + // TODO: improve this. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in + self?.skipScrollUpdates = false + }) channelName = channel?.name ?? "" checkHeaderType() @@ -823,6 +829,7 @@ import SwiftUI } private func updateScrolledIdToNewestMessage() { + guard !skipScrollUpdates else { return } if scrolledId != nil { scrolledId = nil } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerTypeView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerTypeView.swift index 6220ce92d..5b81c376a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerTypeView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerTypeView.swift @@ -25,21 +25,27 @@ public enum AttachmentPickerType: Sendable { case custom } +// TODO: maybe remove this view. /// View for picking the attachment type (media or giphy commands). public struct AttachmentPickerTypeView: View { - @EnvironmentObject private var composerViewModel: MessageComposerViewModel @Injected(\.images) private var images @Injected(\.colors) private var colors @Binding var pickerTypeState: PickerTypeState var channelConfig: ChannelConfig? + var channelController: ChatChannelController + var isSendMessageEnabled: Bool public init( pickerTypeState: Binding, - channelConfig: ChannelConfig? + channelConfig: ChannelConfig?, + channelController: ChatChannelController, + isSendMessageEnabled: Bool ) { _pickerTypeState = pickerTypeState self.channelConfig = channelConfig + self.isSendMessageEnabled = isSendMessageEnabled + self.channelController = channelController } private var commandsAvailable: Bool { @@ -50,7 +56,7 @@ public struct AttachmentPickerTypeView: View { HStack(spacing: 16) { switch pickerTypeState { case let .expanded(attachmentPickerType): - if composerViewModel.channelController.channel?.canUploadFile == true && composerViewModel.isSendMessageEnabled { + if channelController.channel?.canUploadFile == true && isSendMessageEnabled { PickerTypeButton( pickerTypeState: $pickerTypeState, pickerType: .media, @@ -60,7 +66,7 @@ public struct AttachmentPickerTypeView: View { .accessibilityIdentifier("PickerTypeButtonMedia") } - if commandsAvailable && composerViewModel.isSendMessageEnabled { + if commandsAvailable && isSendMessageEnabled { PickerTypeButton( pickerTypeState: $pickerTypeState, pickerType: .instantCommands, @@ -70,7 +76,7 @@ public struct AttachmentPickerTypeView: View { .accessibilityIdentifier("PickerTypeButtonCommands") } case .collapsed: - if composerViewModel.isSendMessageEnabled { + if isSendMessageEnabled { Button { withAnimation { pickerTypeState = .expanded(.none) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerView.swift index f912a112b..5a3be98b9 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentPickerView.swift @@ -8,8 +8,6 @@ import SwiftUI /// View for the attachment picker. public struct AttachmentPickerView: View { - @EnvironmentObject var viewModel: MessageComposerViewModel - @Injected(\.colors) private var colors @Injected(\.fonts) private var fonts @@ -29,7 +27,12 @@ public struct AttachmentPickerView: View { var isDisplayed: Bool var height: CGFloat - + var selectedAssetIds: [String]? + + var channelController: ChatChannelController + var messageController: ChatMessageController? + var canSendPoll: Bool + public init( viewFactory: Factory, selectedPickerState: Binding, @@ -45,7 +48,11 @@ public struct AttachmentPickerView: View { cameraImageAdded: @escaping @MainActor (AddedAsset) -> Void, askForAssetsAccessPermissions: @escaping () -> Void, isDisplayed: Bool, - height: CGFloat + height: CGFloat, + selectedAssetIds: [String]? = nil, + channelController: ChatChannelController, + messageController: ChatMessageController?, + canSendPoll: Bool ) { self.viewFactory = viewFactory _selectedPickerState = selectedPickerState @@ -62,6 +69,10 @@ public struct AttachmentPickerView: View { self.askForAssetsAccessPermissions = askForAssetsAccessPermissions self.isDisplayed = isDisplayed self.height = height + self.selectedAssetIds = selectedAssetIds + self.channelController = channelController + self.messageController = messageController + self.canSendPoll = canSendPoll } public var body: some View { @@ -69,10 +80,10 @@ public struct AttachmentPickerView: View { viewFactory.makeAttachmentSourcePickerView( options: AttachmentSourcePickerViewOptions( selected: selectedPickerState, + canSendPoll: canSendPoll, onPickerStateChange: onPickerStateChange ) ) - .environmentObject(viewModel) if selectedPickerState == .photos { if let assets = photoLibraryAssets { @@ -82,7 +93,8 @@ public struct AttachmentPickerView: View { options: PhotoAttachmentPickerViewOptions( assets: collection, onAssetTap: onAssetTap, - isAssetSelected: isAssetSelected + isAssetSelected: isAssetSelected, + selectedAssetIds: selectedAssetIds ) ) .edgesIgnoringSafeArea(.bottom) @@ -111,8 +123,8 @@ public struct AttachmentPickerView: View { } else if selectedPickerState == .polls { viewFactory.makeComposerPollView( options: ComposerPollViewOptions( - channelController: viewModel.channelController, - messageController: viewModel.messageController + channelController: channelController, + messageController: messageController ) ) } else if selectedPickerState == .custom { @@ -138,20 +150,21 @@ public struct AttachmentPickerView: View { /// View for picking the source of the attachment (photo, files or camera). public struct AttachmentSourcePickerView: View { - @EnvironmentObject var viewModel: MessageComposerViewModel - @Injected(\.colors) private var colors @Injected(\.images) private var images var selected: AttachmentPickerState + var canSendPoll: Bool var onTap: (AttachmentPickerState) -> Void public init( selected: AttachmentPickerState, + canSendPoll: Bool, onTap: @escaping (AttachmentPickerState) -> Void ) { self.selected = selected self.onTap = onTap + self.canSendPoll = canSendPoll } public var body: some View { @@ -181,7 +194,7 @@ public struct AttachmentSourcePickerView: View { ) .accessibilityIdentifier("attachmentPickerCamera") - if viewModel.canSendPoll { + if canSendPoll { AttachmentPickerButton( icon: images.attachmentPickerPolls, pickerType: .polls, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/LeadingComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/LeadingComposerView.swift new file mode 100644 index 000000000..54791a141 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/LeadingComposerView.swift @@ -0,0 +1,55 @@ +// +// Copyright ยฉ 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +struct LeadingComposerView: View { + @Injected(\.colors) var colors + + var factory: Factory + + @Binding var pickerTypeState: PickerTypeState + public let channelConfig: ChannelConfig? + + var body: some View { + HStack { + if #available(iOS 26.0, *) { + Button { + withAnimation { + if pickerTypeState == .collapsed || pickerTypeState == .expanded(.none) { + pickerTypeState = .expanded(.media) + } else { + pickerTypeState = .expanded(.none) + } + } + } label: { + Image(systemName: "plus") + .fontWeight(.semibold) + } + .padding(.all, 12) + .modifier(factory.styles.makeComposerButtonViewModifier(options: .init())) + .foregroundStyle(.primary) + .contentShape(.rect) + } else { + Button { + withAnimation { + if pickerTypeState == .collapsed || pickerTypeState == .expanded(.none) { + pickerTypeState = .expanded(.media) + } else { + pickerTypeState = .expanded(.none) + } + } + } label: { + Image(systemName: "plus") + } + .padding(.all, 12) + .background(Color(UIColor.secondarySystemBackground)) + .clipShape(.circle) + .contentShape(.rect) + } + } + .padding(.leading, 8) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift index 72a5336d2..9ea5bd957 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift @@ -91,7 +91,8 @@ public struct MessageComposerView: View, KeyboardReadable cooldownDuration: viewModel.cooldownDuration, onCustomAttachmentTap: viewModel.customAttachmentTapped(_:), shouldScroll: viewModel.inputComposerShouldScroll, - removeAttachmentWithId: viewModel.removeAttachment(with:) + removeAttachmentWithId: viewModel.removeAttachment(with:), + sendMessage: sendMessage ) ) .environmentObject(viewModel) @@ -107,18 +108,7 @@ public struct MessageComposerView: View, KeyboardReadable options: TrailingComposerViewOptions( enabled: viewModel.sendButtonEnabled, cooldownDuration: viewModel.cooldownDuration, - onTap: { - viewModel.sendMessage( - quotedMessage: quotedMessage, - editedMessage: editedMessage - ) { - // Calling onMessageSent() before erasing the edited and quoted message - // so that onMessageSent can use them for state handling. - onMessageSent() - quotedMessage = nil - editedMessage = nil - } - } + onTap: sendMessage ) ) .environmentObject(viewModel) @@ -178,7 +168,11 @@ public struct MessageComposerView: View, KeyboardReadable askForAssetsAccessPermissions: viewModel.askForPhotosPermission, isDisplayed: viewModel.overlayShown, height: viewModel.overlayShown ? popupSize : 0, - popupHeight: popupSize + popupHeight: popupSize, + selectedAssetIds: viewModel.addedAssets.map(\.id), + channelController: viewModel.channelController, + messageController: viewModel.messageController, + canSendPoll: viewModel.canSendPoll ) ) .environmentObject(viewModel) @@ -234,7 +228,7 @@ public struct MessageComposerView: View, KeyboardReadable .animation(nil) : nil, alignment: .bottom ) - .modifier(factory.makeComposerViewModifier(options: ComposerViewModifierOptions())) + .modifier(factory.styles.makeComposerViewModifier(options: ComposerViewModifierOptions())) .onChange(of: editedMessage) { _ in viewModel.fillEditedMessage(editedMessage) if editedMessage != nil { @@ -262,8 +256,22 @@ public struct MessageComposerView: View, KeyboardReadable } viewModel.pickerTypeState = .expanded(.none) } + .preference(key: FloatingComposerHeightPreferenceKey.self, value: composerHeight) .accessibilityElement(children: .contain) } + + public func sendMessage() { + viewModel.sendMessage( + quotedMessage: quotedMessage, + editedMessage: editedMessage + ) { + // Calling onMessageSent() before erasing the edited and quoted message + // so that onMessageSent can use them for state handling. + onMessageSent() + quotedMessage = nil + editedMessage = nil + } + } } /// View for the composer's input (text and media). @@ -287,6 +295,7 @@ public struct ComposerInputView: View, KeyboardReadable { var cooldownDuration: Int var onCustomAttachmentTap: @MainActor (CustomAttachment) -> Void var removeAttachmentWithId: (String) -> Void + var sendMessage: @MainActor () -> Void @State var textHeight: CGFloat = TextSizeConstants.minimumHeight @State var keyboardShown = false @@ -303,7 +312,8 @@ public struct ComposerInputView: View, KeyboardReadable { maxMessageLength: Int? = nil, cooldownDuration: Int, onCustomAttachmentTap: @escaping @MainActor (CustomAttachment) -> Void, - removeAttachmentWithId: @escaping (String) -> Void + removeAttachmentWithId: @escaping (String) -> Void, + sendMessage: @escaping @MainActor () -> Void ) { self.factory = factory _text = text @@ -317,6 +327,7 @@ public struct ComposerInputView: View, KeyboardReadable { self.cooldownDuration = cooldownDuration self.onCustomAttachmentTap = onCustomAttachmentTap self.removeAttachmentWithId = removeAttachmentWithId + self.sendMessage = sendMessage } var textFieldHeight: CGFloat { @@ -438,19 +449,25 @@ public struct ComposerInputView: View, KeyboardReadable { } : nil ) + + factory.makeComposerInputTrailingView( + options: .init( + viewModel: viewModel, + onTap: sendMessage + ) + ) + .padding(.trailing, 8) } .frame(height: textFieldHeight) } + .padding(.vertical, 2) .padding(.vertical, shouldAddVerticalPadding ? inputPaddingsConfig.vertical : 0) .padding(.leading, inputPaddingsConfig.leading) .padding(.trailing, inputPaddingsConfig.trailing) - .background(composerInputBackground) - .overlay( - RoundedRectangle(cornerRadius: TextSizeConstants.cornerRadius) - .stroke(Color(keyboardShown ? highlightedBorder : colors.innerBorder)) - ) - .clipShape( - RoundedRectangle(cornerRadius: TextSizeConstants.cornerRadius) + .modifier( + factory.styles.makeComposerInputViewModifier( + options: .init(keyboardShown: keyboardShown) + ) ) .onReceive(keyboardWillChangePublisher) { visible in keyboardShown = visible diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift index 013630b58..226aff9d6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift @@ -14,17 +14,25 @@ public struct PhotoAttachmentPickerView: View { var assets: PHFetchResultCollection var onImageTap: (AddedAsset) -> Void var imageSelected: (String) -> Bool + var selectedAssetIds: [String]? + + private var selectedAssetIdsSet: Set? { + guard let selectedAssetIds else { return nil } + return Set(selectedAssetIds) + } let columns = [GridItem(.adaptive(minimum: 120), spacing: 2)] public init( assets: PHFetchResultCollection, onImageTap: @escaping (AddedAsset) -> Void, - imageSelected: @escaping (String) -> Bool + imageSelected: @escaping (String) -> Bool, + selectedAssetIds: [String]? = nil ) { self.assets = assets self.onImageTap = onImageTap self.imageSelected = imageSelected + self.selectedAssetIds = selectedAssetIds } public var body: some View { @@ -35,7 +43,8 @@ public struct PhotoAttachmentPickerView: View { assetLoader: assetLoader, asset: asset, onImageTap: onImageTap, - imageSelected: imageSelected + imageSelected: imageSelected, + selectedAssetIds: selectedAssetIdsSet ) } } @@ -62,6 +71,7 @@ public struct PhotoAttachmentCell: View { var asset: PHAsset var onImageTap: (AddedAsset) -> Void var imageSelected: (String) -> Bool + var selectedAssetIds: Set? private var assetType: AssetType { asset.mediaType == .video ? .video : .image @@ -72,13 +82,15 @@ public struct PhotoAttachmentCell: View { requestId: PHContentEditingInputRequestID? = nil, asset: PHAsset, onImageTap: @escaping (AddedAsset) -> Void, - imageSelected: @escaping (String) -> Bool + imageSelected: @escaping (String) -> Bool, + selectedAssetIds: Set? = nil ) { self.assetLoader = assetLoader _requestId = State(initialValue: requestId) self.asset = asset self.onImageTap = onImageTap self.imageSelected = imageSelected + self.selectedAssetIds = selectedAssetIds } public var body: some View { @@ -135,7 +147,7 @@ public struct PhotoAttachmentCell: View { .aspectRatio(1, contentMode: .fill) .overlay( ZStack { - if imageSelected(asset.localIdentifier) { + if isAssetSelected(asset.localIdentifier) { TopRightView { Image(uiImage: images.checkmarkFilled) .renderingMode(.template) @@ -202,4 +214,11 @@ public struct PhotoAttachmentCell: View { guard let assetData = try? Data(contentsOf: assetURL) else { return nil } return try? UIImage(data: assetData)?.saveAsJpgToTemporaryUrl() } + + private func isAssetSelected(_ id: String) -> Bool { + if let selectedAssetIds { + return selectedAssetIds.contains(id) + } + return imageSelected(id) + } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/TrailingInputComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/TrailingInputComposerView.swift new file mode 100644 index 000000000..f5822c2e8 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/TrailingInputComposerView.swift @@ -0,0 +1,20 @@ +// +// Copyright ยฉ 2025 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct TrailingInputComposerView: View { + @ObservedObject var viewModel: MessageComposerViewModel + var onTap: () -> Void + + var body: some View { + if viewModel.text.isEmpty { + VoiceRecordingButton(viewModel: viewModel) + } else { + SendMessageButton(enabled: viewModel.sendButtonEnabled) { + onTap() + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift index 387bdd8f4..7c63f80b0 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift @@ -97,7 +97,7 @@ public struct VoiceRecordingContainerView: View { player.subscribe(handler) } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/FileAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/FileAttachmentView.swift index cef950987..d9990a4c4 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/FileAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/FileAttachmentView.swift @@ -61,7 +61,7 @@ public struct FileAttachmentsContainer: View { .padding(.all, 4) } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift index 89a5ff495..9558dd136 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/GiphyAttachmentView.swift @@ -69,7 +69,7 @@ public struct GiphyAttachmentView: View { } } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift index d1a83a81b..c80d74379 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift @@ -55,7 +55,7 @@ public struct ImageAttachmentContainer: View { } } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst && message.videoAttachments.isEmpty ) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift index 7c9566846..cbdef9ee1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift @@ -81,7 +81,7 @@ public struct LinkAttachmentContainer: View { } .padding(.bottom, 8) .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 86468e82c..be45431e1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -24,6 +24,7 @@ public struct MessageListView: View, KeyboardReadable { var listId: String var isMessageThread: Bool var shouldShowTypingIndicator: Bool + var bottomInset: CGFloat var onMessageAppear: (Int, ScrollDirection) -> Void var onScrollToBottom: @MainActor () -> Void @@ -74,6 +75,7 @@ public struct MessageListView: View, KeyboardReadable { listId: String, isMessageThread: Bool = false, shouldShowTypingIndicator: Bool = false, + bottomInset: CGFloat = 0, scrollPosition: Binding = .constant(nil), loadingNextMessages: Bool = false, firstUnreadMessageId: Binding = .constant(nil), @@ -94,6 +96,7 @@ public struct MessageListView: View, KeyboardReadable { self.onLongPress = onLongPress self.onJumpToMessage = onJumpToMessage self.shouldShowTypingIndicator = shouldShowTypingIndicator + self.bottomInset = bottomInset self.loadingNextMessages = loadingNextMessages _scrolledId = scrolledId _showScrollToLatestButton = showScrollToLatestButton @@ -139,6 +142,7 @@ public struct MessageListView: View, KeyboardReadable { listId: viewModel.listId, isMessageThread: viewModel.isMessageThread, shouldShowTypingIndicator: viewModel.shouldShowTypingIndicator, + bottomInset: 0, scrollPosition: Binding( get: { viewModel.scrollPosition }, set: { viewModel.scrollPosition = $0 } @@ -198,6 +202,7 @@ public struct MessageListView: View, KeyboardReadable { onMessageAppear(index, scrollDirection) } } + .padding(.bottom, message == messages.first ? bottomInset : 0) .padding( .top, messageDate != nil ? @@ -248,7 +253,7 @@ public struct MessageListView: View, KeyboardReadable { .id(listId) } .delayedRendering() - .modifier(factory.makeMessageListModifier(options: MessageListModifierOptions())) + .modifier(factory.styles.makeMessageListModifier(options: MessageListModifierOptions())) .modifier(ScrollTargetLayoutModifier(enabled: loadingNextMessages)) } .modifier(ScrollPositionModifier(scrollPosition: loadingNextMessages ? $scrollPosition : .constant(nil))) @@ -323,6 +328,7 @@ public struct MessageListView: View, KeyboardReadable { onScrollToBottom: onScrollToBottom ) ) + .offset(y: -bottomInset - 20) } if shouldShowTypingIndicator { @@ -364,7 +370,7 @@ public struct MessageListView: View, KeyboardReadable { ) ) : nil ) - .modifier(factory.makeMessageListContainerModifier(options: MessageListContainerModifierOptions())) + .modifier(factory.styles.makeMessageListContainerModifier(options: MessageListContainerModifierOptions())) .onDisappear { messageRenderingUtil.update(previousTopMessage: nil) } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift index eddc195e5..f1bb8b891 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift @@ -197,7 +197,7 @@ public struct MessageTextView: View { .fixedSize(horizontal: false, vertical: true) } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst @@ -233,7 +233,7 @@ public struct EmojiTextView: View { .font(fonts.emoji) } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift index 493219086..5043a8da6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollAttachmentView.swift @@ -150,7 +150,7 @@ public struct PollAttachmentView: View { .disabled(!viewModel.canInteract) .padding() .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: isFirst diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift index 6c56e8120..f2663d67e 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/QuotedMessageView.swift @@ -126,7 +126,7 @@ public struct QuotedMessageView: View { hasVoiceAttachments ? [.leading, .top, .bottom] : .all, utils.messageListConfig.messagePaddings.quotedViewPadding ) .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: quotedMessage, isFirst: true, @@ -247,7 +247,8 @@ public struct QuotedMessageContentView: View { } .onDisappear(.cancel) .processors([ImageProcessors.Resize(width: options.attachmentSize.width)]) - .priority(.high) } + .priority(.high) + } } .frame(width: hasVoiceAttachments ? nil : options.attachmentSize.width, height: options.attachmentSize.height) .aspectRatio(1, contentMode: .fill) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift index dd21b897c..6b70cfffe 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ReactionsIconProvider.swift @@ -4,17 +4,27 @@ import StreamChat import SwiftUI +import UIKit class ReactionsIconProvider { @MainActor static var colors: ColorPalette = InjectedValues[\.colors] @MainActor static var images: Images = InjectedValues[\.images] @MainActor static func icon(for reaction: MessageReactionType, useLargeIcons: Bool) -> UIImage? { + var icon: UIImage? if useLargeIcons { - images.availableReactions[reaction]?.largeIcon + icon = images.availableReactions[reaction]?.largeIcon } else { - images.availableReactions[reaction]?.smallIcon + icon = images.availableReactions[reaction]?.smallIcon } + if let icon { + return icon + } + guard let emoji = emojiString(from: reaction.rawValue) else { + return nil + } + + return image(from: emoji, useLargeIcons: useLargeIcons) } @MainActor static func color(for reaction: MessageReactionType, userReactionIDs: Set) -> Color? { @@ -28,3 +38,39 @@ class ReactionsIconProvider { } } } + +private extension ReactionsIconProvider { + @MainActor static func emojiString(from identifier: String) -> String? { + let components = identifier.split(separator: "-") + guard components.allSatisfy({ $0.lowercased().hasPrefix("u") }) else { + return identifier + } + + var scalars = String.UnicodeScalarView() + for component in components { + let hex = component.drop { $0 == "u" || $0 == "U" } + guard let value = UInt32(hex, radix: 16), let scalar = UnicodeScalar(value) else { + return nil + } + scalars.append(scalar) + } + + return String(scalars) + } + + @MainActor static func image(from emoji: String, useLargeIcons: Bool) -> UIImage? { + let fontSize: CGFloat = useLargeIcons ? 28 : 22 + let font = UIFont.systemFont(ofSize: fontSize) + let attributes: [NSAttributedString.Key: Any] = [.font: font] + let text = emoji as NSString + var size = text.size(withAttributes: attributes) + size.width = ceil(size.width) + size.height = ceil(size.height) + + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + UIColor.clear.set() + text.draw(at: .zero, withAttributes: attributes) + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift index d573c666b..d6ee2cd2e 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift @@ -32,7 +32,7 @@ public struct VideoAttachmentsContainer: View { ) } .modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: false @@ -54,7 +54,7 @@ public struct VideoAttachmentsContainer: View { } .if(!message.text.isEmpty, transform: { view in view.modifier( - factory.makeMessageViewModifier( + factory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: true, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MoreReactionsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MoreReactionsView.swift new file mode 100644 index 000000000..3c08813b7 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MoreReactionsView.swift @@ -0,0 +1,72 @@ +// +// Copyright ยฉ 2025 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct MoreReactionsView: View { + @Injected(\.colors) private var colors + + private let rows: [GridItem] = Array( + repeating: GridItem(.fixed(56), spacing: 12, alignment: .center), + count: 4 + ) + + private let emojiSize: CGFloat = 52 + + var onEmojiTap: @MainActor (String) -> Void + + init(onEmojiTap: @escaping @MainActor (String) -> Void) { + self.onEmojiTap = onEmojiTap + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: rows, spacing: 12) { + ForEach(Self.orderedEmojiKeys, id: \.self) { key in + if let emoji = Self.emojiMap[key] { + Button { + onEmojiTap(key) + } label: { + Text(emoji) + .font(.system(size: 30)) + .frame(width: emojiSize, height: emojiSize) + } + .buttonStyle(.plain) + .accessibilityLabel(emoji) + } + } + } + .padding(.vertical, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .accessibilityIdentifier("MoreReactionsView") + } +} + +extension MoreReactionsView { + static var emojiValues: [String] { + InjectedValues[\.images].availableEmojis + } + + private static let emojiMap: [String: String] = { + var map: [String: String] = [:] + for emoji in emojiValues { + map[apiIdentifier(for: emoji)] = emoji + } + return map + }() + + private static let orderedEmojiKeys: [String] = emojiValues.map { apiIdentifier(for: $0) } + + private static func apiIdentifier(for emoji: String) -> String { + emoji.unicodeScalars + .map { scalar in + String(format: "u%04X", scalar.value) + } + .joined(separator: "-") + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift index 1876ed0bb..acfbb24f0 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift @@ -13,15 +13,18 @@ struct ReactionsOverlayContainer: View { let message: ChatMessage let contentRect: CGRect var onReactionTap: (MessageReactionType) -> Void + var onMoreReactionsTap: () -> Void init( message: ChatMessage, contentRect: CGRect, - onReactionTap: @escaping (MessageReactionType) -> Void + onReactionTap: @escaping (MessageReactionType) -> Void, + onMoreReactionsTap: @escaping () -> Void ) { self.message = message self.contentRect = contentRect self.onReactionTap = onReactionTap + self.onMoreReactionsTap = onMoreReactionsTap } var body: some View { @@ -31,7 +34,8 @@ struct ReactionsOverlayContainer: View { message: message, useLargeIcons: true, reactions: reactions, - onReactionTap: onReactionTap + onReactionTap: onReactionTap, + onMoreReactionsTap: onMoreReactionsTap ) } @@ -90,6 +94,7 @@ public struct ReactionsAnimatableView: View { var useLargeIcons = false var reactions: [MessageReactionType] var onReactionTap: (MessageReactionType) -> Void + var onMoreReactionsTap: () -> Void @State var animationStates: [CGFloat] @@ -97,12 +102,14 @@ public struct ReactionsAnimatableView: View { message: ChatMessage, useLargeIcons: Bool = false, reactions: [MessageReactionType], - onReactionTap: @escaping (MessageReactionType) -> Void + onReactionTap: @escaping (MessageReactionType) -> Void, + onMoreReactionsTap: @escaping () -> Void ) { self.message = message self.useLargeIcons = useLargeIcons self.reactions = reactions self.onReactionTap = onReactionTap + self.onMoreReactionsTap = onMoreReactionsTap _animationStates = State( initialValue: [CGFloat](repeating: 0, count: reactions.count) ) @@ -120,6 +127,17 @@ public struct ReactionsAnimatableView: View { onReactionTap: onReactionTap ) } + + Button { + onMoreReactionsTap() + } label: { + Image(systemName: "plus") + .foregroundColor(.primary) + .padding(.all, 6) + .overlay( + Circle().stroke(Color(colors.innerBorder), lineWidth: 1) + ) + } } .padding(.all, 6) .padding(.horizontal, 4) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift index 5c74e1b38..87fd95228 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift @@ -19,6 +19,7 @@ public struct ReactionsOverlayView: View { @State private var initialWidth: CGFloat? @State private var orientationChanged = false @State private var initialOrigin: CGFloat? + @State private var moreReactionsShown = false var factory: Factory var channel: ChatChannel @@ -147,6 +148,9 @@ public struct ReactionsOverlayView: View { dismissReactionsOverlay { viewModel.reactionTapped(reaction) } + }, + onMoreReactionsTap: { + moreReactionsShown.toggle() } ) ) @@ -235,6 +239,18 @@ public struct ReactionsOverlayView: View { orientationChanged = true } } + .sheet(isPresented: $moreReactionsShown) { + factory.makeMoreReactionsView(options: .init(onEmojiTap: { reactionKey in + moreReactionsShown = false + let reaction = MessageReactionType(rawValue: reactionKey) + withAnimation { + dismissReactionsOverlay { + viewModel.reactionTapped(reaction) + } + } + })) + .modifier(PresentationDetentsModifier(height: screenHeight / 3)) + } } private var messageView: some View { @@ -397,3 +413,16 @@ extension View { modifier(DeviceRotationViewModifier(action: action)) } } + +struct PresentationDetentsModifier: ViewModifier { + var height: CGFloat + + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content + .presentationDetents([.height(height)]) + } else { + content + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift b/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift index e9f398d4b..4e239f408 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelHelpers.swift @@ -42,6 +42,14 @@ struct HeightPreferenceKey: PreferenceKey { } } +struct FloatingComposerHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + /// View container that allows injecting another view in its bottom right corner. public struct BottomRightView: View { var content: () -> Content diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift index 8731b9386..db8682a39 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift @@ -208,6 +208,6 @@ public struct ChannelsLazyVStack: View { factory.makeChannelListFooterView(options: ChannelListFooterViewOptions()) } - .modifier(factory.makeChannelListModifier(options: ChannelListModifierOptions())) + .modifier(factory.styles.makeChannelListModifier(options: ChannelListModifierOptions())) } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift index 60109f17e..9fe9102a2 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift @@ -260,6 +260,6 @@ public struct ChatChannelListContentView: View { viewFactory.makeChannelListStickyFooterView(options: ChannelListStickyFooterViewOptions()) } - .modifier(viewFactory.makeChannelListContentModifier(options: ChannelListContentModifierOptions())) + .modifier(viewFactory.styles.makeChannelListContentModifier(options: ChannelListContentModifierOptions())) } } diff --git a/Sources/StreamChatSwiftUI/Images.swift b/Sources/StreamChatSwiftUI/Images.swift index 6f0311316..314e321f6 100644 --- a/Sources/StreamChatSwiftUI/Images.swift +++ b/Sources/StreamChatSwiftUI/Images.swift @@ -111,6 +111,25 @@ import UIKit } set { _availableReactions = newValue } } + + public var availableEmojis: [String] = [ + "๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ™ƒ", + "๐Ÿ˜‰", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜š", "๐Ÿ˜™", + "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿคซ", "๐Ÿค”", + "๐Ÿค", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ˜‘", "๐Ÿ˜ถ", "๐Ÿ˜ถโ€๐ŸŒซ๏ธ", "๐Ÿ˜", "๐Ÿ˜’", "๐Ÿ™„", "๐Ÿ˜ฌ", + "๐Ÿคฅ", "๐Ÿ˜Œ", "๐Ÿ˜”", "๐Ÿ˜ช", "๐Ÿคค", "๐Ÿ˜ด", "๐Ÿ˜ท", "๐Ÿค’", "๐Ÿค•", "๐Ÿคข", + "๐Ÿคฎ", "๐Ÿคง", "๐Ÿฅต", "๐Ÿฅถ", "๐Ÿฅด", "๐Ÿ˜ตโ€๐Ÿ’ซ", "๐Ÿคฏ", "๐Ÿค ", "๐Ÿฅณ", "๐Ÿ˜Ž", + "๐Ÿค“", "๐Ÿง", "๐Ÿ˜•", "๐Ÿ˜Ÿ", "๐Ÿ™", "โ˜น๏ธ", "๐Ÿ˜ฎ", "๐Ÿ˜ฏ", "๐Ÿ˜ฒ", "๐Ÿ˜ณ", + "๐Ÿฅบ", "๐Ÿ˜ฆ", "๐Ÿ˜ง", "๐Ÿ˜จ", "๐Ÿ˜ฐ", "๐Ÿ˜ฅ", "๐Ÿ˜ข", "๐Ÿ˜ญ", "๐Ÿ˜ฑ", "๐Ÿ˜–", + "๐Ÿ˜ฃ", "๐Ÿ˜ž", "๐Ÿ˜“", "๐Ÿ˜ฉ", "๐Ÿ˜ซ", "๐Ÿฅฑ", "๐Ÿ˜ค", "๐Ÿ˜ก", "๐Ÿ˜ ", "๐Ÿคฌ", + "๐Ÿ˜ˆ", "๐Ÿ‘ฟ", "๐Ÿ’€", "โ˜ ๏ธ", "๐Ÿ’ฉ", "๐Ÿคก", "๐Ÿ‘น", "๐Ÿ‘บ", "๐Ÿ‘ป", "๐Ÿ‘ฝ", + "๐Ÿ‘พ", "๐Ÿค–", "๐ŸŽƒ", "๐Ÿ˜บ", "๐Ÿ˜ธ", "๐Ÿ˜น", "๐Ÿ˜ป", "๐Ÿ˜ผ", "๐Ÿ˜ฝ", "๐Ÿ™€", + "๐Ÿ˜ฟ", "๐Ÿ˜พ", "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘Œ", "๐ŸคŒ", "๐Ÿค", "โœŒ๏ธ", "๐Ÿคž", "๐ŸคŸ", + "๐Ÿค˜", "๐Ÿค™", "๐Ÿ‘ˆ", "๐Ÿ‘‰", "๐Ÿ‘†", "๐Ÿ‘‡", "โ˜๏ธ", "โœ‹", "๐Ÿคš", "๐Ÿ–๏ธ", + "๐Ÿ––", "๐Ÿ‘‹", "๐Ÿค", "๐Ÿ™", "๐Ÿ’ช", "๐Ÿ‘ฃ", "๐Ÿ‘€", "๐Ÿง ", "๐Ÿซถ", "๐Ÿ’‹", + "โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", + "โฃ๏ธ", "๐Ÿ’•", "๐Ÿ’ž", "๐Ÿ’“", "๐Ÿ’—", "๐Ÿ’–", "๐Ÿ’˜", "๐Ÿ’" + ] // MARK: - MessageList diff --git a/Sources/StreamChatSwiftUI/Styles.swift b/Sources/StreamChatSwiftUI/Styles.swift new file mode 100644 index 000000000..737b9abea --- /dev/null +++ b/Sources/StreamChatSwiftUI/Styles.swift @@ -0,0 +1,137 @@ +// +// Copyright ยฉ 2025 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +@MainActor +public protocol Styles { + var composerPlacement: ComposerPlacement { get set } + + associatedtype ComposerInputViewModifier: ViewModifier + func makeComposerInputViewModifier(options: ComposerInputModifierOptions) -> ComposerInputViewModifier + + associatedtype ComposerButtonViewModifier: ViewModifier + func makeComposerButtonViewModifier(options: ComposerButtonModifierOptions) -> ComposerButtonViewModifier + + associatedtype ChannelListContentModifier: ViewModifier + /// Returns a view modifier applied to the channel list content (including both header and footer views). + func makeChannelListContentModifier(options: ChannelListContentModifierOptions) -> ChannelListContentModifier + + associatedtype ChannelListModifier: ViewModifier + /// Returns a view modifier applied to the channel list. + func makeChannelListModifier(options: ChannelListModifierOptions) -> ChannelListModifier + + associatedtype MessageListModifier: ViewModifier + /// Returns a view modifier applied to the message list. + func makeMessageListModifier(options: MessageListModifierOptions) -> MessageListModifier + + associatedtype MessageListContainerModifier: ViewModifier + /// Returns a view modifier applied to the message list container. + func makeMessageListContainerModifier(options: MessageListContainerModifierOptions) -> MessageListContainerModifier + + associatedtype MessageViewModifier: ViewModifier + /// Returns a view modifier applied to the message view. + /// - Parameter messageModifierInfo: the message modifier info, that will be applied to the message. + func makeMessageViewModifier(for messageModifierInfo: MessageModifierInfo) -> MessageViewModifier + + associatedtype BouncedMessageActionsModifierType: ViewModifier + /// Returns a view modifier applied to the bounced message actions. + /// + /// This modifier is only used if `Utils.messageListConfig.bouncedMessagesAlertActionsEnabled` is `true`. + /// By default the flag is true and the bounced actions are shown as an alert instead of a context menu. + /// - Parameter viewModel: the view model of the chat channel view. + func makeBouncedMessageActionsModifier(viewModel: ChatChannelViewModel) -> BouncedMessageActionsModifierType + + associatedtype ComposerViewModifier: ViewModifier + /// Creates the composer view modifier, that's applied to the whole composer view. + func makeComposerViewModifier(options: ComposerViewModifierOptions) -> ComposerViewModifier +} + +extension Styles { + public func makeChannelListContentModifier(options: ChannelListContentModifierOptions) -> some ViewModifier { + EmptyViewModifier() + } + + public func makeChannelListModifier(options: ChannelListModifierOptions) -> some ViewModifier { + EmptyViewModifier() + } + + public func makeMessageListModifier(options: MessageListModifierOptions) -> some ViewModifier { + EmptyViewModifier() + } + + public func makeMessageListContainerModifier(options: MessageListContainerModifierOptions) -> some ViewModifier { + EmptyViewModifier() + } + + public func makeMessageViewModifier(for messageModifierInfo: MessageModifierInfo) -> some ViewModifier { + MessageBubbleModifier( + message: messageModifierInfo.message, + isFirst: messageModifierInfo.isFirst, + injectedBackgroundColor: messageModifierInfo.injectedBackgroundColor, + cornerRadius: messageModifierInfo.cornerRadius, + forceLeftToRight: messageModifierInfo.forceLeftToRight + ) + } + + public func makeBouncedMessageActionsModifier(viewModel: ChatChannelViewModel) -> some ViewModifier { + BouncedMessageActionsModifier(viewModel: viewModel) + } + + public func makeComposerViewModifier(options: ComposerViewModifierOptions) -> some ViewModifier { + EmptyViewModifier() + } +} + +public class LiquidGlassStyles: Styles { + public var composerPlacement: ComposerPlacement = .floating + + public init() {} + + public func makeComposerInputViewModifier(options: ComposerInputModifierOptions) -> some ViewModifier { + LiquidGlassModifier(shape: CustomRoundedShape()) + } + + public func makeComposerButtonViewModifier(options: ComposerButtonModifierOptions) -> some ViewModifier { + LiquidGlassModifier(shape: .circle) + } +} + +public struct StandardInputViewModifier: ViewModifier { + @Injected(\.colors) var colors + + var keyboardShown: Bool + + public init(keyboardShown: Bool) { + self.keyboardShown = keyboardShown + } + + public func body(content: Content) -> some View { + content + .overlay( + RoundedRectangle(cornerRadius: TextSizeConstants.cornerRadius) + .stroke(Color(keyboardShown ? highlightedBorder : colors.innerBorder)) + ) + .clipShape( + RoundedRectangle(cornerRadius: TextSizeConstants.cornerRadius) + ) + } + + private var highlightedBorder: UIColor { + var colors = colors + return colors.composerInputHighlightedBorder + } +} + +public class ComposerInputModifierOptions { + public let keyboardShown: Bool + + public init(keyboardShown: Bool) { + self.keyboardShown = keyboardShown + } +} + +public class ComposerButtonModifierOptions { + public init() {} +} diff --git a/Sources/StreamChatSwiftUI/Utils/LiquidGlassModifiers.swift b/Sources/StreamChatSwiftUI/Utils/LiquidGlassModifiers.swift new file mode 100644 index 000000000..571979455 --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/LiquidGlassModifiers.swift @@ -0,0 +1,57 @@ +// +// Copyright ยฉ 2025 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct LiquidGlassBackground: ViewModifier { + @Injected(\.colors) var colors + + var shape: BackgroundShape + + func body(content: Content) -> some View { + content + .background( + shape + .stroke(Color(colors.innerBorder), lineWidth: 0.5) + .shadow( + color: .black.opacity(0.2), + radius: 12, + y: 6 + ) + ) + } +} + +// TODO: fallback +public struct LiquidGlassModifier: ViewModifier { + var shape: BackgroundShape + + public init(shape: BackgroundShape) { + self.shape = shape + } + + public func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .modifier(LiquidGlassBackground(shape: shape)) + .glassEffect(.regular, in: shape) + } else { + content + } + } +} + +struct CustomRoundedShape: Shape { + var radius: CGFloat = 16 + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} diff --git a/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift index 6226dc504..a0e88a0a5 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift @@ -177,14 +177,6 @@ extension ViewFactory { ) } - public func makeChannelListContentModifier(options: ChannelListContentModifierOptions) -> some ViewModifier { - EmptyViewModifier() - } - - public func makeChannelListModifier(options: ChannelListModifierOptions) -> some ViewModifier { - EmptyViewModifier() - } - // MARK: messages public func makeChannelDestination(options: ChannelDestinationOptions) -> @MainActor (ChannelSelectionInfo) -> ChatChannelView { @@ -218,28 +210,6 @@ extension ViewFactory { } } - public func makeMessageListModifier(options: MessageListModifierOptions) -> some ViewModifier { - EmptyViewModifier() - } - - public func makeMessageListContainerModifier(options: MessageListContainerModifierOptions) -> some ViewModifier { - EmptyViewModifier() - } - - public func makeMessageViewModifier(for messageModifierInfo: MessageModifierInfo) -> some ViewModifier { - MessageBubbleModifier( - message: messageModifierInfo.message, - isFirst: messageModifierInfo.isFirst, - injectedBackgroundColor: messageModifierInfo.injectedBackgroundColor, - cornerRadius: messageModifierInfo.cornerRadius, - forceLeftToRight: messageModifierInfo.forceLeftToRight - ) - } - - public func makeBouncedMessageActionsModifier(viewModel: ChatChannelViewModel) -> some ViewModifier { - BouncedMessageActionsModifier(viewModel: viewModel) - } - public func makeEmptyMessagesView( options: EmptyMessagesViewOptions ) -> some View { @@ -536,11 +506,11 @@ extension ViewFactory { public func makeLeadingComposerView( options: LeadingComposerViewOptions ) -> some View { - AttachmentPickerTypeView( + LeadingComposerView( + factory: self, pickerTypeState: options.state, channelConfig: options.channelConfig ) - .padding(.bottom, 8) } @ViewBuilder @@ -561,7 +531,8 @@ extension ViewFactory { maxMessageLength: options.maxMessageLength, cooldownDuration: options.cooldownDuration, onCustomAttachmentTap: options.onCustomAttachmentTap, - removeAttachmentWithId: options.removeAttachmentWithId + removeAttachmentWithId: options.removeAttachmentWithId, + sendMessage: options.sendMessage ) } .frame(height: 240) @@ -578,7 +549,8 @@ extension ViewFactory { maxMessageLength: options.maxMessageLength, cooldownDuration: options.cooldownDuration, onCustomAttachmentTap: options.onCustomAttachmentTap, - removeAttachmentWithId: options.removeAttachmentWithId + removeAttachmentWithId: options.removeAttachmentWithId, + sendMessage: options.sendMessage ) } } @@ -597,10 +569,19 @@ extension ViewFactory { ) } + public func makeComposerInputTrailingView( + options: ComposerInputTrailingViewOptions + ) -> some View { + TrailingInputComposerView( + viewModel: options.viewModel, + onTap: options.onTap + ) + } + public func makeTrailingComposerView( options: TrailingComposerViewOptions ) -> some View { - TrailingComposerView(onTap: options.onTap) + EmptyView() } public func makeComposerRecordingView( @@ -620,11 +601,7 @@ extension ViewFactory { public func makeComposerRecordingTipView(options: ComposerRecordingTipViewOptions) -> some View { RecordingTipView() } - - public func makeComposerViewModifier(options: ComposerViewModifierOptions) -> some ViewModifier { - EmptyViewModifier() - } - + public func makeAttachmentPickerView( options: AttachmentPickerViewOptions ) -> some View { @@ -643,7 +620,11 @@ extension ViewFactory { cameraImageAdded: options.cameraImageAdded, askForAssetsAccessPermissions: options.askForAssetsAccessPermissions, isDisplayed: options.isDisplayed, - height: options.height + height: options.height, + selectedAssetIds: options.selectedAssetIds, + channelController: options.channelController, + messageController: options.messageController, + canSendPoll: options.canSendPoll ) .offset(y: options.isDisplayed ? 0 : options.popupHeight) .animation(.spring()) @@ -678,6 +659,7 @@ extension ViewFactory { ) -> some View { AttachmentSourcePickerView( selected: options.selected, + canSendPoll: options.canSendPoll, onTap: options.onPickerStateChange ) } @@ -689,7 +671,8 @@ extension ViewFactory { PhotoAttachmentPickerView( assets: options.assets, onImageTap: options.onAssetTap, - imageSelected: options.isAssetSelected + imageSelected: options.isAssetSelected, + selectedAssetIds: options.selectedAssetIds ) } } @@ -804,7 +787,8 @@ extension ViewFactory { ReactionsOverlayContainer( message: options.message, contentRect: options.contentRect, - onReactionTap: options.onReactionTap + onReactionTap: options.onReactionTap, + onMoreReactionsTap: options.onMoreReactionsTap ) } @@ -816,6 +800,10 @@ extension ViewFactory { .blur(radius: options.popInAnimationInProgress ? 0 : 4) } + public func makeMoreReactionsView(options: MoreReactionsViewOptions) -> some View { + MoreReactionsView(onEmojiTap: options.onEmojiTap) + } + public func makeQuotedMessageHeaderView( options: QuotedMessageHeaderViewOptions ) -> some View { @@ -835,13 +823,13 @@ extension ViewFactory { } public func makeQuotedMessageContentView( - options: QuotedMessageContentViewOptions - ) -> some View { - QuotedMessageContentView( - factory: self, - options: options - ) - } + options: QuotedMessageContentViewOptions + ) -> some View { + QuotedMessageContentView( + factory: self, + options: options + ) + } public func makeCustomAttachmentQuotedView(options: CustomAttachmentQuotedViewOptions) -> some View { EmptyView() @@ -998,6 +986,8 @@ extension ViewFactory { /// Default class conforming to `ViewFactory`, used throughout the SDK. public class DefaultViewFactory: ViewFactory { @Injected(\.chatClient) public var chatClient + + public var styles = LiquidGlassStyles() private init() { // Private init. diff --git a/Sources/StreamChatSwiftUI/ViewFactory/Options/AttachmentViewFactoryOptions.swift b/Sources/StreamChatSwiftUI/ViewFactory/Options/AttachmentViewFactoryOptions.swift index 755c5e2e5..f7f967594 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory/Options/AttachmentViewFactoryOptions.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory/Options/AttachmentViewFactoryOptions.swift @@ -365,6 +365,14 @@ public final class AttachmentPickerViewOptions: Sendable { public let height: CGFloat /// The popup height of the picker. public let popupHeight: CGFloat + /// Snapshot of the currently selected asset identifiers. + public let selectedAssetIds: [String]? + /// The channel controller. + public let channelController: ChatChannelController + /// An optional message controller. + public let messageController: ChatMessageController? + /// Whether a poll can be sent. + public let canSendPoll: Bool public init( attachmentPickerState: Binding, @@ -381,7 +389,11 @@ public final class AttachmentPickerViewOptions: Sendable { askForAssetsAccessPermissions: @escaping @MainActor () -> Void, isDisplayed: Bool, height: CGFloat, - popupHeight: CGFloat + popupHeight: CGFloat, + selectedAssetIds: [String]? = nil, + channelController: ChatChannelController, + messageController: ChatMessageController?, + canSendPoll: Bool ) { self.attachmentPickerState = attachmentPickerState self.filePickerShown = filePickerShown @@ -398,6 +410,10 @@ public final class AttachmentPickerViewOptions: Sendable { self.isDisplayed = isDisplayed self.height = height self.popupHeight = popupHeight + self.selectedAssetIds = selectedAssetIds + self.channelController = channelController + self.messageController = messageController + self.canSendPoll = canSendPoll } } @@ -405,11 +421,18 @@ public final class AttachmentPickerViewOptions: Sendable { public final class AttachmentSourcePickerViewOptions: Sendable { /// The currently selected picker state. public let selected: AttachmentPickerState + /// Whether sending polls is allowed. + public let canSendPoll: Bool /// Callback when the picker state changes. public let onPickerStateChange: @MainActor (AttachmentPickerState) -> Void - public init(selected: AttachmentPickerState, onPickerStateChange: @escaping @MainActor (AttachmentPickerState) -> Void) { + public init( + selected: AttachmentPickerState, + canSendPoll: Bool, + onPickerStateChange: @escaping @MainActor (AttachmentPickerState) -> Void + ) { self.selected = selected + self.canSendPoll = canSendPoll self.onPickerStateChange = onPickerStateChange } } @@ -422,15 +445,19 @@ public final class PhotoAttachmentPickerViewOptions: Sendable { public let onAssetTap: @MainActor (AddedAsset) -> Void /// Function to check if an asset is selected. public let isAssetSelected: @MainActor (String) -> Bool + /// Snapshot of the currently selected asset identifiers. + public let selectedAssetIds: [String]? public init( assets: PHFetchResultCollection, onAssetTap: @escaping @MainActor (AddedAsset) -> Void, - isAssetSelected: @escaping @MainActor (String) -> Bool + isAssetSelected: @escaping @MainActor (String) -> Bool, + selectedAssetIds: [String]? = nil ) { self.assets = assets self.onAssetTap = onAssetTap self.isAssetSelected = isAssetSelected + self.selectedAssetIds = selectedAssetIds } } diff --git a/Sources/StreamChatSwiftUI/ViewFactory/Options/ComposerViewFactoryOptions.swift b/Sources/StreamChatSwiftUI/ViewFactory/Options/ComposerViewFactoryOptions.swift index 782696451..e3fa27c14 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory/Options/ComposerViewFactoryOptions.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory/Options/ComposerViewFactoryOptions.swift @@ -77,6 +77,8 @@ public final class ComposerInputViewOptions: Sendable { public let shouldScroll: Bool /// Callback to remove an attachment by ID. public let removeAttachmentWithId: @MainActor (String) -> Void + /// Sends a message. + public let sendMessage: @MainActor () -> Void public init( text: Binding, @@ -90,7 +92,8 @@ public final class ComposerInputViewOptions: Sendable { cooldownDuration: Int, onCustomAttachmentTap: @escaping @MainActor (CustomAttachment) -> Void, shouldScroll: Bool, - removeAttachmentWithId: @escaping @MainActor (String) -> Void + removeAttachmentWithId: @escaping @MainActor (String) -> Void, + sendMessage: @escaping @MainActor () -> Void ) { self.text = text self.selectedRangeLocation = selectedRangeLocation @@ -104,6 +107,7 @@ public final class ComposerInputViewOptions: Sendable { self.onCustomAttachmentTap = onCustomAttachmentTap self.shouldScroll = shouldScroll self.removeAttachmentWithId = removeAttachmentWithId + self.sendMessage = sendMessage } } @@ -143,6 +147,20 @@ public final class ComposerTextInputViewOptions: Sendable { } } +public final class ComposerInputTrailingViewOptions: Sendable { + // TODO: improve this. + public let viewModel: MessageComposerViewModel + public let onTap: @MainActor () -> Void + + public init( + viewModel: MessageComposerViewModel, + onTap: @escaping @MainActor () -> Void + ) { + self.viewModel = viewModel + self.onTap = onTap + } +} + /// Options for creating the trailing composer view. public final class TrailingComposerViewOptions: Sendable { /// Whether the composer is enabled. diff --git a/Sources/StreamChatSwiftUI/ViewFactory/Options/ReactionsViewFactoryOptions.swift b/Sources/StreamChatSwiftUI/ViewFactory/Options/ReactionsViewFactoryOptions.swift index 592d879e9..73da00221 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory/Options/ReactionsViewFactoryOptions.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory/Options/ReactionsViewFactoryOptions.swift @@ -114,14 +114,28 @@ public final class ReactionsContentViewOptions: Sendable { public let contentRect: CGRect /// Callback when a reaction is tapped. public let onReactionTap: @MainActor (MessageReactionType) -> Void + /// Callback when the more reactions button is tapped. + public let onMoreReactionsTap: @MainActor () -> Void public init( message: ChatMessage, contentRect: CGRect, - onReactionTap: @escaping @MainActor (MessageReactionType) -> Void + onReactionTap: @escaping @MainActor (MessageReactionType) -> Void, + onMoreReactionsTap: @escaping @MainActor () -> Void ) { self.message = message self.contentRect = contentRect self.onReactionTap = onReactionTap + self.onMoreReactionsTap = onMoreReactionsTap + } +} + +/// Options for creating the more reactions view. +public final class MoreReactionsViewOptions: Sendable { + /// Called when an emoji is tapped. + public let onEmojiTap: @MainActor (String) -> Void + + public init(onEmojiTap: @escaping @MainActor (String) -> Void) { + self.onEmojiTap = onEmojiTap } } diff --git a/Sources/StreamChatSwiftUI/ViewFactory/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory/ViewFactory.swift index bc679b4eb..d7f937905 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory/ViewFactory.swift @@ -13,6 +13,9 @@ import SwiftUI /// Returns the navigation bar display mode. func navigationBarDisplayMode() -> NavigationBarItem.TitleDisplayMode + + associatedtype StylesType: Styles + var styles: StylesType { get set } // MARK: - channels @@ -106,14 +109,6 @@ import SwiftUI /// - Returns: view shown in the search results. func makeChannelListSearchResultItem(options: ChannelListSearchResultItemOptions) -> ChannelListSearchResultItem - associatedtype ChannelListContentModifier: ViewModifier - /// Returns a view modifier applied to the channel list content (including both header and footer views). - func makeChannelListContentModifier(options: ChannelListContentModifierOptions) -> ChannelListContentModifier - - associatedtype ChannelListModifier: ViewModifier - /// Returns a view modifier applied to the channel list. - func makeChannelListModifier(options: ChannelListModifierOptions) -> ChannelListModifier - // MARK: - messages associatedtype ChannelDestination: View @@ -130,27 +125,6 @@ import SwiftUI /// - Returns: View shown in the empty messages slot. func makeEmptyMessagesView(options: EmptyMessagesViewOptions) -> EmptyMessagesViewType - associatedtype MessageListModifier: ViewModifier - /// Returns a view modifier applied to the message list. - func makeMessageListModifier(options: MessageListModifierOptions) -> MessageListModifier - - associatedtype MessageListContainerModifier: ViewModifier - /// Returns a view modifier applied to the message list container. - func makeMessageListContainerModifier(options: MessageListContainerModifierOptions) -> MessageListContainerModifier - - associatedtype MessageViewModifier: ViewModifier - /// Returns a view modifier applied to the message view. - /// - Parameter messageModifierInfo: the message modifier info, that will be applied to the message. - func makeMessageViewModifier(for messageModifierInfo: MessageModifierInfo) -> MessageViewModifier - - associatedtype BouncedMessageActionsModifierType: ViewModifier - /// Returns a view modifier applied to the bounced message actions. - /// - /// This modifier is only used if `Utils.messageListConfig.bouncedMessagesAlertActionsEnabled` is `true`. - /// By default the flag is true and the bounced actions are shown as an alert instead of a context menu. - /// - Parameter viewModel: the view model of the chat channel view. - func makeBouncedMessageActionsModifier(viewModel: ChatChannelViewModel) -> BouncedMessageActionsModifierType - associatedtype UserAvatar: View /// Creates the message avatar view. /// - Parameter options: the options for creating the message avatar view. @@ -379,6 +353,9 @@ import SwiftUI /// - Parameter options: the options for creating the composer text input view. /// - Returns: View shown in the composer text input slot. func makeComposerTextInputView(options: ComposerTextInputViewOptions) -> ComposerTextInputViewType + + associatedtype ComposerInputTrailingViewType: View + func makeComposerInputTrailingView(options: ComposerInputTrailingViewOptions) -> ComposerInputTrailingViewType associatedtype TrailingComposerViewType: View /// Creates the trailing composer view. @@ -403,10 +380,6 @@ import SwiftUI /// - Returns: view shown in the recording tip slot. func makeComposerRecordingTipView(options: ComposerRecordingTipViewOptions) -> ComposerRecordingTipViewType - associatedtype ComposerViewModifier: ViewModifier - /// Creates the composer view modifier, that's applied to the whole composer view. - func makeComposerViewModifier(options: ComposerViewModifierOptions) -> ComposerViewModifier - associatedtype AttachmentPickerViewType: View /// Creates the attachment picker view. /// - Parameter options: the options for creating the attachment picker view. @@ -501,6 +474,11 @@ import SwiftUI associatedtype ReactionsContentView: View func makeReactionsContentView(options: ReactionsContentViewOptions) -> ReactionsContentView + + associatedtype MoreReactionsViewType: View + /// Creates the more reactions view. + /// - Parameter options: The options for creating the more reactions view. + func makeMoreReactionsView(options: MoreReactionsViewOptions) -> MoreReactionsViewType associatedtype QuotedMessageHeaderViewType: View /// Creates the quoted message header view in the composer. diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index 9eeda981d..eee499fa0 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ 8402EACD282BF69B00CCA696 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8402EACC282BF69B00CCA696 /* Preview Assets.xcassets */; }; 8402EAD2282BFC8700CCA696 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402EAD1282BFC8700CCA696 /* AppDelegate.swift */; }; 8402EAD4282BFCCA00CCA696 /* UserCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402EAD3282BFCCA00CCA696 /* UserCredentials.swift */; }; + 8406EAA22EF06E0E0054333C /* LeadingComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8406EAA12EF06E0E0054333C /* LeadingComposerView.swift */; }; 840A3F3828193AB20084E9CC /* ChatChannelInfoView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A3F3728193AB20084E9CC /* ChatChannelInfoView_Tests.swift */; }; 8413C4552B4409B600190AF4 /* PinChannelHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8413C4542B4409B600190AF4 /* PinChannelHelpers.swift */; }; 8413D90227A9654600A89432 /* SearchResultsView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8413D90127A9654600A89432 /* SearchResultsView_Tests.swift */; }; @@ -197,6 +198,7 @@ 845161802AE7C4E2000A9230 /* WhatsAppChannelHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451617F2AE7C4E2000A9230 /* WhatsAppChannelHeader.swift */; }; 8451C4912BD7096000849955 /* PollAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451C4902BD7096000849955 /* PollAttachmentView.swift */; }; 8451C4932BD713D600849955 /* PollAttachmentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451C4922BD713D600849955 /* PollAttachmentViewModel.swift */; }; + 845B3FC62EF1B2D90091ED36 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B3FC52EF1B2D90091ED36 /* LiquidGlassModifiers.swift */; }; 845CFD782BDA6BFD0058F691 /* PollResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845CFD772BDA6BFD0058F691 /* PollResultsView.swift */; }; 8463D9262836617F002B1894 /* ChannelListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8463D9252836617F002B1894 /* ChannelListPage.swift */; }; 8465FBBE2746873A00AF091E /* StreamChatSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; }; @@ -457,6 +459,7 @@ 84E6EC23279AEE6B0017207B /* MessageContainerView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E6EC22279AEE6B0017207B /* MessageContainerView_Tests.swift */; }; 84E6EC25279AEE9F0017207B /* StreamChatTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E6EC24279AEE9F0017207B /* StreamChatTestCase.swift */; }; 84E6EC27279B0C930017207B /* ReactionsUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E6EC26279B0C930017207B /* ReactionsUsersView.swift */; }; + 84E7F9952EF981DC00BA56A3 /* MoreReactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E7F9942EF981DC00BA56A3 /* MoreReactionsView.swift */; }; 84E95A77284A486600699FD3 /* StreamChat in Frameworks */ = {isa = PBXBuildFile; productRef = 84E95A76284A486600699FD3 /* StreamChat */; }; 84E95A7D284A491000699FD3 /* StreamChatTestTools in Frameworks */ = {isa = PBXBuildFile; productRef = 84E95A7C284A491000699FD3 /* StreamChatTestTools */; }; 84EADEA22B2735D80046B50C /* VoiceRecordingContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEA12B2735D80046B50C /* VoiceRecordingContainerView.swift */; }; @@ -473,6 +476,8 @@ 84EADEC12B2AFA690046B50C /* MessageComposerViewModel+Recording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEC02B2AFA690046B50C /* MessageComposerViewModel+Recording.swift */; }; 84EADEC32B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEC22B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift */; }; 84EADEC52B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEC42B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift */; }; + 84EB7A612EF40F1B00986E0F /* TrailingInputComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB7A602EF40F1B00986E0F /* TrailingInputComposerView.swift */; }; + 84EB7A632EF414AA00986E0F /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB7A622EF414AA00986E0F /* Styles.swift */; }; 84EB881A2E8ABA610076DC17 /* ParticipantInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB88192E8ABA610076DC17 /* ParticipantInfoView.swift */; }; 84EDBC37274FE5CD0057218D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 84EDBC36274FE5CD0057218D /* Localizable.strings */; }; 84F130C12AEAA957006E7B52 /* StreamLazyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */; }; @@ -727,6 +732,7 @@ 8402EACC282BF69B00CCA696 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 8402EAD1282BFC8700CCA696 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 8402EAD3282BFCCA00CCA696 /* UserCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCredentials.swift; sourceTree = ""; }; + 8406EAA12EF06E0E0054333C /* LeadingComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeadingComposerView.swift; sourceTree = ""; }; 840A3F3728193AB20084E9CC /* ChatChannelInfoView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelInfoView_Tests.swift; sourceTree = ""; }; 8413C4542B4409B600190AF4 /* PinChannelHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinChannelHelpers.swift; sourceTree = ""; }; 8413D90127A9654600A89432 /* SearchResultsView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView_Tests.swift; sourceTree = ""; }; @@ -790,6 +796,7 @@ 8451617F2AE7C4E2000A9230 /* WhatsAppChannelHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppChannelHeader.swift; sourceTree = ""; }; 8451C4902BD7096000849955 /* PollAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollAttachmentView.swift; sourceTree = ""; }; 8451C4922BD713D600849955 /* PollAttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollAttachmentViewModel.swift; sourceTree = ""; }; + 845B3FC52EF1B2D90091ED36 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = ""; }; 845CFD772BDA6BFD0058F691 /* PollResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultsView.swift; sourceTree = ""; }; 8463D9252836617F002B1894 /* ChannelListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListPage.swift; sourceTree = ""; }; 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StreamChatSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1057,6 +1064,7 @@ 84E6EC22279AEE6B0017207B /* MessageContainerView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContainerView_Tests.swift; sourceTree = ""; }; 84E6EC24279AEE9F0017207B /* StreamChatTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamChatTestCase.swift; sourceTree = ""; }; 84E6EC26279B0C930017207B /* ReactionsUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsUsersView.swift; sourceTree = ""; }; + 84E7F9942EF981DC00BA56A3 /* MoreReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreReactionsView.swift; sourceTree = ""; }; 84EADEA12B2735D80046B50C /* VoiceRecordingContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRecordingContainerView.swift; sourceTree = ""; }; 84EADEA32B2746B70046B50C /* VideoDurationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDurationFormatter.swift; sourceTree = ""; }; 84EADEA52B2748AD0046B50C /* AudioRecordingNameFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordingNameFormatter.swift; sourceTree = ""; }; @@ -1071,6 +1079,8 @@ 84EADEC02B2AFA690046B50C /* MessageComposerViewModel+Recording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageComposerViewModel+Recording.swift"; sourceTree = ""; }; 84EADEC22B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionFeedbackGenerator.swift; sourceTree = ""; }; 84EADEC42B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddedVoiceRecordingsView.swift; sourceTree = ""; }; + 84EB7A602EF40F1B00986E0F /* TrailingInputComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingInputComposerView.swift; sourceTree = ""; }; + 84EB7A622EF414AA00986E0F /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = ""; }; 84EB88192E8ABA610076DC17 /* ParticipantInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantInfoView.swift; sourceTree = ""; }; 84EDBC36274FE5CD0057218D /* Localizable.strings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; 84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamLazyImage.swift; sourceTree = ""; }; @@ -1660,6 +1670,7 @@ 8465FD692746A95700AF091E /* Appearance.swift */, 8465FD5E2746A95700AF091E /* Fonts.swift */, 8465FD622746A95700AF091E /* Images.swift */, + 84EB7A622EF414AA00986E0F /* Styles.swift */, 8465FD642746A95700AF091E /* ColorPalette.swift */, 8465FD632746A95700AF091E /* Utils.swift */, 8465FD5F2746A95700AF091E /* InjectedValuesExtensions.swift */, @@ -1808,6 +1819,8 @@ 8465FD122746A95600AF091E /* SendMessageButton.swift */, 8492975127B156D000A8EEB0 /* SlowModeView.swift */, 84EADEB12B2883C60046B50C /* TrailingComposerView.swift */, + 8406EAA12EF06E0E0054333C /* LeadingComposerView.swift */, + 84EB7A602EF40F1B00986E0F /* TrailingInputComposerView.swift */, ); path = Composer; sourceTree = ""; @@ -1825,6 +1838,7 @@ 84E6EC26279B0C930017207B /* ReactionsUsersView.swift */, AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */, 846D6563279FF0800094B36E /* ReactionUserView.swift */, + 84E7F9942EF981DC00BA56A3 /* MoreReactionsView.swift */, ); path = Reactions; sourceTree = ""; @@ -1883,6 +1897,7 @@ 8465FD3B2746A95600AF091E /* StringExtensions.swift */, 4FEAB3172BFF71F70057E511 /* SwiftUI+UIAlertController.swift */, 8465FD322746A95600AF091E /* ViewExtensions.swift */, + 845B3FC52EF1B2D90091ED36 /* LiquidGlassModifiers.swift */, ); path = Utils; sourceTree = ""; @@ -2665,6 +2680,7 @@ 841B64D42775F5540016FF3B /* GiphyCommandHandler.swift in Sources */, 8434E58127707F19001E1B83 /* GridMediaView.swift in Sources */, ADE0F5662CB962470053B8B9 /* ActionBannerView.swift in Sources */, + 8406EAA22EF06E0E0054333C /* LeadingComposerView.swift in Sources */, 84BB4C4C2841104700CBE004 /* MessageListDateUtils.swift in Sources */, 84EB881A2E8ABA610076DC17 /* ParticipantInfoView.swift in Sources */, 8465FDC12746A95700AF091E /* NoChannelsView.swift in Sources */, @@ -2707,6 +2723,7 @@ 8465FD932746A95700AF091E /* PhotoAttachmentPickerView.swift in Sources */, 841B64C82774BA770016FF3B /* CommandsHandler.swift in Sources */, 8465FDC42746A95700AF091E /* ChatChannelListScreen.swift in Sources */, + 84EB7A632EF414AA00986E0F /* Styles.swift in Sources */, 8465FD7F2746A95700AF091E /* MessageTypeResolver.swift in Sources */, 8465FDA42746A95700AF091E /* NukeImageLoader.swift in Sources */, 8465FD842746A95700AF091E /* MessageAvatarView.swift in Sources */, @@ -2720,6 +2737,7 @@ 8465FDBA2746A95700AF091E /* BundleExtensions.swift in Sources */, 84AD8425274E2C380098C3C4 /* ChatChannelScreen.swift in Sources */, 8465FD812746A95700AF091E /* SystemMessageView.swift in Sources */, + 84E7F9952EF981DC00BA56A3 /* MoreReactionsView.swift in Sources */, 8465FD7D2746A95700AF091E /* MessageBubble.swift in Sources */, 84DEC8E82760EABC00172876 /* ChatChannelDataSource.swift in Sources */, 8465FDC62746A95700AF091E /* ChannelHeaderLoader.swift in Sources */, @@ -2907,8 +2925,10 @@ 8465FDAF2746A95700AF091E /* UIImage+Extensions.swift in Sources */, 8465FDCE2746A95700AF091E /* InjectedValuesExtensions.swift in Sources */, 84B738352BE2661B00EC66EC /* PollOptionAllVotesViewModel.swift in Sources */, + 845B3FC62EF1B2D90091ED36 /* LiquidGlassModifiers.swift in Sources */, 8465FDAC2746A95700AF091E /* UIFont+Extensions.swift in Sources */, 846D6564279FF0800094B36E /* ReactionUserView.swift in Sources */, + 84EB7A612EF40F1B00986E0F /* TrailingInputComposerView.swift in Sources */, 8465FDB12746A95700AF091E /* ChatChannelNamer.swift in Sources */, 8465FD9E2746A95700AF091E /* ChatChannelHelpers.swift in Sources */, 84E4F7CF294C69F300DD4CE3 /* MessageIdBuilder.swift in Sources */, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift index 3c2aff650..606f4d285 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift @@ -155,7 +155,8 @@ import XCTest quotedMessage: .constant(nil), cooldownDuration: 15, onCustomAttachmentTap: { _ in }, - removeAttachmentWithId: { _ in } + removeAttachmentWithId: { _ in }, + sendMessage: {} ) .environmentObject(MessageComposerTestUtils.makeComposerViewModel(chatClient: chatClient)) .frame(width: defaultScreenSize.width, height: 100) @@ -283,7 +284,8 @@ import XCTest quotedMessage: .constant(nil), cooldownDuration: 0, onCustomAttachmentTap: { _ in }, - removeAttachmentWithId: { _ in } + removeAttachmentWithId: { _ in }, + sendMessage: {} ) .environmentObject(viewModel) .frame(width: defaultScreenSize.width, height: 100) @@ -527,7 +529,8 @@ import XCTest quotedMessage: .constant(nil), cooldownDuration: 0, onCustomAttachmentTap: { _ in }, - removeAttachmentWithId: { _ in } + removeAttachmentWithId: { _ in }, + sendMessage: {} ) .environmentObject(MessageComposerTestUtils.makeComposerViewModel(chatClient: chatClient)) .frame(width: size.width, height: size.height) @@ -721,7 +724,8 @@ import XCTest cooldownDuration: 0, onCustomAttachmentTap: { _ in }, - removeAttachmentWithId: { _ in } + removeAttachmentWithId: { _ in }, + sendMessage: {} ) .environmentObject(viewModel) .frame(width: size.width, height: size.height) diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift index ae4f2b01e..331c47243 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewLastGroupHeader_Tests.swift @@ -71,6 +71,7 @@ import XCTest class CustomHeaderViewFactory: ViewFactory { @Injected(\.chatClient) var chatClient: ChatClient + var styles = LiquidGlassStyles() func makeLastInGroupHeaderView(options: LastInGroupHeaderViewOptions) -> some View { HStack { diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift index 5523e08e7..341b667e2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageViewMultiRowReactions_Tests.swift @@ -66,6 +66,7 @@ import XCTest class TestViewFactory: ViewFactory { @Injected(\.chatClient) public var chatClient + var styles = LiquidGlassStyles() private init() {} diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift index 652f72434..79c10018d 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/QuotedMessageView_Tests.swift @@ -266,6 +266,7 @@ private struct FootballGameAttachmentPayload: AttachmentPayload { private class CustomQuotedContentViewFactory: ViewFactory { @Injected(\.chatClient) var chatClient + var styles = LiquidGlassStyles() private init() {} diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift index 9c7cb6bee..dde212c41 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift @@ -174,7 +174,8 @@ import XCTest let view = ReactionsOverlayContainer( message: message, contentRect: .init(x: -60, y: 200, width: 300, height: 300), - onReactionTap: { _ in } + onReactionTap: { _ in }, + onMoreReactionsTap: {} ) // Then @@ -190,7 +191,8 @@ import XCTest let view = ReactionsAnimatableView( message: message, reactions: reactions, - onReactionTap: { _ in } + onReactionTap: { _ in }, + onMoreReactionsTap: {} ) // Then diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift index 6e82c6b15..421eccf53 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift @@ -154,6 +154,7 @@ import XCTest class ChannelAvatarViewFactory: ViewFactory { @Injected(\.chatClient) var chatClient + var styles = LiquidGlassStyles() func makeChannelAvatarView( options: ChannelAvatarViewFactoryOptions diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift index c2ad773c4..72007560e 100644 --- a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift @@ -62,6 +62,7 @@ import XCTest class CustomFactory: ViewFactory { @Injected(\.chatClient) public var chatClient + var styles = LiquidGlassStyles() func makeThreadListLoadingView(options: ThreadListLoadingViewOptions) -> some View { LoadingView() diff --git a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift index 5548eaa70..db0ebf8f7 100644 --- a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift +++ b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift @@ -689,7 +689,7 @@ import XCTest let viewFactory = DefaultViewFactory.shared // When - let modifier = viewFactory.makeChannelListModifier(options: ChannelListModifierOptions()) + let modifier = viewFactory.styles.makeChannelListModifier(options: ChannelListModifierOptions()) // Then XCTAssert(modifier is EmptyViewModifier) @@ -700,7 +700,7 @@ import XCTest let viewFactory = DefaultViewFactory.shared // When - let modifier = viewFactory.makeMessageListModifier(options: MessageListModifierOptions()) + let modifier = viewFactory.styles.makeMessageListModifier(options: MessageListModifierOptions()) // Then XCTAssert(modifier is EmptyViewModifier) @@ -711,7 +711,7 @@ import XCTest let viewFactory = DefaultViewFactory.shared // When - let modifier = viewFactory.makeMessageViewModifier( + let modifier = viewFactory.styles.makeMessageViewModifier( for: MessageModifierInfo( message: message, isFirst: false @@ -727,7 +727,7 @@ import XCTest let viewFactory = DefaultViewFactory.shared // When - let modifier = viewFactory.makeComposerViewModifier(options: ComposerViewModifierOptions()) + let modifier = viewFactory.styles.makeComposerViewModifier(options: ComposerViewModifierOptions()) // Then XCTAssert(modifier is EmptyViewModifier) @@ -760,7 +760,7 @@ import XCTest let viewFactory = DefaultViewFactory.shared // When - let viewModifier = viewFactory.makeChannelListContentModifier(options: ChannelListContentModifierOptions()) + let viewModifier = viewFactory.styles.makeChannelListContentModifier(options: ChannelListContentModifierOptions()) // Then XCTAssert(viewModifier is EmptyViewModifier) @@ -865,7 +865,8 @@ import XCTest options: ReactionsContentViewOptions( message: .mock(), contentRect: .zero, - onReactionTap: { _ in } + onReactionTap: { _ in }, + onMoreReactionsTap: {} ) ) @@ -915,7 +916,7 @@ import XCTest let viewFactory = DefaultViewFactory.shared // When - let modifier = viewFactory.makeMessageListContainerModifier(options: MessageListContainerModifierOptions()) + let modifier = viewFactory.styles.makeMessageListContainerModifier(options: MessageListContainerModifierOptions()) // Then XCTAssert(modifier is EmptyViewModifier) diff --git a/ViewFactoryMigration.swift b/ViewFactoryMigration.swift index 85b7be1e9..e30b39e3a 100644 --- a/ViewFactoryMigration.swift +++ b/ViewFactoryMigration.swift @@ -718,7 +718,7 @@ import SwiftUI /// - addedCustomAttachments: list of added custom attachments. /// - cameraImageAdded: called when an asset from the camera is added. /// - askForAssetsAccessPermissions: provides access to photos library (and others if needed). - /// - isDisplayed: thether the attachment picker view is displayed. + /// - isDisplayed: whether the attachment picker view is displayed. /// - height: the current height of the picker. /// - popupHeight: the height of the popup when displayed. /// - Returns: view displayed in the attachment picker slot.