mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
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:
parent
8ee1427809
commit
4b43d901f8
@ -5130,15 +5130,15 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol, @unc
|
|||||||
|
|
||||||
//MARK: - processTextMessage
|
//MARK: - processTextMessage
|
||||||
|
|
||||||
var processTextMessageUnderlyingCallsCount = 0
|
var processTextMessageSelectedRangeUnderlyingCallsCount = 0
|
||||||
var processTextMessageCallsCount: Int {
|
var processTextMessageSelectedRangeCallsCount: Int {
|
||||||
get {
|
get {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
return processTextMessageUnderlyingCallsCount
|
return processTextMessageSelectedRangeUnderlyingCallsCount
|
||||||
} else {
|
} else {
|
||||||
var returnValue: Int? = nil
|
var returnValue: Int? = nil
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
returnValue = processTextMessageUnderlyingCallsCount
|
returnValue = processTextMessageSelectedRangeUnderlyingCallsCount
|
||||||
}
|
}
|
||||||
|
|
||||||
return returnValue!
|
return returnValue!
|
||||||
@ -5146,28 +5146,28 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol, @unc
|
|||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
processTextMessageUnderlyingCallsCount = newValue
|
processTextMessageSelectedRangeUnderlyingCallsCount = newValue
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
processTextMessageUnderlyingCallsCount = newValue
|
processTextMessageSelectedRangeUnderlyingCallsCount = newValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var processTextMessageCalled: Bool {
|
var processTextMessageSelectedRangeCalled: Bool {
|
||||||
return processTextMessageCallsCount > 0
|
return processTextMessageSelectedRangeCallsCount > 0
|
||||||
}
|
}
|
||||||
var processTextMessageReceivedTextMessage: String?
|
var processTextMessageSelectedRangeReceivedArguments: (textMessage: String, selectedRange: NSRange)?
|
||||||
var processTextMessageReceivedInvocations: [String?] = []
|
var processTextMessageSelectedRangeReceivedInvocations: [(textMessage: String, selectedRange: NSRange)] = []
|
||||||
var processTextMessageClosure: ((String?) -> Void)?
|
var processTextMessageSelectedRangeClosure: ((String, NSRange) -> Void)?
|
||||||
|
|
||||||
func processTextMessage(_ textMessage: String?) {
|
func processTextMessage(_ textMessage: String, selectedRange: NSRange) {
|
||||||
processTextMessageCallsCount += 1
|
processTextMessageSelectedRangeCallsCount += 1
|
||||||
processTextMessageReceivedTextMessage = textMessage
|
processTextMessageSelectedRangeReceivedArguments = (textMessage: textMessage, selectedRange: selectedRange)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.processTextMessageReceivedInvocations.append(textMessage)
|
self.processTextMessageSelectedRangeReceivedInvocations.append((textMessage: textMessage, selectedRange: selectedRange))
|
||||||
}
|
}
|
||||||
processTextMessageClosure?(textMessage)
|
processTextMessageSelectedRangeClosure?(textMessage, selectedRange)
|
||||||
}
|
}
|
||||||
//MARK: - setSuggestionTrigger
|
//MARK: - setSuggestionTrigger
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ struct MediaUploadPreviewScreenViewState: BindableState {
|
|||||||
struct MediaUploadPreviewScreenBindings: BindableState {
|
struct MediaUploadPreviewScreenBindings: BindableState {
|
||||||
var caption = NSAttributedString()
|
var caption = NSAttributedString()
|
||||||
var presendCallback: (() -> Void)?
|
var presendCallback: (() -> Void)?
|
||||||
|
var selectedRange = NSRange(location: 0, length: 0)
|
||||||
|
|
||||||
var isPresentingMediaCaptionWarning = false
|
var isPresentingMediaCaptionWarning = false
|
||||||
}
|
}
|
||||||
|
@ -60,6 +60,7 @@ struct MediaUploadPreviewScreen: View {
|
|||||||
MessageComposerTextField(placeholder: L10n.richTextEditorComposerCaptionPlaceholder,
|
MessageComposerTextField(placeholder: L10n.richTextEditorComposerCaptionPlaceholder,
|
||||||
text: $context.caption,
|
text: $context.caption,
|
||||||
presendCallback: $context.presendCallback,
|
presendCallback: $context.presendCallback,
|
||||||
|
selectedRange: $context.selectedRange,
|
||||||
maxHeight: ComposerConstant.maxHeight,
|
maxHeight: ComposerConstant.maxHeight,
|
||||||
keyHandler: handleKeyPress) { _ in }
|
keyHandler: handleKeyPress) { _ in }
|
||||||
.focused($isComposerFocussed)
|
.focused($isComposerFocussed)
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
private enum SuggestionTriggerPattern: Character {
|
private enum SuggestionTriggerRegex {
|
||||||
case at = "@"
|
static let at = /@\w+/
|
||||||
}
|
}
|
||||||
|
|
||||||
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
||||||
@ -42,7 +42,8 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
|||||||
return SuggestionItem.user(item: .init(id: member.userID,
|
return SuggestionItem.user(item: .init(id: member.userID,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
avatarURL: member.avatarURL,
|
avatarURL: member.avatarURL,
|
||||||
range: suggestionTrigger.range))
|
range: suggestionTrigger.range,
|
||||||
|
rawSuggestionText: suggestionTrigger.text))
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.canMentionAllUsers,
|
if self.canMentionAllUsers,
|
||||||
@ -52,7 +53,8 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
|||||||
.insert(SuggestionItem.allUsers(item: .init(id: PillConstants.atRoom,
|
.insert(SuggestionItem.allUsers(item: .init(id: PillConstants.atRoom,
|
||||||
displayName: PillConstants.everyone,
|
displayName: PillConstants.everyone,
|
||||||
avatarURL: self.roomProxy.infoPublisher.value.avatarURL,
|
avatarURL: self.roomProxy.infoPublisher.value.avatarURL,
|
||||||
range: suggestionTrigger.range)), at: 0)
|
range: suggestionTrigger.range,
|
||||||
|
rawSuggestionText: suggestionTrigger.text)), at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return membersSuggestion
|
return membersSuggestion
|
||||||
@ -73,8 +75,8 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processTextMessage(_ textMessage: String?) {
|
func processTextMessage(_ textMessage: String, selectedRange: NSRange) {
|
||||||
setSuggestionTrigger(detectTriggerInText(textMessage))
|
setSuggestionTrigger(detectTriggerInText(textMessage, selectedRange: selectedRange))
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) {
|
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) {
|
||||||
@ -83,23 +85,25 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
|||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func detectTriggerInText(_ text: String?) -> SuggestionTrigger? {
|
private func detectTriggerInText(_ text: String, selectedRange: NSRange) -> SuggestionTrigger? {
|
||||||
guard let text else {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let components = text.components(separatedBy: .whitespaces)
|
var suggestionText = String(text[match.range])
|
||||||
|
suggestionText.removeFirst()
|
||||||
|
|
||||||
guard var lastComponent = components.last,
|
return .init(type: .user, text: suggestionText, range: NSRange(match.range, in: text))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool {
|
private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool {
|
||||||
|
@ -45,13 +45,14 @@ struct MentionSuggestionItem: Identifiable, Equatable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let avatarURL: URL?
|
let avatarURL: URL?
|
||||||
let range: NSRange
|
let range: NSRange
|
||||||
|
let rawSuggestionText: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol CompletionSuggestionServiceProtocol {
|
protocol CompletionSuggestionServiceProtocol {
|
||||||
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
|
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
|
||||||
|
|
||||||
func processTextMessage(_ textMessage: String?)
|
func processTextMessage(_ textMessage: String, selectedRange: NSRange)
|
||||||
|
|
||||||
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?)
|
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?)
|
||||||
}
|
}
|
||||||
|
@ -53,6 +53,7 @@ enum ComposerToolbarViewAction {
|
|||||||
|
|
||||||
case plainComposerTextChanged
|
case plainComposerTextChanged
|
||||||
case didToggleFormattingOptions
|
case didToggleFormattingOptions
|
||||||
|
case selectedTextChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ComposerAttachmentType {
|
enum ComposerAttachmentType {
|
||||||
@ -131,6 +132,7 @@ struct ComposerToolbarViewStateBindings {
|
|||||||
var composerExpanded = false
|
var composerExpanded = false
|
||||||
var formatItems: [FormatItem] = .init()
|
var formatItems: [FormatItem] = .init()
|
||||||
var alertInfo: AlertInfo<UUID>?
|
var alertInfo: AlertInfo<UUID>?
|
||||||
|
var selectedRange = NSRange(location: 0, length: 0)
|
||||||
|
|
||||||
var presendCallback: (() -> Void)?
|
var presendCallback: (() -> Void)?
|
||||||
}
|
}
|
||||||
|
@ -197,7 +197,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
case .voiceMessage(let voiceMessageAction):
|
case .voiceMessage(let voiceMessageAction):
|
||||||
processVoiceMessageAction(voiceMessageAction)
|
processVoiceMessageAction(voiceMessageAction)
|
||||||
case .plainComposerTextChanged:
|
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:
|
case .didToggleFormattingOptions:
|
||||||
if context.composerFormattingEnabled {
|
if context.composerFormattingEnabled {
|
||||||
guard !context.plainComposerText.string.isEmpty else {
|
guard !context.plainComposerText.string.isEmpty else {
|
||||||
@ -483,14 +485,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
|
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
|
||||||
mentionBuilder.handleUserMention(for: attributedString, in: suggestion.range, url: url, userID: item.id, userDisplayName: item.displayName)
|
mentionBuilder.handleUserMention(for: attributedString, in: suggestion.range, url: url, userID: item.id, userDisplayName: item.displayName)
|
||||||
state.bindings.plainComposerText = attributedString
|
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 {
|
if context.composerFormattingEnabled {
|
||||||
wysiwygViewModel.setAtRoomMention()
|
wysiwygViewModel.setAtRoomMention()
|
||||||
} else {
|
} else {
|
||||||
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
|
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
|
||||||
mentionBuilder.handleAllUsersMention(for: attributedString, in: suggestion.range)
|
mentionBuilder.handleAllUsersMention(for: attributedString, in: suggestion.range)
|
||||||
state.bindings.plainComposerText = attributedString
|
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()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
ZStack {
|
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)
|
.readFrame($prototypeListItemFrame)
|
||||||
.hidden()
|
.hidden()
|
||||||
if showBackgroundShadow {
|
if showBackgroundShadow {
|
||||||
@ -110,15 +110,15 @@ private struct BackgroundView<Content: View>: View {
|
|||||||
|
|
||||||
struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview {
|
struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview {
|
||||||
static let multipleItems: [SuggestionItem] = (0...10).map { index in
|
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 {
|
static var previews: some View {
|
||||||
// Putting them is VStack allows the preview to work properly in tests
|
// Putting them is VStack allows the preview to work properly in tests
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()),
|
CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
|
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()))]) { _ in }
|
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: ""))]) { _ in }
|
||||||
}
|
}
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()),
|
CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
|
@ -154,6 +154,7 @@ struct ComposerToolbar: View {
|
|||||||
private var messageComposer: some View {
|
private var messageComposer: some View {
|
||||||
MessageComposer(plainComposerText: $context.plainComposerText,
|
MessageComposer(plainComposerText: $context.plainComposerText,
|
||||||
presendCallback: $context.presendCallback,
|
presendCallback: $context.presendCallback,
|
||||||
|
selectedRange: $context.selectedRange,
|
||||||
composerView: composerView,
|
composerView: composerView,
|
||||||
mode: context.viewState.composerMode,
|
mode: context.viewState.composerMode,
|
||||||
composerFormattingEnabled: context.composerFormattingEnabled,
|
composerFormattingEnabled: context.composerFormattingEnabled,
|
||||||
@ -199,6 +200,9 @@ struct ComposerToolbar: View {
|
|||||||
.onChange(of: context.composerFormattingEnabled) {
|
.onChange(of: context.composerFormattingEnabled) {
|
||||||
context.send(viewAction: .didToggleFormattingOptions)
|
context.send(viewAction: .didToggleFormattingOptions)
|
||||||
}
|
}
|
||||||
|
.onChange(of: context.selectedRange) {
|
||||||
|
context.send(viewAction: .selectedTextChanged)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
composerFocused = context.composerFocused
|
composerFocused = context.composerFocused
|
||||||
}
|
}
|
||||||
@ -307,8 +311,8 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
|||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||||
analyticsService: ServiceLocator.shared.analytics,
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
composerDraftService: ComposerDraftServiceMock())
|
composerDraftService: ComposerDraftServiceMock())
|
||||||
static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, 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()))]
|
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: ""))]
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ComposerToolbar.mock(focused: true)
|
ComposerToolbar.mock(focused: true)
|
||||||
|
@ -38,7 +38,7 @@ struct MentionSuggestionItemView_Previews: PreviewProvider, TestablePreview {
|
|||||||
static let mockMediaProvider = MediaProviderMock(configuration: .init())
|
static let mockMediaProvider = MediaProviderMock(configuration: .init())
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: .mockMXCUserAvatar, 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()))
|
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 {
|
struct MessageComposer: View {
|
||||||
@Binding var plainComposerText: NSAttributedString
|
@Binding var plainComposerText: NSAttributedString
|
||||||
@Binding var presendCallback: (() -> Void)?
|
@Binding var presendCallback: (() -> Void)?
|
||||||
|
@Binding var selectedRange: NSRange
|
||||||
let composerView: WysiwygComposerView
|
let composerView: WysiwygComposerView
|
||||||
let mode: ComposerMode
|
let mode: ComposerMode
|
||||||
let composerFormattingEnabled: Bool
|
let composerFormattingEnabled: Bool
|
||||||
@ -66,6 +67,7 @@ struct MessageComposer: View {
|
|||||||
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||||
text: $plainComposerText,
|
text: $plainComposerText,
|
||||||
presendCallback: $presendCallback,
|
presendCallback: $presendCallback,
|
||||||
|
selectedRange: $selectedRange,
|
||||||
maxHeight: ComposerConstant.maxHeight,
|
maxHeight: ComposerConstant.maxHeight,
|
||||||
keyHandler: { handleKeyPress($0) },
|
keyHandler: { handleKeyPress($0) },
|
||||||
pasteHandler: pasteAction)
|
pasteHandler: pasteAction)
|
||||||
@ -285,6 +287,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
|
|||||||
|
|
||||||
return MessageComposer(plainComposerText: .constant(content),
|
return MessageComposer(plainComposerText: .constant(content),
|
||||||
presendCallback: .constant(nil),
|
presendCallback: .constant(nil),
|
||||||
|
selectedRange: .constant(NSRange(location: 0, length: 0)),
|
||||||
composerView: composerView,
|
composerView: composerView,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
composerFormattingEnabled: false,
|
composerFormattingEnabled: false,
|
||||||
|
@ -12,6 +12,7 @@ struct MessageComposerTextField: View {
|
|||||||
let placeholder: String
|
let placeholder: String
|
||||||
@Binding var text: NSAttributedString
|
@Binding var text: NSAttributedString
|
||||||
@Binding var presendCallback: (() -> Void)?
|
@Binding var presendCallback: (() -> Void)?
|
||||||
|
@Binding var selectedRange: NSRange
|
||||||
|
|
||||||
let maxHeight: CGFloat
|
let maxHeight: CGFloat
|
||||||
let keyHandler: GenericKeyHandler
|
let keyHandler: GenericKeyHandler
|
||||||
@ -20,6 +21,7 @@ struct MessageComposerTextField: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
UITextViewWrapper(text: $text,
|
UITextViewWrapper(text: $text,
|
||||||
presendCallback: $presendCallback,
|
presendCallback: $presendCallback,
|
||||||
|
selectedRange: $selectedRange,
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
keyHandler: keyHandler,
|
keyHandler: keyHandler,
|
||||||
pasteHandler: pasteHandler)
|
pasteHandler: pasteHandler)
|
||||||
@ -54,6 +56,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
@Binding var text: NSAttributedString
|
@Binding var text: NSAttributedString
|
||||||
@Binding var presendCallback: (() -> Void)?
|
@Binding var presendCallback: (() -> Void)?
|
||||||
|
@Binding var selectedRange: NSRange
|
||||||
|
|
||||||
let maxHeight: CGFloat
|
let maxHeight: CGFloat
|
||||||
|
|
||||||
@ -135,12 +138,15 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
// moves the caret back to the bottom of the composer.
|
// moves the caret back to the bottom of the composer.
|
||||||
// https://github.com/element-hq/element-x-ios/issues/3104
|
// https://github.com/element-hq/element-x-ios/issues/3104
|
||||||
textView.selectedTextRange = selection
|
textView.selectedTextRange = selection
|
||||||
|
} else {
|
||||||
|
textView.selectedRange = selectedRange
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(text: $text,
|
Coordinator(text: $text,
|
||||||
|
selectedRange: $selectedRange,
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
keyHandler: keyHandler,
|
keyHandler: keyHandler,
|
||||||
pasteHandler: pasteHandler)
|
pasteHandler: pasteHandler)
|
||||||
@ -148,6 +154,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
|
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
|
||||||
private var text: Binding<NSAttributedString>
|
private var text: Binding<NSAttributedString>
|
||||||
|
private var selectedRange: Binding<NSRange>
|
||||||
|
|
||||||
private let maxHeight: CGFloat
|
private let maxHeight: CGFloat
|
||||||
|
|
||||||
@ -155,10 +162,12 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
private let pasteHandler: PasteHandler
|
private let pasteHandler: PasteHandler
|
||||||
|
|
||||||
init(text: Binding<NSAttributedString>,
|
init(text: Binding<NSAttributedString>,
|
||||||
|
selectedRange: Binding<NSRange>,
|
||||||
maxHeight: CGFloat,
|
maxHeight: CGFloat,
|
||||||
keyHandler: @escaping GenericKeyHandler,
|
keyHandler: @escaping GenericKeyHandler,
|
||||||
pasteHandler: @escaping PasteHandler) {
|
pasteHandler: @escaping PasteHandler) {
|
||||||
self.text = text
|
self.text = text
|
||||||
|
self.selectedRange = selectedRange
|
||||||
self.maxHeight = maxHeight
|
self.maxHeight = maxHeight
|
||||||
self.keyHandler = keyHandler
|
self.keyHandler = keyHandler
|
||||||
self.pasteHandler = pasteHandler
|
self.pasteHandler = pasteHandler
|
||||||
@ -179,6 +188,14 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) {
|
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) {
|
||||||
pasteHandler(provider)
|
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",
|
MessageComposerTextField(placeholder: "Placeholder",
|
||||||
text: $text,
|
text: $text,
|
||||||
presendCallback: .constant(nil),
|
presendCallback: .constant(nil),
|
||||||
|
selectedRange: .constant(NSRange(location: 0, length: 0)),
|
||||||
maxHeight: 300,
|
maxHeight: 300,
|
||||||
keyHandler: { _ in },
|
keyHandler: { _ in },
|
||||||
pasteHandler: { _ in })
|
pasteHandler: { _ in })
|
||||||
|
@ -31,7 +31,7 @@ final class CompletionSuggestionServiceTests: XCTestCase {
|
|||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
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()))
|
service.setSuggestionTrigger(.init(type: .user, text: "ali", range: .init()))
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
@ -68,13 +68,13 @@ final class CompletionSuggestionServiceTests: XCTestCase {
|
|||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
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()))
|
service.setSuggestionTrigger(.init(type: .user, text: "ro", range: .init()))
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
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()))
|
service.setSuggestionTrigger(.init(type: .user, text: "every", range: .init()))
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
@ -94,17 +94,68 @@ final class CompletionSuggestionServiceTests: XCTestCase {
|
|||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
||||||
suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil)),
|
suggestions == [.allUsers(item: .allUsersMention(roomAvatar: nil, rawSuggestionText: "")),
|
||||||
.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL, range: .init())),
|
.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()))]
|
.user(item: .init(id: bob.userID, displayName: bob.displayName, avatarURL: bob.avatarURL, range: .init(), rawSuggestionText: ""))]
|
||||||
}
|
}
|
||||||
service.setSuggestionTrigger(.init(type: .user, text: "", range: .init()))
|
service.setSuggestionTrigger(.init(type: .user, text: "", range: .init()))
|
||||||
try await deferred.fulfill()
|
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 {
|
private extension MentionSuggestionItem {
|
||||||
static func allUsersMention(roomAvatar: URL?) -> Self {
|
static func allUsersMention(roomAvatar: URL?, rawSuggestionText: String) -> Self {
|
||||||
MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar, range: .init())
|
MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar, range: .init(), rawSuggestionText: rawSuggestionText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -91,8 +91,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSuggestions() {
|
func testSuggestions() {
|
||||||
let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, 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()))]
|
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCAvatar, range: .init(), rawSuggestionText: ""))]
|
||||||
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions))
|
let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions))
|
||||||
|
|
||||||
viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
|
||||||
@ -117,7 +117,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSelectedUserSuggestion() {
|
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))
|
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
|
// 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)
|
viewModel.context.send(viewAction: .composerAppeared)
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
let userID = "@test:matrix.org"
|
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))
|
viewModel.context.send(viewAction: .selectedSuggestion(suggestion))
|
||||||
|
|
||||||
let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment
|
let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment
|
||||||
@ -773,6 +773,6 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
private extension MentionSuggestionItem {
|
private extension MentionSuggestionItem {
|
||||||
static func allUsersMention(roomAvatar: URL?) -> Self {
|
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: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user