Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Room mentioning in the composer #3868

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
mediaProvider: userSession.mediaProvider)
self.timelineController = timelineController

let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy)
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy,
roomListPublisher: userSession.clientProxy.staticRoomSummaryProvider?.roomListPublisher.eraseToAnyPublisher() ?? Empty().replaceEmpty(with: []).eraseToAnyPublisher())
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)

let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy,
Expand Down
2 changes: 1 addition & 1 deletion ElementX/Sources/Mocks/RoomSummaryProviderMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ extension Array where Element == RoomSummary {
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: .mute,
canonicalAlias: nil,
canonicalAlias: "#prelude-foundation:matrix.org",
alternativeAliases: [],
hasOngoingCall: true,
isMarkedUnread: false,
Expand Down
3 changes: 3 additions & 0 deletions ElementX/Sources/Other/Avatars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ enum RoomAvatarSizeOnScreen {
case notificationSettings
case roomDirectorySearch
case joinRoom
case suggestions

var value: CGFloat {
switch self {
Expand All @@ -142,6 +143,8 @@ enum RoomAvatarSizeOnScreen {
return 32
case .roomDirectorySearch:
return 32
case .suggestions:
return 32
case .messageForwarding:
return 36
case .globalSearch:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
case .room(let roomID):
mentionBuilder.handleRoomIDMention(for: attributedString, in: range, url: url, roomID: roomID)
case .roomAlias(let alias):
mentionBuilder.handleRoomAliasMention(for: attributedString, in: range, url: url, roomAlias: alias)
mentionBuilder.handleRoomAliasMention(for: attributedString, in: range, url: url, roomAlias: alias, roomDisplayName: nil)
case .eventOnRoomId(let roomID, let eventID):
mentionBuilder.handleEventOnRoomIDMention(for: attributedString, in: range, url: url, eventID: eventID, roomID: roomID)
case .eventOnRoomAlias(let alias, let eventID):
Expand Down Expand Up @@ -356,6 +356,7 @@ extension NSAttributedString.Key {
static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name)
static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name)
static let MatrixUserDisplayName: NSAttributedString.Key = .init(rawValue: UserDisplayNameAttribute.name)
static let MatrixRoomDisplayName: NSAttributedString.Key = .init(rawValue: RoomDisplayNameAttribute.name)
static let MatrixRoomID: NSAttributedString.Key = .init(rawValue: RoomIDAttribute.name)
static let MatrixRoomAlias: NSAttributedString.Key = .init(rawValue: RoomAliasAttribute.name)
static let MatrixEventOnRoomID: NSAttributedString.Key = .init(rawValue: EventOnRoomIDAttribute.name)
Expand All @@ -366,7 +367,7 @@ extension NSAttributedString.Key {
protocol MentionBuilderProtocol {
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String, userDisplayName: String?)
func handleRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomID: String)
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String)
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String, roomDisplayName: String?)
func handleEventOnRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomAlias: String)
func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String)
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ enum UserDisplayNameAttribute: AttributedStringKey {
static var name = "MXUserDisplayNameAttribute"
}

enum RoomDisplayNameAttribute: AttributedStringKey {
typealias Value = String
static var name = "MXRoomDisplayNameAttribute"
}

enum RoomIDAttribute: AttributedStringKey {
typealias Value = String
static var name = "MXRoomIDAttribute"
Expand Down Expand Up @@ -64,6 +69,7 @@ extension AttributeScopes {

let userID: UserIDAttribute
let userDisplayName: UserDisplayNameAttribute
let roomDisplayName: RoomDisplayNameAttribute
let roomID: RoomIDAttribute
let roomAlias: RoomAliasAttribute
let eventOnRoomID: EventOnRoomIDAttribute
Expand Down
14 changes: 12 additions & 2 deletions ElementX/Sources/Other/Pills/MentionBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,22 @@ struct MentionBuilder: MentionBuilderProtocol {
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)

setPillAttachment(attachment: attachment,
attributedString: attributedString,
in: range,
with: attachmentAttributes)
}

func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String) {
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String, roomDisplayName: String?) {
let attributesToRestore = getAttributesToRestore(for: attributedString, in: range)

let attachmentData = PillTextAttachmentData(type: .roomAlias(roomAlias), font: attributesToRestore.font)
guard let attachment = PillTextAttachment(attachmentData: attachmentData) else {
attributedString.addAttribute(.MatrixRoomAlias, value: roomAlias, range: range)
if let roomDisplayName {
attributedString.addAttribute(.MatrixRoomDisplayName, value: roomDisplayName, range: range)
}
return
}

Expand All @@ -100,6 +103,7 @@ struct MentionBuilder: MentionBuilderProtocol {
.font: attributesToRestore.font,
.foregroundColor: attributesToRestore.foregroundColor]
attachmentAttributes.addBlockquoteIfNeeded(attributesToRestore.blockquote)
attachmentAttributes.addMatrixRoomNameIfNeeded(roomDisplayName)

setPillAttachment(attachment: attachment,
attributedString: attributedString,
Expand Down Expand Up @@ -180,4 +184,10 @@ private extension Dictionary where Key == NSAttributedString.Key, Value == Any {
self[.MatrixUserDisplayName] = value
}
}

mutating func addMatrixRoomNameIfNeeded(_ value: String?) {
if let value {
self[.MatrixRoomDisplayName] = value
}
}
}
2 changes: 1 addition & 1 deletion ElementX/Sources/Other/Pills/PlainMentionBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct PlainMentionBuilder: MentionBuilderProtocol {

func handleEventOnRoomIDMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, eventID: String, roomID: String) { }

func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String) { }
func handleRoomAliasMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, roomAlias: String, roomDisplayName: String?) { }

func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,45 +22,23 @@ 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 []
}

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
Expand Down Expand Up @@ -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 {
Expand All @@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,70 @@ import WysiwygComposer
struct SuggestionTrigger: Equatable {
enum SuggestionType: Equatable {
case user
case room
}

let type: SuggestionType
let text: String
let range: NSRange
}

enum SuggestionItem: Identifiable, Equatable {
case user(item: MentionSuggestionItem)
case allUsers(item: MentionSuggestionItem)
struct SuggestionItem: Identifiable, Equatable {
enum SuggestionType: Equatable {
case user(User)
case allUsers(RoomAvatar)
case room(Room)
}

struct User: 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 self {
switch suggestionType {
case .user(let user):
return user.id
user.id
case .allUsers:
return PillConstants.atRoom
PillConstants.atRoom
case .room(let room):
room.id
}
}

var range: NSRange {
switch self {
case .user(let item), .allUsers(let item):
return item.range
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
}
}
}

struct MentionSuggestionItem: Identifiable, Equatable {
let id: String
let displayName: String?
let avatarURL: URL?
let range: NSRange
let rawSuggestionText: String
}

// sourcery: AutoMockable
Expand All @@ -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
}
Expand Down
Loading
Loading