diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index de770ed733..dac5730d3f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ 1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */; }; 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; }; 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; + 20BB987875F99190A3E28632 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F24C1913F809A0039FD0 /* MessageComposerTextField.swift */; }; 20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76310030C831D4610A705603 /* URLComponentsTests.swift */; }; 21813AF91CFC6F3E3896DB53 /* AppLockSetupBiometricsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F130DF775CE6BC51A4E392 /* AppLockSetupBiometricsScreenModels.swift */; }; 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */; }; @@ -1225,6 +1226,7 @@ 170BF6F7923A5C3792442F27 /* CompletionSuggestionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionModels.swift; sourceTree = ""; }; 1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = ""; }; 1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = ""; }; + 1756F24C1913F809A0039FD0 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = ""; }; 18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenModels.swift; sourceTree = ""; }; 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationTimelineItemProtocol.swift; sourceTree = ""; }; 1877038D1AD3D5A029F8AE2C /* TimelineReadReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReadReceiptsView.swift; sourceTree = ""; }; @@ -3114,6 +3116,7 @@ 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */, 2FD0E68C42CA7DDCD4CAD68D /* MentionSuggestionItemView.swift */, A0A01AECFF54281CF35909A6 /* MessageComposer.swift */, + 1756F24C1913F809A0039FD0 /* MessageComposerTextField.swift */, 3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */, BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */, D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */, @@ -6173,6 +6176,7 @@ 5D4643E485C179B2F485C519 /* MentionSuggestionItemView.swift in Sources */, 64AB99285DC4437C0DDE9585 /* MenuSheetLabelStyle.swift in Sources */, 858B0A45257174AAFD448EA0 /* MessageComposer.swift in Sources */, + 20BB987875F99190A3E28632 /* MessageComposerTextField.swift in Sources */, C8E0FA0FF2CD6613264FA6B9 /* MessageForwardingScreen.swift in Sources */, 2BBA132149DEBED6624084A8 /* MessageForwardingScreenCoordinator.swift in Sources */, 695825D20A761C678809345D /* MessageForwardingScreenModels.swift in Sources */, diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index 0a4a0ed43d..10715a5edd 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -91,7 +91,11 @@ struct ComposerToolbarViewState: BindableState { case .previewVoiceMessage: return true default: - return !composerEmpty + if ServiceLocator.shared.settings.richTextEditorEnabled { + return !composerEmpty + } else { + return !bindings.composerPlainText.isEmpty + } } } @@ -99,7 +103,12 @@ struct ComposerToolbarViewState: BindableState { if case .previewVoiceMessage = composerMode { return false } - return composerEmpty + + if ServiceLocator.shared.settings.richTextEditorEnabled { + return composerEmpty + } else { + return bindings.composerPlainText.isEmpty + } } var isVoiceMessageModeActivated: Bool { @@ -113,6 +122,7 @@ struct ComposerToolbarViewState: BindableState { } struct ComposerToolbarViewStateBindings { + var composerPlainText = "" var composerFocused = false var composerActionsEnabled = false var composerExpanded = false diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index 318d3bbd4e..2f63f7db68 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -121,18 +121,20 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } case .sendMessage: guard !state.sendButtonDisabled else { return } - + switch state.composerMode { case .previewVoiceMessage: actionsSubject.send(.voiceMessage(.send)) default: - let sendHTML = appSettings.richTextEditorEnabled - actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown, - html: sendHTML ? wysiwygViewModel.content.html : nil, - mode: state.composerMode, - intentionalMentions: wysiwygViewModel - .getMentionsState() - .toIntentionalMentions())) + if ServiceLocator.shared.settings.richTextEditorEnabled { + let sendHTML = appSettings.richTextEditorEnabled + actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown, + html: sendHTML ? wysiwygViewModel.content.html : nil, + mode: state.composerMode, + intentionalMentions: wysiwygViewModel.getMentionsState().toIntentionalMentions())) + } else { + actionsSubject.send(.sendMessage(plain: context.composerPlainText, html: nil, mode: state.composerMode, intentionalMentions: .empty)) + } } case .cancelReply: set(mode: .default) @@ -260,12 +262,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } private func set(text: String) { - wysiwygViewModel.textView.flushPills() - - if appSettings.richTextEditorEnabled { + if ServiceLocator.shared.settings.richTextEditorEnabled { + wysiwygViewModel.textView.flushPills() + wysiwygViewModel.setHtmlContent(text) } else { - wysiwygViewModel.setMarkdownContent(text) + state.bindings.composerPlainText = text } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index 0103f0772a..b4bb104f0f 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -162,7 +162,8 @@ struct ComposerToolbar: View { } private var messageComposer: some View { - MessageComposer(composerView: composerView, + MessageComposer(plainText: $context.composerPlainText, + composerView: composerView, mode: context.viewState.composerMode, showResizeGrabber: context.viewState.bindings.composerActionsEnabled, isExpanded: $context.composerExpanded) { diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index 75dc827e20..ddaf6c7bc0 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -22,6 +22,7 @@ typealias EnterKeyHandler = () -> Void typealias PasteHandler = (NSItemProvider) -> Void struct MessageComposer: View { + @Binding var plainText: String let composerView: WysiwygComposerView let mode: RoomScreenComposerMode let showResizeGrabber: Bool @@ -32,6 +33,7 @@ struct MessageComposer: View { let editCancellationAction: () -> Void let onAppearAction: () -> Void + @State private var isMultiline = false @State private var composerTranslation: CGFloat = 0 private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular) @@ -66,19 +68,31 @@ struct MessageComposer: View { private var mainContent: some View { VStack(alignment: .leading, spacing: -6) { header - Color.clear - .overlay(alignment: .top) { - composerView - .clipped() - .readFrame($composerFrame) - } - .frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height), - alignment: .top) - .tint(.compound.iconAccentTertiary) - .padding(.vertical, 10) - .onAppear { - onAppearAction() - } + + if ServiceLocator.shared.settings.richTextEditorEnabled { + Color.clear + .overlay(alignment: .top) { + composerView + .clipped() + .readFrame($composerFrame) + } + .frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height), + alignment: .top) + .tint(.compound.iconAccentTertiary) + .padding(.vertical, 10) + .onAppear { + onAppearAction() + } + } else { + MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder, + text: $plainText, + isMultiline: $isMultiline, + maxHeight: 300, + enterKeyHandler: sendAction, + pasteHandler: pasteAction) + .tint(.compound.iconAccentTertiary) + .padding(.vertical, 10) + } } } @@ -212,7 +226,8 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview { keyCommands: nil, pasteHandler: nil) - return MessageComposer(composerView: composerView, + return MessageComposer(plainText: .constant(content), + composerView: composerView, mode: mode, showResizeGrabber: false, isExpanded: .constant(false), diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift new file mode 100644 index 0000000000..42ba1bc920 --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift @@ -0,0 +1,247 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import SwiftUI + +struct MessageComposerTextField: View { + let placeholder: String + @Binding var text: String + @Binding var isMultiline: Bool + + let maxHeight: CGFloat + let enterKeyHandler: EnterKeyHandler + let pasteHandler: PasteHandler + + var body: some View { + UITextViewWrapper(text: $text, + isMultiline: $isMultiline, + maxHeight: maxHeight, + enterKeyHandler: enterKeyHandler, + pasteHandler: pasteHandler) + .accessibilityLabel(placeholder) + .background(placeholderView, alignment: .topLeading) + } + + @ViewBuilder + private var placeholderView: some View { + if text.isEmpty { + Text(placeholder) + .foregroundColor(.compound.textPlaceholder) + .accessibilityHidden(true) + } + } +} + +private struct UITextViewWrapper: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + @Binding var isMultiline: Bool + + let maxHeight: CGFloat + + let enterKeyHandler: EnterKeyHandler + let pasteHandler: PasteHandler + + private let font = UIFont.preferredFont(forTextStyle: .body) + + func makeUIView(context: UIViewRepresentableContext) -> UITextView { + // Need to use TextKit 1 for mentions + let textView = ElementTextView(usingTextLayoutManager: false) + textView.isMultiline = $isMultiline + textView.delegate = context.coordinator + textView.elementDelegate = context.coordinator + textView.textColor = .compound.textPrimary + textView.isEditable = true + textView.font = font + textView.isSelectable = true + textView.isUserInteractionEnabled = true + textView.backgroundColor = UIColor.clear + textView.returnKeyType = .default + textView.textContainer.lineFragmentPadding = 0.0 + textView.textContainerInset = .zero + textView.keyboardType = .default + + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + return textView + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + // Note: Coalescing a width of zero here returns a size for the view with 1 line of text visible. + let newSize = uiView.sizeThatFits(CGSize(width: proposal.width ?? .zero, + height: CGFloat.greatestFiniteMagnitude)) + let width = proposal.width ?? newSize.width + let height = min(maxHeight, newSize.height) + + return CGSize(width: width, height: height) + } + + func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext) { + if textView.text != text { + textView.text = text + + if text.isEmpty { + // text cleared, probably because the written text is sent + // reload keyboard type + if textView.isFirstResponder { + textView.keyboardType = .twitter + textView.reloadInputViews() + textView.keyboardType = .default + textView.reloadInputViews() + } + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, + maxHeight: maxHeight, + enterKeyHandler: enterKeyHandler, + pasteHandler: pasteHandler) + } + + final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate { + private var text: Binding + + private let maxHeight: CGFloat + + private let enterKeyHandler: EnterKeyHandler + private let pasteHandler: PasteHandler + + init(text: Binding, + maxHeight: CGFloat, + enterKeyHandler: @escaping EnterKeyHandler, + pasteHandler: @escaping PasteHandler) { + self.text = text + self.maxHeight = maxHeight + self.enterKeyHandler = enterKeyHandler + self.pasteHandler = pasteHandler + } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + } + + func textViewDidReceiveEnterKeyPress(_ textView: UITextView) { + enterKeyHandler() + } + + func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) { + textView.insertText("\n") + } + + func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) { + pasteHandler(provider) + } + } +} + +private protocol ElementTextViewDelegate: AnyObject { + func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) + func textViewDidReceiveEnterKeyPress(_ textView: UITextView) + func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) +} + +private class ElementTextView: UITextView { + weak var elementDelegate: ElementTextViewDelegate? + + var isMultiline: Binding? + + override var keyCommands: [UIKeyCommand]? { + [UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)), + UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))] + } + + @objc func shiftEnterKeyPressed(sender: UIKeyCommand) { + elementDelegate?.textViewDidReceiveShiftEnterKeyPress(self) + } + + @objc func enterKeyPressed(sender: UIKeyCommand) { + elementDelegate?.textViewDidReceiveEnterKeyPress(self) + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard let isMultiline, let font else { return } + + let numberOfLines = frame.height / font.lineHeight + if numberOfLines > 1.5 { + if !isMultiline.wrappedValue { + isMultiline.wrappedValue = true + } + } else { + if isMultiline.wrappedValue { + isMultiline.wrappedValue = false + } + } + } + + // Pasting support + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if super.canPerformAction(action, withSender: sender) { + return true + } + + guard action == #selector(paste(_:)) else { + return false + } + + return UIPasteboard.general.itemProviders.first?.isSupportedForPasteOrDrop ?? false + } + + override func paste(_ sender: Any?) { + guard let provider = UIPasteboard.general.itemProviders.first, + provider.isSupportedForPasteOrDrop else { + // If the item is not supported for media upload then + // just try pasting its contents into the textfield + super.paste(sender) + return + } + + elementDelegate?.textView(self, didReceivePasteWith: provider) + } +} + +struct MessageComposerTextField_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack(spacing: 16) { + PreviewWrapper(text: "123") + PreviewWrapper(text: "") + PreviewWrapper(text: "A really long message that will wrap to multiple lines on a phone in portrait.") + } + } + + struct PreviewWrapper: View { + @State var text: String + @State var isMultiline: Bool + + init(text: String) { + _text = .init(initialValue: text) + _isMultiline = .init(initialValue: false) + } + + var body: some View { + MessageComposerTextField(placeholder: "Placeholder", + text: $text, + isMultiline: $isMultiline, + maxHeight: 300, + enterKeyHandler: { }, + pasteHandler: { _ in }) + } + } +} diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPad-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPad-en-GB.1.png new file mode 100644 index 0000000000..6db77624b5 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPad-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:210b94e65098c70d9362fb9e8e9fde574eb46efcb06077f81abe3e1e7790da99 +size 101026 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPad-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPad-pseudo.1.png new file mode 100644 index 0000000000..6db77624b5 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPad-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:210b94e65098c70d9362fb9e8e9fde574eb46efcb06077f81abe3e1e7790da99 +size 101026 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPhone-15-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPhone-15-en-GB.1.png new file mode 100644 index 0000000000..abe29efb9e --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPhone-15-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3849590fd946e5d503c6e456da074a9d37e08c9f69bb46ac26493ac0586bc639 +size 59316 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPhone-15-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPhone-15-pseudo.1.png new file mode 100644 index 0000000000..abe29efb9e --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_messageComposerTextField-iPhone-15-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3849590fd946e5d503c6e456da074a9d37e08c9f69bb46ac26493ac0586bc639 +size 59316