From 3cdbc26aed968dbfd87cbaa096f14254652a5193 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 19 Apr 2023 10:45:40 +0200 Subject: [PATCH 01/15] Add basic slash commands support to UserSuggestion module --- Riot/Modules/Room/RoomViewController.m | 8 ++ Riot/Modules/Room/RoomViewController.swift | 16 ++++ .../WysiwygInputToolbarView.swift | 4 + .../UserSuggestionCoordinator.swift | 42 ++++++++- .../UserSuggestionCoordinatorBridge.swift | 5 + .../Service/UserSuggestionService.swift | 93 ++++++++++++++----- .../UserSuggestionServiceProtocol.swift | 11 ++- .../UserSuggestion/UserSuggestionModels.swift | 16 +++- .../UserSuggestionScreenState.swift | 13 ++- .../UserSuggestionViewModel.swift | 18 +++- .../View/UserSuggestionList.swift | 17 ++-- .../View/UserSuggestionListItem.swift | 43 +++++---- 12 files changed, 226 insertions(+), 60 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 70b8d974c9..57a431d2ed 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -8076,6 +8076,14 @@ - (void)userSuggestionCoordinatorBridgeDidRequestMentionForRoom:(UserSuggestionC [self.inputToolbarView pasteText:[UserSuggestionID.room stringByAppendingString:@" "]]; } +- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator + didRequestCommand:(NSString *)command + textTrigger:(NSString *)textTrigger +{ + [self removeTriggerTextFromComposer:textTrigger]; + [self setCommand:command]; +} + - (void)removeTriggerTextFromComposer:(NSString *)textTrigger { RoomInputToolbarView *toolbar = (RoomInputToolbarView *)self.inputToolbarView; diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 3fec13de94..c94111be44 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -58,6 +58,22 @@ extension RoomViewController { } } + @objc func setCommand(_ command: String) { + if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled { + wysiwygInputToolbar.command(command) + wysiwygInputToolbar.becomeFirstResponder() + } else { + guard let attributedText = inputToolbarView.attributedTextMessage else { return } + + let newAttributedString = NSMutableAttributedString(attributedString: attributedText) + newAttributedString.append(NSAttributedString(string: "\(command) ", + attributes: [.font: inputToolbarView.defaultFont])) + + inputToolbarView.attributedTextMessage = newAttributedString + inputToolbarView.becomeFirstResponder() + } + } + /// Send the formatted text message and its raw counterpart to the room /// diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index f3fc1111bd..5700909fac 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -195,6 +195,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp name: member.displayname, mentionType: .user) } + + func command(_ command: String) { + self.wysiwygViewModel.setCommand(name: command) + } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index a2156cd89a..1999e6c072 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -23,6 +23,7 @@ import WysiwygComposer protocol UserSuggestionCoordinatorDelegate: AnyObject { func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) + func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) } @@ -52,6 +53,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { private var userSuggestionService: UserSuggestionServiceProtocol private var userSuggestionViewModel: UserSuggestionViewModelProtocol private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider + private var commandProvider: UserSuggestionCoordinatorCommandProvider private var cancellables = Set() @@ -69,7 +71,8 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { self.parameters = parameters roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider) + commandProvider = UserSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) let view = UserSuggestionList(viewModel: viewModel.context) @@ -90,11 +93,11 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { return } - guard let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first else { - return + if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { + self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0 == identifier }).first { + self.delegate?.userSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.userSuggestionService.currentTextTrigger) } - - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) } } @@ -199,3 +202,32 @@ private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderPr roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } } } + +private class UserSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { + private let room: MXRoom + private let userID: String + + var commands: [String] = [] + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + func updateWithPowerLevels() { + // TODO: filter commands in terms of user power level ? + } + + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + self.commands = [ + "/ban", + "/invite", + "/join", + "/me" + ] + + // TODO: get real data + commands(self.commands.map { CommandsProviderCommand(name: $0) }) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index 0d1f6795e6..ba1bc75ca8 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -20,6 +20,7 @@ import Foundation protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject { func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) func userSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinatorBridge, textTrigger: String?) + func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) } @@ -68,6 +69,10 @@ extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { delegate?.userSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) } + func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { + delegate?.userSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) + } + func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { delegate?.userSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift index a790e28458..76d41e700d 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift @@ -24,6 +24,10 @@ struct RoomMembersProviderMember { var avatarUrl: String } +struct CommandsProviderCommand { + var name: String +} + class UserSuggestionID: NSObject { /// A special case added for suggesting `@room` mentions. @objc static let room = "@room" @@ -34,26 +38,35 @@ protocol RoomMembersProviderProtocol { func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) } +protocol CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) +} + struct UserSuggestionServiceItem: UserSuggestionItemProtocol { let userId: String let displayName: String? let avatarUrl: String? } +struct CommandSuggestionServiceItem: CommandSuggestionItemProtocol { + let name: String +} + class UserSuggestionService: UserSuggestionServiceProtocol { // MARK: - Properties // MARK: Private private let roomMemberProvider: RoomMembersProviderProtocol + private let commandProvider: CommandsProviderProtocol - private var suggestionItems: [UserSuggestionItemProtocol] = [] + private var suggestionItems: [SuggestionItem] = [] private let currentTextTriggerSubject = CurrentValueSubject(nil) private var cancellables = Set() // MARK: Public - var items = CurrentValueSubject<[UserSuggestionItemProtocol], Never>([]) + var items = CurrentValueSubject<[SuggestionItem], Never>([]) var currentTextTrigger: String? { currentTextTriggerSubject.value @@ -61,8 +74,11 @@ class UserSuggestionService: UserSuggestionServiceProtocol { // MARK: - Setup - init(roomMemberProvider: RoomMembersProviderProtocol, shouldDebounce: Bool = true) { + init(roomMemberProvider: RoomMembersProviderProtocol, + commandProvider: CommandsProviderProtocol, + shouldDebounce: Bool = true) { self.roomMemberProvider = roomMemberProvider + self.commandProvider = commandProvider if shouldDebounce { currentTextTriggerSubject @@ -83,7 +99,7 @@ class UserSuggestionService: UserSuggestionServiceProtocol { guard let textMessage = textMessage, textMessage.count > 0, let lastComponent = textMessage.components(separatedBy: .whitespaces).last, - lastComponent.prefix(while: { $0 == "@" }).count == 1 // Partial username should start with one and only one "@" character + lastComponent.prefix(while: { $0 == "@" || $0 == "/" }).count == 1 // Partial username should start with one and only one "@" character else { items.send([]) currentTextTriggerSubject.send(nil) @@ -94,13 +110,22 @@ class UserSuggestionService: UserSuggestionServiceProtocol { } func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - guard let suggestionPattern, suggestionPattern.key == .at else { + guard let suggestionPattern else { items.send([]) currentTextTriggerSubject.send(nil) return } - currentTextTriggerSubject.send("@" + suggestionPattern.text) + switch suggestionPattern.key { + case .at: + currentTextTriggerSubject.send("@" + suggestionPattern.text) + case .hash: + // No room suggestion support yet + items.send([]) + currentTextTriggerSubject.send(nil) + case .slash: + currentTextTriggerSubject.send("/" + suggestionPattern.text) + } } // MARK: - Private @@ -109,24 +134,48 @@ class UserSuggestionService: UserSuggestionServiceProtocol { guard var partialName = textTrigger else { return } - - partialName.removeFirst() // remove the '@' prefix - - roomMemberProvider.fetchMembers { [weak self] members in - guard let self = self else { - return + + switch partialName.first { + case "@": + partialName.removeFirst() // remove the '@' prefix + + roomMemberProvider.fetchMembers { [weak self] members in + guard let self = self else { + return + } + + self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in + SuggestionItem.user(value: UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) + } + + self.items.send(self.suggestionItems.filter { item in + guard case let .user(userSuggestion) = item else { return false } + + let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) + let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) + + return (containedInUsername || containedInDisplayName) + }) } - - self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in - UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl) + case "/": + // TODO: send all commands if only text is "/" + partialName.removeFirst() + + commandProvider.fetchCommands { [weak self] commands in + guard let self else { return } + + self.suggestionItems = commands.map { command in + SuggestionItem.command(value: CommandSuggestionServiceItem(name: command.name)) + } + + self.items.send(self.suggestionItems.filter { item in + guard case let .command(commandSuggestion) = item else { return false } + + return commandSuggestion.name.lowercased().contains(partialName.lowercased()) + }) } - - self.items.send(self.suggestionItems.filter { userSuggestion in - let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) - - return (containedInUsername || containedInDisplayName) - }) + default: + return } } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift index 43006dbed9..4b5787cff4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift @@ -24,8 +24,17 @@ protocol UserSuggestionItemProtocol: Avatarable { var avatarUrl: String? { get } } +protocol CommandSuggestionItemProtocol { + var name: String { get } +} + +enum SuggestionItem { + case command(value: CommandSuggestionItemProtocol) + case user(value: UserSuggestionItemProtocol) +} + protocol UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never> { get } + var items: CurrentValueSubject<[SuggestionItem], Never> { get } var currentTextTrigger: String? { get } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift index d4e984f886..dbaaf92950 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift @@ -24,10 +24,18 @@ enum UserSuggestionViewModelResult { case selectedItemWithIdentifier(String) } -struct UserSuggestionViewStateItem: Identifiable { - let id: String - let avatar: AvatarInputProtocol? - let displayName: String? +enum UserSuggestionViewStateItem: Identifiable { + case command(name: String) + case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) + + var id: String { + switch self { + case .command(let name): + return name + case .user(let id, _, _): + return id + } + } } struct UserSuggestionViewState: BindableState { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift index 0a9395fa56..95aea9dbe6 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift @@ -27,7 +27,7 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { } var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self) + let service = UserSuggestionService(roomMemberProvider: self, commandProvider: self) let listViewModel = UserSuggestionViewModel(userSuggestionService: service) let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in @@ -60,3 +60,14 @@ extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { } } } + +extension MockUserSuggestionScreenState: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands([ + CommandsProviderCommand(name: "/ban"), + CommandsProviderCommand(name: "/invite"), + CommandsProviderCommand(name: "/join"), + CommandsProviderCommand(name: "/me") + ]) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 3999447b7e..68d573bdf9 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -40,14 +40,28 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo self.userSuggestionService = userSuggestionService let items = userSuggestionService.items.value.map { suggestionItem in - UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.displayName) + switch suggestionItem { + case .command(let commandSuggestionItem): + return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) + case .user(let userSuggestionItem): + return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, + avatar: userSuggestionItem, + displayName: userSuggestionItem.displayName) + } } super.init(initialViewState: UserSuggestionViewState(items: items)) userSuggestionService.items.sink { [weak self] items in self?.state.items = items.map { item in - UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName) + switch item { + case .command(let commandSuggestionItem): + return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) + case .user(let userSuggestionItem): + return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, + avatar: userSuggestionItem, + displayName: userSuggestionItem.displayName) + } } }.store(in: &cancellables) } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift index e509a58b3f..fe0c217618 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift @@ -51,9 +51,12 @@ struct UserSuggestionList: View { EmptyView() } else { ZStack { - UserSuggestionListItem(avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), - displayName: "Prototype", - userId: "Prototype") + UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + id: "Prototype", + avatar: AvatarInput(mxContentUri: "", + matrixItemId: "", + displayName: "Prototype"), + displayName: "Prototype")) .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() if showBackgroundShadow { @@ -76,12 +79,8 @@ struct UserSuggestionList: View { Button { viewModel.send(viewAction: .selectedItem(item)) } label: { - UserSuggestionListItem( - avatar: item.avatar, - displayName: item.displayName, - userId: item.id - ) - .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) + UserSuggestionListItem(content: item) + .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) } } .listStyle(PlainListStyle()) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift index 862e7573d6..0175c2abe0 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift @@ -25,26 +25,33 @@ struct UserSuggestionListItem: View { // MARK: Public - let avatar: AvatarInputProtocol? - let displayName: String? - let userId: String + let content: UserSuggestionViewStateItem var body: some View { HStack { - if let avatar = avatar { - AvatarImage(avatarData: avatar, size: .medium) - } - VStack(alignment: .leading) { - Text(displayName ?? "") + switch content { + case .command(let name): + Text(name) .font(theme.fonts.body) .foregroundColor(theme.colors.primaryContent) - .accessibility(identifier: "displayNameText") - .lineLimit(1) - Text(userId) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.tertiaryContent) - .accessibility(identifier: "userIdText") + .accessibility(identifier: "nameText") .lineLimit(1) + case .user(let userId, let avatar, let displayName): + if let avatar = avatar { + AvatarImage(avatarData: avatar, size: .medium) + } + VStack(alignment: .leading) { + Text(displayName ?? "") + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "displayNameText") + .lineLimit(1) + Text(userId) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "userIdText") + .lineLimit(1) + } } } } @@ -54,7 +61,11 @@ struct UserSuggestionListItem: View { struct UserSuggestionHeader_Previews: PreviewProvider { static var previews: some View { - UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org") - .environmentObject(AvatarViewModel.withMockedServices()) + UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + id: "@alice:matrix.org", + avatar: MockAvatarInput.example, + displayName: "Alice" + )) + .environmentObject(AvatarViewModel.withMockedServices()) } } From 6d981004ed2cba87a71bfeff5486af93e106ba40 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 19 Apr 2023 14:22:21 +0200 Subject: [PATCH 02/15] Rename `UserSuggestion` module as `CompletionSuggestion` --- Riot/Modules/Room/RoomViewController.h | 2 +- Riot/Modules/Room/RoomViewController.m | 56 ++++++------ Riot/Modules/Room/RoomViewController.xib | 24 ++---- .../Views/InputToolbar/RoomInputToolbarView.h | 4 +- .../WysiwygInputToolbarView.swift | 2 +- .../Modules/Common/Mock/MockAppScreens.swift | 2 +- .../CompletionSuggestionModels.swift} | 12 +-- .../CompletionSuggestionScreenState.swift} | 16 ++-- .../CompletionSuggestionViewModel.swift | 77 +++++++++++++++++ ...mpletionSuggestionViewModelProtocol.swift} | 10 +-- .../CompletionSuggestionCoordinator.swift} | 86 +++++++++---------- ...ompletionSuggestionCoordinatorBridge.swift | 79 +++++++++++++++++ .../CompletionSuggestionService.swift} | 26 +++--- ...CompletionSuggestionServiceProtocol.swift} | 16 ++-- .../UI/CompletionSuggestionUITests.swift} | 6 +- .../CompletionSuggestionServiceTests.swift} | 60 +++++++++---- .../View/CompletionSuggestionList.swift} | 12 +-- .../View/CompletionSuggestionListItem.swift} | 8 +- .../CompletionSuggestionListWithInput.swift} | 14 +-- .../Composer/MockComposerScreenState.swift | 8 +- .../Room/Composer/Model/ComposerModels.swift | 8 +- .../Modules/Room/Composer/View/Composer.swift | 8 +- .../UserSuggestionCoordinatorBridge.swift | 79 ----------------- .../UserSuggestionViewModel.swift | 77 ----------------- 24 files changed, 353 insertions(+), 339 deletions(-) rename RiotSwiftUI/Modules/Room/{UserSuggestion/UserSuggestionModels.swift => CompletionSuggestion/CompletionSuggestionModels.swift} (76%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/UserSuggestionScreenState.swift => CompletionSuggestion/CompletionSuggestionScreenState.swift} (75%) create mode 100644 RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift rename RiotSwiftUI/Modules/Room/{UserSuggestion/UserSuggestionViewModelProtocol.swift => CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift} (67%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Coordinator/UserSuggestionCoordinator.swift => CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift} (59%) create mode 100644 RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift rename RiotSwiftUI/Modules/Room/{UserSuggestion/Service/UserSuggestionService.swift => CompletionSuggestion/Service/CompletionSuggestionService.swift} (80%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Service/UserSuggestionServiceProtocol.swift => CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift} (71%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Test/UI/UserSuggestionUITests.swift => CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift} (79%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift => CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift} (61%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/View/UserSuggestionList.swift => CompletionSuggestion/View/CompletionSuggestionList.swift} (92%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/View/UserSuggestionListItem.swift => CompletionSuggestion/View/CompletionSuggestionListItem.swift} (89%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/View/UserSuggestionListWithInput.swift => CompletionSuggestion/View/CompletionSuggestionListWithInput.swift} (75%) delete mode 100644 RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift delete mode 100644 RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 072a882a69..6cc25bcfe4 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -61,7 +61,7 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; // The preview header @property (weak, nonatomic, nullable) IBOutlet UIView *previewHeaderContainer; @property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *previewHeaderContainerHeightConstraint; -@property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *userSuggestionContainerHeightConstraint; +@property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *completionSuggestionContainerHeightConstraint; // The jump to last unread banner @property (weak, nonatomic, nullable) IBOutlet UIView *jumpToLastUnreadBannerContainer; diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 57a431d2ed..38f71f8d16 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -97,7 +97,7 @@ @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, CompletionSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate, RoomInputToolbarViewDelegate, ComposerCreateActionListBridgePresenterDelegate> { // The preview header @@ -223,8 +223,8 @@ @interface RoomViewController () - + - - + @@ -13,6 +12,8 @@ + + @@ -32,8 +33,6 @@ - - @@ -48,20 +47,20 @@ - + - + - + - + @@ -237,11 +236,6 @@ - - - - - diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index df71790bed..5bbdeaa51f 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -22,7 +22,7 @@ @class RoomInputToolbarView; @class LinkActionWrapper; @class SuggestionPatternWrapper; -@class UserSuggestionViewModelContextWrapper; +@class CompletionSuggestionViewModelContextWrapper; /** Destination of the message in the composer @@ -84,7 +84,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; -- (UserSuggestionViewModelContextWrapper *)userSuggestionContext; +- (CompletionSuggestionViewModelContextWrapper *)completionSuggestionContext; - (MXMediaManager *)mediaManager; diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 5700909fac..9bc02c21ec 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -223,7 +223,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp let composer = Composer( viewModel: viewModel.context, wysiwygViewModel: wysiwygViewModel, - userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context, + completionSuggestionSharedContext: toolbarViewDelegate.completionSuggestionContext().context, resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 91bf25a51f..0afe12c024 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -51,7 +51,7 @@ enum MockAppScreens { MockStaticLocationViewingScreenState.self, MockLocationSharingScreenState.self, MockAnalyticsPromptScreenState.self, - MockUserSuggestionScreenState.self, + MockCompletionSuggestionScreenState.self, MockPollEditFormScreenState.self, MockSpaceCreationEmailInvitesScreenState.self, MockSpaceSettingsScreenState.self, diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift similarity index 76% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift index dbaaf92950..91fc4ffeb0 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -16,15 +16,15 @@ import Foundation -enum UserSuggestionViewAction { - case selectedItem(UserSuggestionViewStateItem) +enum CompletionSuggestionViewAction { + case selectedItem(CompletionSuggestionViewStateItem) } -enum UserSuggestionViewModelResult { +enum CompletionSuggestionViewModelResult { case selectedItemWithIdentifier(String) } -enum UserSuggestionViewStateItem: Identifiable { +enum CompletionSuggestionViewStateItem: Identifiable { case command(name: String) case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) @@ -38,6 +38,6 @@ enum UserSuggestionViewStateItem: Identifiable { } } -struct UserSuggestionViewState: BindableState { - var items: [UserSuggestionViewStateItem] +struct CompletionSuggestionViewState: BindableState { + var items: [CompletionSuggestionViewStateItem] } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift similarity index 75% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index 95aea9dbe6..1427c3f3fe 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -17,32 +17,32 @@ import Foundation import SwiftUI -enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { +enum MockCompletionSuggestionScreenState: MockScreenState, CaseIterable { case multipleResults private static var members: [RoomMembersProviderMember]! var screenType: Any.Type { - UserSuggestionList.self + CompletionSuggestionList.self } var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self, commandProvider: self) - let listViewModel = UserSuggestionViewModel(userSuggestionService: service) + let service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self) + let listViewModel = CompletionSuggestionViewModel(completionSuggestionService: service) - let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in + let viewModel = CompletionSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in service.processTextMessage(textMessage) } return ( [service, listViewModel], - AnyView(UserSuggestionListWithInput(viewModel: viewModel) + AnyView(CompletionSuggestionListWithInput(viewModel: viewModel) .environmentObject(AvatarViewModel.withMockedServices())) ) } } -extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { +extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { var canMentionRoom: Bool { false } func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { @@ -61,7 +61,7 @@ extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { } } -extension MockUserSuggestionScreenState: CommandsProviderProtocol { +extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ CommandsProviderCommand(name: "/ban"), diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift new file mode 100644 index 0000000000..01c881970c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -0,0 +1,77 @@ +// +// Copyright 2021 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 SwiftUI + +typealias CompletionSuggestionViewModelType = StateStoreViewModel + +class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, CompletionSuggestionViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let completionSuggestionService: CompletionSuggestionServiceProtocol + + // MARK: Public + + var sharedContext: CompletionSuggestionViewModelType.Context { + return self.context + } + + var completion: ((CompletionSuggestionViewModelResult) -> Void)? + + // MARK: - Setup + + init(completionSuggestionService: CompletionSuggestionServiceProtocol) { + self.completionSuggestionService = completionSuggestionService + + let items = completionSuggestionService.items.value.map { suggestionItem in + switch suggestionItem { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + + super.init(initialViewState: CompletionSuggestionViewState(items: items)) + + completionSuggestionService.items.sink { [weak self] items in + self?.state.items = items.map { item in + switch item { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + }.store(in: &cancellables) + } + + // MARK: - Public + + override func process(viewAction: CompletionSuggestionViewAction) { + switch viewAction { + case .selectedItem(let item): + completion?(.selectedItemWithIdentifier(item.id)) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift similarity index 67% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift index 33aa5bb795..d7c51909f1 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift @@ -16,10 +16,10 @@ import Foundation -protocol UserSuggestionViewModelProtocol { - /// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple - /// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` +protocol CompletionSuggestionViewModelProtocol { + /// Defines a shared context providing the ability to use a single `CompletionSuggestionViewModel` for multiple + /// `CompletionSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` /// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload the data. - var sharedContext: UserSuggestionViewModelType.Context { get } - var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } + var sharedContext: CompletionSuggestionViewModelType.Context { get } + var completion: ((CompletionSuggestionViewModelResult) -> Void)? { get set } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift similarity index 59% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 1999e6c072..8da2356fdd 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -20,40 +20,40 @@ import SwiftUI import UIKit import WysiwygComposer -protocol UserSuggestionCoordinatorDelegate: AnyObject { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) +protocol CompletionSuggestionCoordinatorDelegate: AnyObject { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) } -struct UserSuggestionCoordinatorParameters { +struct CompletionSuggestionCoordinatorParameters { let mediaManager: MXMediaManager let room: MXRoom let userID: String } -/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c. -final class UserSuggestionViewModelContextWrapper: NSObject { - let context: UserSuggestionViewModelType.Context +/// Wrapper around `CompletionSuggestionViewModelType.Context` to pass it through obj-c. +final class CompletionSuggestionViewModelContextWrapper: NSObject { + let context: CompletionSuggestionViewModelType.Context - init(context: UserSuggestionViewModelType.Context) { + init(context: CompletionSuggestionViewModelType.Context) { self.context = context } } -final class UserSuggestionCoordinator: Coordinator, Presentable { +final class CompletionSuggestionCoordinator: Coordinator, Presentable { // MARK: - Properties // MARK: Private - private let parameters: UserSuggestionCoordinatorParameters + private let parameters: CompletionSuggestionCoordinatorParameters - private var userSuggestionHostingController: UIHostingController - private var userSuggestionService: UserSuggestionServiceProtocol - private var userSuggestionViewModel: UserSuggestionViewModelProtocol - private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider - private var commandProvider: UserSuggestionCoordinatorCommandProvider + private var completionSuggestionHostingController: UIHostingController + private var completionSuggestionService: CompletionSuggestionServiceProtocol + private var completionSuggestionViewModel: CompletionSuggestionViewModelProtocol + private var roomMemberProvider: CompletionSuggestionCoordinatorRoomMemberProvider + private var commandProvider: CompletionSuggestionCoordinatorCommandProvider private var cancellables = Set() @@ -63,57 +63,57 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { var childCoordinators: [Coordinator] = [] var completion: (() -> Void)? - weak var delegate: UserSuggestionCoordinatorDelegate? + weak var delegate: CompletionSuggestionCoordinatorDelegate? // MARK: - Setup - init(parameters: UserSuggestionCoordinatorParameters) { + init(parameters: CompletionSuggestionCoordinatorParameters) { self.parameters = parameters - roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - commandProvider = UserSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) + roomMemberProvider = CompletionSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) + commandProvider = CompletionSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + completionSuggestionService = CompletionSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - userSuggestionViewModel = viewModel - userSuggestionHostingController = VectorHostingController(rootView: view) + completionSuggestionViewModel = viewModel + completionSuggestionHostingController = VectorHostingController(rootView: view) - userSuggestionViewModel.completion = { [weak self] result in + completionSuggestionViewModel.completion = { [weak self] result in guard let self = self else { return } switch result { case .selectedItemWithIdentifier(let identifier): - if identifier == UserSuggestionID.room { - self.delegate?.userSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.userSuggestionService.currentTextTrigger) + if identifier == CompletionSuggestionUserID.room { + self.delegate?.completionSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.completionSuggestionService.currentTextTrigger) return } if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) + self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) } else if let command = self.commandProvider.commands.filter({ $0 == identifier }).first { - self.delegate?.userSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.userSuggestionService.currentTextTrigger) + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.completionSuggestionService.currentTextTrigger) } } } - userSuggestionService.items.sink { [weak self] _ in + completionSuggestionService.items.sink { [weak self] _ in guard let self = self else { return } - self.delegate?.userSuggestionCoordinator(self, + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) }.store(in: &cancellables) } func processTextMessage(_ textMessage: String) { - userSuggestionService.processTextMessage(textMessage) + completionSuggestionService.processTextMessage(textMessage) } func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - userSuggestionService.processSuggestionPattern(suggestionPattern) + completionSuggestionService.processSuggestionPattern(suggestionPattern) } // MARK: - Public @@ -121,18 +121,18 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { func start() { } func toPresentable() -> UIViewController { - userSuggestionHostingController + completionSuggestionHostingController } - func sharedContext() -> UserSuggestionViewModelContextWrapper { - UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + CompletionSuggestionViewModelContextWrapper(context: completionSuggestionViewModel.sharedContext) } // MARK: - Private private func calculateViewHeight() -> CGFloat { - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) let controller = VectorHostingController(rootView: view) @@ -156,7 +156,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { } } -private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { +private class CompletionSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { private let room: MXRoom private let userID: String @@ -194,7 +194,7 @@ private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderPr self.roomMembers = joinedMembers members(self.roomMembersToProviderMembers(joinedMembers)) } failure: { error in - MXLog.error("[UserSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) + MXLog.error("[CompletionSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) } } @@ -203,7 +203,7 @@ private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderPr } } -private class UserSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { +private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { private let room: MXRoom private let userID: String diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift new file mode 100644 index 0000000000..83a9ed94c4 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift @@ -0,0 +1,79 @@ +// +// Copyright 2021 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 Foundation + +@objc +protocol CompletionSuggestionCoordinatorBridgeDelegate: AnyObject { + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinatorBridge, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) +} + +@objcMembers +final class CompletionSuggestionCoordinatorBridge: NSObject { + private var _completionSuggestionCoordinator: Any? + fileprivate var completionSuggestionCoordinator: CompletionSuggestionCoordinator { + _completionSuggestionCoordinator as! CompletionSuggestionCoordinator + } + + weak var delegate: CompletionSuggestionCoordinatorBridgeDelegate? + + init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { + let parameters = CompletionSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) + let completionSuggestionCoordinator = CompletionSuggestionCoordinator(parameters: parameters) + _completionSuggestionCoordinator = completionSuggestionCoordinator + + super.init() + + completionSuggestionCoordinator.delegate = self + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionCoordinator.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { + completionSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) + } + + func toPresentable() -> UIViewController? { + completionSuggestionCoordinator.toPresentable() + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + completionSuggestionCoordinator.sharedContext() + } +} + +extension CompletionSuggestionCoordinatorBridge: CompletionSuggestionCoordinatorDelegate { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) + } + + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { + delegate?.completionSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift similarity index 80% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 76d41e700d..0353b63d43 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -28,7 +28,7 @@ struct CommandsProviderCommand { var name: String } -class UserSuggestionID: NSObject { +class CompletionSuggestionUserID: NSObject { /// A special case added for suggesting `@room` mentions. @objc static let room = "@room" } @@ -42,17 +42,17 @@ protocol CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) } -struct UserSuggestionServiceItem: UserSuggestionItemProtocol { +struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol { let userId: String let displayName: String? let avatarUrl: String? } -struct CommandSuggestionServiceItem: CommandSuggestionItemProtocol { +struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { let name: String } -class UserSuggestionService: UserSuggestionServiceProtocol { +class CompletionSuggestionService: CompletionSuggestionServiceProtocol { // MARK: - Properties // MARK: Private @@ -60,13 +60,13 @@ class UserSuggestionService: UserSuggestionServiceProtocol { private let roomMemberProvider: RoomMembersProviderProtocol private let commandProvider: CommandsProviderProtocol - private var suggestionItems: [SuggestionItem] = [] + private var suggestionItems: [CompletionSuggestionItem] = [] private let currentTextTriggerSubject = CurrentValueSubject(nil) private var cancellables = Set() // MARK: Public - var items = CurrentValueSubject<[SuggestionItem], Never>([]) + var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) var currentTextTrigger: String? { currentTextTriggerSubject.value @@ -93,7 +93,7 @@ class UserSuggestionService: UserSuggestionServiceProtocol { } } - // MARK: - UserSuggestionServiceProtocol + // MARK: - CompletionSuggestionServiceProtocol func processTextMessage(_ textMessage: String?) { guard let textMessage = textMessage, @@ -145,14 +145,14 @@ class UserSuggestionService: UserSuggestionServiceProtocol { } self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in - SuggestionItem.user(value: UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) + CompletionSuggestionItem.user(value: CompletionSuggestionServiceUserItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) } self.items.send(self.suggestionItems.filter { item in - guard case let .user(userSuggestion) = item else { return false } + guard case let .user(completionSuggestionUserItem) = item else { return false } - let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(partialName.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(partialName.lowercased()) return (containedInUsername || containedInDisplayName) }) @@ -165,7 +165,7 @@ class UserSuggestionService: UserSuggestionServiceProtocol { guard let self else { return } self.suggestionItems = commands.map { command in - SuggestionItem.command(value: CommandSuggestionServiceItem(name: command.name)) + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name)) } self.items.send(self.suggestionItems.filter { item in @@ -184,6 +184,6 @@ extension Array where Element == RoomMembersProviderMember { /// Returns the array with an additional member that represents an `@room` mention. func withRoom(_ canMentionRoom: Bool) -> Self { guard canMentionRoom else { return self } - return self + [RoomMembersProviderMember(userId: UserSuggestionID.room, displayName: "Everyone", avatarUrl: "")] + return self + [RoomMembersProviderMember(userId: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift similarity index 71% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 4b5787cff4..4586e12944 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -18,23 +18,23 @@ import Combine import Foundation import WysiwygComposer -protocol UserSuggestionItemProtocol: Avatarable { +protocol CompletionSuggestionUserItemProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } } -protocol CommandSuggestionItemProtocol { +protocol CompletionSuggestionCommandItemProtocol { var name: String { get } } -enum SuggestionItem { - case command(value: CommandSuggestionItemProtocol) - case user(value: UserSuggestionItemProtocol) +enum CompletionSuggestionItem { + case command(value: CompletionSuggestionCommandItemProtocol) + case user(value: CompletionSuggestionUserItemProtocol) } -protocol UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[SuggestionItem], Never> { get } +protocol CompletionSuggestionServiceProtocol { + var items: CurrentValueSubject<[CompletionSuggestionItem], Never> { get } var currentTextTrigger: String? { get } @@ -44,7 +44,7 @@ protocol UserSuggestionServiceProtocol { // MARK: Avatarable -extension UserSuggestionItemProtocol { +extension CompletionSuggestionUserItemProtocol { var mxContentUri: String? { avatarUrl } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift similarity index 79% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift index f44744a9c1..5ec9d4b9b4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift @@ -17,9 +17,9 @@ import RiotSwiftUI import XCTest -class UserSuggestionUITests: MockScreenTestCase { - func testUserSuggestionScreen() throws { - app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title) +class CompletionSuggestionUITests: MockScreenTestCase { + func testCompletionSuggestionScreen() throws { + app.goToScreenWithIdentifier(MockCompletionSuggestionScreenState.multipleResults.title) let firstButton = app.buttons["displayNameText-userIdText"].firstMatch XCTAssert(firstButton.waitForExistence(timeout: 10)) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift similarity index 61% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift index 7ae0bfa39e..636ba3355c 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -19,51 +19,53 @@ import XCTest @testable import RiotSwiftUI -class UserSuggestionServiceTests: XCTestCase { - var service: UserSuggestionService! +class CompletionSuggestionServiceTests: XCTestCase { + var service: CompletionSuggestionService! var canMentionRoom = false override func setUp() { - service = UserSuggestionService(roomMemberProvider: self, shouldDebounce: false) + service = CompletionSuggestionService(roomMemberProvider: self, + commandProvider: self, + shouldDebounce: false) canMentionRoom = false } func testAlice() { service.processTextMessage("@Al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@ice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@Alice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@alice:matrix.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") } func testBob() { service.processTextMessage("@ob") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") service.processTextMessage("@ob:") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") service.processTextMessage("@b:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") } func testBoth() { service.processTextMessage("@:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") service.processTextMessage("@.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") } func testEmptyResult() { @@ -117,18 +119,18 @@ class UserSuggestionServiceTests: XCTestCase { } func testRoomWithPower() { - // Given a user without the power to mention a room. + // Given a user with the power to mention a room. canMentionRoom = true - // Given a user without the power to mention a room. + // Given a user with the power to mention a room. service.processTextMessage("@ro") // Then the completion for a room mention should be shown. - XCTAssertEqual(service.items.value.first?.userId, UserSuggestionID.room) + XCTAssertEqual(service.items.value.first?.asUser?.userId, CompletionSuggestionUserID.room) } } -extension UserSuggestionServiceTests: RoomMembersProviderProtocol { +extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { let users = [("Alice", "@alice:matrix.org"), ("Bob", "@bob:matrix.org")] @@ -138,3 +140,23 @@ extension UserSuggestionServiceTests: RoomMembersProviderProtocol { }) } } + +extension CompletionSuggestionServiceTests: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + let commandList = ["/ban", "/invite", "/join", "/me"] + + commands(commandList.map { command in + CommandsProviderCommand(name: command) + }) + } +} + +extension CompletionSuggestionItem { + var asUser: CompletionSuggestionUserItemProtocol? { + if case let .user(value) = self { return value } else { return nil } + } + + var asCommand: CompletionSuggestionCommandItemProtocol? { + if case let .command(value) = self { return value } else { return nil } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift similarity index 92% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift index fe0c217618..02aef8a1f0 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift @@ -16,7 +16,7 @@ import SwiftUI -struct UserSuggestionList: View { +struct CompletionSuggestionList: View { private enum Constants { static let topPadding: CGFloat = 8.0 static let listItemPadding: CGFloat = 4.0 @@ -43,7 +43,7 @@ struct UserSuggestionList: View { // MARK: Public - @ObservedObject var viewModel: UserSuggestionViewModel.Context + @ObservedObject var viewModel: CompletionSuggestionViewModel.Context var showBackgroundShadow: Bool = true var body: some View { @@ -51,7 +51,7 @@ struct UserSuggestionList: View { EmptyView() } else { ZStack { - UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", @@ -79,7 +79,7 @@ struct UserSuggestionList: View { Button { viewModel.send(viewAction: .selectedItem(item)) } label: { - UserSuggestionListItem(content: item) + CompletionSuggestionListItem(content: item) .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) } } @@ -134,8 +134,8 @@ private struct BackgroundView: View { // MARK: - Previews -struct UserSuggestion_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestion_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift similarity index 89% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift index 0175c2abe0..c30ec5d89e 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -16,7 +16,7 @@ import SwiftUI -struct UserSuggestionListItem: View { +struct CompletionSuggestionListItem: View { // MARK: - Properties // MARK: Private @@ -25,7 +25,7 @@ struct UserSuggestionListItem: View { // MARK: Public - let content: UserSuggestionViewStateItem + let content: CompletionSuggestionViewStateItem var body: some View { HStack { @@ -59,9 +59,9 @@ struct UserSuggestionListItem: View { // MARK: - Previews -struct UserSuggestionHeader_Previews: PreviewProvider { +struct CompletionSuggestionHeader_Previews: PreviewProvider { static var previews: some View { - UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( id: "@alice:matrix.org", avatar: MockAvatarInput.example, displayName: "Alice" diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift similarity index 75% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 176be8ec41..0b1dd8e8a4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -16,24 +16,24 @@ import SwiftUI -struct UserSuggestionListWithInputViewModel { - let listViewModel: UserSuggestionViewModel +struct CompletionSuggestionListWithInputViewModel { + let listViewModel: CompletionSuggestionViewModel let callback: (String) -> Void } -struct UserSuggestionListWithInput: View { +struct CompletionSuggestionListWithInput: View { // MARK: - Properties // MARK: Private // MARK: Public - var viewModel: UserSuggestionListWithInputViewModel + var viewModel: CompletionSuggestionListWithInputViewModel @State private var inputText = "" var body: some View { VStack(spacing: 0.0) { - UserSuggestionList(viewModel: viewModel.listViewModel.context) + CompletionSuggestionList(viewModel: viewModel.listViewModel.context) TextField("Search for user", text: $inputText) .background(Color.white) .onChange(of: inputText, perform: viewModel.callback) @@ -48,8 +48,8 @@ struct UserSuggestionListWithInput: View { // MARK: - Previews -struct UserSuggestionListWithInput_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestionListWithInput_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 8b5327b14d..79322b78a5 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,7 +29,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel - let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) + let completionSuggestionViewModel = MockCompletionSuggestionViewModel(initialViewState: CompletionSuggestionViewState(items: [])) let bindings = ComposerBindings(focused: false) switch self { @@ -67,7 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, - userSuggestionSharedContext: userSuggestionViewModel.context, + completionSuggestionSharedContext: completionSuggestionViewModel.context, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -82,6 +82,4 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { } } -private final class MockUserSuggestionViewModel: UserSuggestionViewModelType { - -} +private final class MockCompletionSuggestionViewModel: CompletionSuggestionViewModelType { } diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 6f7bab1652..33d73ef4a2 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -257,11 +257,11 @@ final class SuggestionPatternWrapper: NSObject { } } -final class UserSuggestionViewModelWrapper: NSObject { - let userSuggestionViewModel: UserSuggestionViewModel +final class CompletionSuggestionViewModelWrapper: NSObject { + let completionSuggestionViewModel: CompletionSuggestionViewModel - init(_ userSuggestionViewModel: UserSuggestionViewModel) { - self.userSuggestionViewModel = userSuggestionViewModel + init(_ completionSuggestionViewModel: CompletionSuggestionViewModel) { + self.completionSuggestionViewModel = completionSuggestionViewModel super.init() } } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index e4317a2759..a74b0bb4d6 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,7 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel - private let userSuggestionSharedContext: UserSuggestionViewModelType.Context + private let completionSuggestionSharedContext: CompletionSuggestionViewModelType.Context private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -223,13 +223,13 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, - userSuggestionSharedContext: UserSuggestionViewModelType.Context, + completionSuggestionSharedContext: CompletionSuggestionViewModelType.Context, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { self.viewModel = viewModel self.wysiwygViewModel = wysiwygViewModel - self.userSuggestionSharedContext = userSuggestionSharedContext + self.completionSuggestionSharedContext = completionSuggestionSharedContext self.resizeAnimationDuration = resizeAnimationDuration self.sendMessageAction = sendMessageAction self.showSendMediaActions = showSendMediaActions @@ -256,7 +256,7 @@ struct Composer: View { } } if wysiwygViewModel.maximised { - UserSuggestionList(viewModel: userSuggestionSharedContext, showBackgroundShadow: false) + CompletionSuggestionList(viewModel: completionSuggestionSharedContext, showBackgroundShadow: false) } } .frame(height: composerHeight) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift deleted file mode 100644 index ba1bc75ca8..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Copyright 2021 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 Foundation - -@objc -protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject { - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinatorBridge, textTrigger: String?) - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) -} - -@objcMembers -final class UserSuggestionCoordinatorBridge: NSObject { - private var _userSuggestionCoordinator: Any? - fileprivate var userSuggestionCoordinator: UserSuggestionCoordinator { - _userSuggestionCoordinator as! UserSuggestionCoordinator - } - - weak var delegate: UserSuggestionCoordinatorBridgeDelegate? - - init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { - let parameters = UserSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) - let userSuggestionCoordinator = UserSuggestionCoordinator(parameters: parameters) - _userSuggestionCoordinator = userSuggestionCoordinator - - super.init() - - userSuggestionCoordinator.delegate = self - } - - func processTextMessage(_ textMessage: String) { - userSuggestionCoordinator.processTextMessage(textMessage) - } - - func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { - userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) - } - - func toPresentable() -> UIViewController? { - userSuggestionCoordinator.toPresentable() - } - - func sharedContext() -> UserSuggestionViewModelContextWrapper { - userSuggestionCoordinator.sharedContext() - } -} - -extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) - } - - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) - } - - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) - } - - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { - delegate?.userSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift deleted file mode 100644 index 68d573bdf9..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright 2021 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 SwiftUI - -typealias UserSuggestionViewModelType = StateStoreViewModel - -class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewModelProtocol { - // MARK: - Properties - - // MARK: Private - - private let userSuggestionService: UserSuggestionServiceProtocol - - // MARK: Public - - var sharedContext: UserSuggestionViewModelType.Context { - return self.context - } - - var completion: ((UserSuggestionViewModelResult) -> Void)? - - // MARK: - Setup - - init(userSuggestionService: UserSuggestionServiceProtocol) { - self.userSuggestionService = userSuggestionService - - let items = userSuggestionService.items.value.map { suggestionItem in - switch suggestionItem { - case .command(let commandSuggestionItem): - return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) - case .user(let userSuggestionItem): - return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, - avatar: userSuggestionItem, - displayName: userSuggestionItem.displayName) - } - } - - super.init(initialViewState: UserSuggestionViewState(items: items)) - - userSuggestionService.items.sink { [weak self] items in - self?.state.items = items.map { item in - switch item { - case .command(let commandSuggestionItem): - return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) - case .user(let userSuggestionItem): - return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, - avatar: userSuggestionItem, - displayName: userSuggestionItem.displayName) - } - } - }.store(in: &cancellables) - } - - // MARK: - Public - - override func process(viewAction: UserSuggestionViewAction) { - switch viewAction { - case .selectedItem(let item): - completion?(.selectedItemWithIdentifier(item.id)) - } - } -} From d29145748e483bfb5599f06944f9cac80e35f558 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 19 Apr 2023 15:32:30 +0200 Subject: [PATCH 03/15] Display additional command content in suggestion list --- .../CompletionSuggestionModels.swift | 4 +-- .../CompletionSuggestionScreenState.swift | 16 +++++++++--- .../CompletionSuggestionViewModel.swift | 12 +++++++-- .../CompletionSuggestionCoordinator.swift | 26 +++++++++++++------ .../Service/CompletionSuggestionService.swift | 8 ++++-- .../CompletionSuggestionServiceProtocol.swift | 2 ++ .../View/CompletionSuggestionListItem.swift | 26 ++++++++++++++----- 7 files changed, 70 insertions(+), 24 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift index 91fc4ffeb0..8476834b90 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -25,12 +25,12 @@ enum CompletionSuggestionViewModelResult { } enum CompletionSuggestionViewStateItem: Identifiable { - case command(name: String) + case command(name: String, parametersFormat: String, description: String) case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) var id: String { switch self { - case .command(let name): + case .command(let name, _, _): return name case .user(let id, _, _): return id diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index 1427c3f3fe..81d6e20889 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -64,10 +64,18 @@ extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ - CommandsProviderCommand(name: "/ban"), - CommandsProviderCommand(name: "/invite"), - CommandsProviderCommand(name: "/join"), - CommandsProviderCommand(name: "/me") + CommandsProviderCommand(name: "/ban", + parametersFormat: " [reason]", + description: "Bans user with given id"), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room"), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address"), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action") ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift index 01c881970c..0c9c0215c3 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -42,7 +42,11 @@ class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, Completi let items = completionSuggestionService.items.value.map { suggestionItem in switch suggestionItem { case .command(let completionSuggestionCommandItem): - return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) case .user(let completionSuggestionUserItem): return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, avatar: completionSuggestionUserItem, @@ -56,7 +60,11 @@ class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, Completi self?.state.items = items.map { item in switch item { case .command(let completionSuggestionCommandItem): - return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) case .user(let completionSuggestionUserItem): return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, avatar: completionSuggestionUserItem, diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 8da2356fdd..f2dab2dabe 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -95,8 +95,8 @@ final class CompletionSuggestionCoordinator: Coordinator, Presentable { if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) - } else if let command = self.commandProvider.commands.filter({ $0 == identifier }).first { - self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.completionSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0.name == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.name, textTrigger: self.completionSuggestionService.currentTextTrigger) } } } @@ -207,7 +207,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr private let room: MXRoom private let userID: String - var commands: [String] = [] + var commands: [(name: String, parametersFormat: String, description: String)] = [] init(room: MXRoom, userID: String) { self.room = room @@ -221,13 +221,23 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { self.commands = [ - "/ban", - "/invite", - "/join", - "/me" + (name: "/ban", + parametersFormat: " [reason]", + description: "Bans user with given id"), + (name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room"), + (name: "/join", + parametersFormat: "", + description: "Joins room with given address"), + (name: "/me", + parametersFormat: "", + description: "Displays action") ] // TODO: get real data - commands(self.commands.map { CommandsProviderCommand(name: $0) }) + commands(self.commands.map { CommandsProviderCommand(name: $0.name, + parametersFormat: $0.parametersFormat, + description: $0.description) }) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 0353b63d43..5adf4f3c5b 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -25,7 +25,9 @@ struct RoomMembersProviderMember { } struct CommandsProviderCommand { - var name: String + let name: String + let parametersFormat: String + let description: String } class CompletionSuggestionUserID: NSObject { @@ -50,6 +52,8 @@ struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { let name: String + let parametersFormat: String + let description: String } class CompletionSuggestionService: CompletionSuggestionServiceProtocol { @@ -165,7 +169,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { guard let self else { return } self.suggestionItems = commands.map { command in - CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name)) + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name, parametersFormat: command.parametersFormat, description: command.description)) } self.items.send(self.suggestionItems.filter { item in diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 4586e12944..3930c59d16 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -26,6 +26,8 @@ protocol CompletionSuggestionUserItemProtocol: Avatarable { protocol CompletionSuggestionCommandItemProtocol { var name: String { get } + var parametersFormat: String { get } + var description: String { get } } enum CompletionSuggestionItem { diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift index c30ec5d89e..95f81fb75b 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -30,12 +30,26 @@ struct CompletionSuggestionListItem: View { var body: some View { HStack { switch content { - case .command(let name): - Text(name) - .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - .accessibility(identifier: "nameText") - .lineLimit(1) + case .command(let name, let parametersFormat, let description): + VStack(alignment: .leading) { + HStack { + Text(name) + .font(theme.fonts.body.bold()) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "nameText") + .lineLimit(1) + Text(parametersFormat) + .font(theme.fonts.body.italic()) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "parametersFormatText") + .lineLimit(1) + } + Text(description) + .font(theme.fonts.body) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "descriptionText") + .lineLimit(1) + } case .user(let userId, let avatar, let displayName): if let avatar = avatar { AvatarImage(avatarData: avatar, size: .medium) From 01024598f8ac8419121844f6849a8772bb053b35 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 12:10:03 +0200 Subject: [PATCH 04/15] Rework `MXKSlashCommands` to a more Swift-friendly form and use it in suggestion module --- Riot/Modules/MatrixKit/MatrixKit.h | 2 - .../MatrixKit/Models/Room/MXKRoomDataSource.m | 4 +- .../MatrixKit/Models/Room/MXKSlashCommands.h | 34 ------ .../MatrixKit/Models/Room/MXKSlashCommands.m | 30 ------ .../Models/Room/MXKSlashCommands.swift | 101 ++++++++++++++++++ .../Room/DataSources/RoomDataSource.swift | 2 +- Riot/Modules/Room/MXKRoomViewController.m | 43 ++++---- Riot/Modules/Room/RoomViewController.m | 6 +- .../CompletionSuggestionCoordinator.swift | 77 +++++++++---- .../View/CompletionSuggestionListItem.swift | 1 - 10 files changed, 185 insertions(+), 115 deletions(-) delete mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h delete mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift diff --git a/Riot/Modules/MatrixKit/MatrixKit.h b/Riot/Modules/MatrixKit/MatrixKit.h index 2bb02223bf..ce6ea5f1e2 100644 --- a/Riot/Modules/MatrixKit/MatrixKit.h +++ b/Riot/Modules/MatrixKit/MatrixKit.h @@ -145,5 +145,3 @@ #import "MXKCountryPickerViewController.h" #import "MXKLanguagePickerViewController.h" - -#import "MXKSlashCommands.h" diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index 0998122aef..a69f504cc0 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -31,8 +31,6 @@ #import "MXKAppSettings.h" -#import "MXKSlashCommands.h" - #import "GeneratedInterface-Swift.h" const BOOL USE_THREAD_TIMELINE = YES; @@ -316,7 +314,7 @@ - (instancetype)initWithRoomId:(NSString *)roomId andMatrixSession:(MXSession *) _filterMessagesWithURL = NO; - emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote]; + emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]; // Set default data and view classes // Cell data diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h deleted file mode 100644 index ef9c717832..0000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - Copyright 2018 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 Foundation; - -/** - Slash commands used to perform actions from a room. - */ - -FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeDisplayName; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdEmote; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdJoinRoom; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdPartRoom; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdInviteUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdKickUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdBanUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdUnbanUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdSetUserPowerLevel; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdResetUserPowerLevel; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeRoomTopic; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdDiscardSession; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m deleted file mode 100644 index e9d483d9b8..0000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m +++ /dev/null @@ -1,30 +0,0 @@ -/* - Copyright 2018 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 "MXKSlashCommands.h" - -NSString *const kMXKSlashCmdChangeDisplayName = @"/nick"; -NSString *const kMXKSlashCmdEmote = @"/me"; -NSString *const kMXKSlashCmdJoinRoom = @"/join"; -NSString *const kMXKSlashCmdPartRoom = @"/part"; -NSString *const kMXKSlashCmdInviteUser = @"/invite"; -NSString *const kMXKSlashCmdKickUser = @"/kick"; -NSString *const kMXKSlashCmdBanUser = @"/ban"; -NSString *const kMXKSlashCmdUnbanUser = @"/unban"; -NSString *const kMXKSlashCmdSetUserPowerLevel = @"/op"; -NSString *const kMXKSlashCmdResetUserPowerLevel = @"/deop"; -NSString *const kMXKSlashCmdChangeRoomTopic = @"/topic"; -NSString *const kMXKSlashCmdDiscardSession = @"/discardsession"; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift new file mode 100644 index 0000000000..faae85e945 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -0,0 +1,101 @@ +// +// 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. +// + +@objc final class MXKSlashCommandsHelper: NSObject { + @objc static func commandNameFor(_ slashCommand: MXKSlashCommand) -> String { + slashCommand.cmd + } + + @objc static func commandUsageFor(_ slashCommand: MXKSlashCommand) -> String { + "Usage: \(slashCommand.cmd) \(slashCommand.parametersFormat)" + } +} + +@objc enum MXKSlashCommand: Int, CaseIterable { + case changeDisplayName + case emote + case joinRoom + case partRoom + case inviteUser + case kickUser + case banUser + case unbanUser + case setUserPowerLevel + case resetUserPowerLevel + case changeRoomTopic + case discardSession + + var cmd: String { + switch self { + case .changeDisplayName: + return "/nick" + case .emote: + return "/me" + case .joinRoom: + return "/join" + case .partRoom: + return "/part" + case .inviteUser: + return "/invite" + case .kickUser: + return "/kick" + case .banUser: + return "/ban" + case .unbanUser: + return "/unban" + case .setUserPowerLevel: + return "/op" + case .resetUserPowerLevel: + return "/deop" + case .changeRoomTopic: + return "/topic" + case .discardSession: + return "/discardsession" + } + } + + // Note: not localized for consistancy, as commands are in english + // also translating these parameters could lead to inconsistency in + // the UI in case of languages with otherlength translation. + var parametersFormat: String { + switch self { + case .changeDisplayName: + return "" + case .emote: + return "" + case .joinRoom: + return "" + case .partRoom: + return "[]" + case .inviteUser: + return "" + case .kickUser: + return " []" + case .banUser: + return " []" + case .unbanUser: + return "" + case .setUserPowerLevel: + return " " + case .resetUserPowerLevel: + return "" + case .changeRoomTopic: + return "" + case .discardSession: + return "" + } + } +} diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.swift b/Riot/Modules/Room/DataSources/RoomDataSource.swift index 281a7a046a..89cbabe42f 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.swift +++ b/Riot/Modules/Room/DataSources/RoomDataSource.swift @@ -19,7 +19,7 @@ import Foundation extension RoomDataSource { // MARK: - Private Constants private enum Constants { - static let emoteMessageSlashCommandPrefix = String(format: "%@ ", kMXKSlashCmdEmote) + static let emoteMessageSlashCommandPrefix = String(format: "%@ ", MXKSlashCommand.emote.cmd) } // MARK: - NSAttributedString Sending diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index 7c014e0182..2e55c4771d 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -39,7 +39,6 @@ #import "MXKEncryptionKeysImportView.h" #import "NSBundle+MatrixKit.h" -#import "MXKSlashCommands.h" #import "MXKSwiftHeader.h" #import "MXKPreviewViewController.h" @@ -1284,8 +1283,14 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string // TODO: display an alert with the cmd usage in case of error or unrecognized cmd. NSString *cmdUsage; + + NSString* kMXKSlashCmdChangeDisplayName = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeDisplayName]; + NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; + NSString* kMXKSlashCmdPartRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandPartRoom]; + NSString* kMXKSlashCmdChangeRoomTopic = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeRoomTopic]; + - if ([cmd isEqualToString:kMXKSlashCmdEmote]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // send message as an emote [self sendTextMessage:string]; @@ -1320,7 +1325,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /nick "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeDisplayName]; } } else if ([string hasPrefix:kMXKSlashCmdJoinRoom]) @@ -1355,7 +1360,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /join "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } } else if ([string hasPrefix:kMXKSlashCmdPartRoom]) @@ -1413,7 +1418,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /part []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandPartRoom]; } } else if ([string hasPrefix:kMXKSlashCmdChangeRoomTopic]) @@ -1445,10 +1450,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /topic "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeRoomTopic]; } } - else if ([string hasPrefix:kMXKSlashCmdDiscardSession]) + else if ([string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandDiscardSession]]) { [roomDataSource.mxSession.crypto discardOutboundGroupSessionForRoomWithRoomId:roomDataSource.roomId onComplete:^{ MXLogDebug(@"[MXKRoomVC] Manually discarded outbound group session"); @@ -1470,7 +1475,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string userId = nil; } - if ([cmd isEqualToString:kMXKSlashCmdInviteUser]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandInviteUser]]) { if (userId) { @@ -1489,10 +1494,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /invite "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandInviteUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdKickUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandKickUser]]) { if (userId) { @@ -1524,10 +1529,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /kick []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandKickUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdBanUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandBanUser]]) { if (userId) { @@ -1559,10 +1564,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /ban []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandBanUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdUnbanUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandUnbanUser]]) { if (userId) { @@ -1581,10 +1586,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /unban "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandUnbanUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdSetUserPowerLevel]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandSetUserPowerLevel]]) { // Retrieve power level NSString *powerLevel = nil; @@ -1617,10 +1622,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /op "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandSetUserPowerLevel]; } } - else if ([cmd isEqualToString:kMXKSlashCmdResetUserPowerLevel]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandResetUserPowerLevel]]) { if (userId) { @@ -1639,7 +1644,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /deop "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandResetUserPowerLevel]; } } else diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 38f71f8d16..274e7d4375 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -1281,6 +1281,8 @@ - (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string { // Override the default behavior for `/join` command in order to open automatically the joined room + + NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; if ([string hasPrefix:kMXKSlashCmdJoinRoom]) { @@ -1317,7 +1319,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - self.inputToolbarView.placeholder = @"Usage: /join "; + self.inputToolbarView.placeholder = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } return YES; } @@ -5237,7 +5239,7 @@ - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedT if (readyToSend) { BOOL isMessageAHandledCommand = NO; // "/me" command is supported with Pills in RoomDataSource. - if (![attributedTextMessage.string hasPrefix:kMXKSlashCmdEmote]) + if (![attributedTextMessage.string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // Other commands currently work with identifiers (e.g. ban, invite, op, etc). NSString *message; diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index f2dab2dabe..8669e812f4 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -95,8 +95,8 @@ final class CompletionSuggestionCoordinator: Coordinator, Presentable { if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) - } else if let command = self.commandProvider.commands.filter({ $0.name == identifier }).first { - self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.name, textTrigger: self.completionSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0.cmd == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.cmd, textTrigger: self.completionSuggestionService.currentTextTrigger) } } } @@ -207,7 +207,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr private let room: MXRoom private let userID: String - var commands: [(name: String, parametersFormat: String, description: String)] = [] + var commands = MXKSlashCommand.allCases init(room: MXRoom, userID: String) { self.room = room @@ -216,28 +216,59 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr } func updateWithPowerLevels() { - // TODO: filter commands in terms of user power level ? + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + + // Note: for now only filter out `/op` and `/deop` (same as Element-Web), + // but we could use power level for ban/invite/etc to filter further. + let adminOnlyCommands: [MXKSlashCommand] = [.setUserPowerLevel, .resetUserPowerLevel] + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + + if RoomPowerLevel(rawValue: userPowerLevel) != .admin { + self.commands = self.commands.filter { + !adminOnlyCommands.contains($0) + } + } + } } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - self.commands = [ - (name: "/ban", - parametersFormat: " [reason]", - description: "Bans user with given id"), - (name: "/invite", - parametersFormat: "", - description: "Invites user with given id to current room"), - (name: "/join", - parametersFormat: "", - description: "Joins room with given address"), - (name: "/me", - parametersFormat: "", - description: "Displays action") - ] - - // TODO: get real data - commands(self.commands.map { CommandsProviderCommand(name: $0.name, - parametersFormat: $0.parametersFormat, - description: $0.description) }) + commands(self.commands.map { CommandsProviderCommand( + name: $0.cmd, + parametersFormat: $0.parametersFormat, + description: $0.description + )}) + } +} + +private extension MXKSlashCommand { + // TODO: L10N + var description: String { + switch self { + case .changeDisplayName: + return "Changes your display nickname" + case .emote: + return "Displays action" + case .joinRoom: + return "Joins room with given address" + case .partRoom: + return "Leave room" + case .inviteUser: + return "Invites user with given id to current room" + case .kickUser: + return "Removes user with given id from this room" + case .banUser: + return "Bans user with given id" + case .unbanUser: + return "Unbans user with given id" + case .setUserPowerLevel: + return "Define the power level of a user" + case .resetUserPowerLevel: + return "Deops user with given id" + case .changeRoomTopic: + return "Sets the room topic" + case .discardSession: + return "Forces the current outbound group session in an encrypted room to be discarded" + } } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift index 95f81fb75b..4a16161897 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -48,7 +48,6 @@ struct CompletionSuggestionListItem: View { .font(theme.fonts.body) .foregroundColor(theme.colors.tertiaryContent) .accessibility(identifier: "descriptionText") - .lineLimit(1) } case .user(let userId, let avatar, let displayName): if let avatar = avatar { From 670064085ca707d4e37df74988b94583fac8fe9d Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 14:12:55 +0200 Subject: [PATCH 05/15] Display all commands when a single slash is entered --- .../Service/CompletionSuggestionService.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 5adf4f3c5b..b16efc137c 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -162,21 +162,29 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { }) } case "/": - // TODO: send all commands if only text is "/" partialName.removeFirst() commandProvider.fetchCommands { [weak self] commands in guard let self else { return } self.suggestionItems = commands.map { command in - CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name, parametersFormat: command.parametersFormat, description: command.description)) + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( + name: command.name, + parametersFormat: command.parametersFormat, + description: command.description + )) } - self.items.send(self.suggestionItems.filter { item in - guard case let .command(commandSuggestion) = item else { return false } + if partialName.isEmpty { + // A single `/` will display all available commands. + self.items.send(self.suggestionItems) + } else { + self.items.send(self.suggestionItems.filter { item in + guard case let .command(commandSuggestion) = item else { return false } - return commandSuggestion.name.lowercased().contains(partialName.lowercased()) - }) + return commandSuggestion.name.lowercased().contains(partialName.lowercased()) + }) + } } default: return From 1e98305012a793cd4dbc9ac957e1d52aff70bf52 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 15:43:20 +0200 Subject: [PATCH 06/15] Rework `CompletionSuggestionService` text trigger --- .../Service/CompletionSuggestionService.swift | 75 ++++++++++++------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index b16efc137c..86a99370fa 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -65,7 +65,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { private let commandProvider: CommandsProviderProtocol private var suggestionItems: [CompletionSuggestionItem] = [] - private let currentTextTriggerSubject = CurrentValueSubject(nil) + private let currentTextTriggerSubject = CurrentValueSubject(nil) private var cancellables = Set() // MARK: Public @@ -73,7 +73,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) var currentTextTrigger: String? { - currentTextTriggerSubject.value + currentTextTriggerSubject.value?.asString() } // MARK: - Setup @@ -88,11 +88,11 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { currentTextTriggerSubject .debounce(for: 0.5, scheduler: RunLoop.main) .removeDuplicates() - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } .store(in: &cancellables) } else { currentTextTriggerSubject - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } .store(in: &cancellables) } } @@ -101,16 +101,14 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { func processTextMessage(_ textMessage: String?) { guard let textMessage = textMessage, - textMessage.count > 0, - let lastComponent = textMessage.components(separatedBy: .whitespaces).last, - lastComponent.prefix(while: { $0 == "@" || $0 == "/" }).count == 1 // Partial username should start with one and only one "@" character + let textTrigger = textMessage.currentTextTrigger else { items.send([]) currentTextTriggerSubject.send(nil) return } - currentTextTriggerSubject.send(lastComponent) + currentTextTriggerSubject.send(textTrigger) } func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { @@ -122,27 +120,23 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { switch suggestionPattern.key { case .at: - currentTextTriggerSubject.send("@" + suggestionPattern.text) + currentTextTriggerSubject.send(TextTrigger(key: .at, text: suggestionPattern.text)) case .hash: // No room suggestion support yet items.send([]) currentTextTriggerSubject.send(nil) case .slash: - currentTextTriggerSubject.send("/" + suggestionPattern.text) + currentTextTriggerSubject.send(TextTrigger(key: .slash, text: suggestionPattern.text)) } } // MARK: - Private - private func fetchAndFilterMembersForTextTrigger(_ textTrigger: String?) { - guard var partialName = textTrigger else { - return - } - - switch partialName.first { - case "@": - partialName.removeFirst() // remove the '@' prefix + private func fetchAndFilterSuggestionsForTextTrigger(_ textTrigger: TextTrigger?) { + guard let textTrigger else { return } + switch textTrigger.key { + case .at: roomMemberProvider.fetchMembers { [weak self] members in guard let self = self else { return @@ -155,15 +149,13 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { self.items.send(self.suggestionItems.filter { item in guard case let .user(completionSuggestionUserItem) = item else { return false } - let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(partialName.lowercased()) + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(textTrigger.text.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(textTrigger.text.lowercased()) return (containedInUsername || containedInDisplayName) }) } - case "/": - partialName.removeFirst() - + case .slash: commandProvider.fetchCommands { [weak self] commands in guard let self else { return } @@ -175,19 +167,17 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { )) } - if partialName.isEmpty { + if textTrigger.text.isEmpty { // A single `/` will display all available commands. self.items.send(self.suggestionItems) } else { self.items.send(self.suggestionItems.filter { item in guard case let .command(commandSuggestion) = item else { return false } - return commandSuggestion.name.lowercased().contains(partialName.lowercased()) + return commandSuggestion.name.lowercased().contains(textTrigger.text.lowercased()) }) } } - default: - return } } } @@ -199,3 +189,34 @@ extension Array where Element == RoomMembersProviderMember { return self + [RoomMembersProviderMember(userId: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] } } + +private enum SuggestionKey: Character { + case at = "@" + case slash = "/" +} + +private struct TextTrigger: Equatable { + let key: SuggestionKey + let text: String + + func asString() -> String { + return String(key.rawValue) + text + } +} + +private extension String { + // Returns current completion suggestion for a text message, if any. + var currentTextTrigger: TextTrigger? { + let components = self.components(separatedBy: .whitespaces) + guard var lastComponent = components.last, + lastComponent.count > 0, + let suggestionKey = SuggestionKey(rawValue: lastComponent.removeFirst()), + // If a second character exists and is the same as the key it shouldn't trigger. + lastComponent.first != suggestionKey.rawValue, + // Slash commands should be displayed only if there is a single component + !(suggestionKey == .slash && components.count > 1) + else { return nil } + + return TextTrigger(key: suggestionKey, text: lastComponent) + } +} From 46f191c33b2d29a095d3e33be8de8f43ef24e8f1 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 16:01:17 +0200 Subject: [PATCH 07/15] Re-enable unit tests and fix a few lint warnings --- .../CompletionSuggestionScreenState.swift | 2 +- .../CompletionSuggestionViewModel.swift | 2 +- .../CompletionSuggestionCoordinator.swift | 9 ++------- .../Service/CompletionSuggestionService.swift | 4 ++-- .../CompletionSuggestionServiceTests.swift | 19 ++++++++++++++----- .../View/CompletionSuggestionList.swift | 11 +++-------- 6 files changed, 23 insertions(+), 24 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index 81d6e20889..b78c255755 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -65,7 +65,7 @@ extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ CommandsProviderCommand(name: "/ban", - parametersFormat: " [reason]", + parametersFormat: " []", description: "Bans user with given id"), CommandsProviderCommand(name: "/invite", parametersFormat: "", diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift index 0c9c0215c3..53d2c69757 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -29,7 +29,7 @@ class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, Completi // MARK: Public var sharedContext: CompletionSuggestionViewModelType.Context { - return self.context + context } var completion: ((CompletionSuggestionViewModelResult) -> Void)? diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 8669e812f4..102994636b 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -103,8 +103,7 @@ final class CompletionSuggestionCoordinator: Coordinator, Presentable { completionSuggestionService.items.sink { [weak self] _ in guard let self = self else { return } - self.delegate?.completionSuggestionCoordinator(self, - didUpdateViewHeight: self.calculateViewHeight()) + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) }.store(in: &cancellables) } @@ -233,11 +232,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - commands(self.commands.map { CommandsProviderCommand( - name: $0.cmd, - parametersFormat: $0.parametersFormat, - description: $0.description - )}) + commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description) }) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 86a99370fa..09b229ec43 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -200,14 +200,14 @@ private struct TextTrigger: Equatable { let text: String func asString() -> String { - return String(key.rawValue) + text + String(key.rawValue) + text } } private extension String { // Returns current completion suggestion for a text message, if any. var currentTextTrigger: TextTrigger? { - let components = self.components(separatedBy: .whitespaces) + let components = components(separatedBy: .whitespaces) guard var lastComponent = components.last, lastComponent.count > 0, let suggestionKey = SuggestionKey(rawValue: lastComponent.removeFirst()), diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift index 636ba3355c..18283bfb35 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -143,11 +143,20 @@ extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { extension CompletionSuggestionServiceTests: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - let commandList = ["/ban", "/invite", "/join", "/me"] - - commands(commandList.map { command in - CommandsProviderCommand(name: command) - }) + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id"), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room"), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address"), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action") + ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift index 02aef8a1f0..cf8e34e023 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift @@ -30,7 +30,7 @@ struct CompletionSuggestionList: View { to the list items in order to be as close as possible as the `UITableView` display. */ - @available (iOS 16.0, *) + @available(iOS 16.0, *) static let collectionViewPaddingCorrection: CGFloat = -5.0 } @@ -44,19 +44,14 @@ struct CompletionSuggestionList: View { // MARK: Public @ObservedObject var viewModel: CompletionSuggestionViewModel.Context - var showBackgroundShadow: Bool = true + var showBackgroundShadow = true var body: some View { if viewModel.viewState.items.isEmpty { EmptyView() } else { ZStack { - CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( - id: "Prototype", - avatar: AvatarInput(mxContentUri: "", - matrixItemId: "", - displayName: "Prototype"), - displayName: "Prototype")) + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user(id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), displayName: "Prototype")) .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() if showBackgroundShadow { From 6e855584c8e07a4404b6e4e90683b1047fa91004 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 16:43:36 +0200 Subject: [PATCH 08/15] Move room admin condition to be usable in UnitTests and add tests --- .../CompletionSuggestionScreenState.swift | 22 +++- .../CompletionSuggestionCoordinator.swift | 24 ++-- .../Service/CompletionSuggestionService.swift | 11 +- .../CompletionSuggestionServiceTests.swift | 105 +++++++++++++++++- .../CompletionSuggestionListWithInput.swift | 4 +- 5 files changed, 144 insertions(+), 22 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index b78c255755..5bdd720886 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -62,20 +62,34 @@ extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { } extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { + var isRoomAdmin: Bool { false } + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ CommandsProviderCommand(name: "/ban", parametersFormat: " []", - description: "Bans user with given id"), + description: "Bans user with given id", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/invite", parametersFormat: "", - description: "Invites user with given id to current room"), + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/join", parametersFormat: "", - description: "Joins room with given address"), + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), CommandsProviderCommand(name: "/me", parametersFormat: "", - description: "Displays action") + description: "Displays action", + requiresAdminPowerLevel: false) ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 102994636b..6c868ce4c0 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -207,6 +207,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr private let userID: String var commands = MXKSlashCommand.allCases + var isRoomAdmin = false init(room: MXRoom, userID: String) { self.room = room @@ -218,21 +219,13 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr room.state { [weak self] state in guard let self, let powerLevels = state?.powerLevels else { return } - // Note: for now only filter out `/op` and `/deop` (same as Element-Web), - // but we could use power level for ban/invite/etc to filter further. - let adminOnlyCommands: [MXKSlashCommand] = [.setUserPowerLevel, .resetUserPowerLevel] let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - - if RoomPowerLevel(rawValue: userPowerLevel) != .admin { - self.commands = self.commands.filter { - !adminOnlyCommands.contains($0) - } - } + isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin } } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description) }) + commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description, requiresAdminPowerLevel: $0.requiresAdminPowerLevel) }) } } @@ -266,4 +259,15 @@ private extension MXKSlashCommand { return "Forces the current outbound group session in an encrypted room to be discarded" } } + + // Note: for now only filter out `/op` and `/deop` (same as Element-Web), + // but we could use power level for ban/invite/etc to filter further. + var requiresAdminPowerLevel: Bool { + switch self { + case .setUserPowerLevel, .resetUserPowerLevel: + return true + default: + return false + } + } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 09b229ec43..5ded36c2cd 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -28,6 +28,7 @@ struct CommandsProviderCommand { let name: String let parametersFormat: String let description: String + let requiresAdminPowerLevel: Bool } class CompletionSuggestionUserID: NSObject { @@ -41,6 +42,7 @@ protocol RoomMembersProviderProtocol { } protocol CommandsProviderProtocol { + var isRoomAdmin: Bool { get } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) } @@ -159,7 +161,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { commandProvider.fetchCommands { [weak self] commands in guard let self else { return } - self.suggestionItems = commands.map { command in + self.suggestionItems = commands.filtered(isRoomAdmin: self.commandProvider.isRoomAdmin).map { command in CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( name: command.name, parametersFormat: command.parametersFormat, @@ -190,6 +192,13 @@ extension Array where Element == RoomMembersProviderMember { } } +extension Array where Element == CommandsProviderCommand { + func filtered(isRoomAdmin: Bool) -> Self { + guard !isRoomAdmin else { return self } + return filter { !$0.requiresAdminPowerLevel } + } +} + private enum SuggestionKey: Character { case at = "@" case slash = "/" diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift index 18283bfb35..90542868d4 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -22,14 +22,18 @@ import XCTest class CompletionSuggestionServiceTests: XCTestCase { var service: CompletionSuggestionService! var canMentionRoom = false + var isRoomAdmin = false override func setUp() { service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self, shouldDebounce: false) canMentionRoom = false + isRoomAdmin = false } - + + // MARK: - User suggestions + func testAlice() { service.processTextMessage("@Al") XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") @@ -128,6 +132,85 @@ class CompletionSuggestionServiceTests: XCTestCase { // Then the completion for a room mention should be shown. XCTAssertEqual(service.items.value.first?.asUser?.userId, CompletionSuggestionUserID.room) } + + // MARK: - Command suggestions + + func testJoin() { + service.processTextMessage("/jo") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/joi") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/join") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/oin") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + } + + func testInvite() { + service.processTextMessage("/inv") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/invite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/vite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + } + + func testMultipleResults() { + service.processTextMessage("/in") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/invite", "/join"] + ) + } + + func testDoubleSlashDontTrigger() { + service.processTextMessage("//") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testNonLeadingSlashCommandDontTrigger() { + service.processTextMessage("test /joi") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreNotAvailable() { + isRoomAdmin = false + + service.processTextMessage("/op") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreAvailable() { + isRoomAdmin = true + + service.processTextMessage("/op") + XCTAssertEqual(service.items.value.compactMap { $0.asCommand?.name }, ["/op", "/deop"]) + } + + func testDisplayAllCommandsAsStandardUser() { + isRoomAdmin = false + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/me"] + ) + } + + func testDisplayAllCommandsAsAdmin() { + isRoomAdmin = true + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/op", "/deop", "/me"] + ) + } } extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { @@ -146,16 +229,28 @@ extension CompletionSuggestionServiceTests: CommandsProviderProtocol { commands([ CommandsProviderCommand(name: "/ban", parametersFormat: " []", - description: "Bans user with given id"), + description: "Bans user with given id", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/invite", parametersFormat: "", - description: "Invites user with given id to current room"), + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/join", parametersFormat: "", - description: "Joins room with given address"), + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), CommandsProviderCommand(name: "/me", parametersFormat: "", - description: "Displays action") + description: "Displays action", + requiresAdminPowerLevel: false) ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 0b1dd8e8a4..223b4fbc61 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -34,13 +34,13 @@ struct CompletionSuggestionListWithInput: View { var body: some View { VStack(spacing: 0.0) { CompletionSuggestionList(viewModel: viewModel.listViewModel.context) - TextField("Search for user", text: $inputText) + TextField("Search for user/command", text: $inputText) .background(Color.white) .onChange(of: inputText, perform: viewModel.callback) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding([.leading, .trailing]) .onAppear { - inputText = "@-" // Make the list show all available mock results + inputText = "@-" // Make the list show all available user mock results } } } From 07ba5638c441aa84b883b3c41952a9493d341f39 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 16:50:44 +0200 Subject: [PATCH 09/15] Add changelog --- changelog.d/7493.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7493.feature diff --git a/changelog.d/7493.feature b/changelog.d/7493.feature new file mode 100644 index 0000000000..075a7f6a2b --- /dev/null +++ b/changelog.d/7493.feature @@ -0,0 +1 @@ +Add composer suggestions for slash commands From fb5d65e834eb96eaea433b50e9fcb95d718d84fd Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 17:09:02 +0200 Subject: [PATCH 10/15] L10N --- Riot/Assets/en.lproj/Vector.strings | 14 ++++++ Riot/Generated/Strings.swift | 48 +++++++++++++++++++ .../CompletionSuggestionCoordinator.swift | 25 +++++----- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index c1099f168a..38cded4a34 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -614,6 +614,20 @@ Tap the + to start adding people."; "room_join_group_call" = "Join"; "room_no_privileges_to_create_group_call" = "You need to be an admin or a moderator to start a call."; +// Room commands descriptions +"room_command_change_display_name_description" = "Changes your display nickname"; +"room_command_emote_description" = "Displays action"; +"room_command_join_room_description" = "Joins room with given address"; +"room_command_part_room_description" = "Leave room"; +"room_command_invite_user_description" = "Invites user with given id to current room"; +"room_command_kick_user_description" = "Removes user with given id from this room"; +"room_command_ban_user_description" = "Bans user with given id"; +"room_command_unban_user_description" = "Unbans user with given id"; +"room_command_set_user_power_level_description" = "Define the power level of a user"; +"room_command_reset_user_power_level_description" = "Deops user with given id"; +"room_command_change_room_topic_description" = "Sets the room topic"; +"room_command_discard_session_description" = "Forces the current outbound group session in an encrypted room to be discarded"; + // MARK: Threads "room_thread_title" = "Thread"; "thread_copy_link_to_thread" = "Copy link to thread"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 0cdd03d2d7..e48764f1dd 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5211,6 +5211,54 @@ public class VectorL10n: NSObject { public static var roomAvatarViewAccessibilityLabel: String { return VectorL10n.tr("Vector", "room_avatar_view_accessibility_label") } + /// Bans user with given id + public static var roomCommandBanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_ban_user_description") + } + /// Changes your display nickname + public static var roomCommandChangeDisplayNameDescription: String { + return VectorL10n.tr("Vector", "room_command_change_display_name_description") + } + /// Sets the room topic + public static var roomCommandChangeRoomTopicDescription: String { + return VectorL10n.tr("Vector", "room_command_change_room_topic_description") + } + /// Forces the current outbound group session in an encrypted room to be discarded + public static var roomCommandDiscardSessionDescription: String { + return VectorL10n.tr("Vector", "room_command_discard_session_description") + } + /// Displays action + public static var roomCommandEmoteDescription: String { + return VectorL10n.tr("Vector", "room_command_emote_description") + } + /// Invites user with given id to current room + public static var roomCommandInviteUserDescription: String { + return VectorL10n.tr("Vector", "room_command_invite_user_description") + } + /// Joins room with given address + public static var roomCommandJoinRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_join_room_description") + } + /// Removes user with given id from this room + public static var roomCommandKickUserDescription: String { + return VectorL10n.tr("Vector", "room_command_kick_user_description") + } + /// Leave room + public static var roomCommandPartRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_part_room_description") + } + /// Deops user with given id + public static var roomCommandResetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_reset_user_power_level_description") + } + /// Define the power level of a user + public static var roomCommandSetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_set_user_power_level_description") + } + /// Unbans user with given id + public static var roomCommandUnbanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_unban_user_description") + } /// You need permission to manage conference call in this room public static var roomConferenceCallNoPower: String { return VectorL10n.tr("Vector", "room_conference_call_no_power") diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 6c868ce4c0..c02df825fe 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -230,33 +230,32 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr } private extension MXKSlashCommand { - // TODO: L10N var description: String { switch self { case .changeDisplayName: - return "Changes your display nickname" + return VectorL10n.roomCommandChangeDisplayNameDescription case .emote: - return "Displays action" + return VectorL10n.roomCommandEmoteDescription case .joinRoom: - return "Joins room with given address" + return VectorL10n.roomCommandJoinRoomDescription case .partRoom: - return "Leave room" + return VectorL10n.roomCommandPartRoomDescription case .inviteUser: - return "Invites user with given id to current room" + return VectorL10n.roomCommandInviteUserDescription case .kickUser: - return "Removes user with given id from this room" + return VectorL10n.roomCommandKickUserDescription case .banUser: - return "Bans user with given id" + return VectorL10n.roomCommandBanUserDescription case .unbanUser: - return "Unbans user with given id" + return VectorL10n.roomCommandUnbanUserDescription case .setUserPowerLevel: - return "Define the power level of a user" + return VectorL10n.roomCommandSetUserPowerLevelDescription case .resetUserPowerLevel: - return "Deops user with given id" + return VectorL10n.roomCommandResetUserPowerLevelDescription case .changeRoomTopic: - return "Sets the room topic" + return VectorL10n.roomCommandChangeRoomTopicDescription case .discardSession: - return "Forces the current outbound group session in an encrypted room to be discarded" + return VectorL10n.roomCommandDiscardSessionDescription } } From 0bcd27abb5af206932c1f22215d4b6e75c8420b9 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 17:12:09 +0200 Subject: [PATCH 11/15] Fix comment typo --- Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift index faae85e945..7498b57696 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -67,7 +67,7 @@ } } - // Note: not localized for consistancy, as commands are in english + // Note: not localized for consistency, as commands are in english // also translating these parameters could lead to inconsistency in // the UI in case of languages with otherlength translation. var parametersFormat: String { From 1de6e769c81626ff8509a4081ecce606a8c07bb5 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 09:11:35 +0200 Subject: [PATCH 12/15] Fix missing self in closure --- .../Coordinator/CompletionSuggestionCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index c02df825fe..4196da77a8 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -220,7 +220,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr guard let self, let powerLevels = state?.powerLevels else { return } let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin + self.isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin } } From bf1ea7d8552aa6276dd2d5667b44709e27ba6c79 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 09:30:56 +0200 Subject: [PATCH 13/15] Fix `RoomInputToolbarTextView` pills flushing --- .../RoomInputToolbarTextView.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift index 47d981c866..eabd68c9e3 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift @@ -23,6 +23,7 @@ class RoomInputToolbarTextView: UITextView { private var heightConstraint: NSLayoutConstraint! + private var pillViews = [UIView]() weak var toolbarDelegate: RoomInputToolbarTextViewDelegate? @@ -51,12 +52,18 @@ class RoomInputToolbarTextView: UITextView { } override var text: String! { + willSet { + flushPills() + } didSet { updateUI() } } override var attributedText: NSAttributedString! { + willSet { + flushPills() + } didSet { updateUI() } @@ -162,3 +169,17 @@ class RoomInputToolbarTextView: UITextView { delegate.onTouchUp(inside: delegate.rightInputToolbarButton) } } + +extension RoomInputToolbarTextView: PillViewFlusher { + func registerPillView(_ pillView: UIView) { + pillViews.append(pillView) + } + + private func flushPills() { + for view in pillViews { + view.alpha = 0.0 + view.removeFromSuperview() + } + pillViews.removeAll() + } +} From 4856038fd99ffeef52c7404c72e76a4ee01d28d2 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 13:47:15 +0200 Subject: [PATCH 14/15] Fix typo --- Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift index 7498b57696..54ab1ab3c5 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -69,7 +69,7 @@ // Note: not localized for consistency, as commands are in english // also translating these parameters could lead to inconsistency in - // the UI in case of languages with otherlength translation. + // the UI in case of languages with overlength translation. var parametersFormat: String { switch self { case .changeDisplayName: From 05cb486d54e42307b8d247ee36d7c1480a1dbc8a Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 17:13:03 +0200 Subject: [PATCH 15/15] Fix sending command with Pills through RTE --- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 +++ .../MXKRoomInputToolbarView.h | 8 ++++++ Riot/Modules/Room/RoomViewController.m | 21 ++++++++++++++++ Riot/Modules/Room/RoomViewController.swift | 2 +- .../WysiwygInputToolbarView.swift | 25 ++++++++++++++++++- 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 38cded4a34..1aadc203f7 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -627,6 +627,7 @@ Tap the + to start adding people."; "room_command_reset_user_power_level_description" = "Deops user with given id"; "room_command_change_room_topic_description" = "Sets the room topic"; "room_command_discard_session_description" = "Forces the current outbound group session in an encrypted room to be discarded"; +"room_command_error_unknown_command" = "Invalid or unhandled command"; // MARK: Threads "room_thread_title" = "Thread"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index e48764f1dd..db052a786b 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5231,6 +5231,10 @@ public class VectorL10n: NSObject { public static var roomCommandEmoteDescription: String { return VectorL10n.tr("Vector", "room_command_emote_description") } + /// Invalid or unhandled command + public static var roomCommandErrorUnknownCommand: String { + return VectorL10n.tr("Vector", "room_command_error_unknown_command") + } /// Invites user with given id to current room public static var roomCommandInviteUserDescription: String { return VectorL10n.tr("Vector", "room_command_invite_user_description") diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index e366ae2393..abd67ec7e0 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -102,6 +102,14 @@ typedef enum : NSUInteger */ - (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText; +/** + Tells the delegate that the user wants to send a command. + + @param toolbarView the room input toolbar view. + @param commandText the command to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText; + /** Tells the delegate that the user wants to display the send media actions. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 274e7d4375..7646a135e7 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5190,6 +5190,27 @@ - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendFormattedTe }]; } +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText +{ + // Create before sending the message in case of a discussion (direct chat) + MXWeakify(self); + [self createDiscussionIfNeeded:^(BOOL readyToSend) { + MXStrongifyAndReturnIfNil(self); + + if (readyToSend) { + if (![self sendAsIRCStyleCommandIfPossible:commandText]) + { + // Display an error for unknown command + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil + message:[VectorL10n roomCommandErrorUnknownCommand] + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; + } + } + }]; +} + - (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView { NSMutableArray *actionItems = [NSMutableArray new]; diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index c94111be44..727ca8f806 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -107,7 +107,7 @@ extension RoomViewController { "event_id": eventModified.eventId ]) }) - } else if !self.send(asIRCStyleCommandIfPossible: rawTextMsg) { + } else { roomDataSource.sendFormattedTextMessage(rawTextMsg, html: htmlMsg) { response in switch response { case .success: diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 9bc02c21ec..ad488897fc 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -338,7 +338,30 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } private func sendWysiwygMessage(content: WysiwygComposerContent) { - delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + if content.markdown.prefix(while: { $0 == "/" }).count == 1 { + let commandText: String + if content.markdown.hasPrefix(MXKSlashCommand.emote.cmd) { + // `/me` command works with markdown content + commandText = content.markdown + } else if #available(iOS 15.0, *) { + // Other commands should see pills replaced by matrix identifiers + commandText = PillsFormatter.stringByReplacingPills(in: self.wysiwygViewModel.textView.attributedText, mode: .identifier) + } else { + // Without Pills support, just use the raw text for command + commandText = self.wysiwygViewModel.textView.text + } + + // Fix potential command failures due to trailing characters + // or NBSP that are not properly handled by the command interpreter + let sanitizedCommand = commandText + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: String.nbsp, with: " ") + + delegate?.roomInputToolbarView?(self, sendCommand: sanitizedCommand) + } else { + delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + } + if isMaximised { minimise() }