diff --git a/CHANGELOG.md b/CHANGELOG.md index ab8faa467..c75ea1bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### ✅ Added +- Add support for editing message attachments [#806](https://github.com/GetStream/stream-chat-swiftui/pull/806) +### 🐞 Fixed +- Fix scrolling to the bottom when editing a message [#806](https://github.com/GetStream/stream-chat-swiftui/pull/806) +- Fix having message edit action on Giphy messages [#806](https://github.com/GetStream/stream-chat-swiftui/pull/806) +- Fix being able to long press an unsent Giphy message [#806](https://github.com/GetStream/stream-chat-swiftui/pull/806) +- Fix being able to swipe to reply an unsent Giphy message [#806](https://github.com/GetStream/stream-chat-swiftui/pull/806) ### 🔄 Changed +- Deprecated `ComposerConfig.attachmentPayloadConverter` in favour of `MessageComposerViewModel.convertAddedAssetsToPayloads()` [#806](https://github.com/GetStream/stream-chat-swiftui/pull/806) # [4.77.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.77.0) _April 10, 2025_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift index a0e866366..4269a3385 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift @@ -115,7 +115,9 @@ public struct ChatChannelView: View, KeyboardReadable { messageController: viewModel.messageController, quotedMessage: $viewModel.quotedMessage, editedMessage: $viewModel.editedMessage, - onMessageSent: viewModel.scrollToLastMessage + onMessageSent: { + viewModel.messageSentTapped() + } ) .opacity(( utils.messageListConfig.messagePopoverEnabled && messageDisplayInfo != nil && !viewModel diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index f9376c7b6..b6e5c90bc 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -264,7 +264,15 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } } - + + /// The user tapped on the message sent button. + public func messageSentTapped() { + // only scroll if the message is not being edited + if editedMessage == nil { + scrollToLastMessage() + } + } + public func jumpToMessage(messageId: String) -> Bool { if messageId == .unknownMessageId { if firstUnreadMessageId == nil, let lastReadMessageId { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift index 3c3225cf5..3fa0b7fef 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift @@ -17,6 +17,14 @@ public struct ComposerConfig { public var inputPaddingsConfig: PaddingsConfig public var adjustMessageOnSend: (String) -> (String) public var adjustMessageOnRead: (String) -> (String) + + @available( + *, + deprecated, + message: """ + Override the MessageComposerViewModel.inputAttachmentsAsPayloads() in order to convert the message attachments to payloads. + """ + ) public var attachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] public init( @@ -44,8 +52,9 @@ public struct ComposerConfig { self.isVoiceRecordingEnabled = isVoiceRecordingEnabled } - public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { message in - message.allAttachments.toAnyAttachmentPayload() + public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { _ in + /// This now returns empty array by default since attachmentPayloadConverter has been deprecated. + [] } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift index be0dee00a..c8b50be37 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift @@ -27,25 +27,34 @@ public struct AddedAsset: Identifiable, Equatable { public let url: URL public let type: AssetType public var extraData: [String: RawJSON] = [:] - + + /// The payload of the attachment, in case the attachment has been uploaded to server already. + /// This is mostly used when editing an existing message that contains attachments. + public var payload: AttachmentPayload? + public init( image: UIImage, id: String, url: URL, type: AssetType, - extraData: [String: RawJSON] = [:] + extraData: [String: RawJSON] = [:], + payload: AttachmentPayload? = nil ) { self.image = image self.id = id self.url = url self.type = type self.extraData = extraData + self.payload = payload } } extension AddedAsset { func toAttachmentPayload() throws -> AnyAttachmentPayload { - try AnyAttachmentPayload( + if let payload = self.payload { + return AnyAttachmentPayload(payload: payload) + } + return try AnyAttachmentPayload( localFileURL: url, attachmentType: type == .video ? .video : .image, extraData: extraData @@ -63,7 +72,8 @@ extension AnyChatMessageAttachment { id: imageAttachment.id.rawValue, url: imageAttachment.imageURL, type: .image, - extraData: imageAttachment.extraData ?? [:] + extraData: imageAttachment.extraData ?? [:], + payload: imageAttachment.payload ) } else if let videoAttachment = attachment(payloadType: VideoAttachmentPayload.self), let thumbnail = imageThumbnail(for: videoAttachment.payload) { @@ -72,7 +82,8 @@ extension AnyChatMessageAttachment { id: videoAttachment.id.rawValue, url: videoAttachment.videoURL, type: .video, - extraData: videoAttachment.extraData ?? [:] + extraData: videoAttachment.extraData ?? [:], + payload: videoAttachment.payload ) } return nil diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift index 591a6847a..1e921a6cb 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift @@ -102,9 +102,11 @@ public struct MessageComposerView: View, KeyboardReadable 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 - onMessageSent() } } .environmentObject(viewModel) @@ -208,11 +210,10 @@ public struct MessageComposerView: View, KeyboardReadable ) .modifier(factory.makeComposerViewModifier()) .onChange(of: editedMessage) { _ in - viewModel.text = editedMessage?.text ?? "" + viewModel.fillEditedMessage(editedMessage) if editedMessage != nil { becomeFirstResponder() editedMessageWillShow = true - viewModel.selectedRangeLocation = editedMessage?.text.count ?? 0 } } .onAppear(perform: { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift index b6ad2af13..ce5416521 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift @@ -11,7 +11,17 @@ import SwiftUI open class MessageComposerViewModel: ObservableObject { @Injected(\.chatClient) private var chatClient @Injected(\.utils) internal var utils - + + var attachmentsConverter = MessageAttachmentsConverter() + var composerAssets: ComposerAssets { + ComposerAssets( + mediaAssets: addedAssets, + fileAssets: addedFileURLs.map { FileAddedAsset(url: $0, payload: addedRemoteFileURLs[$0]) }, + voiceAssets: addedVoiceRecordings, + customAssets: addedCustomAttachments + ) + } + @Published public var pickerState: AttachmentPickerState = .photos { didSet { if pickerState == .camera { @@ -67,7 +77,9 @@ open class MessageComposerViewModel: ObservableObject { } @Published public var selectedRangeLocation: Int = 0 - + + /// An helper property to store additional information of file attachments. + private var addedRemoteFileURLs: [URL: FileAttachmentPayload] = [:] @Published public var addedFileURLs = [URL]() { didSet { if totalAttachmentsCount > chatClient.config.maxAttachmentCountPerMessage @@ -262,48 +274,23 @@ open class MessageComposerViewModel: ObservableObject { ) } - /// Populates the draft message in the composer with the current controller's draft information. - public func fillDraftMessage() { - guard let message = draftMessage else { + /// Populates the composer with the edited message. + public func fillEditedMessage(_ editedMessage: ChatMessage?) { + guard let message = editedMessage else { + clearInputData() return } - text = message.text - mentionedUsers = message.mentionedUsers - quotedMessage?.wrappedValue = message.quotedMessage - showReplyInChannel = message.showReplyInChannel - - var addedAssets: [AddedAsset] = [] - var addedFileURLs: [URL] = [] - var addedVoiceRecordings: [AddedVoiceRecording] = [] - var addedCustomAttachments: [CustomAttachment] = [] + fillComposer(with: message) + } - message.attachments.forEach { attachment in - switch attachment.type { - case .image, .video: - guard let addedAsset = attachment.toAddedAsset() else { break } - addedAssets.append(addedAsset) - case .file: - guard let url = attachment.attachment(payloadType: FileAttachmentPayload.self)?.assetURL else { - break - } - addedFileURLs.append(url) - case .voiceRecording: - guard let addedVoiceRecording = attachment.toAddedVoiceRecording() else { break } - addedVoiceRecordings.append(addedVoiceRecording) - case .linkPreview, .audio, .giphy, .unknown: - break - default: - guard let anyAttachmentPayload = [attachment].toAnyAttachmentPayload().first else { break } - let customAttachment = CustomAttachment(id: attachment.id.rawValue, content: anyAttachmentPayload) - addedCustomAttachments.append(customAttachment) - } + /// Populates the draft message in the composer with the current controller's draft information. + public func fillDraftMessage() { + guard let draft = draftMessage else { + return } - self.addedAssets = addedAssets - self.addedFileURLs = addedFileURLs - self.addedVoiceRecordings = addedVoiceRecordings - self.addedCustomAttachments = addedCustomAttachments + fillComposer(with: ChatMessage(draft)) } /// Updates the draft message locally and on the server. @@ -315,7 +302,7 @@ open class MessageComposerViewModel: ObservableObject { guard utils.messageListConfig.draftMessagesEnabled && sendButtonEnabled else { return } - let attachments = try? inputAttachmentsAsPayloads() + let attachments = try? convertAddedAssetsToPayloads() let mentionedUserIds = mentionedUsers.map(\.id) let availableCommands = channelController.channel?.config.commands ?? [] let command = availableCommands.first { composerCommand?.id == "/\($0.name)" } @@ -358,11 +345,6 @@ open class MessageComposerViewModel: ObservableObject { } } - /// Checks if the previous value of the content in the composer was not empty and the current value is empty. - private func shouldDeleteDraftMessage(oldValue: any Collection) -> Bool { - !oldValue.isEmpty && !sendButtonEnabled - } - open func sendMessage( quotedMessage: ChatMessage?, editedMessage: ChatMessage?, @@ -375,7 +357,7 @@ open class MessageComposerViewModel: ObservableObject { defer { checkChannelCooldown() } - + if let composerCommand = composerCommand, composerCommand.id != "instantCommands" { commandsHandler.executeOnMessageSent( composerCommand: composerCommand @@ -393,12 +375,16 @@ open class MessageComposerViewModel: ObservableObject { let mentionedUserIds = mentionedUsers.map(\.id) if let editedMessage = editedMessage { - edit(message: editedMessage, completion: completion) + edit( + message: editedMessage, + attachments: try? convertAddedAssetsToPayloads(), + completion: completion + ) return } do { - let attachments = try inputAttachmentsAsPayloads() + let attachments = try convertAddedAssetsToPayloads() if let messageController = messageController { messageController.createNewReply( text: messageText, @@ -641,9 +627,41 @@ open class MessageComposerViewModel: ObservableObject { extraData: extraData ) } - + + /// Converts all added assets to payloads. + open func convertAddedAssetsToPayloads() throws -> [AnyAttachmentPayload] { + try attachmentsConverter.assetsToPayloads(composerAssets) + } + // MARK: - private - + + private func fillComposer(with message: ChatMessage) { + text = message.text + mentionedUsers = message.mentionedUsers + quotedMessage?.wrappedValue = message.quotedMessage + showReplyInChannel = message.showReplyInChannel + selectedRangeLocation = message.text.count + + attachmentsConverter.attachmentsToAssets(message.allAttachments) { [weak self] assets in + self?.updateComposerAssets(assets) + } + } + + private func updateComposerAssets(_ assets: ComposerAssets) { + addedAssets = assets.mediaAssets + addedFileURLs = assets.fileAssets.map(\.url) + addedRemoteFileURLs = assets.fileAssets.reduce(into: [:]) { result, asset in + result[asset.url] = asset.payload + } + addedVoiceRecordings = assets.voiceAssets + addedCustomAttachments = assets.customAssets + } + + /// Checks if the previous value of the content in the composer was not empty and the current value is empty. + private func shouldDeleteDraftMessage(oldValue: any Collection) -> Bool { + !oldValue.isEmpty && !sendButtonEnabled + } + private func fetchAssets() { let fetchOptions = PHFetchOptions() let supportedTypes = utils.composerConfig.gallerySupportedTypes @@ -663,30 +681,6 @@ open class MessageComposerViewModel: ObservableObject { } } - private func inputAttachmentsAsPayloads() throws -> [AnyAttachmentPayload] { - var attachments = try addedAssets.map { try $0.toAttachmentPayload() } - attachments += try addedFileURLs.map { url in - _ = url.startAccessingSecurityScopedResource() - return try AnyAttachmentPayload(localFileURL: url, attachmentType: .file) - } - attachments += try addedVoiceRecordings.map { recording in - _ = recording.url.startAccessingSecurityScopedResource() - var localMetadata = AnyAttachmentLocalMetadata() - localMetadata.duration = recording.duration - localMetadata.waveformData = recording.waveform - return try AnyAttachmentPayload( - localFileURL: recording.url, - attachmentType: .voiceRecording, - localMetadata: localMetadata - ) - } - - attachments += addedCustomAttachments.map { attachment in - attachment.content - } - return attachments - } - private func checkForMentionedUsers( commandId: String?, extraData: [String: Any] @@ -708,6 +702,7 @@ open class MessageComposerViewModel: ObservableObject { private func edit( message: ChatMessage, + attachments: [AnyAttachmentPayload]?, completion: @escaping () -> Void ) { guard let channelId = channelController.channel?.cid else { @@ -717,10 +712,16 @@ open class MessageComposerViewModel: ObservableObject { cid: channelId, messageId: message.id ) - + + var newAttachments = attachments ?? [] + let fallbackAttachments = utils.composerConfig.attachmentPayloadConverter(message) + if !fallbackAttachments.isEmpty { + newAttachments = fallbackAttachments + } + messageController.editMessage( text: adjustedText, - attachments: utils.composerConfig.attachmentPayloadConverter(message) + attachments: newAttachments ) { [weak self] error in if error != nil { self?.errorShown = true @@ -827,7 +828,7 @@ open class MessageComposerViewModel: ObservableObject { /// Same as clearText() but it just clears the command id. private func clearCommandText() { - guard let command = composerCommand else { return } + guard composerCommand != nil else { return } let currentText = text if let value = getValueOfCommand(currentText) { text = value @@ -871,7 +872,9 @@ open class MessageComposerViewModel: ObservableObject { attachmentSizeExceeded = !canAdd return canAdd } catch { - return false + // If for some reason we can't access the file size, we delegate + // the decision to the server. + return true } } @@ -902,3 +905,116 @@ extension MessageComposerViewModel: EventsControllerDelegate { } } } + +// The assets added to the composer. +struct ComposerAssets { + // Image and Video Assets. + var mediaAssets: [AddedAsset] = [] + // File Assets. + var fileAssets: [FileAddedAsset] = [] + // Voice Assets. + var voiceAssets: [AddedVoiceRecording] = [] + // Custom Assets. + var customAssets: [CustomAttachment] = [] +} + +// A asset containing file information. +// If it has a payload, it means that the file is already uploaded to the server. +struct FileAddedAsset { + var url: URL + var payload: FileAttachmentPayload? +} + +// The converter responsible to map attachments to assets and vice versa. +class MessageAttachmentsConverter { + let queue = DispatchQueue(label: "MessageAttachmentsConverter") + + /// Converts the added assets to payloads. + func assetsToPayloads(_ assets: ComposerAssets) throws -> [AnyAttachmentPayload] { + let mediaAssets = assets.mediaAssets + let fileAssets = assets.fileAssets + let voiceAssets = assets.voiceAssets + let customAssets = assets.customAssets + + var attachments = try mediaAssets.map { try $0.toAttachmentPayload() } + attachments += try fileAssets.map { file in + _ = file.url.startAccessingSecurityScopedResource() + if let filePayload = file.payload { + return AnyAttachmentPayload(payload: filePayload) + } + return try AnyAttachmentPayload(localFileURL: file.url, attachmentType: .file) + } + attachments += try voiceAssets.map { recording in + _ = recording.url.startAccessingSecurityScopedResource() + var localMetadata = AnyAttachmentLocalMetadata() + localMetadata.duration = recording.duration + localMetadata.waveformData = recording.waveform + return try AnyAttachmentPayload( + localFileURL: recording.url, + attachmentType: .voiceRecording, + localMetadata: localMetadata + ) + } + + attachments += customAssets.map { attachment in + attachment.content + } + return attachments + } + + /// Converts the attachments to assets. + /// + /// This operation is asynchronous to make sure loading expensive assets are not done in the main thread. + func attachmentsToAssets( + _ attachments: [AnyChatMessageAttachment], + completion: @escaping (ComposerAssets) -> Void + ) { + queue.async { + let addedAssets = self.attachmentsToAssets(attachments) + DispatchQueue.main.async { + completion(addedAssets) + } + } + } + + /// Converts the attachments to assets synchronously. + /// + /// This operation is synchronous and should only be used if all attachments are already loaded. + /// Like for example, for draft messages. + func attachmentsToAssets( + _ attachments: [AnyChatMessageAttachment] + ) -> ComposerAssets { + var addedAssets = ComposerAssets() + + attachments.forEach { attachment in + switch attachment.type { + case .image, .video: + guard let addedAsset = attachment.toAddedAsset() else { break } + addedAssets.mediaAssets.append(addedAsset) + case .file: + guard let filePayload = attachment.attachment(payloadType: FileAttachmentPayload.self) else { + break + } + let fileAsset = FileAddedAsset( + url: filePayload.assetURL, + payload: filePayload.payload + ) + addedAssets.fileAssets.append(fileAsset) + case .voiceRecording: + guard let addedVoiceRecording = attachment.toAddedVoiceRecording() else { break } + addedAssets.voiceAssets.append(addedVoiceRecording) + case .linkPreview, .audio, .giphy, .unknown: + break + default: + guard let anyAttachmentPayload = [attachment].toAnyAttachmentPayload().first else { break } + let customAttachment = CustomAttachment( + id: attachment.id.rawValue, + content: anyAttachmentPayload + ) + addedAssets.customAssets.append(customAttachment) + } + } + + return addedAssets + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index 21ae47c17..eeecbc7f7 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -35,6 +35,10 @@ public struct MessageContainerView: View { private let replyThreshold: CGFloat = 60 private let paddingValue: CGFloat = 8 + var isSwipeToReplyPossible: Bool { + message.isInteractionEnabled && channel.config.repliesEnabled + } + public init( factory: Factory, channel: ChatChannel, @@ -124,9 +128,7 @@ public struct MessageContainerView: View { } } .onLongPressGesture(perform: { - if !message.isDeleted { - handleGestureForMessage(showsMessageActions: true) - } + handleGestureForMessage(showsMessageActions: true) }) .offset(x: min(self.offsetX, maximumHorizontalSwipeDisplacement)) .simultaneousGesture( @@ -135,7 +137,7 @@ public struct MessageContainerView: View { coordinateSpace: .local ) .updating($offset) { (value, gestureState, _) in - if message.isDeleted || !channel.config.repliesEnabled { + guard isSwipeToReplyPossible else { return } // Using updating since onEnded is not called if the gesture is canceled. @@ -363,10 +365,14 @@ public struct MessageContainerView: View { } } - private func handleGestureForMessage( + func handleGestureForMessage( showsMessageActions: Bool, showsBottomContainer: Bool = true ) { + guard message.isInteractionEnabled else { + return + } + computeFrame.toggle() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { triggerHapticFeedback(style: .medium) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift index 0a112b41e..8dc7cc287 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift @@ -146,7 +146,7 @@ public extension MessageAction { } if message.isSentByCurrentUser { - if message.poll == nil { + if message.poll == nil && message.giphyAttachments.isEmpty { let editAction = editMessageAction( for: message, channel: channel, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift index d0e89166d..64e67f5f8 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift @@ -94,6 +94,45 @@ class ChatChannelViewModel_Tests: StreamChatTestCase { XCTAssert(viewModel.scrolledId!.contains(messageId)) } + func test_chatChannelVM_messageSentTapped() { + // Given + let messageId: String = .unique + let message = ChatMessage.mock( + id: messageId, + cid: .unique, + text: "Test message", + author: ChatUser.mock(id: chatClient.currentUserId!) + ) + let channelController = makeChannelController(messages: [message]) + let viewModel = ChatChannelViewModel(channelController: channelController) + + // When + viewModel.messageSentTapped() + + // Then + XCTAssert(viewModel.scrolledId!.contains(messageId)) + } + + func test_chatChannelVM_messageSentTapped_whenEditingMessage_shouldNotScroll() { + // Given + let messageId: String = .unique + let message = ChatMessage.mock( + id: messageId, + cid: .unique, + text: "Test message", + author: ChatUser.mock(id: chatClient.currentUserId!) + ) + let channelController = makeChannelController(messages: [message]) + let viewModel = ChatChannelViewModel(channelController: channelController) + viewModel.editedMessage = .unique + + // When + viewModel.messageSentTapped() + + // Then + XCTAssertNil(viewModel.scrolledId) + } + func test_chatChannelVM_currentDateString() { // Given let expectedDate = "Jan 01" diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift index 12b55f026..83e10a087 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift @@ -4,6 +4,7 @@ @testable import StreamChat @testable import StreamChatSwiftUI +@testable import StreamChatTestTools import XCTest class MessageActions_Tests: StreamChatTestCase { @@ -251,7 +252,47 @@ class MessageActions_Tests: StreamChatTestCase { XCTAssert(messageActions[2].title == "Edit Message") XCTAssert(messageActions[3].title == "Delete Message") } - + + func test_messageActions_giphyMessage_shouldNotContainEditActtion() throws { + // Given + let channel = mockDMChannel + let message = ChatMessage.mock( + id: .unique, + cid: channel.cid, + text: "Test", + author: .mock(id: chatClient.currentUserId!), + attachments: [ + .dummy( + type: .giphy, + payload: try JSONEncoder().encode(GiphyAttachmentPayload( + title: "Test", + previewURL: URL(string: "Url")! + )) + ) + ], + isSentByCurrentUser: true + ) + let factory = DefaultViewFactory.shared + + // When + let messageActions = MessageAction.defaultActions( + factory: factory, + for: message, + channel: channel, + chatClient: chatClient, + onFinish: { _ in }, + onError: { _ in } + ) + + // Then + XCTAssertEqual(messageActions.count, 5) + XCTAssertEqual(messageActions[0].title, "Reply") + XCTAssertEqual(messageActions[1].title, "Thread Reply") + XCTAssertEqual(messageActions[2].title, "Pin to conversation") + XCTAssertEqual(messageActions[3].title, "Copy Message") + XCTAssertEqual(messageActions[4].title, "Delete Message") + } + // MARK: - Private private var mockDMChannel: ChatChannel { diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift index 7d77aabf0..3ed23f001 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift @@ -582,6 +582,7 @@ class MessageComposerView_Tests: StreamChatTestCase { draftMessage: draftMessage ) let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + viewModel.attachmentsConverter = SyncAttachmentsConverter() return MessageComposerView( viewFactory: factory, @@ -629,4 +630,168 @@ class MessageComposerView_Tests: StreamChatTestCase { AssertSnapshot(view, variants: .onlyUserInterfaceStyles, size: size) } + + // MARK: - Editing + + func test_composerView_editingMessageWithText() { + let size = CGSize(width: defaultScreenSize.width, height: 100) + let mockEditedMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "This is a message being edited", + author: .mock(id: .unique) + ) + + let view = makeComposerViewWithEditedMessage(mockEditedMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_editingMessageWithImageAttachment() throws { + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockEditedMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Message with image", + author: .mock(id: .unique), + attachments: [ + .dummy( + type: .image, + payload: try JSONEncoder().encode( + ImageAttachmentPayload( + title: nil, + imageRemoteURL: TestImages.yoda.url, + file: .init(type: .jpeg, size: 10, mimeType: nil) + ) + ) + ) + ] + ) + + let view = makeComposerViewWithEditedMessage(mockEditedMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_editingMessageWithVideoAttachment() throws { + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockEditedMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Message with video", + author: .mock(id: .unique), + attachments: [ + .dummy( + type: .video, + payload: try JSONEncoder().encode( + VideoAttachmentPayload( + title: nil, + videoRemoteURL: TestImages.yoda.url, + thumbnailURL: TestImages.yoda.url, + file: .init(type: .mov, size: 10, mimeType: nil), + extraData: nil + ) + ) + ) + ] + ) + + let view = makeComposerViewWithEditedMessage(mockEditedMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_editingMessageWithFileAttachment() throws { + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockEditedMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Message with file", + author: .mock(id: .unique), + attachments: [ + .dummy( + type: .file, + payload: try JSONEncoder().encode( + FileAttachmentPayload( + title: "Test", + assetRemoteURL: .localYodaQuote, + file: .init(type: .txt, size: 10, mimeType: nil), + extraData: nil + ) + ) + ) + ] + ) + + let view = makeComposerViewWithEditedMessage(mockEditedMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_editingMessageWithVoiceRecording() throws { + let url: URL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)") + let duration: TimeInterval = 100 + let waveformData: [Float] = .init(repeating: 0.5, count: 10) + try Data(count: 1024).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockEditedMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Message with voice recording", + author: .mock(id: .unique), + attachments: [ + .dummy( + type: .voiceRecording, + payload: try JSONEncoder().encode( + VoiceRecordingAttachmentPayload( + title: "Audio", + voiceRecordingRemoteURL: url, + file: .init(type: .aac, size: 120, mimeType: "audio/aac"), + duration: duration, + waveformData: waveformData, + extraData: nil + ) + ) + ) + ] + ) + + let view = makeComposerViewWithEditedMessage(mockEditedMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + private func makeComposerViewWithEditedMessage(_ editedMessage: ChatMessage) -> some View { + let factory = DefaultViewFactory.shared + let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient) + let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + viewModel.attachmentsConverter = SyncAttachmentsConverter() + viewModel.fillEditedMessage(editedMessage) + + return MessageComposerView( + viewFactory: factory, + viewModel: viewModel, + channelController: channelController, + quotedMessage: .constant(nil), + editedMessage: .constant(editedMessage), + onMessageSent: {} + ) + } +} + +class SyncAttachmentsConverter: MessageAttachmentsConverter { + override func attachmentsToAssets( + _ attachments: [AnyChatMessageAttachment], + completion: @escaping (ComposerAssets) -> Void + ) { + let addedAssets = attachmentsToAssets(attachments) + completion(addedAssets) + } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift index 21a1cdb7f..bbd514b6f 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageContainerView_Tests.swift @@ -339,6 +339,146 @@ class MessageContainerView_Tests: StreamChatTestCase { AssertSnapshot(view, size: CGSize(width: 375, height: 200)) } + func test_handleGestureForMessage_whenMessageIsInteractable_shouldLongPress() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + localState: nil, + isSentByCurrentUser: true + ) + + let exp = expectation(description: "Long press triggered") + let view = MessageContainerView( + factory: DefaultViewFactory.shared, + channel: .mockDMChannel(), + message: message, + width: defaultScreenSize.width, + showsAllInfo: true, + isInThread: false, + isLast: false, + scrolledId: .constant(nil), + quotedMessage: .constant(nil) + ) { _ in + exp.fulfill() + } + + view.handleGestureForMessage(showsMessageActions: false, showsBottomContainer: false) + + waitForExpectations(timeout: defaultTimeout) { error in + XCTAssertNil(error, "Long press was not triggered") + } + } + + func test_handleGestureForMessage_whenMessageNotInteractable_shouldNotLongPress() { + // Given + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + type: .ephemeral, + localState: nil, + isSentByCurrentUser: true + ) + + let exp = expectation(description: "Long press should not be triggered") + exp.isInverted = true + let view = MessageContainerView( + factory: DefaultViewFactory.shared, + channel: .mockDMChannel(), + message: message, + width: defaultScreenSize.width, + showsAllInfo: true, + isInThread: false, + isLast: false, + scrolledId: .constant(nil), + quotedMessage: .constant(nil) + ) { _ in + exp.fulfill() + } + + view.handleGestureForMessage(showsMessageActions: false, showsBottomContainer: false) + + waitForExpectations(timeout: 1) + } + + func test_isSwipeToReplyPossible_whenRepliesEnabled_whenMessageInteractable_shouldBeTrue() { + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + localState: nil, + isSentByCurrentUser: true + ) + + let view = MessageContainerView( + factory: DefaultViewFactory.shared, + channel: .mockDMChannel(config: .mock(repliesEnabled: true)), + message: message, + width: defaultScreenSize.width, + showsAllInfo: true, + isInThread: false, + isLast: false, + scrolledId: .constant(nil), + quotedMessage: .constant(nil), + onLongPress: { _ in } + ) + + XCTAssertTrue(view.isSwipeToReplyPossible) + } + + func test_isSwipeToReplyPossible_whenRepliesDisabled_whenMessageInteractable_shouldBeFalse() { + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + localState: nil, + isSentByCurrentUser: true + ) + + let view = MessageContainerView( + factory: DefaultViewFactory.shared, + channel: .mockDMChannel(config: .mock(repliesEnabled: false)), + message: message, + width: defaultScreenSize.width, + showsAllInfo: true, + isInThread: false, + isLast: false, + scrolledId: .constant(nil), + quotedMessage: .constant(nil), + onLongPress: { _ in } + ) + + XCTAssertFalse(view.isSwipeToReplyPossible) + } + + func test_isSwipeToReplyPossible_whenRepliesEnabled_whenMessageNotInteractable_shouldBeFalse() { + let message = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Hello", + type: .ephemeral, + localState: nil, + isSentByCurrentUser: true + ) + + let view = MessageContainerView( + factory: DefaultViewFactory.shared, + channel: .mockDMChannel(config: .mock(repliesEnabled: true)), + message: message, + width: defaultScreenSize.width, + showsAllInfo: true, + isInThread: false, + isLast: false, + scrolledId: .constant(nil), + quotedMessage: .constant(nil), + onLongPress: { _ in } + ) + + XCTAssertFalse(view.isSwipeToReplyPossible) + } + // MARK: - private func testMessageViewContainer(message: ChatMessage) -> some View { diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithFileAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithFileAttachment.default-light.png new file mode 100644 index 000000000..e24488116 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithFileAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithImageAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithImageAttachment.default-light.png new file mode 100644 index 000000000..fa9494b93 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithImageAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithText.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithText.default-light.png new file mode 100644 index 000000000..d7c9666ed Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithText.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithVideoAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithVideoAttachment.default-light.png new file mode 100644 index 000000000..9962427cf Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithVideoAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithVoiceRecording.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithVoiceRecording.default-light.png new file mode 100644 index 000000000..ce3dde67f Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_editingMessageWithVoiceRecording.default-light.png differ