diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index c1099f168a..1aadc203f7 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -614,6 +614,21 @@ 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"; +"room_command_error_unknown_command" = "Invalid or unhandled command"; + // 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..db052a786b 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5211,6 +5211,58 @@ 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") + } + /// 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") + } + /// 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/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..54ab1ab3c5 --- /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 consistency, as commands are in english + // also translating these parameters could lead to inconsistency in + // the UI in case of languages with overlength 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/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/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.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 70b8d974c9..7646a135e7 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 () "; + self.inputToolbarView.placeholder = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } return YES; } @@ -2726,13 +2728,13 @@ - (BOOL)handleUniversalLinkWithParameters:(UniversalLinkParameters*)parameters } } -- (void)setupUserSuggestionViewIfNeeded +- (void)setupCompletionSuggestionViewIfNeeded { if(!self.isViewLoaded) { return; } - UIViewController *suggestionsViewController = self.userSuggestionCoordinator.toPresentable; + UIViewController *suggestionsViewController = self.completionSuggestionCoordinator.toPresentable; if (!suggestionsViewController) { @@ -2742,12 +2744,12 @@ - (void)setupUserSuggestionViewIfNeeded [suggestionsViewController.view setTranslatesAutoresizingMaskIntoConstraints:NO]; [self addChildViewController:suggestionsViewController]; - [self.userSuggestionContainerView addSubview:suggestionsViewController.view]; + [self.completionSuggestionContainerView addSubview:suggestionsViewController.view]; - [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.userSuggestionContainerView.topAnchor], - [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.userSuggestionContainerView.leadingAnchor], - [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.userSuggestionContainerView.trailingAnchor], - [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.userSuggestionContainerView.bottomAnchor],]]; + [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.topAnchor], + [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.leadingAnchor], + [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.trailingAnchor], + [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.bottomAnchor],]]; [suggestionsViewController didMoveToParentViewController:self]; } @@ -5147,17 +5149,17 @@ - (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView - + - - + @@ -13,6 +12,8 @@ + + @@ -32,8 +33,6 @@ - - @@ -48,20 +47,20 @@ - + - + - + - + @@ -237,11 +236,6 @@ - - - - - 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() + } +} 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 f3fc1111bd..ad488897fc 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 @@ -219,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 } @@ -334,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() } 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 51% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift index d4e984f886..8476834b90 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -16,20 +16,28 @@ import Foundation -enum UserSuggestionViewAction { - case selectedItem(UserSuggestionViewStateItem) +enum CompletionSuggestionViewAction { + case selectedItem(CompletionSuggestionViewStateItem) } -enum UserSuggestionViewModelResult { +enum CompletionSuggestionViewModelResult { case selectedItemWithIdentifier(String) } -struct UserSuggestionViewStateItem: Identifiable { - let id: String - let avatar: AvatarInputProtocol? - let displayName: String? +enum CompletionSuggestionViewStateItem: Identifiable { + 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, _, _): + return name + case .user(let id, _, _): + return id + } + } } -struct UserSuggestionViewState: BindableState { - var items: [UserSuggestionViewStateItem] +struct CompletionSuggestionViewState: BindableState { + var items: [CompletionSuggestionViewStateItem] } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift new file mode 100644 index 0000000000..5bdd720886 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -0,0 +1,95 @@ +// +// 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 +import SwiftUI + +enum MockCompletionSuggestionScreenState: MockScreenState, CaseIterable { + case multipleResults + + private static var members: [RoomMembersProviderMember]! + + var screenType: Any.Type { + CompletionSuggestionList.self + } + + var screenView: ([Any], AnyView) { + let service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self) + let listViewModel = CompletionSuggestionViewModel(completionSuggestionService: service) + + let viewModel = CompletionSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in + service.processTextMessage(textMessage) + } + + return ( + [service, listViewModel], + AnyView(CompletionSuggestionListWithInput(viewModel: viewModel) + .environmentObject(AvatarViewModel.withMockedServices())) + ) + } +} + +extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { + var canMentionRoom: Bool { false } + + func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { + if Self.members == nil { + Self.members = generateUsersWithCount(10) + } + + members(Self.members) + } + + private func generateUsersWithCount(_ count: UInt) -> [RoomMembersProviderMember] { + (0.. Void) { + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + 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", + requiresAdminPowerLevel: false) + ]) + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift new file mode 100644 index 0000000000..53d2c69757 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -0,0 +1,85 @@ +// +// 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 { + 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, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) + 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, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) + 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/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift new file mode 100644 index 0000000000..4196da77a8 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -0,0 +1,272 @@ +// +// 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 Foundation +import SwiftUI +import UIKit +import WysiwygComposer + +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 CompletionSuggestionCoordinatorParameters { + let mediaManager: MXMediaManager + let room: MXRoom + let userID: String +} + +/// Wrapper around `CompletionSuggestionViewModelType.Context` to pass it through obj-c. +final class CompletionSuggestionViewModelContextWrapper: NSObject { + let context: CompletionSuggestionViewModelType.Context + + init(context: CompletionSuggestionViewModelType.Context) { + self.context = context + } +} + +final class CompletionSuggestionCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: CompletionSuggestionCoordinatorParameters + + private var completionSuggestionHostingController: UIHostingController + private var completionSuggestionService: CompletionSuggestionServiceProtocol + private var completionSuggestionViewModel: CompletionSuggestionViewModelProtocol + private var roomMemberProvider: CompletionSuggestionCoordinatorRoomMemberProvider + private var commandProvider: CompletionSuggestionCoordinatorCommandProvider + + private var cancellables = Set() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: (() -> Void)? + + weak var delegate: CompletionSuggestionCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: CompletionSuggestionCoordinatorParameters) { + self.parameters = parameters + + roomMemberProvider = CompletionSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) + commandProvider = CompletionSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + completionSuggestionService = CompletionSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) + + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) + + completionSuggestionViewModel = viewModel + completionSuggestionHostingController = VectorHostingController(rootView: view) + + completionSuggestionViewModel.completion = { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .selectedItemWithIdentifier(let identifier): + 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?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, 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) + } + } + } + + completionSuggestionService.items.sink { [weak self] _ in + guard let self = self else { return } + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) + }.store(in: &cancellables) + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionService.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + completionSuggestionService.processSuggestionPattern(suggestionPattern) + } + + // MARK: - Public + + func start() { } + + func toPresentable() -> UIViewController { + completionSuggestionHostingController + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + CompletionSuggestionViewModelContextWrapper(context: completionSuggestionViewModel.sharedContext) + } + + // MARK: - Private + + private func calculateViewHeight() -> CGFloat { + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) + + let controller = VectorHostingController(rootView: view) + guard let view = controller.view else { + return 0 + } + view.isHidden = true + + toPresentable().view.addSubview(view) + controller.didMove(toParent: toPresentable()) + + view.setNeedsLayout() + view.layoutIfNeeded() + + let result = view.intrinsicContentSize.height + + controller.didMove(toParent: nil) + view.removeFromSuperview() + + return result + } +} + +private class CompletionSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { + private let room: MXRoom + private let userID: String + + var roomMembers: [MXRoomMember] = [] + var canMentionRoom = false + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + /// Gets the power levels for the room to update suggestions accordingly. + func updateWithPowerLevels() { + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + let mentionRoomPowerLevel = powerLevels.minimumPowerLevel(forNotifications: kMXRoomPowerLevelNotificationsRoomKey, + defaultPower: kMXRoomPowerLevelNotificationsRoomDefault) + self.canMentionRoom = userPowerLevel >= mentionRoomPowerLevel + } + } + + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { + room.members { [weak self] roomMembers in + guard let self = self, let joinedMembers = roomMembers?.joinedMembers else { + return + } + self.roomMembers = joinedMembers + members(self.roomMembersToProviderMembers(joinedMembers)) + } lazyLoadedMembers: { [weak self] lazyRoomMembers in + guard let self = self, let joinedMembers = lazyRoomMembers?.joinedMembers else { + return + } + self.roomMembers = joinedMembers + members(self.roomMembersToProviderMembers(joinedMembers)) + } failure: { error in + MXLog.error("[CompletionSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) + } + } + + private func roomMembersToProviderMembers(_ roomMembers: [MXRoomMember]) -> [RoomMembersProviderMember] { + roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } + } +} + +private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { + private let room: MXRoom + private let userID: String + + var commands = MXKSlashCommand.allCases + var isRoomAdmin = false + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + func updateWithPowerLevels() { + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + self.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, requiresAdminPowerLevel: $0.requiresAdminPowerLevel) }) + } +} + +private extension MXKSlashCommand { + var description: String { + switch self { + case .changeDisplayName: + return VectorL10n.roomCommandChangeDisplayNameDescription + case .emote: + return VectorL10n.roomCommandEmoteDescription + case .joinRoom: + return VectorL10n.roomCommandJoinRoomDescription + case .partRoom: + return VectorL10n.roomCommandPartRoomDescription + case .inviteUser: + return VectorL10n.roomCommandInviteUserDescription + case .kickUser: + return VectorL10n.roomCommandKickUserDescription + case .banUser: + return VectorL10n.roomCommandBanUserDescription + case .unbanUser: + return VectorL10n.roomCommandUnbanUserDescription + case .setUserPowerLevel: + return VectorL10n.roomCommandSetUserPowerLevelDescription + case .resetUserPowerLevel: + return VectorL10n.roomCommandResetUserPowerLevelDescription + case .changeRoomTopic: + return VectorL10n.roomCommandChangeRoomTopicDescription + case .discardSession: + return VectorL10n.roomCommandDiscardSessionDescription + } + } + + // 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/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/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift new file mode 100644 index 0000000000..5ded36c2cd --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -0,0 +1,231 @@ +// +// 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 Foundation +import WysiwygComposer + +struct RoomMembersProviderMember { + var userId: String + var displayName: String + var avatarUrl: String +} + +struct CommandsProviderCommand { + let name: String + let parametersFormat: String + let description: String + let requiresAdminPowerLevel: Bool +} + +class CompletionSuggestionUserID: NSObject { + /// A special case added for suggesting `@room` mentions. + @objc static let room = "@room" +} + +protocol RoomMembersProviderProtocol { + var canMentionRoom: Bool { get } + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) +} + +protocol CommandsProviderProtocol { + var isRoomAdmin: Bool { get } + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) +} + +struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol { + let userId: String + let displayName: String? + let avatarUrl: String? +} + +struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { + let name: String + let parametersFormat: String + let description: String +} + +class CompletionSuggestionService: CompletionSuggestionServiceProtocol { + // MARK: - Properties + + // MARK: Private + + private let roomMemberProvider: RoomMembersProviderProtocol + private let commandProvider: CommandsProviderProtocol + + private var suggestionItems: [CompletionSuggestionItem] = [] + private let currentTextTriggerSubject = CurrentValueSubject(nil) + private var cancellables = Set() + + // MARK: Public + + var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) + + var currentTextTrigger: String? { + currentTextTriggerSubject.value?.asString() + } + + // MARK: - Setup + + init(roomMemberProvider: RoomMembersProviderProtocol, + commandProvider: CommandsProviderProtocol, + shouldDebounce: Bool = true) { + self.roomMemberProvider = roomMemberProvider + self.commandProvider = commandProvider + + if shouldDebounce { + currentTextTriggerSubject + .debounce(for: 0.5, scheduler: RunLoop.main) + .removeDuplicates() + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } + .store(in: &cancellables) + } else { + currentTextTriggerSubject + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } + .store(in: &cancellables) + } + } + + // MARK: - CompletionSuggestionServiceProtocol + + func processTextMessage(_ textMessage: String?) { + guard let textMessage = textMessage, + let textTrigger = textMessage.currentTextTrigger + else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + currentTextTriggerSubject.send(textTrigger) + } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + guard let suggestionPattern else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + switch suggestionPattern.key { + case .at: + currentTextTriggerSubject.send(TextTrigger(key: .at, text: suggestionPattern.text)) + case .hash: + // No room suggestion support yet + items.send([]) + currentTextTriggerSubject.send(nil) + case .slash: + currentTextTriggerSubject.send(TextTrigger(key: .slash, text: suggestionPattern.text)) + } + } + + // MARK: - Private + + 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 + } + + self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in + 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(completionSuggestionUserItem) = item else { return false } + + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(textTrigger.text.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(textTrigger.text.lowercased()) + + return (containedInUsername || containedInDisplayName) + }) + } + case .slash: + commandProvider.fetchCommands { [weak self] commands in + guard let self else { return } + + self.suggestionItems = commands.filtered(isRoomAdmin: self.commandProvider.isRoomAdmin).map { command in + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( + name: command.name, + parametersFormat: command.parametersFormat, + description: command.description + )) + } + + 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(textTrigger.text.lowercased()) + }) + } + } + } + } +} + +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: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] + } +} + +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 = "/" +} + +private struct TextTrigger: Equatable { + let key: SuggestionKey + let text: String + + func asString() -> String { + String(key.rawValue) + text + } +} + +private extension String { + // Returns current completion suggestion for a text message, if any. + var currentTextTrigger: TextTrigger? { + let components = 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) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift similarity index 65% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 43006dbed9..3930c59d16 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -18,14 +18,25 @@ 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 UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never> { get } +protocol CompletionSuggestionCommandItemProtocol { + var name: String { get } + var parametersFormat: String { get } + var description: String { get } +} + +enum CompletionSuggestionItem { + case command(value: CompletionSuggestionCommandItemProtocol) + case user(value: CompletionSuggestionUserItemProtocol) +} + +protocol CompletionSuggestionServiceProtocol { + var items: CurrentValueSubject<[CompletionSuggestionItem], Never> { get } var currentTextTrigger: String? { get } @@ -35,7 +46,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/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift new file mode 100644 index 0000000000..90542868d4 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -0,0 +1,266 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +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") + + service.processTextMessage("@al") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@ice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@alice:matrix.org") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + } + + func testBob() { + service.processTextMessage("@ob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + + service.processTextMessage("@ob:") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + + service.processTextMessage("@b:matrix") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + } + + func testBoth() { + service.processTextMessage("@:matrix") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") + + service.processTextMessage("@.org") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") + } + + func testEmptyResult() { + service.processTextMessage("Lorem ipsum idolor") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@@") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("alice@matrix.org") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testStuff() { + service.processTextMessage("@@") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testWhitespaces() { + service.processTextMessage("") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("\n") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" \n ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@A ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" @A ") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testRoomWithoutPower() { + // Given a user without the power to mention a room. + canMentionRoom = false + + // Given a user without the power to mention a room. + service.processTextMessage("@ro") + + // Then the completion for a room mention should not be shown. + XCTAssertTrue(service.items.value.isEmpty) + } + + func testRoomWithPower() { + // Given a user with the power to mention a room. + canMentionRoom = true + + // 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?.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 { + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { + let users = [("Alice", "@alice:matrix.org"), + ("Bob", "@bob:matrix.org")] + + members(users.map { user in + RoomMembersProviderMember(userId: user.1, displayName: user.0, avatarUrl: "") + }) + } +} + +extension CompletionSuggestionServiceTests: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + 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", + requiresAdminPowerLevel: false) + ]) + } +} + +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 83% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift index e509a58b3f..cf8e34e023 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 @@ -30,7 +30,7 @@ struct UserSuggestionList: 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 } @@ -43,17 +43,15 @@ struct UserSuggestionList: View { // MARK: Public - @ObservedObject var viewModel: UserSuggestionViewModel.Context - var showBackgroundShadow: Bool = true + @ObservedObject var viewModel: CompletionSuggestionViewModel.Context + var showBackgroundShadow = true var body: some View { if viewModel.viewState.items.isEmpty { EmptyView() } else { ZStack { - UserSuggestionListItem(avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), - displayName: "Prototype", - userId: "Prototype") + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user(id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), displayName: "Prototype")) .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() if showBackgroundShadow { @@ -76,12 +74,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)) + CompletionSuggestionListItem(content: item) + .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) } } .listStyle(PlainListStyle()) @@ -135,8 +129,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/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift new file mode 100644 index 0000000000..4a16161897 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -0,0 +1,84 @@ +// +// 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 SwiftUI + +struct CompletionSuggestionListItem: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let content: CompletionSuggestionViewStateItem + + var body: some View { + HStack { + switch content { + 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") + } + 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) + } + } + } + } +} + +// MARK: - Previews + +struct CompletionSuggestionHeader_Previews: PreviewProvider { + static var previews: some View { + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( + id: "@alice:matrix.org", + avatar: MockAvatarInput.example, + displayName: "Alice" + )) + .environmentObject(AvatarViewModel.withMockedServices()) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift similarity index 70% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 176be8ec41..223b4fbc61 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -16,31 +16,31 @@ 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) - TextField("Search for user", text: $inputText) + CompletionSuggestionList(viewModel: viewModel.listViewModel.context) + 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 } } } @@ -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/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift deleted file mode 100644 index a2156cd89a..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ /dev/null @@ -1,201 +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 Foundation -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, didUpdateViewHeight height: CGFloat) -} - -struct UserSuggestionCoordinatorParameters { - 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 - - init(context: UserSuggestionViewModelType.Context) { - self.context = context - } -} - -final class UserSuggestionCoordinator: Coordinator, Presentable { - // MARK: - Properties - - // MARK: Private - - private let parameters: UserSuggestionCoordinatorParameters - - private var userSuggestionHostingController: UIHostingController - private var userSuggestionService: UserSuggestionServiceProtocol - private var userSuggestionViewModel: UserSuggestionViewModelProtocol - private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider - - private var cancellables = Set() - - // MARK: Public - - // Must be used only internally - var childCoordinators: [Coordinator] = [] - var completion: (() -> Void)? - - weak var delegate: UserSuggestionCoordinatorDelegate? - - // MARK: - Setup - - init(parameters: UserSuggestionCoordinatorParameters) { - self.parameters = parameters - - roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider) - - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - - userSuggestionViewModel = viewModel - userSuggestionHostingController = VectorHostingController(rootView: view) - - userSuggestionViewModel.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) - return - } - - guard let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first else { - return - } - - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) - } - } - - userSuggestionService.items.sink { [weak self] _ in - guard let self = self else { return } - self.delegate?.userSuggestionCoordinator(self, - didUpdateViewHeight: self.calculateViewHeight()) - }.store(in: &cancellables) - } - - func processTextMessage(_ textMessage: String) { - userSuggestionService.processTextMessage(textMessage) - } - - func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - userSuggestionService.processSuggestionPattern(suggestionPattern) - } - - // MARK: - Public - - func start() { } - - func toPresentable() -> UIViewController { - userSuggestionHostingController - } - - func sharedContext() -> UserSuggestionViewModelContextWrapper { - UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) - } - - // MARK: - Private - - private func calculateViewHeight() -> CGFloat { - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - - let controller = VectorHostingController(rootView: view) - guard let view = controller.view else { - return 0 - } - view.isHidden = true - - toPresentable().view.addSubview(view) - controller.didMove(toParent: toPresentable()) - - view.setNeedsLayout() - view.layoutIfNeeded() - - let result = view.intrinsicContentSize.height - - controller.didMove(toParent: nil) - view.removeFromSuperview() - - return result - } -} - -private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { - private let room: MXRoom - private let userID: String - - var roomMembers: [MXRoomMember] = [] - var canMentionRoom = false - - init(room: MXRoom, userID: String) { - self.room = room - self.userID = userID - updateWithPowerLevels() - } - - /// Gets the power levels for the room to update suggestions accordingly. - func updateWithPowerLevels() { - room.state { [weak self] state in - guard let self, let powerLevels = state?.powerLevels else { return } - let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - let mentionRoomPowerLevel = powerLevels.minimumPowerLevel(forNotifications: kMXRoomPowerLevelNotificationsRoomKey, - defaultPower: kMXRoomPowerLevelNotificationsRoomDefault) - self.canMentionRoom = userPowerLevel >= mentionRoomPowerLevel - } - } - - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { - room.members { [weak self] roomMembers in - guard let self = self, let joinedMembers = roomMembers?.joinedMembers else { - return - } - self.roomMembers = joinedMembers - members(self.roomMembersToProviderMembers(joinedMembers)) - } lazyLoadedMembers: { [weak self] lazyRoomMembers in - guard let self = self, let joinedMembers = lazyRoomMembers?.joinedMembers else { - return - } - self.roomMembers = joinedMembers - members(self.roomMembersToProviderMembers(joinedMembers)) - } failure: { error in - MXLog.error("[UserSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) - } - } - - private func roomMembersToProviderMembers(_ roomMembers: [MXRoomMember]) -> [RoomMembersProviderMember] { - roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift deleted file mode 100644 index 0d1f6795e6..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ /dev/null @@ -1,74 +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, 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, 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 deleted file mode 100644 index a790e28458..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ /dev/null @@ -1,140 +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 Foundation -import WysiwygComposer - -struct RoomMembersProviderMember { - var userId: String - var displayName: String - var avatarUrl: String -} - -class UserSuggestionID: NSObject { - /// A special case added for suggesting `@room` mentions. - @objc static let room = "@room" -} - -protocol RoomMembersProviderProtocol { - var canMentionRoom: Bool { get } - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) -} - -struct UserSuggestionServiceItem: UserSuggestionItemProtocol { - let userId: String - let displayName: String? - let avatarUrl: String? -} - -class UserSuggestionService: UserSuggestionServiceProtocol { - // MARK: - Properties - - // MARK: Private - - private let roomMemberProvider: RoomMembersProviderProtocol - - private var suggestionItems: [UserSuggestionItemProtocol] = [] - private let currentTextTriggerSubject = CurrentValueSubject(nil) - private var cancellables = Set() - - // MARK: Public - - var items = CurrentValueSubject<[UserSuggestionItemProtocol], Never>([]) - - var currentTextTrigger: String? { - currentTextTriggerSubject.value - } - - // MARK: - Setup - - init(roomMemberProvider: RoomMembersProviderProtocol, shouldDebounce: Bool = true) { - self.roomMemberProvider = roomMemberProvider - - if shouldDebounce { - currentTextTriggerSubject - .debounce(for: 0.5, scheduler: RunLoop.main) - .removeDuplicates() - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } - .store(in: &cancellables) - } else { - currentTextTriggerSubject - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } - .store(in: &cancellables) - } - } - - // MARK: - UserSuggestionServiceProtocol - - func processTextMessage(_ textMessage: String?) { - 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 - else { - items.send([]) - currentTextTriggerSubject.send(nil) - return - } - - currentTextTriggerSubject.send(lastComponent) - } - - func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - guard let suggestionPattern, suggestionPattern.key == .at else { - items.send([]) - currentTextTriggerSubject.send(nil) - return - } - - currentTextTriggerSubject.send("@" + suggestionPattern.text) - } - - // MARK: - Private - - private func fetchAndFilterMembersForTextTrigger(_ textTrigger: String?) { - guard var partialName = textTrigger else { - return - } - - 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 - UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl) - } - - 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) - }) - } - } -} - -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: "")] - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift deleted file mode 100644 index 7ae0bfa39e..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift +++ /dev/null @@ -1,140 +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 XCTest - -@testable import RiotSwiftUI - -class UserSuggestionServiceTests: XCTestCase { - var service: UserSuggestionService! - var canMentionRoom = false - - override func setUp() { - service = UserSuggestionService(roomMemberProvider: self, shouldDebounce: false) - canMentionRoom = false - } - - func testAlice() { - service.processTextMessage("@Al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@ice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@Alice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@alice:matrix.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - } - - func testBob() { - service.processTextMessage("@ob") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - - service.processTextMessage("@ob:") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - - service.processTextMessage("@b:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - } - - func testBoth() { - service.processTextMessage("@:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") - - service.processTextMessage("@.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") - } - - func testEmptyResult() { - service.processTextMessage("Lorem ipsum idolor") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@@") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("alice@matrix.org") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testStuff() { - service.processTextMessage("@@") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testWhitespaces() { - service.processTextMessage("") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("\n") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" \n ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@A ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" @A ") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testRoomWithoutPower() { - // Given a user without the power to mention a room. - canMentionRoom = false - - // Given a user without the power to mention a room. - service.processTextMessage("@ro") - - // Then the completion for a room mention should not be shown. - XCTAssertTrue(service.items.value.isEmpty) - } - - func testRoomWithPower() { - // Given a user without the power to mention a room. - canMentionRoom = true - - // Given a user without 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) - } -} - -extension UserSuggestionServiceTests: RoomMembersProviderProtocol { - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { - let users = [("Alice", "@alice:matrix.org"), - ("Bob", "@bob:matrix.org")] - - members(users.map { user in - RoomMembersProviderMember(userId: user.1, displayName: user.0, avatarUrl: "") - }) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift deleted file mode 100644 index 0a9395fa56..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ /dev/null @@ -1,62 +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 -import SwiftUI - -enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { - case multipleResults - - private static var members: [RoomMembersProviderMember]! - - var screenType: Any.Type { - UserSuggestionList.self - } - - var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self) - let listViewModel = UserSuggestionViewModel(userSuggestionService: service) - - let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in - service.processTextMessage(textMessage) - } - - return ( - [service, listViewModel], - AnyView(UserSuggestionListWithInput(viewModel: viewModel) - .environmentObject(AvatarViewModel.withMockedServices())) - ) - } -} - -extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { - var canMentionRoom: Bool { false } - - func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { - if Self.members == nil { - Self.members = generateUsersWithCount(10) - } - - members(Self.members) - } - - private func generateUsersWithCount(_ count: UInt) -> [RoomMembersProviderMember] { - (0.. - -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 - UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.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) - } - }.store(in: &cancellables) - } - - // MARK: - Public - - override func process(viewAction: UserSuggestionViewAction) { - switch viewAction { - case .selectedItem(let item): - completion?(.selectedItemWithIdentifier(item.id)) - } - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift deleted file mode 100644 index 862e7573d6..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ /dev/null @@ -1,60 +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 SwiftUI - -struct UserSuggestionListItem: View { - // MARK: - Properties - - // MARK: Private - - @Environment(\.theme) private var theme: ThemeSwiftUI - - // MARK: Public - - let avatar: AvatarInputProtocol? - let displayName: String? - let userId: String - - var body: some View { - HStack { - 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) - } - } - } -} - -// MARK: - Previews - -struct UserSuggestionHeader_Previews: PreviewProvider { - static var previews: some View { - UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org") - .environmentObject(AvatarViewModel.withMockedServices()) - } -} 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