mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -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 {
|
||||||
|
if showAttachmentPopover {
|
||||||
composerFocused = false
|
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) {
|
||||||
|
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||||
|
composerView
|
||||||
|
.tint(.compound.iconAccentTertiary)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.focused($focused)
|
||||||
|
.onAppear {
|
||||||
|
onAppearAction()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||||
text: $text,
|
text: $plainText,
|
||||||
focused: focused,
|
|
||||||
isMultiline: $isMultiline,
|
isMultiline: $isMultiline,
|
||||||
maxHeight: 300,
|
maxHeight: 300,
|
||||||
enterKeyHandler: sendAction,
|
enterKeyHandler: sendAction,
|
||||||
pasteHandler: pasteAction)
|
pasteHandler: pasteAction)
|
||||||
.tint(.compound.iconAccentTertiary)
|
.tint(.compound.iconAccentTertiary)
|
||||||
.padding(.vertical, 10)
|
.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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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 { }
|
||||||
|
|||||||
+6
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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):
|
||||||
|
|||||||
+4
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user