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())
}
}
@@ -12,6 +12,7 @@ import WysiwygComposer
struct SuggestionTrigger: Equatable {
enum SuggestionType: Equatable {
case user
case room
}
let type: SuggestionType
@@ -19,33 +20,62 @@ struct SuggestionTrigger: Equatable {
let range: NSRange
}
enum SuggestionItem: Identifiable, Equatable {
case user(item: MentionSuggestionItem)
case allUsers(item: MentionSuggestionItem)
var id: String {
switch self {
case .user(let user):
return user.id
case .allUsers:
return PillConstants.atRoom
}
struct SuggestionItem: Identifiable, Equatable {
enum SuggestionType: Equatable {
case user(User)
case allUsers(RoomAvatar)
case room(Room)
}
var range: NSRange {
switch self {
case .user(let item), .allUsers(let item):
return item.range
}
struct User: Equatable {
let id: String
let displayName: String?
let avatarURL: URL?
}
}
struct MentionSuggestionItem: Identifiable, Equatable {
let id: String
let displayName: String?
let avatarURL: URL?
struct Room: Equatable {
let id: String
let canonicalAlias: String
let name: String
let avatar: RoomAvatar
}
let suggestionType: SuggestionType
let range: NSRange
let rawSuggestionText: String
var id: String {
switch suggestionType {
case .user(let user):
user.id
case .allUsers:
PillConstants.atRoom
case .room(let room):
room.id
}
}
var displayName: String {
switch suggestionType {
case .allUsers:
return PillConstants.everyone
case .user(let user):
return user.displayName ?? user.id
case .room(let room):
return room.name
}
}
var subtitle: String? {
switch suggestionType {
case .allUsers:
return nil
case .user(let user):
return user.displayName == nil ? nil : user.id
case .room(let room):
return room.canonicalAlias
}
}
}
// sourcery: AutoMockable
@@ -62,6 +92,8 @@ extension WysiwygComposer.SuggestionPattern {
switch key {
case .at:
return SuggestionTrigger(type: .user, text: text, range: .init(location: Int(start), length: Int(end)))
case .hash:
return SuggestionTrigger(type: .room, text: text, range: .init(location: Int(start), length: Int(end)))
default:
return nil
}
@@ -378,22 +378,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
shouldMakeAnotherPass = false
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, stop in
guard let value else { return }
shouldMakeAnotherPass = true
// Remove the attribute so it doesn't get inherited by the new string
attributedString.removeAttribute(.link, range: range)
guard let userID = attributedString.attribute(.MatrixUserID, at: range.location, effectiveRange: nil) as? String else {
if let userID = attributedString.attribute(.MatrixUserID, at: range.location, effectiveRange: nil) as? String {
let displayName = attributedString.attribute(.MatrixUserDisplayName, at: range.location, effectiveRange: nil)
attributedString.replaceCharacters(in: range, with: "[\(displayName ?? userID)](\(value))")
userIDs.insert(userID)
stop.pointee = true
} else if let roomAlias = attributedString.attribute(.MatrixRoomAlias, at: range.location, effectiveRange: nil) as? String {
let displayName = attributedString.attribute(.MatrixRoomDisplayName, at: range.location, effectiveRange: nil)
attributedString.replaceCharacters(in: range, with: "[\(displayName ?? roomAlias)](\(value))")
stop.pointee = true
} else {
return
}
let displayName = attributedString.attribute(.MatrixUserDisplayName, at: range.location, effectiveRange: nil)
attributedString.replaceCharacters(in: range, with: "[\(displayName ?? userID)](\(value))")
userIDs.insert(userID)
stop.pointee = true
}
} while shouldMakeAnotherPass
@@ -473,23 +474,24 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
private func handleSuggestion(_ suggestion: SuggestionItem) {
switch suggestion {
case let .user(item):
guard let url = try? URL(string: matrixToUserPermalink(userId: item.id)) else {
switch suggestion.suggestionType {
case let .user(user):
guard let url = try? URL(string: matrixToUserPermalink(userId: user.id)) else {
MXLog.error("Could not build user permalink")
return
}
if context.composerFormattingEnabled {
wysiwygViewModel.setMention(url: url.absoluteString, name: item.id, mentionType: .user)
wysiwygViewModel.setMention(url: url.absoluteString, name: user.id, mentionType: .user)
} else {
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: user.id, userDisplayName: user.displayName)
state.bindings.plainComposerText = attributedString
let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - item.rawSuggestionText.count, length: 0)
let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - suggestion.rawSuggestionText.count, length: 0)
state.bindings.selectedRange = newSelectedRange
}
case let .allUsers(item):
case .allUsers:
if context.composerFormattingEnabled {
wysiwygViewModel.setAtRoomMention()
} else {
@@ -497,7 +499,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
mentionBuilder.handleAllUsersMention(for: attributedString, in: suggestion.range)
state.bindings.plainComposerText = attributedString
let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - item.rawSuggestionText.count, length: 0)
let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - suggestion.rawSuggestionText.count, length: 0)
state.bindings.selectedRange = newSelectedRange
}
case let .room(room):
guard let url = try? URL(string: matrixToRoomAliasPermalink(roomAlias: room.canonicalAlias)) else {
MXLog.error("Could not build alias permalink")
return
}
if context.composerFormattingEnabled {
wysiwygViewModel.setMention(url: url.absoluteString, name: room.name, mentionType: .room)
} else {
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
mentionBuilder.handleRoomAliasMention(for: attributedString, in: suggestion.range, url: url, roomAlias: room.canonicalAlias, roomDisplayName: room.name)
state.bindings.plainComposerText = attributedString
let newSelectedRange = NSRange(location: state.bindings.selectedRange.location - suggestion.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(), rawSuggestionText: ""))
MentionSuggestionItemView(mediaProvider: nil, item: .init(suggestionType: .user(.init(id: "", displayName: nil, avatarURL: nil)), range: .init(), rawSuggestionText: ""))
.readFrame($prototypeListItemFrame)
.hidden()
if showBackgroundShadow {
@@ -52,10 +52,7 @@ struct CompletionSuggestionView: View {
Button {
onTap(item)
} label: {
switch item {
case .user(let mention), .allUsers(let mention):
MentionSuggestionItemView(mediaProvider: mediaProvider, item: mention)
}
MentionSuggestionItemView(mediaProvider: mediaProvider, item: item)
}
.modifier(ListItemPaddingModifier(isFirst: items.first?.id == item.id))
.listRowInsets(.init(top: 0, leading: Constants.leadingPadding, bottom: 0, trailing: 0))
@@ -110,16 +107,17 @@ 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(), rawSuggestionText: ""))
.init(suggestionType: .user(.init(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(), rawSuggestionText: "")),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: ""))]) { _ in }
items: [.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""),
.init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar)), range: .init(), rawSuggestionText: "")]) { _ in }
}
VStack(spacing: 8) {
CompletionSuggestionView(mediaProvider: MediaProviderMock(configuration: .init()),
items: multipleItems) { _ in }
@@ -321,8 +321,11 @@ 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(), rawSuggestionText: "")),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar, range: .init(), rawSuggestionText: ""))]
static let suggestions: [SuggestionItem] = [
.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""),
.init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar)), range: .init(), rawSuggestionText: "")
]
static var previews: some View {
ComposerToolbar.mock(focused: true)
@@ -9,22 +9,25 @@ import SwiftUI
struct MentionSuggestionItemView: View {
let mediaProvider: MediaProviderProtocol?
let item: MentionSuggestionItem
let item: SuggestionItem
var body: some View {
HStack(alignment: .center, spacing: 16) {
LoadableAvatarImage(url: item.avatarURL,
name: item.displayName,
contentID: item.id,
avatarSize: .user(on: .suggestions),
mediaProvider: mediaProvider)
switch item.suggestionType {
case .user(let user):
LoadableAvatarImage(url: user.avatarURL, name: user.displayName, contentID: user.id, avatarSize: .user(on: .completionSuggestions), mediaProvider: mediaProvider)
case .allUsers(let avatar):
RoomAvatarImage(avatar: avatar, avatarSize: .room(on: .completionSuggestions), mediaProvider: mediaProvider)
case .room(let room):
RoomAvatarImage(avatar: room.avatar, avatarSize: .room(on: .completionSuggestions), mediaProvider: mediaProvider)
}
VStack(alignment: .leading, spacing: 0) {
Text(item.displayName ?? item.id)
Text(item.displayName)
.font(.compound.bodyLG)
.foregroundColor(.compound.textPrimary)
.lineLimit(1)
if item.displayName != nil {
Text(item.id)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
@@ -38,7 +41,20 @@ 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(), rawSuggestionText: ""))
MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil, range: .init(), rawSuggestionText: ""))
MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(suggestionType: .user(.init(id: "test", displayName: "Test", avatarURL: .mockMXCUserAvatar)), range: .init(), rawSuggestionText: ""))
.previewDisplayName("User")
MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(suggestionType: .user(.init(id: "test2", displayName: nil, avatarURL: nil)), range: .init(), rawSuggestionText: ""))
.previewDisplayName("User no display name")
MentionSuggestionItemView(mediaProvider: mockMediaProvider, item: .init(suggestionType: .allUsers(.room(id: "room", name: "Room", avatarURL: .mockMXCAvatar)), range: .init(), rawSuggestionText: ""))
.previewDisplayName("All users")
MentionSuggestionItemView(mediaProvider: mockMediaProvider,
item: .init(suggestionType: .room(.init(id: "room",
canonicalAlias: "#room:matrix.org",
name: "Room",
avatar: .room(id: "room",
name: "Room", avatarURL: .mockMXCAvatar))),
range: .init(),
rawSuggestionText: ""))
.previewDisplayName("Room")
}
}