Room mentioning in the composer (#3868)

* refactored the suggestion item structure

to scale with the room pill

* Implemented a way for the rooms

to appear in the suggestions view for the RTE, however I need to add the pills to the composer and the compatibility with the plain text composer

* small code correction

* fix

* fixed a bug where the suggestion wasn't returning

the right suggestion type and the suggestion text properly

* implementation done!

also updated some tests, but we need more of them

* updated toolbar view model tests

* updated tests

* updated preview tests

* renamed the Avatars case for the suggestions
This commit is contained in:
Mauro
2025-03-06 11:32:37 +01:00
committed by GitHub
parent 2d77a1b10b
commit 5f59e867b5
43 changed files with 564 additions and 180 deletions
@@ -9,7 +9,10 @@ import Combine
import Foundation
private enum SuggestionTriggerRegex {
static let at = /@\w+/
static let atOrHash = /[@#]\w*/
static let at: Character = "@"
static let hash: Character = "#"
}
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
@@ -19,12 +22,13 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionTrigger?, Never>(nil)
init(roomProxy: JoinedRoomProxyProtocol) {
init(roomProxy: JoinedRoomProxyProtocol,
roomListPublisher: AnyPublisher<[RoomSummary], Never>) {
self.roomProxy = roomProxy
suggestionsPublisher = suggestionTriggerSubject
.combineLatest(roomProxy.membersPublisher)
.map { [weak self, ownUserID = roomProxy.ownUserID] suggestionTrigger, members -> [SuggestionItem] in
.combineLatest(roomProxy.membersPublisher, roomListPublisher)
.map { [weak self, ownUserID = roomProxy.ownUserID] suggestionTrigger, members, roomSummaries -> [SuggestionItem] in
guard let self,
let suggestionTrigger else {
return []
@@ -32,32 +36,9 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
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,
rawSuggestionText: suggestionTrigger.text))
}
if self.canMentionAllUsers,
!self.roomProxy.isDirectOneToOneRoom,
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.infoPublisher.value.avatarURL,
range: suggestionTrigger.range,
rawSuggestionText: suggestionTrigger.text)), at: 0)
}
return membersSuggestion
return membersSuggestions(suggestionTrigger: suggestionTrigger, members: members, ownUserID: ownUserID)
case .room:
return roomSuggestions(suggestionTrigger: suggestionTrigger, roomSummaries: roomSummaries)
}
}
// We only debounce if the suggestion is nil
@@ -85,25 +66,71 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
// MARK: - Private
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
private func membersSuggestions(suggestionTrigger: SuggestionTrigger,
members: [RoomMemberProxyProtocol],
ownUserID: String) -> [SuggestionItem] {
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 .init(suggestionType: .user(.init(id: member.userID, displayName: member.displayName, avatarURL: member.avatarURL)), range: suggestionTrigger.range, rawSuggestionText: suggestionTrigger.text)
}
if canMentionAllUsers,
!roomProxy.isDirectOneToOneRoom,
Self.shouldIncludeMember(userID: PillConstants.atRoom, displayName: PillConstants.everyone, searchText: suggestionTrigger.text) {
membersSuggestion
.insert(SuggestionItem(suggestionType: .allUsers(roomProxy.details.avatar), range: suggestionTrigger.range, rawSuggestionText: suggestionTrigger.text), at: 0)
}
return membersSuggestion
}
private func roomSuggestions(suggestionTrigger: SuggestionTrigger,
roomSummaries: [RoomSummary]) -> [SuggestionItem] {
roomSummaries
.compactMap { roomSummary -> SuggestionItem? in
guard let canonicalAlias = roomSummary.canonicalAlias,
Self.shouldIncludeRoom(roomName: roomSummary.name, roomAlias: canonicalAlias, searchText: suggestionTrigger.text) else {
return nil
}
return .init(suggestionType: .room(.init(id: roomSummary.id,
canonicalAlias: canonicalAlias,
name: roomSummary.name,
avatar: roomSummary.avatar)),
range: suggestionTrigger.range, rawSuggestionText: suggestionTrigger.text)
}
}
private func detectTriggerInText(_ text: String, selectedRange: NSRange) -> SuggestionTrigger? {
let matches = text.matches(of: SuggestionTriggerRegex.atOrHash)
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
}
var suggestionText = String(text[match.range])
suggestionText.removeFirst()
let firstChar = suggestionText.removeFirst()
return .init(type: .user, text: suggestionText, range: NSRange(match.range, in: text))
switch firstChar {
case SuggestionTriggerRegex.at:
return .init(type: .user, text: suggestionText, range: NSRange(match.range, in: text))
case SuggestionTriggerRegex.hash:
return .init(type: .room, text: suggestionText, range: NSRange(match.range, in: text))
default:
return nil
}
}
private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool {
@@ -122,4 +149,12 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
return containedInUserID || containedInDisplayName
}
private static func shouldIncludeRoom(roomName: String, roomAlias: String, searchText: String) -> Bool {
// If the search text is empty give back all the results
guard !searchText.isEmpty else {
return true
}
return roomName.localizedStandardContains(searchText.lowercased()) || roomAlias.localizedStandardContains(searchText.lowercased())
}
}