diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 7b8bb7272..681b745c7 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -5130,15 +5130,15 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol, @unc //MARK: - processTextMessage - var processTextMessageUnderlyingCallsCount = 0 - var processTextMessageCallsCount: Int { + var processTextMessageSelectedRangeUnderlyingCallsCount = 0 + var processTextMessageSelectedRangeCallsCount: Int { get { if Thread.isMainThread { - return processTextMessageUnderlyingCallsCount + return processTextMessageSelectedRangeUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = processTextMessageUnderlyingCallsCount + returnValue = processTextMessageSelectedRangeUnderlyingCallsCount } return returnValue! @@ -5146,28 +5146,28 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol, @unc } set { if Thread.isMainThread { - processTextMessageUnderlyingCallsCount = newValue + processTextMessageSelectedRangeUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - processTextMessageUnderlyingCallsCount = newValue + processTextMessageSelectedRangeUnderlyingCallsCount = newValue } } } } - var processTextMessageCalled: Bool { - return processTextMessageCallsCount > 0 + var processTextMessageSelectedRangeCalled: Bool { + return processTextMessageSelectedRangeCallsCount > 0 } - var processTextMessageReceivedTextMessage: String? - var processTextMessageReceivedInvocations: [String?] = [] - var processTextMessageClosure: ((String?) -> Void)? + var processTextMessageSelectedRangeReceivedArguments: (textMessage: String, selectedRange: NSRange)? + var processTextMessageSelectedRangeReceivedInvocations: [(textMessage: String, selectedRange: NSRange)] = [] + var processTextMessageSelectedRangeClosure: ((String, NSRange) -> Void)? - func processTextMessage(_ textMessage: String?) { - processTextMessageCallsCount += 1 - processTextMessageReceivedTextMessage = textMessage + func processTextMessage(_ textMessage: String, selectedRange: NSRange) { + processTextMessageSelectedRangeCallsCount += 1 + processTextMessageSelectedRangeReceivedArguments = (textMessage: textMessage, selectedRange: selectedRange) DispatchQueue.main.async { - self.processTextMessageReceivedInvocations.append(textMessage) + self.processTextMessageSelectedRangeReceivedInvocations.append((textMessage: textMessage, selectedRange: selectedRange)) } - processTextMessageClosure?(textMessage) + processTextMessageSelectedRangeClosure?(textMessage, selectedRange) } //MARK: - setSuggestionTrigger diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift index dc9b843ac..4ed62945d 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift @@ -23,6 +23,7 @@ struct MediaUploadPreviewScreenViewState: BindableState { struct MediaUploadPreviewScreenBindings: BindableState { var caption = NSAttributedString() var presendCallback: (() -> Void)? + var selectedRange = NSRange(location: 0, length: 0) var isPresentingMediaCaptionWarning = false } diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift index c0b8ab261..60e10f83b 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift @@ -60,6 +60,7 @@ struct MediaUploadPreviewScreen: View { MessageComposerTextField(placeholder: L10n.richTextEditorComposerCaptionPlaceholder, text: $context.caption, presendCallback: $context.presendCallback, + selectedRange: $context.selectedRange, maxHeight: ComposerConstant.maxHeight, keyHandler: handleKeyPress) { _ in } .focused($isComposerFocussed) diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionService.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionService.swift index 20760cf81..387156109 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionService.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionService.swift @@ -8,8 +8,8 @@ import Combine import Foundation -private enum SuggestionTriggerPattern: Character { - case at = "@" +private enum SuggestionTriggerRegex { + static let at = /@\w+/ } final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { @@ -42,7 +42,8 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { return SuggestionItem.user(item: .init(id: member.userID, displayName: member.displayName, avatarURL: member.avatarURL, - range: suggestionTrigger.range)) + range: suggestionTrigger.range, + rawSuggestionText: suggestionTrigger.text)) } if self.canMentionAllUsers, @@ -52,7 +53,8 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { .insert(SuggestionItem.allUsers(item: .init(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: self.roomProxy.infoPublisher.value.avatarURL, - range: suggestionTrigger.range)), at: 0) + range: suggestionTrigger.range, + rawSuggestionText: suggestionTrigger.text)), at: 0) } return membersSuggestion @@ -73,8 +75,8 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { } } - func processTextMessage(_ textMessage: String?) { - setSuggestionTrigger(detectTriggerInText(textMessage)) + func processTextMessage(_ textMessage: String, selectedRange: NSRange) { + setSuggestionTrigger(detectTriggerInText(textMessage, selectedRange: selectedRange)) } func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) { @@ -83,23 +85,25 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { // MARK: - Private - private func detectTriggerInText(_ text: String?) -> SuggestionTrigger? { - guard let text else { + private func detectTriggerInText(_ text: String, selectedRange: NSRange) -> SuggestionTrigger? { + let matches = text.matches(of: SuggestionTriggerRegex.at) + let match = matches + .first { matchResult in + let lowerBound = matchResult.range.lowerBound.utf16Offset(in: matchResult.base) + let upperBound = matchResult.range.upperBound.utf16Offset(in: matchResult.base) + return selectedRange.location >= lowerBound + && selectedRange.location <= upperBound + && selectedRange.length <= upperBound - lowerBound + } + + guard let match else { return nil } - let components = text.components(separatedBy: .whitespaces) + var suggestionText = String(text[match.range]) + suggestionText.removeFirst() - guard var lastComponent = components.last, - let range = text.range(of: lastComponent, options: .backwards), - lastComponent.count > 0, - let suggestionKey = SuggestionTriggerPattern(rawValue: lastComponent.removeFirst()), - // If a second character exists and is the same as the key it shouldn't trigger. - lastComponent.first != suggestionKey.rawValue else { - return nil - } - - return .init(type: .user, text: lastComponent, range: NSRange(range, in: text)) + return .init(type: .user, text: suggestionText, range: NSRange(match.range, in: text)) } private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool { diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionServiceProtocol.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionServiceProtocol.swift index 8273d8d82..3d59bdb8f 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionServiceProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionServiceProtocol.swift @@ -45,13 +45,14 @@ struct MentionSuggestionItem: Identifiable, Equatable { let displayName: String? let avatarURL: URL? let range: NSRange + let rawSuggestionText: String } // sourcery: AutoMockable protocol CompletionSuggestionServiceProtocol { var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get } - func processTextMessage(_ textMessage: String?) + func processTextMessage(_ textMessage: String, selectedRange: NSRange) func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift index 39dd99202..215f0d018 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift @@ -53,6 +53,7 @@ enum ComposerToolbarViewAction { case plainComposerTextChanged case didToggleFormattingOptions + case selectedTextChanged } enum ComposerAttachmentType { @@ -131,6 +132,7 @@ struct ComposerToolbarViewStateBindings { var composerExpanded = false var formatItems: [FormatItem] = .init() var alertInfo: AlertInfo? + var selectedRange = NSRange(location: 0, length: 0) var presendCallback: (() -> Void)? } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index 0f22c1d2f..7168723c1 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -197,7 +197,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool case .voiceMessage(let voiceMessageAction): processVoiceMessageAction(voiceMessageAction) case .plainComposerTextChanged: - completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string) + completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string, selectedRange: context.viewState.bindings.selectedRange) + case .selectedTextChanged: + completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string, selectedRange: context.viewState.bindings.selectedRange) case .didToggleFormattingOptions: if context.composerFormattingEnabled { guard !context.plainComposerText.string.isEmpty else { @@ -483,14 +485,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText) mentionBuilder.handleUserMention(for: attributedString, in: suggestion.range, url: url, userID: item.id, userDisplayName: item.displayName) state.bindings.plainComposerText = attributedString + let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - item.rawSuggestionText.count, length: 0) + state.bindings.selectedRange = newSelectedRange } - case .allUsers: + case let .allUsers(item): if context.composerFormattingEnabled { wysiwygViewModel.setAtRoomMention() } else { let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText) mentionBuilder.handleAllUsersMention(for: attributedString, in: suggestion.range) state.bindings.plainComposerText = attributedString + + let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - item.rawSuggestionText.count, length: 0) + state.bindings.selectedRange = newSelectedRange } } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/CompletionSuggestionView.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/CompletionSuggestionView.swift index b4bd67653..917e49bf5 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/CompletionSuggestionView.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/CompletionSuggestionView.swift @@ -32,7 +32,7 @@ struct CompletionSuggestionView: View { EmptyView() } else { ZStack { - MentionSuggestionItemView(mediaProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil, range: .init())) + MentionSuggestionItemView(mediaProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil, range: .init(), rawSuggestionText: "")) .readFrame($prototypeListItemFrame) .hidden() if showBackgroundShadow { @@ -110,15 +110,15 @@ private struct BackgroundView: View { struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview { static let multipleItems: [SuggestionItem] = (0...10).map { index in - SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil, range: .init())) + SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil, range: .init(), rawSuggestionText: "")) } static var previews: some View { // Putting them is VStack allows the preview to work properly in tests VStack(spacing: 8) { CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()), - items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())), - .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init()))]) { _ in } + items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init(), rawSuggestionText: "")), + .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: ""))]) { _ in } } VStack(spacing: 8) { CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()), diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift index 2b7f56edf..52eadc61f 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift @@ -154,6 +154,7 @@ struct ComposerToolbar: View { private var messageComposer: some View { MessageComposer(plainComposerText: $context.plainComposerText, presendCallback: $context.presendCallback, + selectedRange: $context.selectedRange, composerView: composerView, mode: context.viewState.composerMode, composerFormattingEnabled: context.composerFormattingEnabled, @@ -199,6 +200,9 @@ struct ComposerToolbar: View { .onChange(of: context.composerFormattingEnabled) { context.send(viewAction: .didToggleFormattingOptions) } + .onChange(of: context.selectedRange) { + context.send(viewAction: .selectedTextChanged) + } .onAppear { composerFocused = context.composerFocused } @@ -307,8 +311,8 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { mentionDisplayHelper: ComposerMentionDisplayHelper.mock, analyticsService: ServiceLocator.shared.analytics, composerDraftService: ComposerDraftServiceMock()) - static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())), - .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init()))] + static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init(), rawSuggestionText: "")), + .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: ""))] static var previews: some View { ComposerToolbar.mock(focused: true) diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MentionSuggestionItemView.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MentionSuggestionItemView.swift index c4d312145..55fe8bc20 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MentionSuggestionItemView.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MentionSuggestionItemView.swift @@ -38,7 +38,7 @@ struct MentionSuggestionItemView_Previews: PreviewProvider, TestablePreview { static let mockMediaProvider = MediaProviderMock(configuration: .init()) static var previews: some View { - MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: .mockMXCUserAvatar, range: .init())) - MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil, range: .init())) + MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: "")) + MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil, range: .init(), rawSuggestionText: "")) } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift index 0aa263f12..d0ff8baf5 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift @@ -15,6 +15,7 @@ typealias PasteHandler = (NSItemProvider) -> Void struct MessageComposer: View { @Binding var plainComposerText: NSAttributedString @Binding var presendCallback: (() -> Void)? + @Binding var selectedRange: NSRange let composerView: WysiwygComposerView let mode: ComposerMode let composerFormattingEnabled: Bool @@ -66,6 +67,7 @@ struct MessageComposer: View { MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder, text: $plainComposerText, presendCallback: $presendCallback, + selectedRange: $selectedRange, maxHeight: ComposerConstant.maxHeight, keyHandler: { handleKeyPress($0) }, pasteHandler: pasteAction) @@ -285,6 +287,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview { return MessageComposer(plainComposerText: .constant(content), presendCallback: .constant(nil), + selectedRange: .constant(NSRange(location: 0, length: 0)), composerView: composerView, mode: mode, composerFormattingEnabled: false, diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift index 5b3c04f00..ce412edce 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift @@ -12,6 +12,7 @@ struct MessageComposerTextField: View { let placeholder: String @Binding var text: NSAttributedString @Binding var presendCallback: (() -> Void)? + @Binding var selectedRange: NSRange let maxHeight: CGFloat let keyHandler: GenericKeyHandler @@ -20,6 +21,7 @@ struct MessageComposerTextField: View { var body: some View { UITextViewWrapper(text: $text, presendCallback: $presendCallback, + selectedRange: $selectedRange, maxHeight: maxHeight, keyHandler: keyHandler, pasteHandler: pasteHandler) @@ -54,6 +56,7 @@ private struct UITextViewWrapper: UIViewRepresentable { @Binding var text: NSAttributedString @Binding var presendCallback: (() -> Void)? + @Binding var selectedRange: NSRange let maxHeight: CGFloat @@ -135,12 +138,15 @@ private struct UITextViewWrapper: UIViewRepresentable { // moves the caret back to the bottom of the composer. // https://github.com/element-hq/element-x-ios/issues/3104 textView.selectedTextRange = selection + } else { + textView.selectedRange = selectedRange } } } func makeCoordinator() -> Coordinator { Coordinator(text: $text, + selectedRange: $selectedRange, maxHeight: maxHeight, keyHandler: keyHandler, pasteHandler: pasteHandler) @@ -148,6 +154,7 @@ private struct UITextViewWrapper: UIViewRepresentable { final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate { private var text: Binding + private var selectedRange: Binding private let maxHeight: CGFloat @@ -155,10 +162,12 @@ private struct UITextViewWrapper: UIViewRepresentable { private let pasteHandler: PasteHandler init(text: Binding, + selectedRange: Binding, maxHeight: CGFloat, keyHandler: @escaping GenericKeyHandler, pasteHandler: @escaping PasteHandler) { self.text = text + self.selectedRange = selectedRange self.maxHeight = maxHeight self.keyHandler = keyHandler self.pasteHandler = pasteHandler @@ -179,6 +188,14 @@ private struct UITextViewWrapper: UIViewRepresentable { func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) { pasteHandler(provider) } + + func textViewDidChangeSelection(_ textView: UITextView) { + if selectedRange.wrappedValue != textView.selectedRange { + DispatchQueue.main.async { + self.selectedRange.wrappedValue = textView.selectedRange + } + } + } } } @@ -331,6 +348,7 @@ struct MessageComposerTextField_Previews: PreviewProvider, TestablePreview { MessageComposerTextField(placeholder: "Placeholder", text: $text, presendCallback: .constant(nil), + selectedRange: .constant(NSRange(location: 0, length: 0)), maxHeight: 300, keyHandler: { _ in }, pasteHandler: { _ in }) diff --git a/UnitTests/Sources/CompletionSuggestionServiceTests.swift b/UnitTests/Sources/CompletionSuggestionServiceTests.swift index 9e59882af..f93dec8cd 100644 --- a/UnitTests/Sources/CompletionSuggestionServiceTests.swift +++ b/UnitTests/Sources/CompletionSuggestionServiceTests.swift @@ -31,7 +31,7 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in - suggestions == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init()))] + suggestions == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init(), rawSuggestionText: "ali"))] } service.setSuggestionTrigger(.init(type: .user, text: "ali", range: .init())) try await deferred.fulfill() @@ -68,13 +68,13 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in - suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil))] + suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil, rawSuggestionText: "ro"))] } service.setSuggestionTrigger(.init(type: .user, text: "ro", range: .init())) try await deferred.fulfill() deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in - suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil))] + suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil, rawSuggestionText: "every"))] } service.setSuggestionTrigger(.init(type: .user, text: "every", range: .init())) try await deferred.fulfill() @@ -94,17 +94,68 @@ final class CompletionSuggestionServiceTests: XCTestCase { try await deferred.fulfill() deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in - suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil)), - .user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init())), - .user(item: .init(id: bob.userID, displayName: bob.displayName, avatarURL: bob.avatarURL, range: .init()))] + suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil, rawSuggestionText: "")), + .user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init(), rawSuggestionText: "")), + .user(item: .init(id: bob.userID, displayName: bob.displayName, avatarURL: bob.avatarURL, range: .init(), rawSuggestionText: ""))] } service.setSuggestionTrigger(.init(type: .user, text: "", range: .init())) try await deferred.fulfill() } + + func testUserSuggestionInDifferentMessagePositions() async throws { + let alice: RoomMemberProxyMock = .mockAlice + let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] + let roomProxyMock = JoinedRoomProxyMock(.init(name: "test", members: members)) + let service = CompletionSuggestionService(roomProxy: roomProxyMock) + + var deferred = deferFulfillment(service.suggestionsPublisher) { suggestion in + suggestion == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init(location: 0, length: 3), rawSuggestionText: "al"))] + } + service.processTextMessage("@al hello", selectedRange: .init(location: 0, length: 1)) + try await deferred.fulfill() + + deferred = deferFulfillment(service.suggestionsPublisher) { suggestion in + suggestion == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init(location: 5, length: 3), rawSuggestionText: "al"))] + } + service.processTextMessage("test @al", selectedRange: .init(location: 5, length: 1)) + try await deferred.fulfill() + + deferred = deferFulfillment(service.suggestionsPublisher) { suggestion in + suggestion == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init(location: 5, length: 3), rawSuggestionText: "al"))] + } + service.processTextMessage("test @al test", selectedRange: .init(location: 5, length: 1)) + try await deferred.fulfill() + } + + func testUserSuggestionWithMultipleMentionSymbol() async throws { + let alice: RoomMemberProxyMock = .mockAlice + let bob: RoomMemberProxyMock = .mockBob + let members: [RoomMemberProxyMock] = [alice, bob, .mockCharlie, .mockMe] + let roomProxyMock = JoinedRoomProxyMock(.init(name: "test", members: members)) + let service = CompletionSuggestionService(roomProxy: roomProxyMock) + + var deffered = deferFulfillment(service.suggestionsPublisher) { suggestion in + suggestion == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init(location: 0, length: 3), rawSuggestionText: "al"))] + } + service.processTextMessage("@al test @bo", selectedRange: .init(location: 0, length: 1)) + try await deffered.fulfill() + + deffered = deferFulfillment(service.suggestionsPublisher) { suggestion in + suggestion == [.user(item: .init(id: bob.userID, displayName: bob.displayName, avatarURL: bob.avatarURL, range: .init(location: 9, length: 3), rawSuggestionText: "bo"))] + } + service.processTextMessage("@al test @bo", selectedRange: .init(location: 9, length: 1)) + try await deffered.fulfill() + + deffered = deferFulfillment(service.suggestionsPublisher) { suggestion in + suggestion == [] + } + service.processTextMessage("@al test @bo", selectedRange: .init(location: 4, length: 1)) + try await deffered.fulfill() + } } private extension MentionSuggestionItem { - static func allUsersMention(roomAvatar: URL?) -> Self { - MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar, range: .init()) + static func allUsersMention(roomAvatar: URL?, rawSuggestionText: String) -> Self { + MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar, range: .init(), rawSuggestionText: rawSuggestionText) } } diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index 0a6482a47..3a70b90c0 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -91,8 +91,8 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testSuggestions() { - let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())), - .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCAvatar, range: .init()))] + let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init(), rawSuggestionText: "")), + .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCAvatar, range: .init(), rawSuggestionText: ""))] let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)) viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()), @@ -117,7 +117,7 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testSelectedUserSuggestion() { - let suggestion = SuggestionItem.user(item: .init(id: "@test:matrix.org", displayName: "Test", avatarURL: nil, range: .init())) + let suggestion = SuggestionItem.user(item: .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 @@ -138,7 +138,7 @@ class ComposerToolbarViewModelTests: XCTestCase { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let userID = "@test:matrix.org" - let suggestion = SuggestionItem.user(item: .init(id: userID, displayName: "Test", avatarURL: nil, range: .init())) + let suggestion = SuggestionItem.user(item: .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 @@ -773,6 +773,6 @@ class ComposerToolbarViewModelTests: XCTestCase { private extension MentionSuggestionItem { static func allUsersMention(roomAvatar: URL?) -> Self { - MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar, range: .init()) + MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar, range: .init(), rawSuggestionText: "") } }