// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Combine @testable import ElementX import Foundation import MatrixRustSDK import Testing import WysiwygComposer @MainActor final class ComposerToolbarViewModelTests { private var appSettings: AppSettings! private var wysiwygViewModel: WysiwygComposerViewModel! private var viewModel: ComposerToolbarViewModel! private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! private var draftServiceMock: ComposerDraftServiceMock! init() { AppSettings.resetAllSettings() appSettings = AppSettings() ServiceLocator.shared.register(appSettings: appSettings) setUpViewModel() } deinit { AppSettings.resetAllSettings() } @Test func composerFocus() { viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("mock"), type: .default))) #expect(viewModel.state.bindings.composerFocused) viewModel.process(timelineAction: .removeFocus) #expect(!viewModel.state.bindings.composerFocused) } @Test func composerMode() { let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) viewModel.process(timelineAction: .setMode(mode: mode)) #expect(viewModel.state.composerMode == mode) viewModel.process(timelineAction: .clear) #expect(viewModel.state.composerMode == .default) } @Test func composerModeIsPublished() async throws { let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) let deferred = deferFulfillment(viewModel.context.$viewState.map(\.composerMode).removeDuplicates().dropFirst()) { $0 == mode } viewModel.process(timelineAction: .setMode(mode: mode)) try await deferred.fulfill() } @Test func handleKeyCommand() { #expect(viewModel.context.viewState.keyCommands.count == 1) } @Test func composerFocusAfterEnablingRTE() { viewModel.process(viewAction: .enableTextFormatting) #expect(viewModel.state.bindings.composerFocused) } @Test func rteEnabledAfterSendingMessage() { viewModel.process(viewAction: .enableTextFormatting) #expect(viewModel.state.bindings.composerFocused) viewModel.state.composerEmpty = false viewModel.process(viewAction: .sendMessage) #expect(viewModel.state.bindings.composerFormattingEnabled) } @Test func alertIsShownAfterLinkAction() { #expect(viewModel.state.bindings.alertInfo == nil) viewModel.process(viewAction: .enableTextFormatting) viewModel.process(viewAction: .composerAction(action: .link)) #expect(viewModel.state.bindings.alertInfo != nil) } @Test func suggestions() { let suggestions: [SuggestionItem] = [.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""), .init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: nil)), range: .init(), rawSuggestionText: "")] let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)) viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: mockCompletionSuggestionService, mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) #expect(viewModel.state.suggestions == suggestions) } @Test func suggestionTrigger() async throws { let deferred = deferFulfillment(wysiwygViewModel.$attributedContent) { $0.plainText == "#room-alias-test" } wysiwygViewModel.setMarkdownContent("@user-test") wysiwygViewModel.setMarkdownContent("#room-alias-test") try await deferred.fulfill() // The first one is nil because when initialised the view model is empty #expect(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations == [nil, .init(type: .user, text: "user-test", range: .init(location: 0, length: 10)), .init(type: .room, text: "room-alias-test", range: .init(location: 0, length: 16))]) } @Test func selectedUserSuggestion() { let suggestion = SuggestionItem(suggestionType: .user(.init(id: "@test:matrix.org", displayName: "Test", avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) // The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names #expect(wysiwygViewModel.content.html == "@test:matrix.org ") } @Test func selectedRoomSuggestion() { let suggestion = SuggestionItem(suggestionType: .room(.init(id: "!room:matrix.org", canonicalAlias: "#room-alias:matrix.org", name: "Room", avatar: .room(id: "!room:matrix.org", name: "Room", avatarURL: nil))), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) // The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names #expect(wysiwygViewModel.content.html == "#room-alias:matrix.org ") } @Test func allUsersSuggestion() throws { let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) var string = "@room" try string.unicodeScalars.append(#require(UnicodeScalar(String.nbsp))) #expect(wysiwygViewModel.content.html == string) } @Test func userMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let userID = "@test:matrix.org" let suggestion = SuggestionItem(suggestionType: .user(.init(id: userID, displayName: "Test", avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment #expect(attachment?.pillData?.type == .user(userID: userID)) } @Test func roomMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let roomAlias = "#test:matrix.org" let suggestion = SuggestionItem(suggestionType: .room(.init(id: "room-id", canonicalAlias: roomAlias, name: "Room", avatar: .room(id: "room-id", name: "Room", avatarURL: nil))), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment #expect(attachment?.pillData?.type == .roomAlias(roomAlias)) } @Test func allUsersMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment #expect(attachment?.pillData?.type == .allUsers) } @Test func intentionalMentions() async throws { wysiwygViewModel.setHtmlContent("""
Hello @room \ and especially hello to Test
""") let deferred = deferFulfillment(viewModel.actions) { action in switch action { case let .sendMessage(_, _, _, intentionalMentions): return intentionalMentions == IntentionalMentions(userIDs: ["@test:matrix.org"], atRoom: true) default: return false } } viewModel.context.send(viewAction: .sendMessage) try await deferred.fulfill() } // MARK: - Draft @Test func saveDraftPlainText() async throws { viewModel.context.composerFormattingEnabled = false viewModel.context.plainComposerText = .init(string: "Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "Hello world!") #expect(draft.htmlText == nil) #expect(draft.draftType == .newMessage) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftFormattedText() async throws { viewModel.context.composerFormattingEnabled = true wysiwygViewModel.setHtmlContent("Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "__Hello__ world!") #expect(draft.htmlText == "Hello world!") #expect(draft.draftType == .newMessage) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftEdit() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("testID"), type: .default))) viewModel.context.plainComposerText = .init(string: "Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "Hello world!") #expect(draft.htmlText == nil) #expect(draft.draftType == .edit(eventID: "testID")) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftReply() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID", replyDetails: .loaded(sender: .init(id: ""), eventID: "testID", eventContent: .message(.text(.init(body: "reply text")))), isThread: false))) viewModel.context.plainComposerText = .init(string: "Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "Hello world!") #expect(draft.htmlText == nil) #expect(draft.draftType == .reply(eventID: "testID")) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftWhenEmptyReply() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID", replyDetails: .loaded(sender: .init(id: ""), eventID: "testID", eventContent: .message(.text(.init(body: "reply text")))), isThread: false))) var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "") #expect(draft.htmlText == nil) #expect(draft.draftType == .reply(eventID: "testID")) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func clearDraftWhenEmptyNormalMessage() async { viewModel.context.composerFormattingEnabled = false await waitForConfirmation("Clear draft") { confirmation in draftServiceMock.clearDraftClosure = { confirmation() return .success(()) } viewModel.saveDraft() } #expect(!draftServiceMock.saveDraftCalled) #expect(draftServiceMock.clearDraftCallsCount == 1) #expect(!draftServiceMock.loadDraftCalled) } @Test func clearDraftForNonTextMode() async { viewModel.context.composerFormattingEnabled = false let waveformData: [Float] = Array(repeating: 1.0, count: 1000) viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.process(timelineAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, title: "", duration: 10.0), waveform: .data(waveformData), isUploading: false))) await waitForConfirmation("Clear draft") { confirmation in draftServiceMock.clearDraftClosure = { confirmation() return .success(()) } viewModel.saveDraft() } #expect(!draftServiceMock.saveDraftCalled) #expect(draftServiceMock.clearDraftCallsCount == 1) #expect(!draftServiceMock.loadDraftCalled) } @Test func nothingToRestore() async { viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(nil) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerEmpty) #expect(viewModel.state.composerMode == .default) } @Test func restoreNormalPlainTextMessage() async { viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: "Hello world!", htmlText: nil, draftType: .newMessage)) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .default) #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!")) } @Test func restoreNormalFormattedTextMessage() async throws { viewModel.context.composerFormattingEnabled = false try await confirmation { confirmation in draftServiceMock.loadDraftClosure = { defer { confirmation() } return .success(.init(plainText: "__Hello__ world!", htmlText: "Hello world!", draftType: .newMessage)) } let deferred = deferFulfillment(wysiwygViewModel.$isContentEmpty) { !$0 } await viewModel.loadDraft() try await deferred.fulfill() } #expect(viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .default) #expect(wysiwygViewModel.content.html == "Hello world!") #expect(wysiwygViewModel.content.markdown == "__Hello__ world!") } @Test func restoreEdit() async { viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: "Hello world!", htmlText: nil, draftType: .edit(eventID: "testID"))) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .edit(originalEventOrTransactionID: .eventID("testID"), type: .default)) #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!")) } @Test func restoreReply() async throws { let testEventID = "testID" let text = "Hello world!" let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", displayName: "Username"), eventID: testEventID, eventContent: .message(.text(.init(body: "Reply text")))) viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: text, htmlText: nil, draftType: .reply(eventID: testEventID))) } let deferredReplyLoaded = deferFulfillment(viewModel.context.$viewState) { $0.composerMode == .reply(eventID: testEventID, replyDetails: loadedReply, isThread: true) } draftServiceMock.getReplyEventIDClosure = { eventID in #expect(eventID == testEventID) try? await Task.sleep(for: .seconds(1)) return .success(.init(details: loadedReply, isThreaded: true)) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) // Testing the loading state first #expect(viewModel.state.composerMode == .reply(eventID: testEventID, replyDetails: .loading(eventID: testEventID), isThread: false)) #expect(viewModel.context.plainComposerText == NSAttributedString(string: text)) try await deferredReplyLoaded.fulfill() #expect(viewModel.state.composerMode == .reply(eventID: testEventID, replyDetails: loadedReply, isThread: true)) } @Test func restoreReplyAndCancelReplyMode() async throws { let testEventID = "testID" let text = "Hello world!" let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", displayName: "Username"), eventID: testEventID, eventContent: .message(.text(.init(body: "Reply text")))) viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: text, htmlText: nil, draftType: .reply(eventID: testEventID))) } let replyLoadedSubject = PassthroughSubject