Fix message completion trigger to work anywhere in the message (#3696)

* Fix message completion trigger to work anywhere in the message

* Add tests for display suggestion in difference message positions

* Fix suggestion trigger could support multiple mention symbol

* Remove optional parameter type and tweak argument name to hide

* Optimize the suggestions trigger using regex

* Fix the cursor at wrong position after selected suggestion

* Modify the rawSuggestionText pass data in right way

* Modify mention symbol to use regex type

* Update ElementX/Sources/Screens/RoomScreen/ComposerToolbar/CompletionSuggestionService.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* Update generated mock

---------

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Vickcoo
2025-02-13 23:13:09 +08:00
committed by GitHub
parent 8ee1427809
commit 4b43d901f8
14 changed files with 151 additions and 59 deletions
@@ -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)
}
}
@@ -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: "")
}
}