diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e8097f8e2..43c42e9d400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChatUI ### ✅ Added - Validates file size limit per attachment type defined in Stream's Dashboard [#3105](https://github.com/GetStream/stream-chat-swift/pull/3105) +- Make it easier to customize `ComposerVC.updateContent()` [#3112](https://github.com/GetStream/stream-chat-swift/pull/3112) ### 🐞 Fixed - Fix support for markdown ordered list with all numbers [#3090](https://github.com/GetStream/stream-chat-swift/pull/3090) - Fix support for markdown italic and bold styles inside snake-styled text [#3094](https://github.com/GetStream/stream-chat-swift/pull/3094) diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index 38c937cef43..a79cd130637 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -100,6 +100,11 @@ open class ComposerVC: _ViewController, cooldownTime > 0 } + /// A boolean that checks if the composer is in voice recording mode. + public var isVoiceRecording: Bool { + state == .recording || state == .recordingLocked + } + /// A boolean that checks if the message only contains link attachments. public var hasOnlyLinkAttachments: Bool { let linkAttachmentsCount = attachments.filter { $0.type == .linkPreview }.count @@ -474,6 +479,20 @@ open class ComposerVC: _ViewController, composerView.pin(to: view) } + open func setupAttachmentsView() { + addChildViewController(attachmentsVC, embedIn: composerView.inputMessageView.attachmentsViewContainer) + attachmentsVC.didTapRemoveItemButton = { [weak self] index in + self?.content.attachments.remove(at: index) + } + } + + open func setupVoiceRecordingView() { + voiceRecordingVC.delegate = self + addChild(voiceRecordingVC) + voiceRecordingVC.didMove(toParent: self) + voiceRecordingVC.setUp() + } + override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -486,92 +505,154 @@ open class ComposerVC: _ViewController, dismissSuggestions() } + // MARK: Update Content + override open func updateContent() { super.updateContent() + // Note: The order of the calls is important. + updateText() + updateKeystrokeEvents() + updateTitleLabel() + updateCommandsButtonVisibility() + updateConfirmButtonVisibility() + updateSendButtonVisibility() + updateAttachmentButtonVisibility() + updateHeaderViewVisibility() + updateRecordButtonVisibility() + updateCooldownView() + updateCooldownViewVisibility() + updateSendButtonEnabled() + updateConfirmButtonEnabled() + updateInputMessageView() + updateInputMessageViewVisibility() + updateInputAttachmentsView() + updateLinkPreview() + updateCheckbox() + updateBottomContainerVisibility() + updateLeadingContainerVisibility() + updateCommandSuggestions() + updateMentionSuggestions() + updatePlaceholderLabel() + dismissSuggestions() + } + + open func updateText() { if composerView.inputMessageView.textView.text != content.text { // Updating the text unnecessarily makes the caret jump to the end of input composerView.inputMessageView.textView.text = content.text } + } + open func updateKeystrokeEvents() { if !content.isEmpty && channelConfig?.typingEventsEnabled == true { channelController?.sendKeystrokeEvent(parentMessageId: content.threadMessage?.id) } + } - switch content.state { - case .new: - composerView.inputMessageView.textView.placeholderLabel.text = content.isSlowModeOn - ? L10n.Composer.Placeholder.slowMode - : L10n.Composer.Placeholder.message - Animate { - self.composerView.confirmButton.isHidden = true - self.composerView.sendButton.isHidden = self.content.isSlowModeOn - self.composerView.recordButton.isHidden = self.composerView.sendButton.isHidden || !self.components.isVoiceRecordingEnabled || !self.isAttachmentsEnabled - self.composerView.headerView.isHidden = true - self.composerView.cooldownView.isHidden = !self.content.isSlowModeOn - self.composerView.leadingContainer.isHidden = false - self.composerView.inputMessageView.isHidden = false - } - case .recording: - Animate { - self.composerView.confirmButton.isHidden = true - self.composerView.sendButton.isHidden = true + open func updateRecordButtonVisibility() { + guard isSendMessageEnabled else { + composerView.recordButton.isHidden = true + return + } + + let isSendButtonHidden = composerView.sendButton.isHidden + let isConfirmButtonHidden = composerView.confirmButton.isHidden + let isVoiceRecordingEnabled = components.isVoiceRecordingEnabled + Animate { + switch self.content.state { + case .new: + self.composerView.recordButton.isHidden = isSendButtonHidden || !isVoiceRecordingEnabled || !self.isAttachmentsEnabled + case .recording: self.composerView.recordButton.isHidden = false - self.composerView.headerView.isHidden = true - self.composerView.cooldownView.isHidden = true - self.composerView.leadingContainer.isHidden = true - self.composerView.inputMessageView.isHidden = true - } - case .recordingLocked: - Animate { - self.composerView.headerView.isHidden = false + case .recordingLocked: self.composerView.recordButton.isHidden = true + case .quote: + self.composerView.recordButton.isHidden = isSendButtonHidden || !isVoiceRecordingEnabled || !self.isAttachmentsEnabled + case .edit: + self.composerView.recordButton.isHidden = isConfirmButtonHidden || !self.isAttachmentsEnabled + default: + break } - case .quote: - composerView.titleLabel.text = L10n.Composer.Title.reply - Animate { - self.composerView.confirmButton.isHidden = true - self.composerView.sendButton.isHidden = self.content.isSlowModeOn - self.composerView.recordButton.isHidden = self.composerView.sendButton.isHidden || !self.components.isVoiceRecordingEnabled || !self.isAttachmentsEnabled - self.composerView.headerView.isHidden = false - self.composerView.cooldownView.isHidden = !self.content.isSlowModeOn - self.composerView.leadingContainer.isHidden = false - self.composerView.inputMessageView.isHidden = false - } + } + } + + open func updateTitleLabel() { + switch content.state { case .edit: composerView.titleLabel.text = L10n.Composer.Title.edit - Animate { - self.composerView.confirmButton.isHidden = false - self.composerView.sendButton.isHidden = true - self.composerView.recordButton.isHidden = self.composerView.confirmButton.isHidden || !self.isAttachmentsEnabled - self.composerView.headerView.isHidden = false - self.composerView.cooldownView.isHidden = true - self.composerView.leadingContainer.isHidden = false - self.composerView.inputMessageView.isHidden = false - } + case .quote: + composerView.titleLabel.text = L10n.Composer.Title.reply default: - log.warning("The composer state \(content.state.description) was not handled.") + break } + } + open func updateCooldownView() { composerView.cooldownView.content = .init(cooldown: content.cooldownTime) + } + + open func updateCooldownViewVisibility() { + Animate { + switch self.content.state { + case .new, .quote: + self.composerView.cooldownView.isHidden = !self.content.isSlowModeOn + case .edit, .recording, .recordingLocked: + self.composerView.cooldownView.isHidden = true + default: + break + } + } + } + open func updateSendButtonEnabled() { composerView.sendButton.isEnabled = !content.isEmpty + } + + open func updateConfirmButtonEnabled() { composerView.confirmButton.isEnabled = !content.isEmpty + } - let isAttachmentButtonHidden = !isAttachmentsEnabled || content.hasCommand || !composerView.shrinkInputButton.isHidden - let isCommandsButtonHidden = !isCommandsEnabled || content.hasCommand || !composerView.shrinkInputButton.isHidden + open func updateAttachmentButtonVisibility() { + guard isSendMessageEnabled else { + composerView.attachmentButton.isHidden = true + return + } + let isAttachmentButtonHidden = !isAttachmentsEnabled || content.hasCommand || !composerView.shrinkInputButton.isHidden Animate { self.composerView.attachmentButton.isHidden = isAttachmentButtonHidden + } + } + + open func updateCommandsButtonVisibility() { + guard isSendMessageEnabled else { + composerView.commandsButton.isHidden = true + return + } + + let isCommandsButtonHidden = !isCommandsEnabled || content.hasCommand || !composerView.shrinkInputButton.isHidden + Animate { self.composerView.commandsButton.isHidden = isCommandsButtonHidden } + } + open func updateInputMessageView() { composerView.inputMessageView.content = .init( quotingMessage: content.quotingMessage, command: content.command, channel: channelController?.channel ) + composerView.inputMessageView.isUserInteractionEnabled = isSendMessageEnabled + } + open func updateInputMessageViewVisibility() { + Animate { + self.composerView.inputMessageView.isHidden = self.content.isVoiceRecording + } + } + + open func updateInputAttachmentsView() { attachmentsVC.content = content.attachments.map { if let provider = $0.payload as? AttachmentPreviewProvider { return provider @@ -584,13 +665,17 @@ open class ComposerVC: _ViewController, } } composerView.inputMessageView.attachmentsViewContainer.isHidden = content.attachments.isEmpty + } + open func updateLinkPreview() { // Since we don't want to show link previews with other attachment types, we dismiss the // link preview in case it is being shown and there are other types of attachments in the message. if content.hasOnlyLinkAttachments == false && content.skipEnrichUrl == false { dismissLinkPreview() } + } + open func updateCheckbox() { if content.isInsideThread { if channelController?.channel?.isDirectMessageChannel == true { composerView.checkboxControl.label.text = L10n.Composer.Checkmark.directMessageReply @@ -598,45 +683,82 @@ open class ComposerVC: _ViewController, composerView.checkboxControl.label.text = L10n.Composer.Checkmark.channelReply } } + } + + open func updateBottomContainerVisibility() { Animate { self.composerView.bottomContainer.isHidden = !self.content.isInsideThread } + } + + open func updateLeadingContainerVisibility() { + Animate { + self.composerView.leadingContainer.isHidden = self.content.isVoiceRecording + } + } + open func updateCommandSuggestions() { if isCommandsEnabled, let typingCommand = typingCommand(in: composerView.inputMessageView.textView) { showCommandSuggestions(for: typingCommand) return } + } + open func updateMentionSuggestions() { if isMentionsEnabled, let (typingMention, mentionRange) = typingMention(in: composerView.inputMessageView.textView) { userMentionsDebouncer.execute { [weak self] in self?.showMentionSuggestions(for: typingMention, mentionRange: mentionRange) } return } + } - if !isSendMessageEnabled { + open func updatePlaceholderLabel() { + guard isSendMessageEnabled else { composerView.inputMessageView.textView.placeholderLabel.text = L10n.Composer.Placeholder.messageDisabled - composerView.recordButton.isHidden = true - composerView.attachmentButton.isHidden = true - composerView.commandsButton.isHidden = true + return } - composerView.inputMessageView.isUserInteractionEnabled = isSendMessageEnabled - dismissSuggestions() + composerView.inputMessageView.textView.placeholderLabel.text = content.isSlowModeOn + ? L10n.Composer.Placeholder.slowMode + : L10n.Composer.Placeholder.message } - open func setupAttachmentsView() { - addChildViewController(attachmentsVC, embedIn: composerView.inputMessageView.attachmentsViewContainer) - attachmentsVC.didTapRemoveItemButton = { [weak self] index in - self?.content.attachments.remove(at: index) + open func updateConfirmButtonVisibility() { + guard isSendMessageEnabled else { + composerView.confirmButton.isHidden = true + return + } + + Animate { + self.composerView.confirmButton.isHidden = self.content.state != .edit } } - open func setupVoiceRecordingView() { - voiceRecordingVC.delegate = self - addChild(voiceRecordingVC) - voiceRecordingVC.didMove(toParent: self) - voiceRecordingVC.setUp() + open func updateSendButtonVisibility() { + Animate { + switch self.content.state { + case .new, .quote: + self.composerView.sendButton.isHidden = self.content.isSlowModeOn + case .edit, .recording, .recordingLocked: + self.composerView.sendButton.isHidden = true + default: + break + } + } + } + + open func updateHeaderViewVisibility() { + Animate { + switch self.content.state { + case .new, .recording: + self.composerView.headerView.isHidden = true + case .edit, .quote, .recordingLocked: + self.composerView.headerView.isHidden = false + default: + break + } + } } // MARK: - Actions @@ -1293,15 +1415,7 @@ open class ComposerVC: _ViewController, // MARK: - UITextViewDelegate open func textViewDidChange(_ textView: UITextView) { - Animate { - let leadingViews = self.composerView.leadingContainer.subviews - let isNotShrinkInputButton: (UIView) -> Bool = { $0 !== self.composerView.shrinkInputButton } - let isLeadingActionsVisible = leadingViews - .filter { isNotShrinkInputButton($0) && self.composerView.shrinkInputButton.isHidden } - .filter(\.isHidden).isEmpty - self.composerView.shrinkInputButton.isHidden = textView.text.isEmpty || self.content - .hasCommand || !isLeadingActionsVisible - } + updateShrinkButtonVisibility() // This guard removes the possibility of having a loop when updating the `UITextView`. // The aim is that `UITextView.text` is always in sync with `Content.text`. @@ -1321,6 +1435,19 @@ open class ComposerVC: _ViewController, return textView.text.count + (text.count - range.length) <= maxMessageLength } + open func updateShrinkButtonVisibility() { + let textView = composerView.inputMessageView.textView + Animate { + let leadingViews = self.composerView.leadingContainer.subviews + let isNotShrinkInputButton: (UIView) -> Bool = { $0 !== self.composerView.shrinkInputButton } + let isLeadingActionsVisible = leadingViews + .filter { isNotShrinkInputButton($0) && self.composerView.shrinkInputButton.isHidden } + .filter(\.isHidden).isEmpty + self.composerView.shrinkInputButton.isHidden = textView.text.isEmpty || self.content + .hasCommand || !isLeadingActionsVisible + } + } + // MARK: - UIImagePickerControllerDelegate open func imagePickerController( diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift index 922ae86d733..a3150f605a2 100644 --- a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift @@ -240,61 +240,6 @@ final class ComposerVC_Tests: XCTestCase { AssertSnapshot(composerVC) } - - func test_whenSuggestionsLookupIsLocal_onlyChannelMembersAreShown() { - final class ComposerContainerVC: UIViewController { - var composerVC: ComposerVC! - var textWithMention = "" - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .white - addChildViewController(composerVC, targetView: view) - composerVC.view.pin(anchors: [.leading, .trailing, .bottom], to: view) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - composerVC.content.text = textWithMention - } - } - - let member: ChatChannelMember = .mock( - id: "1", - name: "Yoda (member)", - imageURL: nil - ) - - let watcher: ChatUser = .mock( - id: "2", - name: "Yoda (watcher)", - imageURL: nil - ) - - let mockUserSearchController = ChatUserSearchController_Mock.mock() - - let mockChannelController = ChatChannelController_Mock.mock() - mockChannelController.client.authenticationRepository.setMockToken() - mockChannelController.channel_mock = .mock( - cid: .unique, - ownCapabilities: [.uploadFile, .sendMessage], - lastActiveMembers: [member], - lastActiveWatchers: [watcher] - ) - - composerVC.userSearchController = mockUserSearchController - composerVC.channelController = mockChannelController - - let containerVC = ComposerContainerVC() - containerVC.composerVC = composerVC - containerVC.composerVC.userMentionsDebouncer = .init(0, queue: .main) - containerVC.textWithMention = "@Yo" - containerVC.composerVC.composerView.inputMessageView.textView.placeholderLabel.isHidden = true - - AssertSnapshot(containerVC, variants: [.defaultLight]) - } func test_makeMentionSuggestionsDataSource_whenMentionAllAppUsers_shouldSearchUsers() throws { composerVC.components.mentionAllAppUsers = true diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_whenSuggestionsLookupIsLocal_onlyChannelMembersAreShown.default-light.png b/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_whenSuggestionsLookupIsLocal_onlyChannelMembersAreShown.default-light.png deleted file mode 100644 index 25aaae3b05d..00000000000 Binary files a/Tests/StreamChatUITests/SnapshotTests/Composer/__Snapshots__/ComposerVC_Tests/test_whenSuggestionsLookupIsLocal_onlyChannelMembersAreShown.default-light.png and /dev/null differ