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
@@ -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 {
@@ -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?)
}
@@ -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<UUID>?
var selectedRange = NSRange(location: 0, length: 0)
var presendCallback: (() -> Void)?
}
@@ -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
}
}
}
@@ -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<Content: View>: 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()),
@@ -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)
@@ -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: ""))
}
}
@@ -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,
@@ -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<NSAttributedString>
private var selectedRange: Binding<NSRange>
private let maxHeight: CGFloat
@@ -155,10 +162,12 @@ private struct UITextViewWrapper: UIViewRepresentable {
private let pasteHandler: PasteHandler
init(text: Binding<NSAttributedString>,
selectedRange: Binding<NSRange>,
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 })