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
This commit is contained in:
aringenbach
2023-08-29 14:37:13 +02:00
committed by GitHub
parent 08ca969526
commit d1573ae40d
26 changed files with 334 additions and 214 deletions
+17
View File
@@ -199,6 +199,7 @@
43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */; }; 43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */; };
440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */; }; 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */; };
44121202B4A260C98BF615A7 /* RoomMembersListScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B7A755E985FA14469E86B2 /* RoomMembersListScreenUITests.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 */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; };
46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */; }; 46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */; };
46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565F1B2B300597C616B37888 /* FullscreenDialog.swift */; }; 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565F1B2B300597C616B37888 /* FullscreenDialog.swift */; };
@@ -1635,6 +1636,7 @@
36AD4DD4C798E22584ED3200 /* URLRouting in Frameworks */, 36AD4DD4C798E22584ED3200 /* URLRouting in Frameworks */,
36CD6E11B37396E14F032CB6 /* Version in Frameworks */, 36CD6E11B37396E14F032CB6 /* Version in Frameworks */,
A0D7E5BD0298A97DCBDCE40B /* Emojibase in Frameworks */, A0D7E5BD0298A97DCBDCE40B /* Emojibase in Frameworks */,
44F0E1B576C7599DF8022071 /* WysiwygComposer in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -3893,6 +3895,7 @@
E9BAB8A793FE3B54CDD47102 /* URLRouting */, E9BAB8A793FE3B54CDD47102 /* URLRouting */,
A05AF81DDD14AD58CB0E1B9B /* Version */, A05AF81DDD14AD58CB0E1B9B /* Version */,
C05729B1684C331F5FFE9232 /* Emojibase */, C05729B1684C331F5FFE9232 /* Emojibase */,
CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */,
); );
productName = ElementX; productName = ElementX;
productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */; productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */;
@@ -4025,6 +4028,7 @@
9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
0020F10A9DA1895036A72013 /* XCRemoteSwiftPackageReference "swift-url-routing" */, 0020F10A9DA1895036A72013 /* XCRemoteSwiftPackageReference "swift-url-routing" */,
EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */, EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */,
945C99D66CE013061F6A3C71 /* XCRemoteSwiftPackageReference "matrix-wysiwyg-composer-swift" */,
); );
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -5538,6 +5542,14 @@
minimumVersion = 1.0.0; 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" */ = { 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/PostHog/posthog-ios"; repositoryURL = "https://github.com/PostHog/posthog-ios";
@@ -5868,6 +5880,11 @@
package = 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; package = 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */;
productName = MatrixRustSDK; productName = MatrixRustSDK;
}; };
CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */ = {
isa = XCSwiftPackageProductDependency;
package = 945C99D66CE013061F6A3C71 /* XCRemoteSwiftPackageReference "matrix-wysiwyg-composer-swift" */;
productName = WysiwygComposer;
};
CCE5BF78B125320CBF3BB834 /* PostHog */ = { CCE5BF78B125320CBF3BB834 /* PostHog */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */; package = 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */;
@@ -133,6 +133,15 @@
"version" : "1.1.2" "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", "identity" : "posthog-ios",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -235,7 +244,7 @@
{ {
"identity" : "swiftui-introspect", "identity" : "swiftui-introspect",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect", "location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : { "state" : {
"revision" : "b94da693e57eaf79d16464b8b7c90d09cba4e290", "revision" : "b94da693e57eaf79d16464b8b7c90d09cba4e290",
"version" : "0.9.2" "version" : "0.9.2"
@@ -191,7 +191,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
return return
} }
let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID)
switch await roomProxy?.sendMessage(replyText) { switch await roomProxy?.sendMessage(replyText, html: nil) {
case .success: case .success:
break break
default: default:
@@ -40,6 +40,7 @@ final class AppSettings {
case notificationSettingsEnabled case notificationSettingsEnabled
case swiftUITimelineEnabled case swiftUITimelineEnabled
case pollsInTimeline case pollsInTimeline
case richTextEditorEnabled
} }
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -237,4 +238,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.pollsInTimeline, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.pollsInTimeline, defaultValue: false, storageType: .userDefaults(store))
var pollsInTimelineEnabled var pollsInTimelineEnabled
@UserPreference(key: UserDefaultsKeys.richTextEditorEnabled, defaultValue: false, storageType: .userDefaults(store))
var richTextEditorEnabled
} }
@@ -964,23 +964,23 @@ class RoomProxyMock: RoomProxyProtocol {
} }
//MARK: - sendMessage //MARK: - sendMessage
var sendMessageInReplyToCallsCount = 0 var sendMessageHtmlInReplyToCallsCount = 0
var sendMessageInReplyToCalled: Bool { var sendMessageHtmlInReplyToCalled: Bool {
return sendMessageInReplyToCallsCount > 0 return sendMessageHtmlInReplyToCallsCount > 0
} }
var sendMessageInReplyToReceivedArguments: (message: String, eventID: String?)? var sendMessageHtmlInReplyToReceivedArguments: (message: String, html: String?, eventID: String?)?
var sendMessageInReplyToReceivedInvocations: [(message: String, eventID: String?)] = [] var sendMessageHtmlInReplyToReceivedInvocations: [(message: String, html: String?, eventID: String?)] = []
var sendMessageInReplyToReturnValue: Result<Void, RoomProxyError>! var sendMessageHtmlInReplyToReturnValue: Result<Void, RoomProxyError>!
var sendMessageInReplyToClosure: ((String, String?) async -> Result<Void, RoomProxyError>)? var sendMessageHtmlInReplyToClosure: ((String, String?, String?) async -> Result<Void, RoomProxyError>)?
func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError> { func sendMessage(_ message: String, html: String?, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError> {
sendMessageInReplyToCallsCount += 1 sendMessageHtmlInReplyToCallsCount += 1
sendMessageInReplyToReceivedArguments = (message: message, eventID: eventID) sendMessageHtmlInReplyToReceivedArguments = (message: message, html: html, eventID: eventID)
sendMessageInReplyToReceivedInvocations.append((message: message, eventID: eventID)) sendMessageHtmlInReplyToReceivedInvocations.append((message: message, html: html, eventID: eventID))
if let sendMessageInReplyToClosure = sendMessageInReplyToClosure { if let sendMessageHtmlInReplyToClosure = sendMessageHtmlInReplyToClosure {
return await sendMessageInReplyToClosure(message, eventID) return await sendMessageHtmlInReplyToClosure(message, html, eventID)
} else { } else {
return sendMessageInReplyToReturnValue return sendMessageHtmlInReplyToReturnValue
} }
} }
//MARK: - toggleReaction //MARK: - toggleReaction
@@ -1127,23 +1127,23 @@ class RoomProxyMock: RoomProxyProtocol {
} }
//MARK: - editMessage //MARK: - editMessage
var editMessageOriginalCallsCount = 0 var editMessageHtmlOriginalCallsCount = 0
var editMessageOriginalCalled: Bool { var editMessageHtmlOriginalCalled: Bool {
return editMessageOriginalCallsCount > 0 return editMessageHtmlOriginalCallsCount > 0
} }
var editMessageOriginalReceivedArguments: (newMessage: String, eventID: String)? var editMessageHtmlOriginalReceivedArguments: (newMessage: String, html: String?, eventID: String)?
var editMessageOriginalReceivedInvocations: [(newMessage: String, eventID: String)] = [] var editMessageHtmlOriginalReceivedInvocations: [(newMessage: String, html: String?, eventID: String)] = []
var editMessageOriginalReturnValue: Result<Void, RoomProxyError>! var editMessageHtmlOriginalReturnValue: Result<Void, RoomProxyError>!
var editMessageOriginalClosure: ((String, String) async -> Result<Void, RoomProxyError>)? var editMessageHtmlOriginalClosure: ((String, String?, String) async -> Result<Void, RoomProxyError>)?
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> { func editMessage(_ newMessage: String, html: String?, original eventID: String) async -> Result<Void, RoomProxyError> {
editMessageOriginalCallsCount += 1 editMessageHtmlOriginalCallsCount += 1
editMessageOriginalReceivedArguments = (newMessage: newMessage, eventID: eventID) editMessageHtmlOriginalReceivedArguments = (newMessage: newMessage, html: html, eventID: eventID)
editMessageOriginalReceivedInvocations.append((newMessage: newMessage, eventID: eventID)) editMessageHtmlOriginalReceivedInvocations.append((newMessage: newMessage, html: html, eventID: eventID))
if let editMessageOriginalClosure = editMessageOriginalClosure { if let editMessageHtmlOriginalClosure = editMessageHtmlOriginalClosure {
return await editMessageOriginalClosure(newMessage, eventID) return await editMessageHtmlOriginalClosure(newMessage, html, eventID)
} else { } else {
return editMessageOriginalReturnValue return editMessageHtmlOriginalReturnValue
} }
} }
//MARK: - redact //MARK: - redact
@@ -17,7 +17,8 @@
import UIKit import UIKit
enum ComposerToolbarViewModelAction { 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 displayCameraPicker
case displayMediaPicker case displayMediaPicker
@@ -27,11 +28,12 @@ enum ComposerToolbarViewModelAction {
case handlePasteOrDrop(provider: NSItemProvider) case handlePasteOrDrop(provider: NSItemProvider)
case composerModeChanged(mode: RoomScreenComposerMode) case composerModeChanged(mode: RoomScreenComposerMode)
case focusedChanged(isFocused: Bool) case composerFocusedChanged(isFocused: Bool)
} }
enum ComposerToolbarViewAction { enum ComposerToolbarViewAction {
case sendMessage(message: String, mode: RoomScreenComposerMode) case composerAppeared
case sendMessage
case cancelReply case cancelReply
case cancelEdit case cancelEdit
case displayCameraPicker case displayCameraPicker
@@ -43,21 +45,28 @@ enum ComposerToolbarViewAction {
struct ComposerToolbarViewState: BindableState { struct ComposerToolbarViewState: BindableState {
var composerMode: RoomScreenComposerMode = .default var composerMode: RoomScreenComposerMode = .default
var composerEmpty = true
var bindings: ComposerToolbarViewStateBindings var bindings: ComposerToolbarViewStateBindings
var sendButtonDisabled: Bool { var sendButtonDisabled: Bool {
bindings.composerText.count == 0 if ServiceLocator.shared.settings.richTextEditorEnabled {
return composerEmpty
} else {
return bindings.composerPlainText.isEmpty
}
} }
} }
struct ComposerToolbarViewStateBindings { struct ComposerToolbarViewStateBindings {
var composerText: String var composerPlainText: String
var composerFocused: Bool var composerFocused: Bool
var showAttachmentPopover = false { var showAttachmentPopover = false {
didSet { didSet {
composerFocused = false if showAttachmentPopover {
composerFocused = false
}
} }
} }
} }
@@ -15,17 +15,21 @@
// //
import Combine import Combine
import WysiwygComposer
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction> typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol { final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol {
private let wysiwygViewModel: WysiwygComposerViewModel
private let actionsSubject: PassthroughSubject<ComposerToolbarViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<ComposerToolbarViewModelAction, Never> = .init()
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init() { init(wysiwygViewModel: WysiwygComposerViewModel) {
super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerText: "", composerFocused: false))) self.wysiwygViewModel = wysiwygViewModel
super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerPlainText: "", composerFocused: false)))
context.$viewState context.$viewState
.map(\.composerMode) .map(\.composerMode)
@@ -36,7 +40,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
context.$viewState context.$viewState
.map(\.bindings.composerFocused) .map(\.bindings.composerFocused)
.removeDuplicates() .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) .store(in: &cancellables)
} }
@@ -44,8 +52,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
override func process(viewAction: ComposerToolbarViewAction) { override func process(viewAction: ComposerToolbarViewAction) {
switch viewAction { switch viewAction {
case .sendMessage(let message, let mode): case .composerAppeared:
actionsSubject.send(.sendMessage(message: message, mode: mode)) 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: case .cancelReply:
set(mode: .default) set(mode: .default)
case .cancelEdit: 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 // MARK: - Private
private func set(mode: RoomScreenComposerMode) { private func set(mode: RoomScreenComposerMode) {
@@ -91,6 +120,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
private func set(text: String) { private func set(text: String) {
state.bindings.composerText = text if ServiceLocator.shared.settings.richTextEditorEnabled {
wysiwygViewModel.setMarkdownContent(text)
} else {
state.bindings.composerPlainText = text
}
} }
} }
@@ -15,9 +15,13 @@
// //
import SwiftUI import SwiftUI
import WysiwygComposer
struct ComposerToolbar: View { struct ComposerToolbar: View {
@ObservedObject var context: ComposerToolbarViewModel.Context @ObservedObject var context: ComposerToolbarViewModel.Context
let wysiwygViewModel: WysiwygComposerViewModel
let keyCommandHandler: KeyCommandHandler
@FocusState private var composerFocused: Bool @FocusState private var composerFocused: Bool
var body: some View { var body: some View {
@@ -27,7 +31,27 @@ struct ComposerToolbar: View {
messageComposer messageComposer
.environmentObject(context) .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 .onChange(of: context.composerFocused) { newValue in
guard composerFocused != newValue else { return }
composerFocused = newValue composerFocused = newValue
} }
.onChange(of: composerFocused) { newValue in .onChange(of: composerFocused) { newValue in
@@ -35,23 +59,30 @@ struct ComposerToolbar: View {
} }
} }
private var messageComposer: some View { private var composerView: WysiwygComposerView {
MessageComposer(text: $context.composerText, WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder,
focused: $composerFocused, viewModel: wysiwygViewModel,
sendingDisabled: context.viewState.sendButtonDisabled, itemProviderHelper: ItemProviderHelper(),
mode: context.viewState.composerMode) { keyCommandHandler: keyCommandHandler) { provider in
sendMessage()
} pasteAction: { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider)) context.send(viewAction: .handlePasteOrDrop(provider: provider))
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
} }
} }
private func sendMessage() { private class ItemProviderHelper: WysiwygItemProviderHelper {
guard !context.viewState.sendButtonDisabled else { return } func isPasteSupported(for itemProvider: NSItemProvider) -> Bool {
context.send(viewAction: .sendMessage(message: context.composerText, mode: context.viewState.composerMode)) 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 })
} }
} }
@@ -15,17 +15,22 @@
// //
import SwiftUI import SwiftUI
import WysiwygComposer
typealias EnterKeyHandler = () -> Void
typealias PasteHandler = (NSItemProvider) -> Void
struct MessageComposer: View { struct MessageComposer: View {
@Binding var text: String @Binding var plainText: String
var focused: FocusState<Bool>.Binding let composerView: WysiwygComposerView
let sendingDisabled: Bool let sendingDisabled: Bool
let mode: RoomScreenComposerMode let mode: RoomScreenComposerMode
let sendAction: EnterKeyHandler let sendAction: EnterKeyHandler
let pasteAction: PasteHandler let pasteAction: PasteHandler
let replyCancellationAction: () -> Void let replyCancellationAction: () -> Void
let editCancellationAction: () -> Void let editCancellationAction: () -> Void
let onAppearAction: () -> Void
@FocusState private var focused: Bool
@State private var isMultiline = false @State private var isMultiline = false
@ScaledMetric private var sendButtonIconSize = 16 @ScaledMetric private var sendButtonIconSize = 16
@@ -35,15 +40,25 @@ struct MessageComposer: View {
VStack(alignment: .leading, spacing: -6) { VStack(alignment: .leading, spacing: -6) {
header header
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder, if ServiceLocator.shared.settings.richTextEditorEnabled {
text: $text, composerView
focused: focused, .tint(.compound.iconAccentTertiary)
isMultiline: $isMultiline, .padding(.vertical, 10)
maxHeight: 300, .focused($focused)
enterKeyHandler: sendAction, .onAppear {
pasteHandler: pasteAction) onAppearAction()
.tint(.compound.iconAccentTertiary) }
.padding(.vertical, 10) } else {
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
text: $plainText,
isMultiline: $isMultiline,
maxHeight: 300,
enterKeyHandler: sendAction,
pasteHandler: pasteAction)
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
.focused($focused)
}
Button { Button {
sendAction() sendAction()
@@ -64,13 +79,14 @@ struct MessageComposer: View {
} }
} }
.padding(.leading, 12.0) .padding(.leading, 12.0)
.clipped()
.background { .background {
ZStack { ZStack {
roundedRectangle roundedRectangle
.fill(Color.compound.bgSubtleSecondary) .fill(Color.compound.bgSubtleSecondary)
roundedRectangle roundedRectangle
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1) .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 // Explicitly disable all animations to fix weirdness with the header immediately
@@ -174,63 +190,40 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
struct MessageComposer_Previews: PreviewProvider { struct MessageComposer_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock 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 { static var previews: some View {
VStack { VStack {
MessageComposer(text: .constant(""), messageComposer(sendingDisabled: true)
focused: FocusState<Bool>().projectedValue,
sendingDisabled: true,
mode: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant("This is a short message."), messageComposer("Some message",
focused: FocusState<Bool>().projectedValue, mode: .edit(originalItemId: .random))
sendingDisabled: false,
mode: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant("This is a very long message that will wrap to 2 lines on an iPhone 14."), messageComposer(mode: .reply(itemID: .random,
focused: FocusState<Bool>().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<Bool>().projectedValue,
sendingDisabled: false,
mode: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant("Some message"),
focused: FocusState<Bool>().projectedValue,
sendingDisabled: false,
mode: .edit(originalItemId: .random),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant(""),
focused: FocusState<Bool>().projectedValue,
sendingDisabled: false,
mode: .reply(itemID: .random,
replyDetails: .loaded(sender: .init(id: "Kirk"), replyDetails: .loaded(sender: .init(id: "Kirk"),
contentType: .text(.init(body: "Text: Where the wild things are")))), contentType: .text(.init(body: "Text: Where the wild things are")))))
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
} }
.padding(.horizontal) .padding(.horizontal)
@@ -253,15 +246,8 @@ struct MessageComposer_Previews: PreviewProvider {
] ]
ForEach(replyTypes, id: \.self) { replyDetails in ForEach(replyTypes, id: \.self) { replyDetails in
MessageComposer(text: .constant(""), messageComposer(mode: .reply(itemID: .random,
focused: FocusState<Bool>().projectedValue, replyDetails: replyDetails))
sendingDisabled: false,
mode: .reply(itemID: .random,
replyDetails: replyDetails),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
} }
} }
} }
@@ -13,16 +13,11 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import SwiftUI import SwiftUI
typealias EnterKeyHandler = () -> Void
typealias PasteHandler = (NSItemProvider) -> Void
struct MessageComposerTextField: View { struct MessageComposerTextField: View {
let placeholder: String let placeholder: String
@Binding var text: String @Binding var text: String
var focused: FocusState<Bool>.Binding
@Binding var isMultiline: Bool @Binding var isMultiline: Bool
let maxHeight: CGFloat let maxHeight: CGFloat
@@ -37,7 +32,6 @@ struct MessageComposerTextField: View {
pasteHandler: pasteHandler) pasteHandler: pasteHandler)
.accessibilityLabel(placeholder) .accessibilityLabel(placeholder)
.background(placeholderView, alignment: .topLeading) .background(placeholderView, alignment: .topLeading)
.focused(focused)
} }
@ViewBuilder @ViewBuilder
@@ -243,7 +237,6 @@ struct MessageComposerTextField_Previews: PreviewProvider {
var body: some View { var body: some View {
MessageComposerTextField(placeholder: "Placeholder", MessageComposerTextField(placeholder: "Placeholder",
text: $text, text: $text,
focused: FocusState().projectedValue,
isMultiline: $isMultiline, isMultiline: $isMultiline,
maxHeight: 300, maxHeight: 300,
enterKeyHandler: { }, enterKeyHandler: { },
@@ -15,6 +15,7 @@
// //
import SwiftUI import SwiftUI
import WysiwygComposer
struct RoomAttachmentPicker: View { struct RoomAttachmentPicker: View {
@ObservedObject var context: ComposerToolbarViewModel.Context @ObservedObject var context: ComposerToolbarViewModel.Context
@@ -106,7 +107,7 @@ struct RoomAttachmentPicker: View {
} }
struct RoomAttachmentPicker_Previews: PreviewProvider { struct RoomAttachmentPicker_Previews: PreviewProvider {
static let viewModel = ComposerToolbarViewModel() static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel())
static var previews: some View { static var previews: some View {
RoomAttachmentPicker(context: viewModel.context) RoomAttachmentPicker(context: viewModel.context)
@@ -16,6 +16,7 @@
import Combine import Combine
import SwiftUI import SwiftUI
import WysiwygComposer
struct RoomScreenCoordinatorParameters { struct RoomScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol let roomProxy: RoomProxyProtocol
@@ -40,6 +41,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
private var parameters: RoomScreenCoordinatorParameters private var parameters: RoomScreenCoordinatorParameters
private var viewModel: RoomScreenViewModelProtocol private var viewModel: RoomScreenViewModelProtocol
private var composerViewModel: ComposerToolbarViewModel private var composerViewModel: ComposerToolbarViewModel
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -58,7 +60,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
composerViewModel = ComposerToolbarViewModel() wysiwygViewModel = WysiwygComposerViewModel(minHeight: 22, maxExpandedHeight: 250)
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
} }
// MARK: - Public // MARK: - Public
@@ -111,6 +114,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
} }
func toPresentable() -> AnyView { 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))
} }
} }
@@ -146,8 +146,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
func process(composerAction: ComposerToolbarViewModelAction) { func process(composerAction: ComposerToolbarViewModelAction) {
switch composerAction { switch composerAction {
case .sendMessage(let message, let mode): case .sendMessage(let message, let html, let mode):
Task { await sendCurrentMessage(message, mode: 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: case .displayCameraPicker:
actionsSubject.send(.displayCameraPicker) actionsSubject.send(.displayCameraPicker)
case .displayMediaPicker: case .displayMediaPicker:
@@ -160,7 +162,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
handlePasteOrDrop(provider) handlePasteOrDrop(provider)
case .composerModeChanged(mode: let mode): case .composerModeChanged(mode: let mode):
trackComposerMode(mode) trackComposerMode(mode)
case .focusedChanged(isFocused: let isFocused): case .composerFocusedChanged(isFocused: let isFocused):
composerFocusedSubject.send(isFocused) composerFocusedSubject.send(isFocused)
} }
} }
@@ -427,8 +429,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
} }
private func sendCurrentMessage(_ currentMessage: String, mode: RoomScreenComposerMode) async { private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode) async {
guard !currentMessage.isEmpty else { guard !message.isEmpty else {
fatalError("This message should never be empty") fatalError("This message should never be empty")
} }
@@ -436,11 +438,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch mode { switch mode {
case .reply(let itemId, _): case .reply(let itemId, _):
await timelineController.sendMessage(currentMessage, inReplyTo: itemId) await timelineController.sendMessage(message, html: html, inReplyTo: itemId)
case .edit(let originalItemId): case .edit(let originalItemId):
await timelineController.editMessage(currentMessage, original: originalItemId) await timelineController.editMessage(message, html: html, original: originalItemId)
default: default:
await timelineController.sendMessage(currentMessage) await timelineController.sendMessage(message, html: html)
} }
} }
@@ -15,6 +15,7 @@
// //
import SwiftUI import SwiftUI
import WysiwygComposer
struct RoomScreen: View { struct RoomScreen: View {
@ObservedObject var context: RoomScreenViewModel.Context @ObservedObject var context: RoomScreenViewModel.Context
@@ -153,11 +154,9 @@ struct RoomScreen_Previews: PreviewProvider {
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
static let composerViewModel = ComposerToolbarViewModel()
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)) RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
} }
} }
} }
@@ -15,6 +15,7 @@
// //
import SwiftUI import SwiftUI
import WysiwygComposer
/// A table view wrapper that displays the timeline of a room. /// A table view wrapper that displays the timeline of a room.
struct UITimelineView: UIViewControllerRepresentable { struct UITimelineView: UIViewControllerRepresentable {
@@ -86,11 +87,9 @@ struct UITimelineView_Previews: PreviewProvider {
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
static let composerViewModel = ComposerToolbarViewModel()
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)) RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
} }
} }
} }
@@ -18,6 +18,7 @@ import Combine
import SwiftUI import SwiftUI
import SwiftUIIntrospect import SwiftUIIntrospect
import WysiwygComposer
struct TimelineView: View { struct TimelineView: View {
let viewState: TimelineViewState let viewState: TimelineViewState
@@ -174,11 +175,9 @@ struct TimelineView_Previews: PreviewProvider {
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
static let composerViewModel = ComposerToolbarViewModel()
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)) RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
} }
} }
} }
@@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var notificationSettingsEnabled: Bool { get set } var notificationSettingsEnabled: Bool { get set }
var swiftUITimelineEnabled: Bool { get set } var swiftUITimelineEnabled: Bool { get set }
var pollsInTimelineEnabled: Bool { get set } var pollsInTimelineEnabled: Bool { get set }
var richTextEditorEnabled: Bool { get set }
} }
extension AppSettings: DeveloperOptionsProtocol { } extension AppSettings: DeveloperOptionsProtocol { }
@@ -72,6 +72,12 @@ struct DeveloperOptionsScreen: View {
} }
} }
Section("Rich Text Editor") {
Toggle(isOn: $context.richTextEditorEnabled) {
Text("Use the Rich Text Editor")
}
}
Section { Section {
Button { Button {
showConfetti = true showConfetti = true
+14 -5
View File
@@ -262,15 +262,19 @@ class RoomProxy: RoomProxyProtocol {
} }
} }
func sendMessage(_ message: String, inReplyTo eventID: String? = nil) async -> Result<Void, RoomProxyError> { func sendMessage(_ message: String, html: String?, inReplyTo eventID: String? = nil) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer { defer {
sendMessageBackgroundTask?.stop() sendMessageBackgroundTask?.stop()
} }
let transactionId = genTransactionId() 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) { return await Task.dispatch(on: messageSendingDispatchQueue) {
do { do {
if let eventID { if let eventID {
@@ -443,17 +447,22 @@ class RoomProxy: RoomProxyProtocol {
} }
} }
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> { func editMessage(_ newMessage: String, html: String?, original eventID: String) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer { defer {
sendMessageBackgroundTask?.stop() sendMessageBackgroundTask?.stop()
} }
let transactionId = genTransactionId() 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) { return await Task.dispatch(on: messageSendingDispatchQueue) {
do { do {
let newMessageContent = messageEventContentFromMarkdown(md: newMessage)
try self.room.edit(newMsg: newMessageContent, originalEventId: eventID, txnId: transactionId) try self.room.edit(newMsg: newMessageContent, originalEventId: eventID, txnId: transactionId)
return .success(()) return .success(())
} catch { } catch {
@@ -92,7 +92,7 @@ protocol RoomProxyProtocol {
func sendMessageEventContent(_ messageContent: RoomMessageEventContentWithoutRelation) async -> Result<Void, RoomProxyError> func sendMessageEventContent(_ messageContent: RoomMessageEventContentWithoutRelation) async -> Result<Void, RoomProxyError>
func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError> func sendMessage(_ message: String, html: String?, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError>
func toggleReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError> func toggleReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError>
@@ -130,7 +130,7 @@ protocol RoomProxyProtocol {
/// Cancels a failed message given its transaction ID from the timeline /// Cancels a failed message given its transaction ID from the timeline
func cancelSend(transactionID: String) async func cancelSend(transactionID: String) async
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> func editMessage(_ newMessage: String, html: String?, original eventID: String) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
@@ -181,8 +181,8 @@ extension RoomProxyProtocol {
} }
} }
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> { func sendMessage(_ message: String, html: String?) async -> Result<Void, RoomProxyError> {
await sendMessage(message, inReplyTo: nil) await sendMessage(message, html: html, inReplyTo: nil)
} }
// Avoids to duplicate the same logic around in the app // Avoids to duplicate the same logic around in the app
@@ -66,11 +66,11 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { .none } 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 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 { } func redact(_ itemID: TimelineItemIdentifier) async { }
@@ -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? var inReplyTo: String?
if itemID == nil { if itemID == nil {
MXLog.info("Send message in \(roomID)") MXLog.info("Send message in \(roomID)")
@@ -133,7 +133,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return return
} }
switch await roomProxy.sendMessage(message, inReplyTo: inReplyTo) { switch await roomProxy.sendMessage(message, html: html, inReplyTo: inReplyTo) {
case .success: case .success:
MXLog.info("Finished sending message") MXLog.info("Finished sending message")
case .failure(let error): 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)") MXLog.info("Edit message in \(roomID)")
if let timelineItem = timelineItems.firstUsingStableID(itemID), if let timelineItem = timelineItems.firstUsingStableID(itemID),
let item = timelineItem as? EventBasedTimelineItemProtocol, let item = timelineItem as? EventBasedTimelineItemProtocol,
item.hasFailedToSend { item.hasFailedToSend {
MXLog.info("Editing a failed echo, will cancel and resend it as a new message") MXLog.info("Editing a failed echo, will cancel and resend it as a new message")
await cancelSend(itemID) await cancelSend(itemID)
await sendMessage(newMessage) await sendMessage(newMessage, html: html)
} else if let eventID = itemID.eventID { } else if let eventID = itemID.eventID {
switch await roomProxy.editMessage(newMessage, original: eventID) { switch await roomProxy.editMessage(newMessage, html: html, original: eventID) {
case .success: case .success:
MXLog.info("Finished editing message") MXLog.info("Finished editing message")
case .failure(let error): case .failure(let error):
@@ -51,9 +51,9 @@ protocol RoomTimelineControllerProtocol {
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError> func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError>
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 func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async
@@ -67,7 +67,7 @@ protocol RoomTimelineControllerProtocol {
} }
extension RoomTimelineControllerProtocol { extension RoomTimelineControllerProtocol {
func sendMessage(_ message: String) async { func sendMessage(_ message: String, html: String?) async {
await sendMessage(message, inReplyTo: nil) await sendMessage(message, html: html, inReplyTo: nil)
} }
} }
+1
View File
@@ -178,6 +178,7 @@ targets:
- package: URLRouting - package: URLRouting
- package: Version - package: Version
- package: Emojibase - package: Emojibase
- package: WysiwygComposer
sources: sources:
- path: ../Sources - path: ../Sources
@@ -15,12 +15,14 @@
// //
@testable import ElementX @testable import ElementX
import WysiwygComposer
import XCTest import XCTest
@MainActor @MainActor
class ComposerToolbarViewModelTests: XCTestCase { class ComposerToolbarViewModelTests: XCTestCase {
func testComposerFocus() { func testComposerFocus() {
let viewModel = ComposerToolbarViewModel() let wysiwygViewModel = WysiwygComposerViewModel()
let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")))) viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))))
XCTAssertTrue(viewModel.state.bindings.composerFocused) XCTAssertTrue(viewModel.state.bindings.composerFocused)
viewModel.process(roomAction: .removeFocus) viewModel.process(roomAction: .removeFocus)
@@ -28,7 +30,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
} }
func testComposerMode() { func testComposerMode() {
let viewModel = ComposerToolbarViewModel() let wysiwygViewModel = WysiwygComposerViewModel()
let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")) let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))
viewModel.process(roomAction: .setMode(mode: mode)) viewModel.process(roomAction: .setMode(mode: mode))
XCTAssertEqual(viewModel.state.composerMode, mode) XCTAssertEqual(viewModel.state.composerMode, mode)
@@ -37,7 +40,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
} }
func testComposerModeIsPublished() { func testComposerModeIsPublished() {
let viewModel = ComposerToolbarViewModel() let wysiwygViewModel = WysiwygComposerViewModel()
let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")) let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))
let expectation = expectation(description: "Composer mode is published") let expectation = expectation(description: "Composer mode is published")
let cancellable = viewModel let cancellable = viewModel
@@ -56,4 +60,11 @@ class ComposerToolbarViewModelTests: XCTestCase {
wait(for: [expectation], timeout: 2.0) wait(for: [expectation], timeout: 2.0)
cancellable.cancel() cancellable.cancel()
} }
func testHandleKeyCommand() {
let wysiwygViewModel = WysiwygComposerViewModel()
let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
XCTAssertTrue(viewModel.handleKeyCommand(.enter))
XCTAssertFalse(viewModel.handleKeyCommand(.shiftEnter))
}
} }
+3
View File
@@ -112,3 +112,6 @@ packages:
Version: Version:
url: https://github.com/mxcl/Version url: https://github.com/mxcl/Version
minorVersion: 2.0.0 minorVersion: 2.0.0
WysiwygComposer:
url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift
exactVersion: 2.6.3