Plain composer suggestions and pills (#2751)

* Move ComposerToolbar files to withing the RoomScreen folder (as it's not a screen on its own)

* Switch the plain composer to NSAttributedStrings

* Enable suggestions on the plain composer

* Introduce a new `MatrixUserDisplayName` attributed string attributed and use it for mention building

* Implement mention pill rendering and sending on the plain message composer

* Fix plain composer snapshot tests

* Fix broken formatting options layout

* Add clarifying comment.
This commit is contained in:
Stefan Ceriu
2024-05-03 12:06:16 +03:00
committed by GitHub
parent d067e844da
commit 38416dbb5c
27 changed files with 418 additions and 232 deletions
@@ -0,0 +1,130 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
private enum SuggestionTriggerPattern: Character {
case at = "@"
}
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
private let roomProxy: RoomProxyProtocol
private var canMentionAllUsers = false
private(set) var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> = Empty().eraseToAnyPublisher()
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionTrigger?, Never>(nil)
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
suggestionsPublisher = suggestionTriggerSubject
.combineLatest(roomProxy.membersPublisher)
.map { [weak self, ownUserID = roomProxy.ownUserID] suggestionTrigger, members -> [SuggestionItem] in
guard let self,
let suggestionTrigger else {
return []
}
switch suggestionTrigger.type {
case .user:
var membersSuggestion = members
.compactMap { member -> SuggestionItem? in
guard member.userID != ownUserID,
member.membership == .join,
Self.shouldIncludeMember(userID: member.userID, displayName: member.displayName, searchText: suggestionTrigger.text) else {
return nil
}
return SuggestionItem.user(item: .init(id: member.userID,
displayName: member.displayName,
avatarURL: member.avatarURL,
range: suggestionTrigger.range))
}
if self.canMentionAllUsers,
!self.roomProxy.isEncryptedOneToOneRoom,
Self.shouldIncludeMember(userID: PillConstants.atRoom, displayName: PillConstants.everyone, searchText: suggestionTrigger.text) {
membersSuggestion
.insert(SuggestionItem.allUsers(item: .init(id: PillConstants.atRoom,
displayName: PillConstants.everyone,
avatarURL: self.roomProxy.avatarURL,
range: suggestionTrigger.range)), at: 0)
}
return membersSuggestion
}
}
// We only debounce if the suggestion is nil
.debounceAndRemoveDuplicates(on: DispatchQueue.main) { [weak self] _ in
self?.suggestionTriggerSubject.value != nil ? .milliseconds(500) : .milliseconds(0)
}
Task {
switch await roomProxy.canUserTriggerRoomNotification(userID: roomProxy.ownUserID) {
case .success(let value):
canMentionAllUsers = value
case .failure:
canMentionAllUsers = false
}
}
}
func processTextMessage(_ textMessage: String?) {
setSuggestionTrigger(detectTriggerInText(textMessage))
}
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) {
suggestionTriggerSubject.value = suggestionTrigger
}
// MARK: - Private
private func detectTriggerInText(_ text: String?) -> SuggestionTrigger? {
guard let text else {
return nil
}
let components = text.components(separatedBy: .whitespaces)
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))
}
private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool {
// If the search text is empty give back all the results
guard !searchText.isEmpty else {
return true
}
let containedInUserID = userID.localizedStandardContains(searchText.lowercased())
let containedInDisplayName: Bool
if let displayName {
containedInDisplayName = displayName.localizedStandardContains(searchText.lowercased())
} else {
containedInDisplayName = false
}
return containedInUserID || containedInDisplayName
}
}