diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index 4ed1910fa..c39e48db4 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -506,10 +506,54 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool wysiwygViewModel.textView.flushPills() wysiwygViewModel.setHtmlContent(text) } else { - state.bindings.plainComposerText = .init(string: text) + let attributedString = NSMutableAttributedString(string: text) + + parseUserMentionsMarkdown(text) { range, url in + // Call your handleUserMention function here + attributedString.addAttribute(.link, value: url, range: range) + } + + let matches = MatrixEntityRegex.allUsersRegex.matches(in: attributedString.string) + for match in matches { + attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range) + } + + attributedStringBuilder.detectPermalinks(attributedString) + + state.bindings.plainComposerText = attributedString } } - + + private func parseUserMentionsMarkdown(_ text: String, callback: (NSRange, URL) -> Void) { + // Define the regex pattern + let pattern = "\\[(.*?)\\]\\(https://matrix\\.to/#/(@.*?)\\)" + + // Create the regex + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return + } + + // Find matches in the input text + let nsText = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) + + // Process each match + for match in matches.sorted(by: { $0.range.length > $1.range.length }) { + // Extract the display name, user ID, and full URL + guard match.numberOfRanges == 3 else { continue } + + let userIDRange = match.range(at: 2) + let fullRange = match.range(at: 0) + + let userID = nsText.substring(with: userIDRange) + let fullURLString = "https://matrix.to/#/\(userID)" + + if let url = URL(string: fullURLString) { + callback(fullRange, url) + } + } + } + private func createLinkAlert() { let linkAction = wysiwygViewModel.getLinkAction() currentLinkData = WysiwygLinkData(action: linkAction, diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index 5e25244ab..8cc1004bc 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -553,6 +553,85 @@ class ComposerToolbarViewModelTests: XCTestCase { await fulfillment(of: [expectation1, expectation2]) XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world")) } + + func testRestoreUserMentionInPlainText() async throws { + viewModel.context.composerFormattingEnabled = false + let text = "Hello [TestName](https://matrix.to/#/@test:matrix.org)!" + viewModel.process(roomAction: .setText(plainText: text, htmlText: nil)) + + let deferred = deferFulfillment(viewModel.actions) { action in + switch action { + case let .sendMessage(plainText, _, _, intentionalMentions): + // As of right now the markdown loses the display name when restored + return plainText == "Hello [@test:matrix.org](https://matrix.to/#/@test:matrix.org)!" && + intentionalMentions == IntentionalMentions(userIDs: ["@test:matrix.org"], atRoom: false) + default: + return false + } + } + + viewModel.process(viewAction: .sendMessage) + try await deferred.fulfill() + } + + func testRestoreAllUsersMentionInPlainText() async throws { + viewModel.context.composerFormattingEnabled = false + let text = "Hello @room" + viewModel.process(roomAction: .setText(plainText: text, htmlText: nil)) + + let deferred = deferFulfillment(viewModel.actions) { action in + switch action { + case let .sendMessage(plainText, _, _, intentionalMentions): + return plainText == "Hello @room" && + intentionalMentions == IntentionalMentions(userIDs: [], atRoom: true) + default: + return false + } + } + + viewModel.process(viewAction: .sendMessage) + try await deferred.fulfill() + } + + func testRestoreMixedMentionsInPlainText() async throws { + viewModel.context.composerFormattingEnabled = false + let text = "Hello [User1](https://matrix.to/#/@user1:matrix.org), [User2](https://matrix.to/#/@user2:matrix.org) and @room" + viewModel.process(roomAction: .setText(plainText: text, htmlText: nil)) + + let deferred = deferFulfillment(viewModel.actions) { action in + switch action { + case let .sendMessage(plainText, _, _, intentionalMentions): + // As of right now the markdown loses the display name when restored + return plainText == "Hello [@user1:matrix.org](https://matrix.to/#/@user1:matrix.org), [@user2:matrix.org](https://matrix.to/#/@user2:matrix.org) and @room" && + intentionalMentions == IntentionalMentions(userIDs: ["@user1:matrix.org", "@user2:matrix.org"], atRoom: true) + default: + return false + } + } + + viewModel.process(viewAction: .sendMessage) + try await deferred.fulfill() + } + + func testRestoreAmbiguousMention() async throws { + viewModel.context.composerFormattingEnabled = false + let text = "Hello [User1](https://matrix.to/#/@roomuser:matrix.org)" + viewModel.process(roomAction: .setText(plainText: text, htmlText: nil)) + + let deferred = deferFulfillment(viewModel.actions) { action in + switch action { + case let .sendMessage(plainText, _, _, intentionalMentions): + // As of right now the markdown loses the display name when restored + return plainText == "Hello [@roomuser:matrix.org](https://matrix.to/#/@roomuser:matrix.org)" && + intentionalMentions == IntentionalMentions(userIDs: ["@roomuser:matrix.org"], atRoom: false) + default: + return false + } + } + + viewModel.process(viewAction: .sendMessage) + try await deferred.fulfill() + } } private extension MentionSuggestionItem {