From cddacbc146895ec651001468829a10db3116d207 Mon Sep 17 00:00:00 2001 From: aringenbach <80891108+aringenbach@users.noreply.github.com> Date: Tue, 29 Aug 2023 14:37:13 +0200 Subject: [PATCH] Initial integration of RTE (#1464) * Initial integration of RTE * Fix `clipped`, `focused` and composer view type * Remove horizontal padding * Add `ComposerToolbar` mock * Restore `composerFocusedSubject` * Allow using HTML from RTE on message sent * Fix new message content API * Add feature flag for Rich Text Editor --- ElementX.xcodeproj/project.pbxproj | 17 +++ .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Sources/Application/AppCoordinator.swift | 2 +- .../Sources/Application/AppSettings.swift | 4 + .../Mocks/Generated/GeneratedMocks.swift | 56 ++++---- .../ComposerToolbarModels.swift | 21 ++- .../ComposerToolbarViewModel.swift | 45 +++++- .../View/ComposerToolbar.swift | 59 ++++++-- .../View/MessageComposer.swift | 136 ++++++++---------- .../View/MessageComposerTextField.swift | 71 +++++---- .../View/RoomAttachmentPicker.swift | 3 +- .../RoomScreen/RoomScreenCoordinator.swift | 11 +- .../RoomScreen/RoomScreenViewModel.swift | 18 +-- .../Screens/RoomScreen/View/RoomScreen.swift | 7 +- .../View/Timeline/UITimelineView.swift | 5 +- .../RoomScreen/View/TimelineView.swift | 5 +- .../DeveloperOptionsScreenModels.swift | 1 + .../View/DeveloperOptionsScreen.swift | 6 + .../Sources/Services/Room/RoomProxy.swift | 19 ++- .../Services/Room/RoomProxyProtocol.swift | 8 +- .../MockRoomTimelineController.swift | 4 +- .../RoomTimelineController.swift | 10 +- .../RoomTimelineControllerProtocol.swift | 8 +- ElementX/SupportingFiles/target.yml | 1 + .../ComposerToolbarViewModelTests.swift | 17 ++- project.yml | 3 + 26 files changed, 334 insertions(+), 214 deletions(-) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 6c21953c4..0606184d6 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */; }; 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */; }; 44121202B4A260C98BF615A7 /* RoomMembersListScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B7A755E985FA14469E86B2 /* RoomMembersListScreenUITests.swift */; }; + 44F0E1B576C7599DF8022071 /* WysiwygComposer in Frameworks */ = {isa = PBXBuildFile; productRef = CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; 46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */; }; 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565F1B2B300597C616B37888 /* FullscreenDialog.swift */; }; @@ -1635,6 +1636,7 @@ 36AD4DD4C798E22584ED3200 /* URLRouting in Frameworks */, 36CD6E11B37396E14F032CB6 /* Version in Frameworks */, A0D7E5BD0298A97DCBDCE40B /* Emojibase in Frameworks */, + 44F0E1B576C7599DF8022071 /* WysiwygComposer in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3893,6 +3895,7 @@ E9BAB8A793FE3B54CDD47102 /* URLRouting */, A05AF81DDD14AD58CB0E1B9B /* Version */, C05729B1684C331F5FFE9232 /* Emojibase */, + CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */, ); productName = ElementX; productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */; @@ -4025,6 +4028,7 @@ 9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 0020F10A9DA1895036A72013 /* XCRemoteSwiftPackageReference "swift-url-routing" */, EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */, + 945C99D66CE013061F6A3C71 /* XCRemoteSwiftPackageReference "matrix-wysiwyg-composer-swift" */, ); projectDirPath = ""; projectRoot = ""; @@ -5538,6 +5542,14 @@ minimumVersion = 1.0.0; }; }; + 945C99D66CE013061F6A3C71 /* XCRemoteSwiftPackageReference "matrix-wysiwyg-composer-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/matrix-org/matrix-wysiwyg-composer-swift"; + requirement = { + kind = exactVersion; + version = 2.6.3; + }; + }; 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PostHog/posthog-ios"; @@ -5868,6 +5880,11 @@ package = 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; productName = MatrixRustSDK; }; + CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */ = { + isa = XCSwiftPackageProductDependency; + package = 945C99D66CE013061F6A3C71 /* XCRemoteSwiftPackageReference "matrix-wysiwyg-composer-swift" */; + productName = WysiwygComposer; + }; CCE5BF78B125320CBF3BB834 /* PostHog */ = { isa = XCSwiftPackageProductDependency; package = 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 16995fc54..49a358369 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -133,6 +133,15 @@ "version" : "1.1.2" } }, + { + "identity" : "matrix-wysiwyg-composer-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", + "state" : { + "revision" : "2827fea6508b62b8c458a29cb74282d99e63ecf7", + "version" : "2.6.3" + } + }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", @@ -235,7 +244,7 @@ { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/SwiftUI-Introspect", + "location" : "https://github.com/siteline/SwiftUI-Introspect.git", "state" : { "revision" : "b94da693e57eaf79d16464b8b7c90d09cba4e290", "version" : "0.9.2" diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 1c7a7e715..fbe13eba7 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -191,7 +191,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, return } let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) - switch await roomProxy?.sendMessage(replyText) { + switch await roomProxy?.sendMessage(replyText, html: nil) { case .success: break default: diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 63ccaec54..e15ec140b 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -40,6 +40,7 @@ final class AppSettings { case notificationSettingsEnabled case swiftUITimelineEnabled case pollsInTimeline + case richTextEditorEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -237,4 +238,7 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.pollsInTimeline, defaultValue: false, storageType: .userDefaults(store)) var pollsInTimelineEnabled + + @UserPreference(key: UserDefaultsKeys.richTextEditorEnabled, defaultValue: false, storageType: .userDefaults(store)) + var richTextEditorEnabled } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index b1db7ab34..8ef94c293 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -964,23 +964,23 @@ class RoomProxyMock: RoomProxyProtocol { } //MARK: - sendMessage - var sendMessageInReplyToCallsCount = 0 - var sendMessageInReplyToCalled: Bool { - return sendMessageInReplyToCallsCount > 0 + var sendMessageHtmlInReplyToCallsCount = 0 + var sendMessageHtmlInReplyToCalled: Bool { + return sendMessageHtmlInReplyToCallsCount > 0 } - var sendMessageInReplyToReceivedArguments: (message: String, eventID: String?)? - var sendMessageInReplyToReceivedInvocations: [(message: String, eventID: String?)] = [] - var sendMessageInReplyToReturnValue: Result! - var sendMessageInReplyToClosure: ((String, String?) async -> Result)? + var sendMessageHtmlInReplyToReceivedArguments: (message: String, html: String?, eventID: String?)? + var sendMessageHtmlInReplyToReceivedInvocations: [(message: String, html: String?, eventID: String?)] = [] + var sendMessageHtmlInReplyToReturnValue: Result! + var sendMessageHtmlInReplyToClosure: ((String, String?, String?) async -> Result)? - func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result { - sendMessageInReplyToCallsCount += 1 - sendMessageInReplyToReceivedArguments = (message: message, eventID: eventID) - sendMessageInReplyToReceivedInvocations.append((message: message, eventID: eventID)) - if let sendMessageInReplyToClosure = sendMessageInReplyToClosure { - return await sendMessageInReplyToClosure(message, eventID) + func sendMessage(_ message: String, html: String?, inReplyTo eventID: String?) async -> Result { + sendMessageHtmlInReplyToCallsCount += 1 + sendMessageHtmlInReplyToReceivedArguments = (message: message, html: html, eventID: eventID) + sendMessageHtmlInReplyToReceivedInvocations.append((message: message, html: html, eventID: eventID)) + if let sendMessageHtmlInReplyToClosure = sendMessageHtmlInReplyToClosure { + return await sendMessageHtmlInReplyToClosure(message, html, eventID) } else { - return sendMessageInReplyToReturnValue + return sendMessageHtmlInReplyToReturnValue } } //MARK: - toggleReaction @@ -1127,23 +1127,23 @@ class RoomProxyMock: RoomProxyProtocol { } //MARK: - editMessage - var editMessageOriginalCallsCount = 0 - var editMessageOriginalCalled: Bool { - return editMessageOriginalCallsCount > 0 + var editMessageHtmlOriginalCallsCount = 0 + var editMessageHtmlOriginalCalled: Bool { + return editMessageHtmlOriginalCallsCount > 0 } - var editMessageOriginalReceivedArguments: (newMessage: String, eventID: String)? - var editMessageOriginalReceivedInvocations: [(newMessage: String, eventID: String)] = [] - var editMessageOriginalReturnValue: Result! - var editMessageOriginalClosure: ((String, String) async -> Result)? + var editMessageHtmlOriginalReceivedArguments: (newMessage: String, html: String?, eventID: String)? + var editMessageHtmlOriginalReceivedInvocations: [(newMessage: String, html: String?, eventID: String)] = [] + var editMessageHtmlOriginalReturnValue: Result! + var editMessageHtmlOriginalClosure: ((String, String?, String) async -> Result)? - func editMessage(_ newMessage: String, original eventID: String) async -> Result { - editMessageOriginalCallsCount += 1 - editMessageOriginalReceivedArguments = (newMessage: newMessage, eventID: eventID) - editMessageOriginalReceivedInvocations.append((newMessage: newMessage, eventID: eventID)) - if let editMessageOriginalClosure = editMessageOriginalClosure { - return await editMessageOriginalClosure(newMessage, eventID) + func editMessage(_ newMessage: String, html: String?, original eventID: String) async -> Result { + editMessageHtmlOriginalCallsCount += 1 + editMessageHtmlOriginalReceivedArguments = (newMessage: newMessage, html: html, eventID: eventID) + editMessageHtmlOriginalReceivedInvocations.append((newMessage: newMessage, html: html, eventID: eventID)) + if let editMessageHtmlOriginalClosure = editMessageHtmlOriginalClosure { + return await editMessageHtmlOriginalClosure(newMessage, html, eventID) } else { - return editMessageOriginalReturnValue + return editMessageHtmlOriginalReturnValue } } //MARK: - redact diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index 8a5a8a83a..668c92af8 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -17,7 +17,8 @@ import UIKit enum ComposerToolbarViewModelAction { - case sendMessage(message: String, mode: RoomScreenComposerMode) + case sendMessage(plain: String, html: String, mode: RoomScreenComposerMode) + case sendPlainTextMessage(message: String, mode: RoomScreenComposerMode) case displayCameraPicker case displayMediaPicker @@ -27,11 +28,12 @@ enum ComposerToolbarViewModelAction { case handlePasteOrDrop(provider: NSItemProvider) case composerModeChanged(mode: RoomScreenComposerMode) - case focusedChanged(isFocused: Bool) + case composerFocusedChanged(isFocused: Bool) } enum ComposerToolbarViewAction { - case sendMessage(message: String, mode: RoomScreenComposerMode) + case composerAppeared + case sendMessage case cancelReply case cancelEdit case displayCameraPicker @@ -43,21 +45,28 @@ enum ComposerToolbarViewAction { struct ComposerToolbarViewState: BindableState { var composerMode: RoomScreenComposerMode = .default + var composerEmpty = true var bindings: ComposerToolbarViewStateBindings var sendButtonDisabled: Bool { - bindings.composerText.count == 0 + if ServiceLocator.shared.settings.richTextEditorEnabled { + return composerEmpty + } else { + return bindings.composerPlainText.isEmpty + } } } struct ComposerToolbarViewStateBindings { - var composerText: String + var composerPlainText: String var composerFocused: Bool var showAttachmentPopover = false { didSet { - composerFocused = false + if showAttachmentPopover { + composerFocused = false + } } } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index 40d2ca623..255101792 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -15,17 +15,21 @@ // import Combine +import WysiwygComposer typealias ComposerToolbarViewModelType = StateStoreViewModel final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol { + private let wysiwygViewModel: WysiwygComposerViewModel private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init() { - super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerText: "", composerFocused: false))) + init(wysiwygViewModel: WysiwygComposerViewModel) { + self.wysiwygViewModel = wysiwygViewModel + + super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerPlainText: "", composerFocused: false))) context.$viewState .map(\.composerMode) @@ -36,7 +40,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool context.$viewState .map(\.bindings.composerFocused) .removeDuplicates() - .sink { [weak self] in self?.actionsSubject.send(.focusedChanged(isFocused: $0)) } + .sink { [weak self] in self?.actionsSubject.send(.composerFocusedChanged(isFocused: $0)) } + .store(in: &cancellables) + + wysiwygViewModel.$isContentEmpty + .weakAssign(to: \.state.composerEmpty, on: self) .store(in: &cancellables) } @@ -44,8 +52,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool override func process(viewAction: ComposerToolbarViewAction) { switch viewAction { - case .sendMessage(let message, let mode): - actionsSubject.send(.sendMessage(message: message, mode: mode)) + case .composerAppeared: + wysiwygViewModel.setup() + case .sendMessage: + guard !state.sendButtonDisabled else { return } + + if ServiceLocator.shared.settings.richTextEditorEnabled { + actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown, + html: wysiwygViewModel.content.html, + mode: state.composerMode)) + } else { + actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText, + mode: state.composerMode)) + } case .cancelReply: set(mode: .default) case .cancelEdit: @@ -78,6 +97,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } } + func handleKeyCommand(_ keyCommand: WysiwygKeyCommand) -> Bool { + switch keyCommand { + case .enter: + process(viewAction: .sendMessage) + return true + case .shiftEnter: + return false + } + } + // MARK: - Private private func set(mode: RoomScreenComposerMode) { @@ -91,6 +120,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } private func set(text: String) { - state.bindings.composerText = text + if ServiceLocator.shared.settings.richTextEditorEnabled { + wysiwygViewModel.setMarkdownContent(text) + } else { + state.bindings.composerPlainText = text + } } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index 7a018ab3d..414d1b5b7 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -15,9 +15,13 @@ // import SwiftUI +import WysiwygComposer struct ComposerToolbar: View { @ObservedObject var context: ComposerToolbarViewModel.Context + let wysiwygViewModel: WysiwygComposerViewModel + let keyCommandHandler: KeyCommandHandler + @FocusState private var composerFocused: Bool var body: some View { @@ -27,7 +31,27 @@ struct ComposerToolbar: View { messageComposer .environmentObject(context) } + } + + private var messageComposer: some View { + MessageComposer(plainText: $context.composerPlainText, + composerView: composerView, + sendingDisabled: context.viewState.sendButtonDisabled, + mode: context.viewState.composerMode) { + context.send(viewAction: .sendMessage) + } pasteAction: { provider in + context.send(viewAction: .handlePasteOrDrop(provider: provider)) + } replyCancellationAction: { + context.send(viewAction: .cancelReply) + } editCancellationAction: { + context.send(viewAction: .cancelEdit) + } onAppearAction: { + context.send(viewAction: .composerAppeared) + } + .focused($composerFocused) .onChange(of: context.composerFocused) { newValue in + guard composerFocused != newValue else { return } + composerFocused = newValue } .onChange(of: composerFocused) { newValue in @@ -35,23 +59,30 @@ struct ComposerToolbar: View { } } - private var messageComposer: some View { - MessageComposer(text: $context.composerText, - focused: $composerFocused, - sendingDisabled: context.viewState.sendButtonDisabled, - mode: context.viewState.composerMode) { - sendMessage() - } pasteAction: { provider in + private var composerView: WysiwygComposerView { + WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder, + viewModel: wysiwygViewModel, + itemProviderHelper: ItemProviderHelper(), + keyCommandHandler: keyCommandHandler) { provider in context.send(viewAction: .handlePasteOrDrop(provider: provider)) - } replyCancellationAction: { - context.send(viewAction: .cancelReply) - } editCancellationAction: { - context.send(viewAction: .cancelEdit) } } - private func sendMessage() { - guard !context.viewState.sendButtonDisabled else { return } - context.send(viewAction: .sendMessage(message: context.composerText, mode: context.viewState.composerMode)) + private class ItemProviderHelper: WysiwygItemProviderHelper { + func isPasteSupported(for itemProvider: NSItemProvider) -> Bool { + itemProvider.isSupportedForPasteOrDrop + } + } +} + +// MARK: - Mock + +extension ComposerToolbar { + static func mock() -> ComposerToolbar { + let wysiwygViewModel = WysiwygComposerViewModel() + let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) + return ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommandHandler: { _ in false }) } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index 5f1c7ec09..65fb6619b 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -15,18 +15,23 @@ // import SwiftUI +import WysiwygComposer + +typealias EnterKeyHandler = () -> Void +typealias PasteHandler = (NSItemProvider) -> Void struct MessageComposer: View { - @Binding var text: String - var focused: FocusState.Binding + @Binding var plainText: String + let composerView: WysiwygComposerView let sendingDisabled: Bool let mode: RoomScreenComposerMode - let sendAction: EnterKeyHandler let pasteAction: PasteHandler let replyCancellationAction: () -> Void let editCancellationAction: () -> Void - + let onAppearAction: () -> Void + @FocusState private var focused: Bool + @State private var isMultiline = false @ScaledMetric private var sendButtonIconSize = 16 @@ -35,15 +40,25 @@ struct MessageComposer: View { VStack(alignment: .leading, spacing: -6) { header HStack(alignment: .bottom) { - MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder, - text: $text, - focused: focused, - isMultiline: $isMultiline, - maxHeight: 300, - enterKeyHandler: sendAction, - pasteHandler: pasteAction) - .tint(.compound.iconAccentTertiary) - .padding(.vertical, 10) + if ServiceLocator.shared.settings.richTextEditorEnabled { + composerView + .tint(.compound.iconAccentTertiary) + .padding(.vertical, 10) + .focused($focused) + .onAppear { + onAppearAction() + } + } else { + MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder, + text: $plainText, + isMultiline: $isMultiline, + maxHeight: 300, + enterKeyHandler: sendAction, + pasteHandler: pasteAction) + .tint(.compound.iconAccentTertiary) + .padding(.vertical, 10) + .focused($focused) + } Button { sendAction() @@ -64,13 +79,14 @@ struct MessageComposer: View { } } .padding(.leading, 12.0) + .clipped() .background { ZStack { roundedRectangle .fill(Color.compound.bgSubtleSecondary) roundedRectangle .stroke(Color.compound._borderTextFieldFocused, lineWidth: 1) - .opacity(focused.wrappedValue ? 1 : 0) + .opacity(focused ? 1 : 0) } } // Explicitly disable all animations to fix weirdness with the header immediately @@ -174,63 +190,40 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle { struct MessageComposer_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock + static func messageComposer(_ content: String = "", + sendingDisabled: Bool = false, + mode: RoomScreenComposerMode = .default) -> MessageComposer { + let viewModel = WysiwygComposerViewModel(minHeight: 22, + maxExpandedHeight: 250) + viewModel.setMarkdownContent(content) + + let composerView = WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder, + viewModel: viewModel, + itemProviderHelper: nil, + keyCommandHandler: nil, + pasteHandler: nil) + + return MessageComposer(plainText: .constant(content), + composerView: composerView, + sendingDisabled: sendingDisabled, + mode: mode, + sendAction: { }, + pasteAction: { _ in }, + replyCancellationAction: { }, + editCancellationAction: { }, + onAppearAction: { viewModel.setup() }) + } + static var previews: some View { VStack { - MessageComposer(text: .constant(""), - focused: FocusState().projectedValue, - sendingDisabled: true, - mode: .default, - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) + messageComposer(sendingDisabled: true) - MessageComposer(text: .constant("This is a short message."), - focused: FocusState().projectedValue, - sendingDisabled: false, - mode: .default, - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) + messageComposer("Some message", + mode: .edit(originalItemId: .random)) - MessageComposer(text: .constant("This is a very long message that will wrap to 2 lines on an iPhone 14."), - focused: FocusState().projectedValue, - sendingDisabled: false, - mode: .default, - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) - - MessageComposer(text: .constant("This is an even longer message that will wrap to 3 lines on an iPhone 14, just to see the difference it makes."), - focused: FocusState().projectedValue, - sendingDisabled: false, - mode: .default, - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) - - MessageComposer(text: .constant("Some message"), - focused: FocusState().projectedValue, - sendingDisabled: false, - mode: .edit(originalItemId: .random), - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) - - MessageComposer(text: .constant(""), - focused: FocusState().projectedValue, - sendingDisabled: false, - mode: .reply(itemID: .random, + messageComposer(mode: .reply(itemID: .random, replyDetails: .loaded(sender: .init(id: "Kirk"), - contentType: .text(.init(body: "Text: Where the wild things are")))), - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) + contentType: .text(.init(body: "Text: Where the wild things are"))))) } .padding(.horizontal) @@ -253,15 +246,8 @@ struct MessageComposer_Previews: PreviewProvider { ] ForEach(replyTypes, id: \.self) { replyDetails in - MessageComposer(text: .constant(""), - focused: FocusState().projectedValue, - sendingDisabled: false, - mode: .reply(itemID: .random, - replyDetails: replyDetails), - sendAction: { }, - pasteAction: { _ in }, - replyCancellationAction: { }, - editCancellationAction: { }) + messageComposer(mode: .reply(itemID: .random, + replyDetails: replyDetails)) } } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift index 74bcfeefb..e38d9ab37 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposerTextField.swift @@ -13,22 +13,17 @@ // See the License for the specific language governing permissions and // limitations under the License. // - import SwiftUI -typealias EnterKeyHandler = () -> Void -typealias PasteHandler = (NSItemProvider) -> Void - struct MessageComposerTextField: View { let placeholder: String @Binding var text: String - var focused: FocusState.Binding @Binding var isMultiline: Bool - + let maxHeight: CGFloat let enterKeyHandler: EnterKeyHandler let pasteHandler: PasteHandler - + var body: some View { UITextViewWrapper(text: $text, isMultiline: $isMultiline, @@ -37,9 +32,8 @@ struct MessageComposerTextField: View { pasteHandler: pasteHandler) .accessibilityLabel(placeholder) .background(placeholderView, alignment: .topLeading) - .focused(focused) } - + @ViewBuilder private var placeholderView: some View { if text.isEmpty { @@ -55,14 +49,14 @@ private struct UITextViewWrapper: UIViewRepresentable { @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 { let textView = ElementTextView() textView.isMultiline = $isMultiline @@ -83,14 +77,14 @@ private struct UITextViewWrapper: UIViewRepresentable { 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) } @@ -117,15 +111,15 @@ private struct UITextViewWrapper: UIViewRepresentable { 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, @@ -135,19 +129,19 @@ private struct UITextViewWrapper: UIViewRepresentable { 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) } @@ -162,27 +156,27 @@ private protocol ElementTextViewDelegate: AnyObject { 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 { @@ -194,21 +188,21 @@ private class ElementTextView: UITextView { } } } - + // 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 { @@ -217,7 +211,7 @@ private class ElementTextView: UITextView { super.paste(sender) return } - + elementDelegate?.textView(self, didReceivePasteWith: provider) } } @@ -230,20 +224,19 @@ struct MessageComposerTextField_Previews: PreviewProvider { 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, - focused: FocusState().projectedValue, isMultiline: $isMultiline, maxHeight: 300, enterKeyHandler: { }, diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift b/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift index edfd86e32..37c517810 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift @@ -15,6 +15,7 @@ // import SwiftUI +import WysiwygComposer struct RoomAttachmentPicker: View { @ObservedObject var context: ComposerToolbarViewModel.Context @@ -106,7 +107,7 @@ struct RoomAttachmentPicker: View { } struct RoomAttachmentPicker_Previews: PreviewProvider { - static let viewModel = ComposerToolbarViewModel() + static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel()) static var previews: some View { RoomAttachmentPicker(context: viewModel.context) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 56ab8ae34..a7f2cc734 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -16,6 +16,7 @@ import Combine import SwiftUI +import WysiwygComposer struct RoomScreenCoordinatorParameters { let roomProxy: RoomProxyProtocol @@ -40,6 +41,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { private var parameters: RoomScreenCoordinatorParameters private var viewModel: RoomScreenViewModelProtocol private var composerViewModel: ComposerToolbarViewModel + private var wysiwygViewModel: WysiwygComposerViewModel private var cancellables = Set() @@ -58,7 +60,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) - composerViewModel = ComposerToolbarViewModel() + wysiwygViewModel = WysiwygComposerViewModel(minHeight: 22, maxExpandedHeight: 250) + composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) } // MARK: - Public @@ -111,6 +114,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol { } func toPresentable() -> AnyView { - AnyView(RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context))) + let composerToolbar = ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommandHandler: composerViewModel.handleKeyCommand) + + return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar)) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 73982a67a..240e8f5d8 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -146,8 +146,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol func process(composerAction: ComposerToolbarViewModelAction) { switch composerAction { - case .sendMessage(let message, let mode): - Task { await sendCurrentMessage(message, mode: mode) } + case .sendMessage(let message, let html, let mode): + Task { await sendCurrentMessage(message, html: html, mode: mode) } + case .sendPlainTextMessage(let message, let mode): + Task { await sendCurrentMessage(message, html: nil, mode: mode) } case .displayCameraPicker: actionsSubject.send(.displayCameraPicker) case .displayMediaPicker: @@ -160,7 +162,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol handlePasteOrDrop(provider) case .composerModeChanged(mode: let mode): trackComposerMode(mode) - case .focusedChanged(isFocused: let isFocused): + case .composerFocusedChanged(isFocused: let isFocused): composerFocusedSubject.send(isFocused) } } @@ -427,8 +429,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender } - private func sendCurrentMessage(_ currentMessage: String, mode: RoomScreenComposerMode) async { - guard !currentMessage.isEmpty else { + private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode) async { + guard !message.isEmpty else { fatalError("This message should never be empty") } @@ -436,11 +438,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch mode { case .reply(let itemId, _): - await timelineController.sendMessage(currentMessage, inReplyTo: itemId) + await timelineController.sendMessage(message, html: html, inReplyTo: itemId) case .edit(let originalItemId): - await timelineController.editMessage(currentMessage, original: originalItemId) + await timelineController.editMessage(message, html: html, original: originalItemId) default: - await timelineController.sendMessage(currentMessage) + await timelineController.sendMessage(message, html: html) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index f354e2073..99cc196fb 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -15,6 +15,7 @@ // import SwiftUI +import WysiwygComposer struct RoomScreen: View { @ObservedObject var context: RoomScreenViewModel.Context @@ -22,7 +23,7 @@ struct RoomScreen: View { let composerToolbar: ComposerToolbar private let attachmentButtonPadding = 10.0 - + var body: some View { timeline .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) @@ -152,12 +153,10 @@ struct RoomScreen_Previews: PreviewProvider { appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) - - static let composerViewModel = ComposerToolbarViewModel() static var previews: some View { NavigationStack { - RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)) + RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock()) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift index 1986e92fb..5906f0a39 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift @@ -15,6 +15,7 @@ // import SwiftUI +import WysiwygComposer /// A table view wrapper that displays the timeline of a room. struct UITimelineView: UIViewControllerRepresentable { @@ -86,11 +87,9 @@ struct UITimelineView_Previews: PreviewProvider { analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) - static let composerViewModel = ComposerToolbarViewModel() - static var previews: some View { NavigationStack { - RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)) + RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock()) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 68af3ec73..5f97fcdda 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -18,6 +18,7 @@ import Combine import SwiftUI import SwiftUIIntrospect +import WysiwygComposer struct TimelineView: View { let viewState: TimelineViewState @@ -174,11 +175,9 @@ struct TimelineView_Previews: PreviewProvider { analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) - static let composerViewModel = ComposerToolbarViewModel() - static var previews: some View { NavigationStack { - RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)) + RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock()) } } } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 1a623fae4..5950e827a 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var notificationSettingsEnabled: Bool { get set } var swiftUITimelineEnabled: Bool { get set } var pollsInTimelineEnabled: Bool { get set } + var richTextEditorEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 3f91ed6e2..af2288c96 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -72,6 +72,12 @@ struct DeveloperOptionsScreen: View { } } + Section("Rich Text Editor") { + Toggle(isOn: $context.richTextEditorEnabled) { + Text("Use the Rich Text Editor") + } + } + Section { Button { showConfetti = true diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index d3d2a911c..f916be1c3 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -262,15 +262,19 @@ class RoomProxy: RoomProxyProtocol { } } - func sendMessage(_ message: String, inReplyTo eventID: String? = nil) async -> Result { + func sendMessage(_ message: String, html: String?, inReplyTo eventID: String? = nil) async -> Result { sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) defer { sendMessageBackgroundTask?.stop() } let transactionId = genTransactionId() - let messageContent = messageEventContentFromMarkdown(md: message) - + let messageContent: RoomMessageEventContentWithoutRelation + if let html { + messageContent = messageEventContentFromHtml(body: message, htmlBody: html) + } else { + messageContent = messageEventContentFromMarkdown(md: message) + } return await Task.dispatch(on: messageSendingDispatchQueue) { do { if let eventID { @@ -443,17 +447,22 @@ class RoomProxy: RoomProxyProtocol { } } - func editMessage(_ newMessage: String, original eventID: String) async -> Result { + func editMessage(_ newMessage: String, html: String?, original eventID: String) async -> Result { sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) defer { sendMessageBackgroundTask?.stop() } let transactionId = genTransactionId() + let newMessageContent: RoomMessageEventContentWithoutRelation + if let html { + newMessageContent = messageEventContentFromHtml(body: newMessage, htmlBody: html) + } else { + newMessageContent = messageEventContentFromMarkdown(md: newMessage) + } return await Task.dispatch(on: messageSendingDispatchQueue) { do { - let newMessageContent = messageEventContentFromMarkdown(md: newMessage) try self.room.edit(newMsg: newMessageContent, originalEventId: eventID, txnId: transactionId) return .success(()) } catch { diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index b954e38d3..91116ef2e 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -92,7 +92,7 @@ protocol RoomProxyProtocol { func sendMessageEventContent(_ messageContent: RoomMessageEventContentWithoutRelation) async -> Result - func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result + func sendMessage(_ message: String, html: String?, inReplyTo eventID: String?) async -> Result func toggleReaction(_ reaction: String, to eventID: String) async -> Result @@ -130,7 +130,7 @@ protocol RoomProxyProtocol { /// Cancels a failed message given its transaction ID from the timeline func cancelSend(transactionID: String) async - func editMessage(_ newMessage: String, original eventID: String) async -> Result + func editMessage(_ newMessage: String, html: String?, original eventID: String) async -> Result func redact(_ eventID: String) async -> Result @@ -181,8 +181,8 @@ extension RoomProxyProtocol { } } - func sendMessage(_ message: String) async -> Result { - await sendMessage(message, inReplyTo: nil) + func sendMessage(_ message: String, html: String?) async -> Result { + await sendMessage(message, html: html, inReplyTo: nil) } // Avoids to duplicate the same logic around in the app diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index c394b721b..87d698bc4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -66,11 +66,11 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { .none } - func sendMessage(_ message: String, inReplyTo itemID: TimelineItemIdentifier?) async { } + func sendMessage(_ message: String, html: String?, inReplyTo itemID: TimelineItemIdentifier?) async { } func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async { } - func editMessage(_ newMessage: String, original itemID: TimelineItemIdentifier) async { } + func editMessage(_ newMessage: String, html: String?, original itemID: TimelineItemIdentifier) async { } func redact(_ itemID: TimelineItemIdentifier) async { } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index a0791ea0a..63d73c3b1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -121,7 +121,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } - func sendMessage(_ message: String, inReplyTo itemID: TimelineItemIdentifier?) async { + func sendMessage(_ message: String, html: String?, inReplyTo itemID: TimelineItemIdentifier?) async { var inReplyTo: String? if itemID == nil { MXLog.info("Send message in \(roomID)") @@ -133,7 +133,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return } - switch await roomProxy.sendMessage(message, inReplyTo: inReplyTo) { + switch await roomProxy.sendMessage(message, html: html, inReplyTo: inReplyTo) { case .success: MXLog.info("Finished sending message") case .failure(let error): @@ -156,16 +156,16 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } - func editMessage(_ newMessage: String, original itemID: TimelineItemIdentifier) async { + func editMessage(_ newMessage: String, html: String?, original itemID: TimelineItemIdentifier) async { MXLog.info("Edit message in \(roomID)") if let timelineItem = timelineItems.firstUsingStableID(itemID), let item = timelineItem as? EventBasedTimelineItemProtocol, item.hasFailedToSend { MXLog.info("Editing a failed echo, will cancel and resend it as a new message") await cancelSend(itemID) - await sendMessage(newMessage) + await sendMessage(newMessage, html: html) } else if let eventID = itemID.eventID { - switch await roomProxy.editMessage(newMessage, original: eventID) { + switch await roomProxy.editMessage(newMessage, html: html, original: eventID) { case .success: MXLog.info("Finished editing message") case .failure(let error): diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index f0d1faf4a..03f7b84aa 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -51,9 +51,9 @@ protocol RoomTimelineControllerProtocol { func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result - func sendMessage(_ message: String, inReplyTo itemID: TimelineItemIdentifier?) async + func sendMessage(_ message: String, html: String?, inReplyTo itemID: TimelineItemIdentifier?) async - func editMessage(_ newMessage: String, original itemID: TimelineItemIdentifier) async + func editMessage(_ newMessage: String, html: String?, original itemID: TimelineItemIdentifier) async func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async @@ -67,7 +67,7 @@ protocol RoomTimelineControllerProtocol { } extension RoomTimelineControllerProtocol { - func sendMessage(_ message: String) async { - await sendMessage(message, inReplyTo: nil) + func sendMessage(_ message: String, html: String?) async { + await sendMessage(message, html: html, inReplyTo: nil) } } diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 6fcd5528f..efecddbae 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -178,6 +178,7 @@ targets: - package: URLRouting - package: Version - package: Emojibase + - package: WysiwygComposer sources: - path: ../Sources diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index ef47c2703..c08f2ac65 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -15,12 +15,14 @@ // @testable import ElementX +import WysiwygComposer import XCTest @MainActor class ComposerToolbarViewModelTests: XCTestCase { func testComposerFocus() { - let viewModel = ComposerToolbarViewModel() + let wysiwygViewModel = WysiwygComposerViewModel() + let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")))) XCTAssertTrue(viewModel.state.bindings.composerFocused) viewModel.process(roomAction: .removeFocus) @@ -28,7 +30,8 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testComposerMode() { - let viewModel = ComposerToolbarViewModel() + let wysiwygViewModel = WysiwygComposerViewModel() + let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")) viewModel.process(roomAction: .setMode(mode: mode)) XCTAssertEqual(viewModel.state.composerMode, mode) @@ -37,7 +40,8 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testComposerModeIsPublished() { - let viewModel = ComposerToolbarViewModel() + let wysiwygViewModel = WysiwygComposerViewModel() + let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")) let expectation = expectation(description: "Composer mode is published") let cancellable = viewModel @@ -56,4 +60,11 @@ class ComposerToolbarViewModelTests: XCTestCase { wait(for: [expectation], timeout: 2.0) cancellable.cancel() } + + func testHandleKeyCommand() { + let wysiwygViewModel = WysiwygComposerViewModel() + let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) + XCTAssertTrue(viewModel.handleKeyCommand(.enter)) + XCTAssertFalse(viewModel.handleKeyCommand(.shiftEnter)) + } } diff --git a/project.yml b/project.yml index d21fd7bce..a9c169400 100644 --- a/project.yml +++ b/project.yml @@ -112,3 +112,6 @@ packages: Version: url: https://github.com/mxcl/Version minorVersion: 2.0.0 + WysiwygComposer: + url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift + exactVersion: 2.6.3