diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b308f5..c9a7e44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add avatar customization in add users popup [#787](https://github.com/GetStream/stream-chat-swiftui/pull/787) +- Add support for Draft Messages when `Utils.messageListConfig.draftMessagesEnabled` is `true` [#775](https://github.com/GetStream/stream-chat-swiftui/pull/775) +- Add draft preview in Channel List and Thread List if drafts are enabled [#775](https://github.com/GetStream/stream-chat-swiftui/pull/775) # [4.74.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.74.0) _March 14, 2025_ diff --git a/DemoAppSwiftUI/AppDelegate.swift b/DemoAppSwiftUI/AppDelegate.swift index 9a2cd7eb..e0c1e5bf 100644 --- a/DemoAppSwiftUI/AppDelegate.swift +++ b/DemoAppSwiftUI/AppDelegate.swift @@ -69,7 +69,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { bouncedMessagesAlertActionsEnabled: true, skipEditedMessageLabel: { message in message.extraData["ai_generated"]?.boolValue == true - } + }, + draftMessagesEnabled: true ), composerConfig: ComposerConfig(isVoiceRecordingEnabled: true) ) diff --git a/DemoAppSwiftUI/AppleMessageComposerView.swift b/DemoAppSwiftUI/AppleMessageComposerView.swift index 2f1972c7..234a1e7f 100644 --- a/DemoAppSwiftUI/AppleMessageComposerView.swift +++ b/DemoAppSwiftUI/AppleMessageComposerView.swift @@ -42,7 +42,8 @@ struct AppleMessageComposerView: View, KeyboardReadable { channelConfig = channelController.channel?.config let vm = viewModel ?? ViewModelsFactory.makeMessageComposerViewModel( with: channelController, - messageController: messageController + messageController: messageController, + quotedMessage: quotedMessage ) _viewModel = StateObject( wrappedValue: vm diff --git a/DemoAppSwiftUI/PinChannelHelpers.swift b/DemoAppSwiftUI/PinChannelHelpers.swift index 2cb7ba94..495d1bf2 100644 --- a/DemoAppSwiftUI/PinChannelHelpers.swift +++ b/DemoAppSwiftUI/PinChannelHelpers.swift @@ -85,7 +85,16 @@ struct DemoAppChatChannelListItem: View { TypingIndicatorView() } } - SubtitleText(text: injectedChannelInfo?.subtitle ?? channel.subtitleText) + if let draftText = channel.draftMessageText { + HStack(spacing: 2) { + Text("Draft:") + .font(fonts.caption1).bold() + .foregroundColor(Color(colors.highlightedAccentBackground)) + SubtitleText(text: draftText) + } + } else { + SubtitleText(text: injectedChannelInfo?.subtitle ?? channel.subtitleText) + } Spacer() } .accessibilityIdentifier("subtitleView") diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift index afb470c7..be0dee00 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import AVFoundation import StreamChat import SwiftUI @@ -52,6 +53,48 @@ extension AddedAsset { } } +extension AnyChatMessageAttachment { + func toAddedAsset() -> AddedAsset? { + if let imageAttachment = attachment(payloadType: ImageAttachmentPayload.self), + let imageData = try? Data(contentsOf: imageAttachment.imageURL), + let image = UIImage(data: imageData) { + return AddedAsset( + image: image, + id: imageAttachment.id.rawValue, + url: imageAttachment.imageURL, + type: .image, + extraData: imageAttachment.extraData ?? [:] + ) + } else if let videoAttachment = attachment(payloadType: VideoAttachmentPayload.self), + let thumbnail = imageThumbnail(for: videoAttachment.payload) { + return AddedAsset( + image: thumbnail, + id: videoAttachment.id.rawValue, + url: videoAttachment.videoURL, + type: .video, + extraData: videoAttachment.extraData ?? [:] + ) + } + return nil + } + + private func imageThumbnail(for videoAttachmentPayload: VideoAttachmentPayload) -> UIImage? { + if let thumbnailURL = videoAttachmentPayload.thumbnailURL, let data = try? Data(contentsOf: thumbnailURL) { + return UIImage(data: data) + } + let asset = AVURLAsset(url: videoAttachmentPayload.videoURL, options: nil) + let imageGenerator = AVAssetImageGenerator(asset: asset) + imageGenerator.appliesPreferredTrackTransform = true + guard let cgImage = try? imageGenerator.copyCGImage( + at: CMTimeMake(value: 0, timescale: 1), + actualTime: nil + ) else { + return nil + } + return UIImage(cgImage: cgImage) + } +} + /// Type of asset added to the composer. public enum AssetType { case image @@ -92,3 +135,16 @@ public struct AddedVoiceRecording: Identifiable, Equatable { self.waveform = waveform } } + +extension AnyChatMessageAttachment { + func toAddedVoiceRecording() -> AddedVoiceRecording? { + guard let voiceAttachment = attachment(payloadType: VoiceRecordingAttachmentPayload.self) else { return nil } + guard let duration = voiceAttachment.duration else { return nil } + guard let waveform = voiceAttachment.waveformData else { return nil } + return AddedVoiceRecording( + url: voiceAttachment.voiceRecordingURL, + duration: duration, + waveform: waveform + ) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift index e54f4a6a..694a84d5 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift @@ -20,7 +20,7 @@ public struct MessageComposerView: View, KeyboardReadable private var channelConfig: ChannelConfig? @Binding var quotedMessage: ChatMessage? @Binding var editedMessage: ChatMessage? - + private let recordingViewHeight: CGFloat = 80 public init( @@ -37,7 +37,8 @@ public struct MessageComposerView: View, KeyboardReadable _viewModel = StateObject( wrappedValue: viewModel ?? ViewModelsFactory.makeMessageComposerViewModel( with: channelController, - messageController: messageController + messageController: messageController, + quotedMessage: quotedMessage ) ) _quotedMessage = quotedMessage @@ -214,6 +215,12 @@ public struct MessageComposerView: View, KeyboardReadable viewModel.selectedRangeLocation = editedMessage?.text.count ?? 0 } } + .onAppear(perform: { + viewModel.fillDraftMessage() + }) + .onDisappear(perform: { + viewModel.updateDraftMessage(quotedMessage: quotedMessage) + }) .accessibilityElement(children: .contain) } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift index 30d1c213..abefa83b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift @@ -54,6 +54,10 @@ open class MessageComposerViewModel: ObservableObject { selectedRangeLocation = 0 suggestions = [String: Any]() mentionedUsers = Set() + + if oldValue != "" && !sendButtonEnabled { + deleteDraftMessage() + } } } } @@ -115,14 +119,21 @@ open class MessageComposerViewModel: ObservableObject { didSet { if oldValue?.id != composerCommand?.id && composerCommand?.displayInfo?.isInstant == true { - clearText() + clearCommandText() } if oldValue != nil && composerCommand == nil { pickerTypeState = .expanded(.none) } } } - + + public var draftMessage: DraftMessage? { + if let messageController { + return messageController.message?.draftReply + } + return channelController.channel?.draftMessage + } + @Published public var filePickerShown = false @Published public var cameraPickerShown = false @Published public var errorShown = false @@ -149,9 +160,11 @@ open class MessageComposerViewModel: ObservableObject { } @Published public var audioRecordingInfo = AudioRecordingInfo.initial - + public let channelController: ChatChannelController public var messageController: ChatMessageController? + public let eventsController: EventsController + public var quotedMessage: Binding? public var waveformTargetSamples: Int = 100 public internal(set) var pendingAudioRecording: AddedVoiceRecording? @@ -203,14 +216,22 @@ open class MessageComposerViewModel: ObservableObject { private var canAddAdditionalAttachments: Bool { totalAttachmentsCount < chatClient.config.maxAttachmentCountPerMessage } - + public init( channelController: ChatChannelController, - messageController: ChatMessageController? + messageController: ChatMessageController?, + eventsController: EventsController? = nil, + quotedMessage: Binding? = nil ) { self.channelController = channelController self.messageController = messageController + self.eventsController = eventsController ?? channelController.client.eventsController() + self.quotedMessage = quotedMessage + + self.eventsController.delegate = self + listenToCooldownUpdates() + NotificationCenter.default.addObserver( self, selector: #selector(applicationWillEnterForeground), @@ -218,7 +239,97 @@ open class MessageComposerViewModel: ObservableObject { object: nil ) } - + + /// Populates the draft message in the composer with the current controller's draft information. + public func fillDraftMessage() { + guard let message = draftMessage else { + return + } + + text = message.text + mentionedUsers = message.mentionedUsers + quotedMessage?.wrappedValue = message.quotedMessage + showReplyInChannel = message.showReplyInChannel + + addedAssets.removeAll() + addedFileURLs.removeAll() + addedVoiceRecordings.removeAll() + addedCustomAttachments.removeAll() + + 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) + } + } + } + + /// Updates the draft message locally and on the server. + public func updateDraftMessage( + quotedMessage: ChatMessage?, + isSilent: Bool = false, + extraData: [String: RawJSON] = [:] + ) { + guard utils.messageListConfig.draftMessagesEnabled && sendButtonEnabled else { + return + } + let attachments = try? inputAttachmentsAsPayloads() + let mentionedUserIds = mentionedUsers.map(\.id) + let availableCommands = channelController.channel?.config.commands ?? [] + let command = availableCommands.first { composerCommand?.id == "/\($0.name)" } + + if let messageController = messageController { + messageController.updateDraftReply( + text: messageText, + isSilent: isSilent, + attachments: attachments ?? [], + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessage?.id, + showReplyInChannel: showReplyInChannel, + command: command, + extraData: extraData + ) + return + } + + channelController.updateDraftMessage( + text: messageText, + isSilent: isSilent, + attachments: attachments ?? [], + mentionedUserIds: mentionedUserIds, + quotedMessageId: quotedMessage?.id, + command: command, + extraData: extraData + ) + } + + private func deleteDraftMessage() { + guard draftMessage != nil else { + return + } + + if let messageController = messageController { + messageController.deleteDraftReply() + } else { + channelController.deleteDraftMessage() + } + } + open func sendMessage( quotedMessage: ChatMessage?, editedMessage: ChatMessage?, @@ -254,27 +365,7 @@ open class MessageComposerViewModel: ObservableObject { } do { - 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 - } - + let attachments = try inputAttachmentsAsPayloads() if let messageController = messageController { messageController.createNewReply( text: messageText, @@ -313,7 +404,7 @@ open class MessageComposerViewModel: ObservableObject { } } } - + clearInputData() } catch { errorShown = true @@ -524,7 +615,31 @@ open class MessageComposerViewModel: ObservableObject { self?.imageAssets = assets } } - + + 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] @@ -634,7 +749,7 @@ open class MessageComposerViewModel: ObservableObject { } .store(in: &cancellables) } - + private func checkChannelCooldown() { let duration = channelController.channel?.cooldownDuration ?? 0 if duration > 0 && timer == nil && !isSlowModeDisabled { @@ -662,7 +777,34 @@ open class MessageComposerViewModel: ObservableObject { self?.text = "" } } - + + /// Same as clearText() but it just clears the command id. + private func clearCommandText() { + guard let command = composerCommand else { return } + let currentText = text + if let value = getValueOfCommand(currentText) { + text = value + return + } + text = "" + } + + private func getValueOfCommand(_ currentText: String) -> String? { + let pattern = "/\\S+\\s+(.*)" + if let regex = try? NSRegularExpression(pattern: pattern) { + let range = NSRange(currentText.startIndex.. Bool { if !canAddAdditionalAttachments { return false @@ -693,3 +835,23 @@ open class MessageComposerViewModel: ObservableObject { } } } + +extension MessageComposerViewModel: EventsControllerDelegate { + public func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + if let event = event as? DraftUpdatedEvent { + let isFromSameThread = messageController?.messageId == event.draftMessage.threadId + let isFromSameChannel = channelController.cid == event.cid && messageController == nil + if isFromSameThread || isFromSameChannel { + fillDraftMessage() + } + } + + if let event = event as? DraftDeletedEvent { + let isFromSameThread = messageController?.messageId == event.threadId + let isFromSameChannel = channelController.cid == event.cid && messageController == nil + if isFromSameThread || isFromSameChannel { + clearInputData() + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/GiphyCommandHandler.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/GiphyCommandHandler.swift index 10c90dc9..0159bb37 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/GiphyCommandHandler.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/GiphyCommandHandler.swift @@ -38,7 +38,7 @@ public struct GiphyCommandHandler: CommandHandler { } public func canHandleCommand(in text: String, caretLocation: Int) -> ComposerCommand? { - if text == id { + if text.hasPrefix(id) { return ComposerCommand( id: id, typingSuggestion: TypingSuggestion( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift index a1bebbcb..b4675304 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/Suggestions/InstantCommands/TwoStepMentionCommand.swift @@ -41,7 +41,7 @@ open class TwoStepMentionCommand: CommandHandler { } open func canHandleCommand(in text: String, caretLocation: Int) -> ComposerCommand? { - if text == id { + if text.hasPrefix(id) { return ComposerCommand( id: id, typingSuggestion: TypingSuggestion( diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift index 4e2ecdb6..e9fa2fe2 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift @@ -34,7 +34,8 @@ public struct MessageListConfig { markdownSupportEnabled: Bool = true, userBlockingEnabled: Bool = false, bouncedMessagesAlertActionsEnabled: Bool = true, - skipEditedMessageLabel: @escaping (ChatMessage) -> Bool = { _ in false } + skipEditedMessageLabel: @escaping (ChatMessage) -> Bool = { _ in false }, + draftMessagesEnabled: Bool = false ) { self.messageListType = messageListType self.typingIndicatorPlacement = typingIndicatorPlacement @@ -62,6 +63,7 @@ public struct MessageListConfig { self.userBlockingEnabled = userBlockingEnabled self.bouncedMessagesAlertActionsEnabled = bouncedMessagesAlertActionsEnabled self.skipEditedMessageLabel = skipEditedMessageLabel + self.draftMessagesEnabled = draftMessagesEnabled } public let messageListType: MessageListType @@ -95,6 +97,11 @@ public struct MessageListConfig { public let bouncedMessagesAlertActionsEnabled: Bool public let skipEditedMessageLabel: (ChatMessage) -> Bool + + /// A boolean value indicating if draft messages should be enabled. + /// + /// If enabled, the SDK will save the message content as a draft when the user navigates away from the composer. + public let draftMessagesEnabled: Bool } /// Contains information about the message paddings. diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index a5e2e55d..e36f5de9 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -106,7 +106,16 @@ public struct ChatChannelListItem: View { TypingIndicatorView() } } - SubtitleText(text: injectedChannelInfo?.subtitle ?? channel.subtitleText) + if utils.messageListConfig.draftMessagesEnabled, let draftText = channel.draftMessageText { + HStack(spacing: 2) { + Text("\(L10n.Message.Preview.draft):") + .font(fonts.caption1).bold() + .foregroundColor(Color(colors.highlightedAccentBackground)) + SubtitleText(text: draftText) + } + } else { + SubtitleText(text: injectedChannelInfo?.subtitle ?? channel.subtitleText) + } Spacer() } .accessibilityIdentifier("subtitleView") @@ -288,7 +297,13 @@ extension ChatChannel { let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter return messageFormatter.format(previewMessage, in: self) } - + + public var draftMessageText: String? { + guard let draftMessage = draftMessage else { return nil } + let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter + return messageFormatter.formatContent(for: ChatMessage(draftMessage), in: self) + } + public var lastMessageText: String? { guard let latestMessage = latestMessages.first else { return nil } let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift index 9114dd40..28a313fb 100644 --- a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift @@ -24,7 +24,8 @@ public struct ChatThreadListItem: View { replyAuthorUrl: viewModel.latestReplyAuthorImageURL, replyAuthorIsOnline: viewModel.isLatestReplyAuthorOnline, replyMessageText: viewModel.latestReplyMessageText, - replyTimestampText: viewModel.latestReplyTimestampText + replyTimestampText: viewModel.latestReplyTimestampText, + draftText: viewModel.draftReplyText ) } } @@ -77,6 +78,14 @@ public struct ChatThreadListItemViewModel { ) } + /// The formatted draft reply text. + public var draftReplyText: String? { + guard utils.messageListConfig.draftMessagesEnabled else { return nil } + guard let draftMessage = thread.parentMessage.draftReply else { return nil } + let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter + return messageFormatter.formatContent(for: ChatMessage(draftMessage), in: thread.channel) + } + /// The number of unread replies. public var unreadRepliesCount: Int { let currentUserRead = thread.reads.first( @@ -126,6 +135,7 @@ struct ChatThreadListItemContentView: View { var replyAuthorIsOnline: Bool var replyMessageText: String var replyTimestampText: String + var draftText: String? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -175,11 +185,24 @@ struct ChatThreadListItemContentView: View { .foregroundColor(Color(colors.text)) .font(fonts.subheadlineBold) HStack { - SubtitleText(text: replyMessageText) + if let draftText { + HStack(spacing: 2) { + draftPrefixView + SubtitleText(text: draftText) + } + } else { + SubtitleText(text: replyMessageText) + } Spacer() SubtitleText(text: replyTimestampText) } } } } + + var draftPrefixView: some View { + Text("\(L10n.Message.Preview.draft):") + .font(fonts.caption1).bold() + .foregroundColor(Color(colors.highlightedAccentBackground)) + } } diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index da90dcc0..3d1f1baa 100644 --- a/Sources/StreamChatSwiftUI/Generated/L10n.swift +++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift @@ -506,6 +506,10 @@ internal enum L10n { internal static var resultsTitle: String { L10n.tr("Localizable", "message.polls.toolbar.results-title") } } } + internal enum Preview { + /// Draft + internal static var draft: String { L10n.tr("Localizable", "message.preview.draft") } + } internal enum Reactions { /// You internal static var currentUser: String { L10n.tr("Localizable", "message.reactions.currentUser") } diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings index 3bbb5368..0eab6da4 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings @@ -178,6 +178,8 @@ "message.bounce.title" = "Message was bounced"; +"message.preview.draft" = "Draft"; + "current-selection" = "%d of %d"; "attachment.max-size-exceeded" = "Attachment size exceed the limit."; diff --git a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift index 334532a4..e5ac1d98 100644 --- a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift @@ -4,6 +4,7 @@ import Foundation import StreamChat +import SwiftUI /// Factory used to create view models. public class ViewModelsFactory { @@ -64,16 +65,19 @@ public class ViewModelsFactory { /// Makes the message composer view model. /// - Parameters: - /// - channelController: the channel controller. + /// - channelController: the channel controller. /// - messageController: optional message controller (used in threads). + /// - quotedMessage: the quoted message. /// - Returns: `MessageComposerViewModel`. public static func makeMessageComposerViewModel( with channelController: ChatChannelController, - messageController: ChatMessageController? + messageController: ChatMessageController?, + quotedMessage: Binding? = nil ) -> MessageComposerViewModel { MessageComposerViewModel( channelController: channelController, - messageController: messageController + messageController: messageController, + quotedMessage: quotedMessage ) } diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift index ed1f430c..67abb741 100644 --- a/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift +++ b/StreamChatSwiftUITests/Infrastructure/Mocks/ChatMessageControllerSUI_Mock.swift @@ -13,7 +13,7 @@ public class ChatMessageControllerSUI_Mock: ChatMessageController { currentUserId: UserId = "ID", cid: ChannelId? = nil, messageId: String = "MockMessage" - ) -> ChatMessageController_Mock { + ) -> ChatMessageControllerSUI_Mock { if let authenticationRepository = chatClient.authenticationRepository as? AuthenticationRepository_Mock { authenticationRepository.mockedCurrentUserId = currentUserId } @@ -59,6 +59,30 @@ public class ChatMessageControllerSUI_Mock: ChatMessageController { override public func synchronize(_ completion: ((Error?) -> Void)? = nil) { synchronize_callCount += 1 } + + var updateDraftReply_callCount = 0 + var updateDraftReply_text: String? + + override public func updateDraftReply( + text: String, + isSilent: Bool = false, + attachments: [AnyAttachmentPayload] = [], + mentionedUserIds: [UserId] = [], + quotedMessageId: MessageId? = nil, + showReplyInChannel: Bool = false, + command: Command? = nil, + extraData: [String: RawJSON] = [:], + completion: ((Result) -> Void)? = nil + ) { + updateDraftReply_callCount += 1 + updateDraftReply_text = text + } + + var deleteDraftReply_callCount = 0 + + override public func deleteDraftReply(completion: (((any Error)?) -> Void)? = nil) { + deleteDraftReply_callCount += 1 + } } public extension ChatMessageControllerSUI_Mock { diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift index 685d08e8..37e4da92 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift @@ -210,6 +210,19 @@ class ChatChannelDataSource_Tests: StreamChatTestCase { return threadDataSource } + private func makeMessageThreadDataSource( + messages: [ChatMessage], + messageController: ChatMessageControllerSUI_Mock + ) -> MessageThreadDataSource { + let channelController = makeChannelController(messages: messages) + let threadDataSource = MessageThreadDataSource( + channelController: channelController, + messageController: messageController + ) + + return threadDataSource + } + private func makeChannelController(messages: [ChatMessage]) -> ChatChannelController_Mock { let channelController = ChatChannelTestHelpers.makeChannelController( chatClient: chatClient, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift index c8a27d51..3a88216a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift @@ -5,6 +5,7 @@ @testable import StreamChat @testable import StreamChatSwiftUI @testable import StreamChatTestTools +import SwiftUI import XCTest class MessageComposerViewModel_Tests: StreamChatTestCase { @@ -692,8 +693,311 @@ class MessageComposerViewModel_Tests: StreamChatTestCase { XCTAssert(viewModel.audioRecordingInfo == .initial) } - // MARK: - private + // MARK: - Draft Message Tests + + func test_messageComposerVM_command() { + // Given + let draftMessage = DraftMessage.mock(text: "/giphy text") + let channelController = makeChannelController() + channelController.channel_mock = .mock( + cid: channelController.cid!, + config: ChannelConfig(commands: [Command(name: "giphy", description: "", set: "", args: "")]), + draftMessage: draftMessage + ) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: nil + ) + viewModel.fillDraftMessage() + + // When + XCTAssertEqual(viewModel.composerCommand?.id, "/giphy") + XCTAssertEqual(viewModel.text, "text") + } + + func test_messageComposerVM_updateDraftMessage() { + // Given + let channelController = makeChannelController() + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: nil + ) + let quotedMessage = ChatMessage.mock(id: .unique, cid: .unique, text: "Quoted message", author: .mock(id: .unique)) + + // When + viewModel.text = "Draft text" + viewModel.updateDraftMessage(quotedMessage: quotedMessage) + + // Then + XCTAssertEqual(channelController.updateDraftMessage_text, "Draft text") + XCTAssertEqual(channelController.updateDraftMessage_callCount, 1) + } + + func test_messageComposerVM_updateDraftReply() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + let quotedMessage = ChatMessage.mock(id: .unique, cid: .unique, text: "Quoted message", author: .mock(id: .unique)) + + // When + viewModel.text = "Draft reply" + viewModel.updateDraftMessage(quotedMessage: quotedMessage) + + // Then + XCTAssertEqual(messageController.updateDraftReply_text, "Draft reply") + XCTAssertEqual(messageController.updateDraftReply_callCount, 1) + } + + func test_messageComposerVM_whenTextErased_shouldDeleteDraftMessage() { + // Given + let draftMessage = DraftMessage.mock(text: "text") + let channelController = makeChannelController() + channelController.channel_mock = .mock(cid: channelController.cid!, draftMessage: draftMessage) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: nil + ) + viewModel.text = "text" + + // When + viewModel.text = "" + + // Then + XCTAssertEqual(channelController.deleteDraftMessage_callCount, 1) + } + func test_messageComposerVM_whenTextErased_deleteDraftReply() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let draftMessage = DraftMessage.mock(text: "reply") + messageController.message_mock = .mock(draftReply: draftMessage) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + viewModel.text = "reply" + + // When + viewModel.text = "" + + // Then + XCTAssertEqual(messageController.deleteDraftReply_callCount, 1) + } + + func test_messageComposerVM_whenMessagePublished_deleteDraftMessage() { + // Given + let channelController = makeChannelController() + let draftMessage = DraftMessage.mock(text: "text") + channelController.channel_mock = .mock(cid: channelController.cid!, draftMessage: draftMessage) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: nil + ) + viewModel.text = "text" + + // When + viewModel.sendMessage(quotedMessage: nil, editedMessage: nil) {} + + // Then + // Sending a message will clear the input, deleting the draft message. + XCTAssertEqual(channelController.deleteDraftMessage_callCount, 1) + } + + func test_messageComposerVM_whenMessagePublished_deleteDraftReply() { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: .unique, + messageId: .unique + ) + let draftMessage = DraftMessage.mock(text: "reply") + messageController.message_mock = .mock(draftReply: draftMessage) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + viewModel.text = "reply" + + // When + viewModel.sendMessage(quotedMessage: nil, editedMessage: nil) {} + + // Then + XCTAssertEqual(messageController.deleteDraftReply_callCount, 1) + } + + func test_messageComposerVM_draftMessageUpdatedEvent() throws { + // Given + let channelController = makeChannelController() + channelController.channel_mock = .mock(cid: .unique, draftMessage: .mock(text: "Draft")) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: nil + ) + + // When + let draftMessage = DraftMessage.mock(text: "Draft from event") + channelController.channel_mock = .mock(cid: .unique, draftMessage: draftMessage) + let cid = try XCTUnwrap(channelController.cid) + let event = DraftUpdatedEvent(cid: cid, channel: .mock(cid: cid), draftMessage: draftMessage, createdAt: .unique) + viewModel.eventsController(viewModel.eventsController, didReceiveEvent: event) + + // Then + XCTAssertEqual(viewModel.text, "Draft from event") + } + + func test_messageComposerVM_draftReplyUpdatedEvent() throws { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: channelController.cid!, + messageId: .unique + ) + messageController.message_mock = .mock(draftReply: .mock(text: "Draft")) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + + // When + let draftMessage = DraftMessage.mock( + threadId: messageController.messageId, + text: "Draft reply from event" + ) + messageController.message_mock = .mock(draftReply: draftMessage) + let cid = try XCTUnwrap(channelController.cid) + let event = DraftUpdatedEvent(cid: cid, channel: .mock(cid: cid), draftMessage: draftMessage, createdAt: .unique) + viewModel.eventsController(viewModel.eventsController, didReceiveEvent: event) + + // Then + XCTAssertEqual(viewModel.text, "Draft reply from event") + } + + func test_messageComposerVM_draftReplyUpdatedEventFromOtherThread_shouldNotUpdate() throws { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: channelController.cid!, + messageId: .unique + ) + messageController.message_mock = .mock(draftReply: .mock(text: "Draft")) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + viewModel.fillDraftMessage() + + // When + let draftMessage = DraftMessage.mock( + threadId: .unique, + text: "Draft reply from event" + ) + messageController.message_mock = .mock(draftReply: draftMessage) + let cid = try XCTUnwrap(channelController.cid) + let event = DraftUpdatedEvent(cid: cid, channel: .mock(cid: cid), draftMessage: draftMessage, createdAt: .unique) + viewModel.eventsController(viewModel.eventsController, didReceiveEvent: event) + + // Then + XCTAssertEqual(viewModel.text, "Draft") + } + + func test_messageComposerVM_draftMessageDeletedEvent() throws { + // Given + let channelController = makeChannelController() + channelController.channel_mock = .mock(cid: .unique, draftMessage: .mock(text: "Draft")) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: nil + ) + + // When + channelController.channel_mock = .mock(cid: .unique, draftMessage: nil) + let cid = try XCTUnwrap(channelController.cid) + let event = DraftDeletedEvent(cid: cid, threadId: nil, createdAt: .unique) + viewModel.eventsController(viewModel.eventsController, didReceiveEvent: event) + + // Then + XCTAssertEqual(viewModel.text, "") + } + + func test_messageComposerVM_draftReplyDeletedEvent() throws { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: channelController.cid!, + messageId: .unique + ) + messageController.message_mock = .mock(draftReply: .mock(text: "Draft")) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + + // When + messageController.message_mock = .mock(draftReply: nil) + let cid = try XCTUnwrap(channelController.cid) + let event = DraftDeletedEvent(cid: cid, threadId: messageController.messageId, createdAt: .unique) + viewModel.eventsController(viewModel.eventsController, didReceiveEvent: event) + + // Then + XCTAssertEqual(viewModel.text, "") + } + + func test_messageComposerVM_draftReplyDeletedEventFromOtherThread_shouldNotUpdate() throws { + // Given + let channelController = makeChannelController() + let messageController = ChatMessageControllerSUI_Mock.mock( + chatClient: chatClient, + cid: channelController.cid!, + messageId: .unique + ) + messageController.message_mock = .mock(draftReply: .mock(text: "Draft")) + let viewModel = makeComposerDraftsViewModel( + channelController: channelController, + messageController: messageController + ) + viewModel.fillDraftMessage() + + // When + messageController.message_mock = .mock(draftReply: nil) + let cid = try XCTUnwrap(channelController.cid) + let event = DraftDeletedEvent(cid: cid, threadId: .unique, createdAt: .unique) + viewModel.eventsController(viewModel.eventsController, didReceiveEvent: event) + + // Then + XCTAssertEqual(viewModel.text, "Draft") + } + + // MARK: - private + + private func makeComposerDraftsViewModel( + channelController: ChatChannelController, + messageController: ChatMessageController? + ) -> MessageComposerViewModel { + let viewModel = MessageComposerViewModel( + channelController: channelController, + messageController: messageController + ) + viewModel.utils = .init(messageListConfig: .init(draftMessagesEnabled: true)) + return viewModel + } + private func makeComposerViewModel() -> MessageComposerViewModel { MessageComposerTestUtils.makeComposerViewModel(chatClient: chatClient) } diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift index a8d3c24c..7d77aabf 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift @@ -16,7 +16,10 @@ class MessageComposerView_Tests: StreamChatTestCase { override func setUp() { super.setUp() let utils = Utils( - messageListConfig: MessageListConfig(becomesFirstResponderOnOpen: true), + messageListConfig: MessageListConfig( + becomesFirstResponderOnOpen: true, + draftMessagesEnabled: true + ), composerConfig: ComposerConfig(isVoiceRecordingEnabled: true) ) streamChat = StreamChat(chatClient: chatClient, utils: utils) @@ -439,6 +442,156 @@ class MessageComposerView_Tests: StreamChatTestCase { streamChat?.appearance.colors.staticColorText = .black AssertSnapshot(view, variants: .onlyUserInterfaceStyles, size: size, suffix: "themed") } + + // MARK: - Drafts + + // Note: For some reason the text is not rendered in the composer, + // Maybe it's because of the `UITextView` that is used in the `InputTextView`. + // Either way, the test of the content is covered. + + func test_composerView_draftWithImageAttachment() throws { + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockDraftMessage = DraftMessage.mock( + attachments: [ + .dummy( + type: .image, + payload: try JSONEncoder().encode( + ImageAttachmentPayload( + title: nil, + imageRemoteURL: TestImages.yoda.url, + file: .init(type: .jpeg, size: 10, mimeType: nil) + ) + ) + ), + .dummy( + type: .image, + payload: try JSONEncoder().encode( + ImageAttachmentPayload( + title: nil, + imageRemoteURL: TestImages.chewbacca.url, + file: .init(type: .jpeg, size: 10, mimeType: nil) + ) + ) + ) + ] + ) + + let view = makeComposerView(with: mockDraftMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_draftWithVideoAttachment() throws { + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockDraftMessage = DraftMessage.mock( + 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 = makeComposerView(with: mockDraftMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_draftWithFileAttachment() throws { + let size = CGSize(width: defaultScreenSize.width, height: 200) + let mockDraftMessage = DraftMessage.mock( + 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 = makeComposerView(with: mockDraftMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_draftWithVoiceRecordingAttachment() 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 mockDraftMessage = DraftMessage.mock( + 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 = makeComposerView(with: mockDraftMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + func test_composerView_draftWithCommand() throws { + let size = CGSize(width: defaultScreenSize.width, height: 100) + let mockDraftMessage = DraftMessage.mock( + text: "/giphy test" + ) + + let view = makeComposerView(with: mockDraftMessage) + .frame(width: size.width, height: size.height) + + AssertSnapshot(view, variants: [.defaultLight], size: size) + } + + private func makeComposerView(with draftMessage: DraftMessage) -> some View { + let factory = DefaultViewFactory.shared + let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient) + channelController.channel_mock = .mock( + cid: .unique, + config: ChannelConfig(commands: [Command(name: "giphy", description: "", set: "", args: "")]), + draftMessage: draftMessage + ) + let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil) + + return MessageComposerView( + viewFactory: factory, + viewModel: viewModel, + channelController: channelController, + quotedMessage: .constant(nil), + editedMessage: .constant(nil), + onMessageSent: {} + ) + } func test_composerQuotedMessage_translated() { let factory = DefaultViewFactory.shared diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithCommand.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithCommand.default-light.png new file mode 100644 index 00000000..0078f32a Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithCommand.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithFileAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithFileAttachment.default-light.png new file mode 100644 index 00000000..bd8fdb57 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithFileAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithImageAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithImageAttachment.default-light.png new file mode 100644 index 00000000..c75b1063 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithImageAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithVideoAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithVideoAttachment.default-light.png new file mode 100644 index 00000000..307f9a03 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithVideoAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithVoiceRecordingAttachment.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithVoiceRecordingAttachment.default-light.png new file mode 100644 index 00000000..41c8e7b2 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_composerView_draftWithVoiceRecordingAttachment.default-light.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift index e1abbf7c..32e426e8 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -18,8 +18,9 @@ final class ChatChannelListItemView_Tests: StreamChatTestCase { streamChat?.utils.channelHeaderLoader.placeholder2 = circleImage streamChat?.utils.channelHeaderLoader.placeholder3 = circleImage streamChat?.utils.channelHeaderLoader.placeholder4 = circleImage + streamChat?.utils.messageListConfig = .init(draftMessagesEnabled: true) } - + func test_channelListItem_audioMessage() throws { // Given let message = try mockAudioMessage(text: "Audio", isSentByCurrentUser: true) @@ -258,6 +259,46 @@ final class ChatChannelListItemView_Tests: StreamChatTestCase { assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + func test_channelListItem_draftMessage() throws { + // Given + let message = DraftMessage.mock(text: "Draft message") + let channel = ChatChannel.mock(cid: .unique, previewMessage: .mock(), draftMessage: message) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_channelListItem_draftMessageWithAttachment() throws { + // Given + let message = DraftMessage.mock(text: "Draft message", attachments: [.dummy(payload: try JSONEncoder().encode( + ImageAttachmentPayload(title: "Test", imageRemoteURL: .localYodaImage, file: .init(url: .localYodaImage)) + ))]) + let channel = ChatChannel.mock(cid: .unique, previewMessage: .mock(), draftMessage: message) + + // When + let view = ChatChannelListItem( + channel: channel, + channelName: "Test", + avatar: .circleImage, + onlineIndicatorShown: true, + onItemTap: { _ in } + ) + .frame(width: defaultScreenSize.width) + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + // MARK: - private private func mockAudioMessage(text: String, isSentByCurrentUser: Bool) throws -> ChatMessage { diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_draftMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_draftMessage.1.png new file mode 100644 index 00000000..d07c0490 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_draftMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_draftMessageWithAttachment.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_draftMessageWithAttachment.1.png new file mode 100644 index 00000000..d1ecb2b2 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_channelListItem_draftMessageWithAttachment.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift index d2de6ac9..19dd5a40 100644 --- a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift @@ -23,6 +23,7 @@ final class ChatThreadListItemView_Tests: StreamChatTestCase { streamChat?.utils.channelHeaderLoader.placeholder2 = circleImage streamChat?.utils.channelHeaderLoader.placeholder3 = circleImage streamChat?.utils.channelHeaderLoader.placeholder4 = circleImage + streamChat?.utils.messageListConfig = .init(draftMessagesEnabled: true) currentUser = ChatUser.mock(id: StreamChatTestCase.currentUserId, name: "Vader", imageURL: nil) @@ -130,6 +131,32 @@ final class ChatThreadListItemView_Tests: StreamChatTestCase { assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + func test_threadListItem_whenDraftMessage() { + let thread = mockThread + .with(parentMessage: .mock(text: "Parent", draftReply: .mock(text: "Draft message"))) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_whenDraftMessageHasAttachment() throws { + let message = DraftMessage.mock(text: "Draft message", attachments: [.dummy(payload: try JSONEncoder().encode( + ImageAttachmentPayload(title: "Test", imageRemoteURL: .localYodaImage, file: .init(url: .localYodaImage)) + ))]) + let thread = mockThread + .with(parentMessage: .mock( + text: "Parent", + draftReply: message + )) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } } extension ChatThreadListItem { diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenDraftMessage.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenDraftMessage.1.png new file mode 100644 index 00000000..6c062c06 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenDraftMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenDraftMessageHasAttachment.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenDraftMessageHasAttachment.1.png new file mode 100644 index 00000000..08d5e749 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenDraftMessageHasAttachment.1.png differ