From 59dffb5806a86e5e70a516f8e24e31092e7060c3 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Tue, 18 Apr 2023 11:06:25 +0100 Subject: [PATCH 01/51] Deprecate MXLegacyCrypto --- Config/CommonConfiguration.swift | 23 +- Config/Configurable.swift | 3 - Riot/Assets/en.lproj/Vector.strings | 3 - .../MXBugReportRestClient+Riot.swift | 1 - Riot/Experiments/CryptoSDKFeature.swift | 116 --------- Riot/Generated/Strings.swift | 12 - Riot/Modules/Analytics/Analytics.swift | 2 +- .../Analytics/SentryMonitoringClient.swift | 3 - Riot/Modules/Application/LegacyAppDelegate.m | 242 +----------------- .../AuthenticationCoordinator.swift | 9 +- .../LegacyAuthenticationCoordinator.swift | 9 +- .../SessionVerificationListener.swift | 15 +- Riot/Modules/Call/CallViewController.m | 28 +- .../AllChats/AllChatsViewController.swift | 3 +- .../LaunchLoading/LaunchLoadingView.swift | 3 - .../MatrixKit/Models/Account/MXKAccount.m | 10 +- Riot/Modules/Room/RoomViewController.m | 17 +- .../RoomKeyRequestViewController.h | 62 ----- .../RoomKeyRequestViewController.m | 195 -------------- .../Modules/Settings/SettingsViewController.m | 44 +--- .../UserDevices/UsersDevicesViewController.m | 18 +- RiotNSE/NotificationService.swift | 14 +- RiotShareExtension/Shared/ShareManager.m | 5 - .../Service/MatrixSDK/QRLoginService.swift | 11 - .../Experiments/CryptoSDKFeatureTests.swift | 79 ------ .../SendMessage/SendMessageIntentHandler.m | 6 - changelog.d/pr-7508.change | 1 + 27 files changed, 38 insertions(+), 896 deletions(-) delete mode 100644 Riot/Experiments/CryptoSDKFeature.swift delete mode 100644 Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h delete mode 100644 Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m delete mode 100644 RiotTests/Experiments/CryptoSDKFeatureTests.swift create mode 100644 changelog.d/pr-7508.change diff --git a/Config/CommonConfiguration.swift b/Config/CommonConfiguration.swift index b00f188318..4a2c057856 100644 --- a/Config/CommonConfiguration.swift +++ b/Config/CommonConfiguration.swift @@ -92,8 +92,7 @@ class CommonConfiguration: NSObject, Configurable { sdkOptions.enableNewClientInformationFeature = RiotSettings.shared.enableClientInformationFeature - // Configure Crypto SDK feature deciding which crypto module to use - sdkOptions.cryptoSDKFeature = CryptoSDKFeature.shared + sdkOptions.cryptoMigrationDelegate = self } private func makeASCIIUserAgent() -> String? { @@ -168,14 +167,16 @@ class CommonConfiguration: NSObject, Configurable { if RiotSettings.shared.allowStunServerFallback, let stunServerFallback = BuildSettings.stunServerFallbackUrlString { callManager.fallbackSTUNServer = stunServerFallback } + } +} + +extension CommonConfiguration: MXCryptoV2MigrationDelegate { + var needsVerificationUpgrade: Bool { + get { + RiotSettings.shared.showVerificationUpgradeAlert + } + set { + RiotSettings.shared.showVerificationUpgradeAlert = newValue + } } - - - // MARK: - Per loaded matrix session settings - - func setupSettingsWhenLoaded(for matrixSession: MXSession) { - // Do not warn for unknown devices. We have cross-signing now - (matrixSession.crypto as? MXLegacyCrypto)?.warnOnUnknowDevices = false - } - } diff --git a/Config/Configurable.swift b/Config/Configurable.swift index acfb97605d..2f1c46a037 100644 --- a/Config/Configurable.swift +++ b/Config/Configurable.swift @@ -24,7 +24,4 @@ import MatrixSDK // MARK: - Per matrix session settings func setupSettings(for matrixSession: MXSession) - - // MARK: - Per loaded matrix session settings - func setupSettingsWhenLoaded(for matrixSession: MXSession) } diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index d88b99b9d6..c1099f168a 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -807,9 +807,6 @@ Tap the + to start adding people."; "settings_labs_enable_new_app_layout" = "New Application Layout"; "settings_labs_enable_wysiwyg_composer" = "Try out the rich text editor"; "settings_labs_enable_voice_broadcast" = "Voice broadcast"; -"settings_labs_enable_crypto_sdk" = "Rust end-to-end encryption"; -"settings_labs_confirm_crypto_sdk" = "Please be advised that as this feature is still in its experimental stage, it may not function as expected and could potentially have unintended consequences. To revert the feature, simply log out and log back in. Use at your own discretion and with caution."; -"settings_labs_disable_crypto_sdk" = "Rust end-to-end encryption (log out to disable)"; "settings_version" = "Version %@"; "settings_olm_version" = "Olm Version %@"; diff --git a/Riot/Categories/MXBugReportRestClient+Riot.swift b/Riot/Categories/MXBugReportRestClient+Riot.swift index fef876f921..b836f1ab44 100644 --- a/Riot/Categories/MXBugReportRestClient+Riot.swift +++ b/Riot/Categories/MXBugReportRestClient+Riot.swift @@ -70,7 +70,6 @@ extension MXBugReportRestClient { // SDKs userInfo["matrix_sdk_version"] = MatrixSDKVersion - userInfo["crypto_module"] = MXSDKOptions.sharedInstance().cryptoModuleId if let crypto = mainAccount?.mxSession?.crypto { userInfo["crypto_module_version"] = crypto.version } diff --git a/Riot/Experiments/CryptoSDKFeature.swift b/Riot/Experiments/CryptoSDKFeature.swift deleted file mode 100644 index e52fc637b9..0000000000 --- a/Riot/Experiments/CryptoSDKFeature.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// 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. -// - -import Foundation -import MatrixSDKCrypto - -/// An implementation of `MXCryptoV2Feature` which uses `UserDefaults` to persist the enabled status -/// of `CryptoSDK`, and which uses feature flags to control rollout availability. -/// -/// The implementation uses both remote and local feature flags to control the availability of `CryptoSDK`. -/// Whilst remote is more convenient in that it allows changes to the rollout without new app releases, -/// it is not available to all users because it requires data tracking user consent. Remote therefore -/// represents the safer, albeit limited rollout strategy, whereas the local feature flags allows eventually -/// targetting all users, but each target change requires new app release. -/// -/// Additionally users can manually enable this feature from the settings if they are not already in the -/// feature group. -@objc class CryptoSDKFeature: NSObject, MXCryptoV2Feature { - @objc static let shared = CryptoSDKFeature() - - var isEnabled: Bool { - RiotSettings.shared.enableCryptoSDK - } - - var needsVerificationUpgrade: Bool { - get { - return RiotSettings.shared.showVerificationUpgradeAlert - } - set { - RiotSettings.shared.showVerificationUpgradeAlert = newValue - } - } - - private static let FeatureName = "ios-crypto-sdk" - private static let FeatureNameV2 = "ios-crypto-sdk-v2" - - private let remoteFeature: RemoteFeaturesClientProtocol - private let localFeature: PhasedRolloutFeature - - init( - remoteFeature: RemoteFeaturesClientProtocol = PostHogAnalyticsClient.shared, - localTargetPercentage: Double = 1 - ) { - self.remoteFeature = remoteFeature - self.localFeature = PhasedRolloutFeature( - name: Self.FeatureName, - targetPercentage: localTargetPercentage - ) - } - - func enable() { - RiotSettings.shared.enableCryptoSDK = true - Analytics.shared.trackCryptoSDKEnabled() - - MXLog.debug("[CryptoSDKFeature] Crypto SDK enabled") - } - - func enableIfAvailable(forUserId userId: String!) { - guard !isEnabled else { - MXLog.debug("[CryptoSDKFeature] enableIfAvailable: Feature is already enabled") - return - } - - guard let userId else { - MXLog.failure("[CryptoSDKFeature] enableIfAvailable: Missing user id") - return - } - - guard isFeatureEnabled(userId: userId) else { - MXLog.debug("[CryptoSDKFeature] enableIfAvailable: Feature is currently not available for this user") - return - } - - MXLog.debug("[CryptoSDKFeature] enableIfAvailable: Feature has become available for this user and will be enabled") - enable() - } - - @objc func canManuallyEnable(forUserId userId: String!) -> Bool { - guard let userId else { - MXLog.failure("[CryptoSDKFeature] canManuallyEnable: Missing user id") - return false - } - - // User can manually enable only if not already within the automatic feature group - return !isFeatureEnabled(userId: userId) - } - - @objc func reset() { - RiotSettings.shared.enableCryptoSDK = false - MXLog.debug("[CryptoSDKFeature] Crypto SDK disabled") - } - - private func isFeatureEnabled(userId: String) -> Bool { - // This feature includes app version with a bug, and thus will not be rolled out to 100% users - remoteFeature.isFeatureEnabled(Self.FeatureName) - - // Second version of the remote feature with a bugfix and released eventually to 100% users - || remoteFeature.isFeatureEnabled(Self.FeatureNameV2) - - // Local feature - || localFeature.isEnabled(userId: userId) - } -} diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index c02a605c6d..0cdd03d2d7 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -7647,18 +7647,10 @@ public class VectorL10n: NSObject { public static var settingsLabs: String { return VectorL10n.tr("Vector", "settings_labs") } - /// Please be advised that as this feature is still in its experimental stage, it may not function as expected and could potentially have unintended consequences. To revert the feature, simply log out and log back in. Use at your own discretion and with caution. - public static var settingsLabsConfirmCryptoSdk: String { - return VectorL10n.tr("Vector", "settings_labs_confirm_crypto_sdk") - } /// Create conference calls with jitsi public static var settingsLabsCreateConferenceWithJitsi: String { return VectorL10n.tr("Vector", "settings_labs_create_conference_with_jitsi") } - /// Rust end-to-end encryption (log out to disable) - public static var settingsLabsDisableCryptoSdk: String { - return VectorL10n.tr("Vector", "settings_labs_disable_crypto_sdk") - } /// End-to-End Encryption public static var settingsLabsE2eEncryption: String { return VectorL10n.tr("Vector", "settings_labs_e2e_encryption") @@ -7671,10 +7663,6 @@ public class VectorL10n: NSObject { public static var settingsLabsEnableAutoReportDecryptionErrors: String { return VectorL10n.tr("Vector", "settings_labs_enable_auto_report_decryption_errors") } - /// Rust end-to-end encryption - public static var settingsLabsEnableCryptoSdk: String { - return VectorL10n.tr("Vector", "settings_labs_enable_crypto_sdk") - } /// Live location sharing - share current location (active development, and temporarily, locations persist in room history) public static var settingsLabsEnableLiveLocationSharing: String { return VectorL10n.tr("Vector", "settings_labs_enable_live_location_sharing") diff --git a/Riot/Modules/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index c48b447e58..1a30841b98 100644 --- a/Riot/Modules/Analytics/Analytics.swift +++ b/Riot/Modules/Analytics/Analytics.swift @@ -274,7 +274,7 @@ extension Analytics { func trackE2EEError(_ reason: DecryptionFailureReason, context: String) { let event = AnalyticsEvent.Error( context: context, - cryptoModule: MXSDKOptions.sharedInstance().enableCryptoSDK ? .Rust : .Native, + cryptoModule: .Rust, domain: .E2EE, name: reason.errorName ) diff --git a/Riot/Modules/Analytics/SentryMonitoringClient.swift b/Riot/Modules/Analytics/SentryMonitoringClient.swift index 78450551ba..54933a7ab3 100644 --- a/Riot/Modules/Analytics/SentryMonitoringClient.swift +++ b/Riot/Modules/Analytics/SentryMonitoringClient.swift @@ -46,9 +46,6 @@ struct SentryMonitoringClient { if let message = event.message?.formatted { event.fingerprint = [message] } - event.tags = [ - "crypto_module": MXSDKOptions.sharedInstance().cryptoModuleId - ] MXLog.debug("[SentryMonitoringClient] Issue detected: \(event)") return event } diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 208cb46ebb..8678f5ab8e 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -33,7 +33,6 @@ #import "ContactDetailsViewController.h" #import "BugReportViewController.h" -#import "RoomKeyRequestViewController.h" #import "DecryptionFailureTracker.h" #import "Tools.h" @@ -114,11 +113,6 @@ @interface LegacyAppDelegate () 0 && keysCount < 3) - || (mxSession.crypto.crossSigning.canTrustCrossSigning && !mxSession.crypto.crossSigning.canCrossSign)) - { - // We should have 3 of them. If not, request them again as mitigation - MXLogDebug(@"[AppDelegate] checkLocalPrivateKeysInSession: request keys because keysCount = %@", @(keysCount)); - [crypto requestAllPrivateKeys]; - } -} - - (void)authenticationDidComplete { [self handleAppState]; @@ -3461,173 +3396,6 @@ - (void)checkCrossSigningForSession:(MXSession*)mxSession } } - -#pragma mark - Incoming room key requests handling - -- (void)enableRoomKeyRequestObserver:(MXSession*)mxSession -{ - roomKeyRequestObserver = - [[NSNotificationCenter defaultCenter] addObserverForName:kMXCryptoRoomKeyRequestNotification - object:mxSession.crypto - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification *notif) - { - [self checkPendingRoomKeyRequestsInSession:mxSession]; - }]; - - roomKeyRequestCancellationObserver = - [[NSNotificationCenter defaultCenter] addObserverForName:kMXCryptoRoomKeyRequestCancellationNotification - object:mxSession.crypto - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification *notif) - { - [self checkPendingRoomKeyRequestsInSession:mxSession]; - }]; -} - -- (void)disableRoomKeyRequestObserver:(MXSession*)mxSession -{ - if (roomKeyRequestObserver) - { - [[NSNotificationCenter defaultCenter] removeObserver:roomKeyRequestObserver]; - roomKeyRequestObserver = nil; - } - - if (roomKeyRequestCancellationObserver) - { - [[NSNotificationCenter defaultCenter] removeObserver:roomKeyRequestCancellationObserver]; - roomKeyRequestCancellationObserver = nil; - } -} - -// Check if a key share dialog must be displayed for the given session -- (void)checkPendingRoomKeyRequestsInSession:(MXSession*)mxSession -{ - if ([UIApplication sharedApplication].applicationState != UIApplicationStateActive) - { - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession called while the app is not active. Ignore it."); - return; - } - - if (![mxSession.crypto isKindOfClass:[MXLegacyCrypto class]]) - { - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Only legacy crypto allows manually accepting/rejecting key requests"); - return; - } - MXLegacyCrypto *crypto = (MXLegacyCrypto *)mxSession.crypto; - - MXWeakify(self); - [crypto pendingKeyRequests:^(MXUsersDevicesMap *> *pendingKeyRequests) { - - MXStrongifyAndReturnIfNil(self); - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: cross-signing state: %ld, pendingKeyRequests.count: %@. Already displayed: %@", - crypto.crossSigning.state, - @(pendingKeyRequests.count), - self->roomKeyRequestViewController ? @"YES" : @"NO"); - - if (!crypto.crossSigning || crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped) - { - if (self->roomKeyRequestViewController) - { - // Check if the current RoomKeyRequestViewController is still valid - MXSession *currentMXSession = self->roomKeyRequestViewController.mxSession; - NSString *currentUser = self->roomKeyRequestViewController.device.userId; - NSString *currentDevice = self->roomKeyRequestViewController.device.deviceId; - - NSArray *currentPendingRequest = [pendingKeyRequests objectForDevice:currentDevice forUser:currentUser]; - - if (currentMXSession == mxSession && currentPendingRequest.count == 0) - { - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Cancel current dialog"); - - // The key request has been probably cancelled, remove the popup - [self->roomKeyRequestViewController hide]; - self->roomKeyRequestViewController = nil; - } - } - } - - if (!self->roomKeyRequestViewController && pendingKeyRequests.count) - { - // Pick the first coming user/device pair - NSString *userId = pendingKeyRequests.userIds.firstObject; - NSString *deviceId = [pendingKeyRequests deviceIdsForUser:userId].firstObject; - - // Give the client a chance to refresh the device list - MXWeakify(self); - [crypto downloadKeys:@[userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { - - MXStrongifyAndReturnIfNil(self); - MXDeviceInfo *deviceInfo = [usersDevicesInfoMap objectForDevice:deviceId forUser:userId]; - if (deviceInfo) - { - if (!crypto.crossSigning || crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped) - { - BOOL wasNewDevice = (deviceInfo.trustLevel.localVerificationStatus == MXDeviceUnknown); - - void (^openDialog)(void) = ^void() - { - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Open dialog for %@", deviceInfo); - - self->roomKeyRequestViewController = [[RoomKeyRequestViewController alloc] initWithDeviceInfo:deviceInfo wasNewDevice:wasNewDevice andMatrixSession:mxSession crypto:crypto onComplete:^{ - - self->roomKeyRequestViewController = nil; - - // Check next pending key request, if any - [self checkPendingRoomKeyRequests]; - }]; - - [self->roomKeyRequestViewController show]; - }; - - // If the device was new before, it's not any more. - if (wasNewDevice) - { - [crypto setDeviceVerification:MXDeviceUnverified forDevice:deviceId ofUser:userId success:openDialog failure:nil]; - } - else - { - openDialog(); - } - } - else if (deviceInfo.trustLevel.isVerified) - { - [crypto acceptAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ - [self checkPendingRoomKeyRequests]; - }]; - } - else - { - [crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ - [self checkPendingRoomKeyRequests]; - }]; - } - } - else - { - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: No details found for device %@:%@", userId, deviceId); - [crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ - [self checkPendingRoomKeyRequests]; - }]; - } - } failure:^(NSError *error) { - // Retry later - MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Failed to download device keys. Retry"); - [self checkPendingRoomKeyRequests]; - }]; - } - }]; -} - -// Check all opened MXSessions for key share dialog -- (void)checkPendingRoomKeyRequests -{ - for (MXSession *mxSession in mxSessionArray) - { - [self checkPendingRoomKeyRequestsInSession:mxSession]; - } -} - #pragma mark - Incoming key verification handling - (void)enableIncomingKeyVerificationObserver:(MXSession*)mxSession @@ -3785,12 +3553,6 @@ - (BOOL)presentSelfVerificationForOtherDeviceId:(NSString*)deviceId inSession:(M - (void)keyVerificationCoordinatorBridgePresenterDelegateDidComplete:(KeyVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter otherUserId:(NSString * _Nonnull)otherUserId otherDeviceId:(NSString * _Nonnull)otherDeviceId { - id crypto = coordinatorBridgePresenter.session.crypto; - if ([crypto isKindOfClass:[MXLegacyCrypto class]] && (!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled)) - { - MXLogDebug(@"[AppDelegate][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys"); - [(MXLegacyCrypto *)crypto setOutgoingKeyRequestsEnabled:YES onComplete:nil]; - } [self dismissKeyVerificationCoordinatorBridgePresenter]; } diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 9f2e7083b6..a245147cdc 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -613,8 +613,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// Replace the contents of the navigation router with a loading animation. private func showLoadingAnimation() { - let startupProgress: MXSessionStartupProgress? = MXSDKOptions.sharedInstance().enableStartupProgress ? session?.startupProgress : nil - let loadingViewController = LaunchLoadingViewController(startupProgress: startupProgress) + let loadingViewController = LaunchLoadingViewController(startupProgress: session?.startupProgress) loadingViewController.modalPresentationStyle = .fullScreen // Replace the navigation stack with the loading animation @@ -759,12 +758,6 @@ extension AuthenticationCoordinator: AuthenticationServiceDelegate { // MARK: - KeyVerificationCoordinatorDelegate extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { - if let crypto = session?.crypto as? MXLegacyCrypto, let backup = crypto.backup, - !backup.hasPrivateKeyInCryptoStore || !backup.enabled { - MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - } - navigationRouter.dismissModule(animated: true) { [weak self] in self?.authenticationDidComplete() } diff --git a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift index d6270edae1..4aea0b8b92 100644 --- a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift @@ -106,8 +106,7 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator // MARK: - Private private func showLoadingAnimation() { - let startupProgress: MXSessionStartupProgress? = MXSDKOptions.sharedInstance().enableStartupProgress ? session?.startupProgress : nil - let loadingViewController = LaunchLoadingViewController(startupProgress: startupProgress) + let loadingViewController = LaunchLoadingViewController(startupProgress: session?.startupProgress) loadingViewController.modalPresentationStyle = .fullScreen // Replace the navigation stack with the loading animation @@ -220,12 +219,6 @@ extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate // MARK: - KeyVerificationCoordinatorDelegate extension LegacyAuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { - if let crypto = session?.crypto as? MXLegacyCrypto, let backup = crypto.backup, - !backup.hasPrivateKeyInCryptoStore || !backup.enabled { - MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - } - navigationRouter.dismissModule(animated: true) { [weak self] in self?.authenticationDidComplete() } diff --git a/Riot/Modules/Authentication/SessionVerificationListener.swift b/Riot/Modules/Authentication/SessionVerificationListener.swift index 214c76695f..ffefd839a2 100644 --- a/Riot/Modules/Authentication/SessionVerificationListener.swift +++ b/Riot/Modules/Authentication/SessionVerificationListener.swift @@ -68,14 +68,7 @@ class SessionVerificationListener { return } - if session.state == .storeDataReady { - if let crypto = session.crypto as? MXLegacyCrypto { - // Do not make key share requests while the "Complete security" is not complete. - // If the device is self-verified, the SDK will restore the existing key backup. - // Then, it will re-enable outgoing key share requests - crypto.setOutgoingKeyRequestsEnabled(false, onComplete: nil) - } - } else if session.state == .running { + if session.state == .running { unregisterSessionStateChangeNotification() if let crypto = session.crypto { @@ -101,7 +94,6 @@ class SessionVerificationListener { self.completion?(.authenticationIsComplete) } failure: { error in MXLog.error("[SessionVerificationListener] sessionStateDidChange: Bootstrap failed", context: error) - (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } } else { @@ -111,12 +103,10 @@ class SessionVerificationListener { self.completion?(.authenticationIsComplete) } failure: { error in MXLog.error("[SessionVerificationListener] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") - (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } } } else { - (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } case .crossSigningExists: @@ -124,13 +114,10 @@ class SessionVerificationListener { self.completion?(.needsVerification) default: MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Nothing to do") - - (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } } failure: { [weak self] error in MXLog.error("[SessionVerificationListener] sessionStateDidChange: Fail to refresh crypto state", context: error) - (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self?.completion?(.authenticationIsComplete) } } else { diff --git a/Riot/Modules/Call/CallViewController.m b/Riot/Modules/Call/CallViewController.m index 3e8227e7cf..680d330fab 100644 --- a/Riot/Modules/Call/CallViewController.m +++ b/Riot/Modules/Call/CallViewController.m @@ -370,28 +370,16 @@ - (void)call:(MXCall *)call didEncounterError:(NSError *)error reason:(MXCallHan { typeof(self) self = weakSelf; self->currentAlert = nil; - - // Acknowledge the existence of all devices - [self startActivityIndicator]; - if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) + + // Retry the call + if (call.isIncoming) { - MXLogFailure(@"[CallViewController] call: Only legacy crypto supports manual setting of known devices"); - return; + [call answer]; + } + else + { + [call callWithVideo:call.isVideoCall]; } - [(MXLegacyCrypto *)self.mainSession.crypto setDevicesKnown:unknownDevices complete:^{ - - [self stopActivityIndicator]; - - // Retry the call - if (call.isIncoming) - { - [call answer]; - } - else - { - [call callWithVideo:call.isVideoCall]; - } - }]; } }]]; diff --git a/Riot/Modules/Home/AllChats/AllChatsViewController.swift b/Riot/Modules/Home/AllChats/AllChatsViewController.swift index ea96873baf..6108d01c2d 100644 --- a/Riot/Modules/Home/AllChats/AllChatsViewController.swift +++ b/Riot/Modules/Home/AllChats/AllChatsViewController.swift @@ -988,8 +988,7 @@ extension AllChatsViewController: SplitViewMasterViewControllerProtocol { let title: String let message: String - if let feature = MXSDKOptions.sharedInstance().cryptoSDKFeature, - feature.isEnabled && feature.needsVerificationUpgrade { + if MXSDKOptions.sharedInstance().cryptoMigrationDelegate?.needsVerificationUpgrade == true { title = VectorL10n.keyVerificationSelfVerifySecurityUpgradeAlertTitle message = VectorL10n.keyVerificationSelfVerifySecurityUpgradeAlertMessage } else { diff --git a/Riot/Modules/LaunchLoading/LaunchLoadingView.swift b/Riot/Modules/LaunchLoading/LaunchLoadingView.swift index 8398c659d5..c4cdee4224 100644 --- a/Riot/Modules/LaunchLoading/LaunchLoadingView.swift +++ b/Riot/Modules/LaunchLoading/LaunchLoadingView.swift @@ -69,9 +69,6 @@ final class LaunchLoadingView: UIView, NibLoadable, Themable { extension LaunchLoadingView: MXSessionStartupProgressDelegate { func sessionDidUpdateStartupProgress(state: MXSessionStartupProgress.State) { - guard MXSDKOptions.sharedInstance().enableStartupProgress else { - return - } update(with: state) } diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index 548442ab77..1d03823759 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -946,15 +946,7 @@ - (void)closeSession:(BOOL)clearStore [MXKRoomDataSourceManager removeSharedManagerForMatrixSession:mxSession]; if (clearStore) - { - // Force a reload of device keys at the next session start, unless we are just about to migrate - // all data and device keys into CryptoSDK. - // This will fix potential UISIs other peoples receive for our messages. - if ([mxSession.crypto isKindOfClass:[MXLegacyCrypto class]] && !MXSDKOptions.sharedInstance.enableCryptoSDK) - { - [(MXLegacyCrypto *)mxSession.crypto resetDeviceKeys]; - } - + { // Clean other stores [mxSession.scanManager deleteAllAntivirusScans]; [mxSession.aggregations resetData]; diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 398e12e8f7..70b8d974c9 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -6356,21 +6356,10 @@ - (void)eventDidChangeSentState:(NSNotification *)notif self->currentAlert = nil; // Acknowledge the existence of all devices - [self startActivityIndicator]; + self->unknownDevices = nil; - if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) - { - MXLogFailure(@"[RoomVC] eventDidChangeSentState: Only legacy crypto supports manual setting of known devices"); - return; - } - [(MXLegacyCrypto *)self.mainSession.crypto setDevicesKnown:self->unknownDevices complete:^{ - - self->unknownDevices = nil; - [self stopActivityIndicator]; - - // And resend pending messages - [self resendAllUnsentMessages]; - }]; + // And resend pending messages + [self resendAllUnsentMessages]; } }]]; diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h deleted file mode 100644 index e9db3a583d..0000000000 --- a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h +++ /dev/null @@ -1,62 +0,0 @@ -/* - Copyright 2017 Vector Creations 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 - -#import - -/** - The `RoomKeyRequestViewController` display a modal dialog at the top of the - application asking the user if he wants to share room keys with a user's device. - For the moment, the user is himself. - */ -@interface RoomKeyRequestViewController : NSObject - -/** - The UIAlertController instance which handles the dialog. - */ -@property (nonatomic, readonly) UIAlertController *alertController; - -@property (nonatomic, readonly) MXSession *mxSession; -@property (nonatomic, readonly) MXDeviceInfo *device; - -/** - Initialise an `RoomKeyRequestViewController` instance. - - @param deviceInfo the device to share keys to. - @param wasNewDevice flag indicating whether this is the first time we meet the device. - @param session the related matrix session. - @param crypto the related (legacy) crypto module - @param onComplete a block called when the the dialog is closed. - @return the newly created instance. - */ -- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo - wasNewDevice:(BOOL)wasNewDevice - andMatrixSession:(MXSession*)session - crypto:(MXLegacyCrypto *)crypto - onComplete:(void (^)(void))onComplete; - -/** - Show the dialog in a modal way. - */ -- (void)show; - -/** - Hide the dialog. - */ -- (void)hide; - -@end diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m deleted file mode 100644 index 6f638bd78f..0000000000 --- a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m +++ /dev/null @@ -1,195 +0,0 @@ -/* - Copyright 2017 Vector Creations 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 "RoomKeyRequestViewController.h" - -#import "GeneratedInterface-Swift.h" - -@interface RoomKeyRequestViewController () -{ - void (^onComplete)(void); - - KeyVerificationCoordinatorBridgePresenter *keyVerificationCoordinatorBridgePresenter; - - BOOL wasNewDevice; -} - -@property (nonatomic, strong) MXLegacyCrypto *crypto; - -@end - -@implementation RoomKeyRequestViewController - -- (instancetype)initWithDeviceInfo:(MXDeviceInfo *)deviceInfo - wasNewDevice:(BOOL)theWasNewDevice - andMatrixSession:(MXSession *)session - crypto:(MXLegacyCrypto *)crypto - onComplete:(void (^)(void))onCompleteBlock -{ - self = [super init]; - if (self) - { - _mxSession = session; - _crypto = crypto; - _device = deviceInfo; - wasNewDevice = theWasNewDevice; - onComplete = onCompleteBlock; - } - return self; -} - -- (void)show -{ - // Show it modally on the root view controller - UIViewController *rootViewController = [AppDelegate theDelegate].window.rootViewController; - if (rootViewController) - { - NSString *title = [VectorL10n e2eRoomKeyRequestTitle]; - NSString *message; - if (wasNewDevice) - { - message = [VectorL10n e2eRoomKeyRequestMessageNewDevice:_device.displayName]; - } - else - { - message = [VectorL10n e2eRoomKeyRequestMessage:_device.displayName]; - } - - _alertController = [UIAlertController alertControllerWithTitle:title - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - __weak typeof(self) weakSelf = self; - - [_alertController addAction:[UIAlertAction actionWithTitle:[VectorL10n e2eRoomKeyRequestStartVerification] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - - self->_alertController = nil; - [self showVerificationView]; - } - }]]; - - [_alertController addAction:[UIAlertAction actionWithTitle:[VectorL10n e2eRoomKeyRequestShareWithoutVerifying] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - - self->_alertController = nil; - - // Accept the received requests from this device - [self.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ - - self->onComplete(); - }]; - } - }]]; - - [_alertController addAction:[UIAlertAction actionWithTitle:[VectorL10n e2eRoomKeyRequestIgnoreRequest] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - - self->_alertController = nil; - - // Ignore all pending requests from this device - [self.crypto ignoreAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ - - self->onComplete(); - }]; - } - }]]; - - [rootViewController presentViewController:_alertController animated:YES completion:nil]; - } -} - -- (void)hide -{ - if (_alertController) - { - [_alertController dismissViewControllerAnimated:YES completion:nil]; - _alertController = nil; - } -} - - -- (void)showVerificationView -{ - // Show it modally on the root view controller - UIViewController *rootViewController = [AppDelegate theDelegate].window.rootViewController; - if (rootViewController) - { - keyVerificationCoordinatorBridgePresenter = [[KeyVerificationCoordinatorBridgePresenter alloc] initWithSession:_mxSession]; - keyVerificationCoordinatorBridgePresenter.delegate = self; - - [keyVerificationCoordinatorBridgePresenter presentFrom:rootViewController otherUserId:_device.userId otherDeviceId:_device.deviceId animated:YES]; - } -} - -#pragma mark - DeviceVerificationCoordinatorBridgePresenterDelegate - -- (void)keyVerificationCoordinatorBridgePresenterDelegateDidComplete:(KeyVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter otherUserId:(NSString * _Nonnull)otherUserId otherDeviceId:(NSString * _Nonnull)otherDeviceId -{ - [self dismissKeyVerificationCoordinatorBridgePresenter]; -} - -- (void)keyVerificationCoordinatorBridgePresenterDelegateDidCancel:(KeyVerificationCoordinatorBridgePresenter * _Nonnull)coordinatorBridgePresenter -{ - [self dismissKeyVerificationCoordinatorBridgePresenter]; -} - -- (void)dismissKeyVerificationCoordinatorBridgePresenter -{ - [keyVerificationCoordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; - keyVerificationCoordinatorBridgePresenter = nil; - - // Check device new status - [self.crypto downloadKeys:@[self.device.userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { - - MXDeviceInfo *deviceInfo = [usersDevicesInfoMap objectForDevice:self.device.deviceId forUser:self.device.userId]; - if (deviceInfo && deviceInfo.trustLevel.localVerificationStatus == MXDeviceVerified) - { - // Accept the received requests from this device - // As the device is now verified, all other key requests will be automatically accepted. - [self.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ - - self->onComplete(); - }]; - } - else - { - // Come back to self.alertController - ie, reopen it - [self show]; - } - } failure:^(NSError *error) { - - // Should not happen (the device is in the crypto db) - [self show]; - }]; -} - -@end diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 244e28be01..055841f3fe 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -176,8 +176,7 @@ typedef NS_ENUM(NSUInteger, LABS_ENABLE) LABS_ENABLE_NEW_SESSION_MANAGER, LABS_ENABLE_NEW_CLIENT_INFO_FEATURE, LABS_ENABLE_WYSIWYG_COMPOSER, - LABS_ENABLE_VOICE_BROADCAST, - LABS_ENABLE_CRYPTO_SDK + LABS_ENABLE_VOICE_BROADCAST }; typedef NS_ENUM(NSUInteger, SECURITY) @@ -588,11 +587,6 @@ - (void)updateSections if (BuildSettings.settingsScreenShowLabSettings) { Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS]; - if ([CryptoSDKFeature.shared canManuallyEnableForUserId:self.mainSession.myUserId]) - { - [sectionLabs addRowWithTag:LABS_ENABLE_CRYPTO_SDK]; - } - [sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX]; [sectionLabs addRowWithTag:LABS_ENABLE_THREADS_INDEX]; [sectionLabs addRowWithTag:LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS]; @@ -2587,18 +2581,6 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableVoiceBroadcastFeature:) forControlEvents:UIControlEventTouchUpInside]; - cell = labelAndSwitchCell; - } - else if (row == LABS_ENABLE_CRYPTO_SDK) - { - MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; - BOOL isEnabled = MXSDKOptions.sharedInstance.enableCryptoSDK; - labelAndSwitchCell.mxkLabel.text = isEnabled ? VectorL10n.settingsLabsDisableCryptoSdk : VectorL10n.settingsLabsEnableCryptoSdk; - labelAndSwitchCell.mxkSwitch.on = isEnabled; - [labelAndSwitchCell.mxkSwitch setEnabled:!isEnabled]; - labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor; - [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(enableCryptoSDKFeature:) forControlEvents:UIControlEventTouchUpInside]; - cell = labelAndSwitchCell; } } @@ -3372,30 +3354,6 @@ - (void)toggleEnableVoiceBroadcastFeature:(UISwitch *)sender RiotSettings.shared.enableVoiceBroadcast = sender.isOn; } -- (void)enableCryptoSDKFeature:(UISwitch *)sender -{ - [currentAlert dismissViewControllerAnimated:NO completion:nil]; - UIAlertController *confirmationAlert = [UIAlertController alertControllerWithTitle:VectorL10n.settingsLabsEnableCryptoSdk - message:VectorL10n.settingsLabsConfirmCryptoSdk - preferredStyle:UIAlertControllerStyleAlert]; - - MXWeakify(self); - [confirmationAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { - MXStrongifyAndReturnIfNil(self); - self->currentAlert = nil; - - [sender setOn:NO animated:YES]; - }]]; - - [confirmationAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n continue] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { - [CryptoSDKFeature.shared enable]; - [[AppDelegate theDelegate] reloadMatrixSessions:YES]; - }]]; - - [self presentViewController:confirmationAlert animated:YES completion:nil]; - currentAlert = confirmationAlert; -} - - (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender { RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn; diff --git a/Riot/Modules/UserDevices/UsersDevicesViewController.m b/Riot/Modules/UserDevices/UsersDevicesViewController.m index 3b5b8c9a86..fcd7bd567e 100644 --- a/Riot/Modules/UserDevices/UsersDevicesViewController.m +++ b/Riot/Modules/UserDevices/UsersDevicesViewController.m @@ -273,22 +273,12 @@ - (void)dismissKeyVerificationCoordinatorBridgePresenter - (IBAction)onDone:(id)sender { // Acknowledge the existence of all devices before leaving this screen - [self startActivityIndicator]; - if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) + [self dismissViewControllerAnimated:YES completion:nil]; + + if (self->onCompleteBlock) { - MXLogFailure(@"[UsersDevicesViewController] onDone: Only legacy crypto supports manual setting of known devices"); - return; + self->onCompleteBlock(YES); } - [(MXLegacyCrypto *)mxSession.crypto setDevicesKnown:usersDevices complete:^{ - - [self stopActivityIndicator]; - [self dismissViewControllerAnimated:YES completion:nil]; - - if (self->onCompleteBlock) - { - self->onCompleteBlock(YES); - } - }]; } - (IBAction)onCancel:(id)sender diff --git a/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift index 0c7257dba9..5880165e8f 100644 --- a/RiotNSE/NotificationService.swift +++ b/RiotNSE/NotificationService.swift @@ -41,7 +41,6 @@ class NotificationService: UNNotificationServiceExtension { private var ongoingVoIPPushRequests: [String: Bool] = [:] private var userAccount: MXKAccount? - private var isCryptoSDKEnabled = false /// Best attempt contents. Will be updated incrementally, if something fails during the process, this best attempt content will be showed as notification. Keys are eventId's private var bestAttemptContents: [String: UNMutableNotificationContent] = [:] @@ -196,13 +195,12 @@ class NotificationService: UNNotificationServiceExtension { self.userAccount = MXKAccountManager.shared()?.activeAccounts.first if let userAccount = userAccount { Self.backgroundServiceInitQueue.sync { - if hasChangedCryptoSDK() || NotificationService.backgroundSyncService?.credentials != userAccount.mxCredentials { + if NotificationService.backgroundSyncService?.credentials != userAccount.mxCredentials { MXLog.debug("[NotificationService] setup: MXBackgroundSyncService init: BEFORE") self.logMemory() NotificationService.backgroundSyncService = MXBackgroundSyncService( withCredentials: userAccount.mxCredentials, - isCryptoSDKEnabled: isCryptoSDKEnabled, persistTokenDataHandler: { persistTokenDataHandler in MXKAccountManager.shared().readAndWriteCredentials(persistTokenDataHandler) }, unauthenticatedHandler: { error, softLogout, refreshTokenAuth, completion in @@ -219,16 +217,6 @@ class NotificationService: UNNotificationServiceExtension { } } - /// Determine whether we have switched from using crypto v1 to v2 or vice versa which will require - /// rebuilding `MXBackgroundSyncService` - private func hasChangedCryptoSDK() -> Bool { - guard isCryptoSDKEnabled != MXSDKOptions.sharedInstance().enableCryptoSDK else { - return false - } - isCryptoSDKEnabled = MXSDKOptions.sharedInstance().enableCryptoSDK - return true - } - /// Attempts to preprocess payload and attach room display name to the best attempt content /// - Parameters: /// - eventId: Event identifier to mutate best attempt content diff --git a/RiotShareExtension/Shared/ShareManager.m b/RiotShareExtension/Shared/ShareManager.m index 22d0063be2..0e1c74cf72 100644 --- a/RiotShareExtension/Shared/ShareManager.m +++ b/RiotShareExtension/Shared/ShareManager.m @@ -102,11 +102,6 @@ - (void)shareViewController:(ShareViewController *)shareViewController didReques [session setStore:self.fileStore success:^{ MXStrongifyAndReturnIfNil(session); - if ([session.crypto isKindOfClass:[MXLegacyCrypto class]]) - { - ((MXLegacyCrypto *)session.crypto).warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now - } - self.selectedRooms = [NSMutableArray array]; for (NSString *roomIdentifier in roomIdentifiers) { MXRoom *room = [MXRoom loadRoomFromStore:self.fileStore withRoomId:roomIdentifier matrixSession:session]; diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift index 9c91000872..9cf127bb7d 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift @@ -267,17 +267,6 @@ class QRLoginService: NSObject, QRLoginServiceProtocol { let session = sessionCreator.createSession(credentials: credentials, client: client, removeOtherAccounts: false) -// MXLog.debug("[QRLoginService] Session created without E2EE support. Inform the interlocutor of finishing") -// guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginFinish, outcome: .success)), -// case .success = await rendezvousService.send(data: requestData) else { -// await teardownRendezvous(state: .failed(error: .rendezvousFailed)) -// return -// } -// -// MXLog.debug("[QRLoginService] Login flow finished, returning session") -// state = .completed(session: session, securityCompleted: false) -// return - let cryptoResult = await withCheckedContinuation { continuation in session.enableCrypto(true) { response in continuation.resume(returning: response) diff --git a/RiotTests/Experiments/CryptoSDKFeatureTests.swift b/RiotTests/Experiments/CryptoSDKFeatureTests.swift deleted file mode 100644 index a512b71c6b..0000000000 --- a/RiotTests/Experiments/CryptoSDKFeatureTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// 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. -// - -import Foundation -import XCTest -@testable import Element - -class CryptoSDKFeatureTests: XCTestCase { - class RemoteFeatureClient: RemoteFeaturesClientProtocol { - var isEnabled = false - func isFeatureEnabled(_ feature: String) -> Bool { - isEnabled - } - } - - var remote: RemoteFeatureClient! - var feature: CryptoSDKFeature! - - override func setUp() { - RiotSettings.shared.enableCryptoSDK = false - remote = RemoteFeatureClient() - feature = CryptoSDKFeature(remoteFeature: remote, localTargetPercentage: 0) - } - - override func tearDown() { - RiotSettings.shared.enableCryptoSDK = false - } - - func test_disabledByDefault() { - XCTAssertFalse(feature.isEnabled) - } - - func test_enable() { - feature.enable() - XCTAssertTrue(feature.isEnabled) - } - - func test_enableIfAvailable_remainsEnabledWhenRemoteClientDisabled() { - feature.enable() - remote.isEnabled = false - - feature.enableIfAvailable(forUserId: "alice") - - XCTAssertTrue(feature.isEnabled) - } - - func test_enableIfAvailable_notEnabledIfRemoteFeatureDisabled() { - remote.isEnabled = false - feature.enableIfAvailable(forUserId: "alice") - XCTAssertFalse(feature.isEnabled) - } - - func test_canManuallyEnable() { - remote.isEnabled = false - XCTAssertTrue(feature.canManuallyEnable(forUserId: "alice")) - - remote.isEnabled = true - XCTAssertFalse(feature.canManuallyEnable(forUserId: "alice")) - } - - func test_reset() { - feature.enable() - feature.reset() - XCTAssertFalse(RiotSettings.shared.enableCryptoSDK) - } -} diff --git a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m index 34ebb66e9b..5bc037790a 100644 --- a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m +++ b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m @@ -117,12 +117,6 @@ - (void)handleSendMessage:(INSendMessageIntent *)intent completion:(void (^)(INS self.selectedRoom = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; - // Do not warn for unknown devices. We have cross-signing now - if ([session.crypto isKindOfClass:[MXLegacyCrypto class]]) - { - ((MXLegacyCrypto *)session.crypto).warnOnUnknowDevices = NO; - } - MXWeakify(self); [self.selectedRoom sendTextMessage:intent.content threadId:nil diff --git a/changelog.d/pr-7508.change b/changelog.d/pr-7508.change new file mode 100644 index 0000000000..dbe206b348 --- /dev/null +++ b/changelog.d/pr-7508.change @@ -0,0 +1 @@ +Crypto: Deprecate MXLegacyCrypto From 9aa4b4d045134fb4e674ea0a49fea1d390af2dfb Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 18 Apr 2023 20:11:40 +0100 Subject: [PATCH 02/51] Prepare for new sprint --- Config/AppVersion.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 5f74246b2c..3e986e4b13 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.10.11 -CURRENT_PROJECT_VERSION = 1.10.11 +MARKETING_VERSION = 1.10.12 +CURRENT_PROJECT_VERSION = 1.10.12 From 855c8d7c9099eba79adf4db5db42ef197571fb6b Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Mon, 13 Mar 2023 16:18:11 +0000 Subject: [PATCH 03/51] Refactor encryption trust level --- Riot/Categories/MXRoom+Riot.m | 30 +-- Riot/Categories/MXRoomSummary+Riot.h | 12 +- Riot/Categories/MXRoomSummary+Riot.m | 29 +-- .../EncryptionInfo/EncryptionInfoView.h | 0 .../EncryptionInfo/EncryptionInfoView.m | 0 .../EncryptionInfo/EncryptionInfoView.xib | 0 .../Encryption/EncryptionTrustLevel.swift | 68 +++++++ ...EncryptionTrustLevelBadgeImageHelper.swift | 0 .../Encryption/RoomEncryptionTrustLevel.h | 25 +++ .../UserEncryptionTrustLevel.h | 0 Riot/SupportingFiles/Riot-Bridging-Header.h | 1 + .../RiotShareExtension-Bridging-Header.h | 2 + RiotShareExtension/target.yml | 1 + .../EncryptionTrustLevelTests.swift | 177 ++++++++++++++++++ 14 files changed, 286 insertions(+), 59 deletions(-) rename Riot/Modules/{ => Encryption}/EncryptionInfo/EncryptionInfoView.h (100%) rename Riot/Modules/{ => Encryption}/EncryptionInfo/EncryptionInfoView.m (100%) rename Riot/Modules/{ => Encryption}/EncryptionInfo/EncryptionInfoView.xib (100%) create mode 100644 Riot/Modules/Encryption/EncryptionTrustLevel.swift rename Riot/{Utils => Modules/Encryption}/EncryptionTrustLevelBadgeImageHelper.swift (100%) create mode 100644 Riot/Modules/Encryption/RoomEncryptionTrustLevel.h rename Riot/Modules/{Room/Members/Detail => Encryption}/UserEncryptionTrustLevel.h (100%) create mode 100644 RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift diff --git a/Riot/Categories/MXRoom+Riot.m b/Riot/Categories/MXRoom+Riot.m index 04538ba804..b81da17596 100644 --- a/Riot/Categories/MXRoom+Riot.m +++ b/Riot/Categories/MXRoom+Riot.m @@ -20,7 +20,7 @@ #import "AvatarGenerator.h" #import "MatrixKit.h" - +#import "GeneratedInterface-Swift.h" #import @implementation MXRoom (Riot) @@ -331,30 +331,10 @@ - (void)encryptionTrustLevelForUserId:(NSString*)userId onComplete:(void (^)(Use { [self.mxSession.crypto trustLevelSummaryForUserIds:@[userId] forceDownload:NO success:^(MXUsersTrustLevelSummary *usersTrustLevelSummary) { - UserEncryptionTrustLevel userEncryptionTrustLevel; - double trustedDevicesPercentage = usersTrustLevelSummary.trustedDevicesProgress.fractionCompleted; - - if (trustedDevicesPercentage >= 1.0) - { - userEncryptionTrustLevel = UserEncryptionTrustLevelTrusted; - } - else if (trustedDevicesPercentage == 0.0) - { - // Verify if the user has the user has cross-signing enabled - if ([self.mxSession.crypto.crossSigning crossSigningKeysForUser:userId]) - { - userEncryptionTrustLevel = UserEncryptionTrustLevelNotVerified; - } - else - { - userEncryptionTrustLevel = UserEncryptionTrustLevelNoCrossSigning; - } - } - else - { - userEncryptionTrustLevel = UserEncryptionTrustLevelWarning; - } - + MXCrossSigningInfo *crossSigningInfo = [self.mxSession.crypto.crossSigning crossSigningKeysForUser:userId]; + EncryptionTrustLevel *encryption = [[EncryptionTrustLevel alloc] init]; + UserEncryptionTrustLevel userEncryptionTrustLevel = [encryption userTrustLevelWithCrossSigning:crossSigningInfo + trustedDevicesProgress:usersTrustLevelSummary.trustedDevicesProgress]; onComplete(userEncryptionTrustLevel); } failure:^(NSError *error) { diff --git a/Riot/Categories/MXRoomSummary+Riot.h b/Riot/Categories/MXRoomSummary+Riot.h index d25cdee5f3..324a7f3698 100644 --- a/Riot/Categories/MXRoomSummary+Riot.h +++ b/Riot/Categories/MXRoomSummary+Riot.h @@ -15,17 +15,7 @@ */ #import "MatrixKit.h" - -/** - RoomEncryptionTrustLevel represents the trust level in an encrypted room. - */ -typedef NS_ENUM(NSUInteger, RoomEncryptionTrustLevel) { - RoomEncryptionTrustLevelTrusted, - RoomEncryptionTrustLevelWarning, - RoomEncryptionTrustLevelNormal, - RoomEncryptionTrustLevelUnknown -}; - +#import "RoomEncryptionTrustLevel.h" /** Define a `MXRoomSummary` category at Riot level. diff --git a/Riot/Categories/MXRoomSummary+Riot.m b/Riot/Categories/MXRoomSummary+Riot.m index c6a55a230e..b2c1eeb407 100644 --- a/Riot/Categories/MXRoomSummary+Riot.m +++ b/Riot/Categories/MXRoomSummary+Riot.m @@ -33,32 +33,15 @@ - (void)setRoomAvatarImageIn:(MXKImageView*)mxkImageView - (RoomEncryptionTrustLevel)roomEncryptionTrustLevel { - RoomEncryptionTrustLevel roomEncryptionTrustLevel = RoomEncryptionTrustLevelUnknown; - if (self.trust) + MXUsersTrustLevelSummary *trust = self.trust; + if (!trust) { - double trustedUsersPercentage = self.trust.trustedUsersProgress.fractionCompleted; - double trustedDevicesPercentage = self.trust.trustedDevicesProgress.fractionCompleted; - - if (trustedUsersPercentage >= 1.0) - { - if (trustedDevicesPercentage >= 1.0) - { - roomEncryptionTrustLevel = RoomEncryptionTrustLevelTrusted; - } - else - { - roomEncryptionTrustLevel = RoomEncryptionTrustLevelWarning; - } - } - else - { - roomEncryptionTrustLevel = RoomEncryptionTrustLevelNormal; - } - - roomEncryptionTrustLevel = roomEncryptionTrustLevel; + MXLogError(@"[MXRoomSummary] roomEncryptionTrustLevel: trust is missing"); + return RoomEncryptionTrustLevelUnknown; } - return roomEncryptionTrustLevel; + EncryptionTrustLevel *encryption = [[EncryptionTrustLevel alloc] init]; + return [encryption roomTrustLevelWithSummary:trust]; } - (BOOL)isJoined diff --git a/Riot/Modules/EncryptionInfo/EncryptionInfoView.h b/Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.h similarity index 100% rename from Riot/Modules/EncryptionInfo/EncryptionInfoView.h rename to Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.h diff --git a/Riot/Modules/EncryptionInfo/EncryptionInfoView.m b/Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.m similarity index 100% rename from Riot/Modules/EncryptionInfo/EncryptionInfoView.m rename to Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.m diff --git a/Riot/Modules/EncryptionInfo/EncryptionInfoView.xib b/Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.xib similarity index 100% rename from Riot/Modules/EncryptionInfo/EncryptionInfoView.xib rename to Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.xib diff --git a/Riot/Modules/Encryption/EncryptionTrustLevel.swift b/Riot/Modules/Encryption/EncryptionTrustLevel.swift new file mode 100644 index 0000000000..275d74ffc4 --- /dev/null +++ b/Riot/Modules/Encryption/EncryptionTrustLevel.swift @@ -0,0 +1,68 @@ +// +// 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. +// + +import Foundation + +/// Object responsible for calculating user and room trust level +/// +/// For legacy reasons, the trust of multiple items is represented as `Progress` object, +/// where `completedUnitCount` represents the number of trusted users / devices. +@objc class EncryptionTrustLevel: NSObject { + struct TrustSummary { + let totalCount: Int64 + let trustedCount: Int64 + let areAllTrusted: Bool + + init(progress: Progress) { + totalCount = max(progress.totalUnitCount, progress.completedUnitCount) + trustedCount = progress.completedUnitCount + areAllTrusted = trustedCount == totalCount + } + } + + + /// Calculate trust level for a single user given their cross-signing info + @objc func userTrustLevel( + crossSigning: MXCrossSigningInfo?, + trustedDevicesProgress: Progress + ) -> UserEncryptionTrustLevel { + let devices = TrustSummary(progress: trustedDevicesProgress) + + // If we could cross-sign but we haven't, the user is simply not verified + if let crossSigning, !crossSigning.trustLevel.isVerified { + return .notVerified + + // If we cannot cross-sign the user (legacy behaviour) and have not signed + // any devices manually, the user is not verified + } else if crossSigning == nil && devices.trustedCount == 0 { + return .notVerified + } + + // In all other cases we check devices for trust level + return devices.areAllTrusted ? .trusted : .warning + } + + /// Calculate trust level for a room given trust level of users and their devices + @objc func roomTrustLevel(summary: MXUsersTrustLevelSummary) -> RoomEncryptionTrustLevel { + let users = TrustSummary(progress: summary.trustedUsersProgress) + let devices = TrustSummary(progress: summary.trustedDevicesProgress) + + guard users.totalCount > 0 && users.areAllTrusted else { + return .normal + } + return devices.areAllTrusted ? .trusted : .warning + } +} diff --git a/Riot/Utils/EncryptionTrustLevelBadgeImageHelper.swift b/Riot/Modules/Encryption/EncryptionTrustLevelBadgeImageHelper.swift similarity index 100% rename from Riot/Utils/EncryptionTrustLevelBadgeImageHelper.swift rename to Riot/Modules/Encryption/EncryptionTrustLevelBadgeImageHelper.swift diff --git a/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h b/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h new file mode 100644 index 0000000000..a942f53606 --- /dev/null +++ b/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h @@ -0,0 +1,25 @@ +// +// 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. +// + +/** + RoomEncryptionTrustLevel represents the trust level in an encrypted room. + */ +typedef NS_ENUM(NSUInteger, RoomEncryptionTrustLevel) { + RoomEncryptionTrustLevelTrusted, + RoomEncryptionTrustLevelWarning, + RoomEncryptionTrustLevelNormal, + RoomEncryptionTrustLevelUnknown +}; diff --git a/Riot/Modules/Room/Members/Detail/UserEncryptionTrustLevel.h b/Riot/Modules/Encryption/UserEncryptionTrustLevel.h similarity index 100% rename from Riot/Modules/Room/Members/Detail/UserEncryptionTrustLevel.h rename to Riot/Modules/Encryption/UserEncryptionTrustLevel.h diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index e86152e1ce..296545a4e9 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -18,6 +18,7 @@ #import "RoomBubbleCellData.h" #import "MXKRoomBubbleTableViewCell+Riot.h" #import "UserEncryptionTrustLevel.h" +#import "RoomEncryptionTrustLevel.h" #import "RoomReactionsViewSizer.h" #import "RoomEncryptedDataBubbleCell.h" #import "LegacyAppDelegate.h" diff --git a/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h b/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h index 9a7cf3af1e..618849c4d6 100644 --- a/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h +++ b/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h @@ -6,6 +6,8 @@ #import "AvatarGenerator.h" #import "BuildInfo.h" #import "ShareItemSender.h" +#import "UserEncryptionTrustLevel.h" +#import "RoomEncryptionTrustLevel.h" // MatrixKit imports #import "MatrixKit-Bridging-Header.h" diff --git a/RiotShareExtension/target.yml b/RiotShareExtension/target.yml index eaf51ce3c8..b289f234bf 100644 --- a/RiotShareExtension/target.yml +++ b/RiotShareExtension/target.yml @@ -87,3 +87,4 @@ targets: - "**/*.md" # excludes all files with the .md extension - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK + - path: ../Riot/Modules/Encryption/EncryptionTrustLevel.swift diff --git a/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift b/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift new file mode 100644 index 0000000000..f038ba5e22 --- /dev/null +++ b/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift @@ -0,0 +1,177 @@ +// +// 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. +// + +import Foundation +import XCTest +@testable import Element +@testable import MatrixSDK + +class EncryptionTrustLevelTests: XCTestCase { + + var encryption: EncryptionTrustLevel! + override func setUp() { + encryption = EncryptionTrustLevel() + } + + // MARK: - Helpers + + func makeCrossSigning(isVerified: Bool) -> MXCrossSigningInfo { + return .init( + userIdentity: .init( + identity: .other( + userId: "Bob", + masterKey: "MSK", + selfSigningKey: "SSK" + ), + isVerified: isVerified + ) + ) + } + + func makeProgress(trusted: Int, total: Int) -> Progress { + let progress = Progress(totalUnitCount: Int64(total)) + progress.completedUnitCount = Int64(trusted) + return progress + } + + // MARK: - Users + + func test_userTrustLevel_whenCrossSigningDisabled() { + let devicesToTrustLevel: [(Progress, UserEncryptionTrustLevel)] = [ + (makeProgress(trusted: 0, total: 0), .notVerified), + (makeProgress(trusted: 0, total: 2), .notVerified), + (makeProgress(trusted: 1, total: 2), .warning), + (makeProgress(trusted: 3, total: 4), .warning), + (makeProgress(trusted: 5, total: 5), .trusted), + (makeProgress(trusted: 10, total: 5), .trusted) + ] + + for (devices, expected) in devicesToTrustLevel { + let trustLevel = encryption.userTrustLevel( + crossSigning: nil, + trustedDevicesProgress: devices + ) + XCTAssertEqual(trustLevel, expected, "\(devices.completedUnitCount) trusted device(s) out of \(devices.totalUnitCount)") + } + } + + func test_userTrustLevel_whenCrossSigningNotVerified() { + let devicesToTrustLevel: [(Progress, UserEncryptionTrustLevel)] = [ + (makeProgress(trusted: 0, total: 0), .notVerified), + (makeProgress(trusted: 0, total: 2), .notVerified), + (makeProgress(trusted: 1, total: 2), .notVerified), + (makeProgress(trusted: 3, total: 4), .notVerified), + (makeProgress(trusted: 5, total: 5), .notVerified), + (makeProgress(trusted: 10, total: 5), .notVerified) + ] + + for (devices, expected) in devicesToTrustLevel { + let trustLevel = encryption.userTrustLevel( + crossSigning: makeCrossSigning(isVerified: false), + trustedDevicesProgress: devices + ) + XCTAssertEqual(trustLevel, expected, "\(devices.completedUnitCount) trusted device(s) out of \(devices.totalUnitCount)") + } + } + + func test_userTrustLevel_whenCrossSigningVerified() { + let devicesToTrustLevel: [(Progress, UserEncryptionTrustLevel)] = [ + (makeProgress(trusted: 0, total: 0), .trusted), + (makeProgress(trusted: 0, total: 2), .warning), + (makeProgress(trusted: 1, total: 2), .warning), + (makeProgress(trusted: 3, total: 4), .warning), + (makeProgress(trusted: 5, total: 5), .trusted), + (makeProgress(trusted: 10, total: 5), .trusted) + ] + + for (devices, expected) in devicesToTrustLevel { + let trustLevel = encryption.userTrustLevel( + crossSigning: makeCrossSigning(isVerified: true), + trustedDevicesProgress: devices + ) + XCTAssertEqual(trustLevel, expected, "\(devices.completedUnitCount) trusted device(s) out of \(devices.totalUnitCount)") + } + } + + // MARK: - Rooms + + func test_roomTrustLevel() { + let usersDevicesToTrustLevel: [(Progress, Progress, RoomEncryptionTrustLevel)] = [ + // No users verified + (makeProgress(trusted: 0, total: 0), makeProgress(trusted: 0, total: 0), .normal), + + // Only some users verified + (makeProgress(trusted: 0, total: 1), makeProgress(trusted: 0, total: 1), .normal), + (makeProgress(trusted: 3, total: 4), makeProgress(trusted: 5, total: 5), .normal), + (makeProgress(trusted: 3, total: 4), makeProgress(trusted: 5, total: 5), .normal), + + // All users verified + (makeProgress(trusted: 2, total: 2), makeProgress(trusted: 0, total: 0), .trusted), + (makeProgress(trusted: 3, total: 3), makeProgress(trusted: 0, total: 1), .warning), + (makeProgress(trusted: 3, total: 3), makeProgress(trusted: 3, total: 4), .warning), + (makeProgress(trusted: 4, total: 4), makeProgress(trusted: 5, total: 5), .trusted), + (makeProgress(trusted: 10, total: 4), makeProgress(trusted: 10, total: 5), .trusted), + ] + + for (users, devices, expected) in usersDevicesToTrustLevel { + let trustLevel = encryption.roomTrustLevel( + summary: MXUsersTrustLevelSummary( + trustedUsersProgress: users, + andTrustedDevicesProgress: devices + ) + ) + XCTAssertEqual(trustLevel, expected, "\(users.completedUnitCount)/\(users.totalUnitCount) trusted users(s), \(devices.completedUnitCount)/\(devices.totalUnitCount) trusted device(s)") + } + } +} + +extension UserEncryptionTrustLevel: CustomStringConvertible { + public var description: String { + switch self { + case .trusted: + return "trusted" + case .warning: + return "warning" + case .notVerified: + return "notVerified" + case .noCrossSigning: + return "noCrossSigning" + case .none: + return "none" + case .unknown: + return "unknown" + @unknown default: + return "unknown" + } + } +} + +extension RoomEncryptionTrustLevel: CustomStringConvertible { + public var description: String { + switch self { + case .trusted: + return "trusted" + case .warning: + return "warning" + case .normal: + return "normal" + case .unknown: + return "unknown" + @unknown default: + return "unknown" + } + } +} From e3ae9caf8df24a3cbd850620ae8cacd1ae7dd39a Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Tue, 18 Apr 2023 11:37:34 +0200 Subject: [PATCH 04/51] Fix: Calculation of the frame for a bubble component --- .../MXKRoomBubbleTableViewCell+Riot.m | 38 ++++++++++++------- .../Models/Room/MXKRoomBubbleCellData.h | 9 +++++ .../Models/Room/MXKRoomBubbleCellData.m | 17 +++++++-- ...eOutgoingWithoutSenderInfoBubbleCell.swift | 9 +++++ changelog.d/pr-7512.bugfix | 1 + 5 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 changelog.d/pr-7512.bugfix diff --git a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m index a7bfd69f14..907dd5ff22 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m @@ -600,36 +600,47 @@ - (CGRect)componentFrameInContentViewForIndex:(NSInteger)componentIndex } else if (roomBubbleTableViewCell.messageTextView) { + // Force the textView used underneath to layout its frame properly + [roomBubbleTableViewCell setNeedsLayout]; + [roomBubbleTableViewCell layoutIfNeeded]; + + // Compute the height CGFloat textMessageHeight = 0; - if ([bubbleCellData isKindOfClass:[RoomBubbleCellData class]]) { RoomBubbleCellData *roomBubbleCellData = (RoomBubbleCellData*)bubbleCellData; if (!roomBubbleCellData.attachment && selectedComponent.attributedTextMessage) { - textMessageHeight = [roomBubbleCellData rawTextHeight:selectedComponent.attributedTextMessage]; + // Get the width of messageTextView to compute the needed height + CGFloat maxTextWidth = CGRectGetWidth(roomBubbleTableViewCell.messageTextView.bounds); + + // Compute text message height + textMessageHeight = [roomBubbleCellData rawTextHeight:selectedComponent.attributedTextMessage withMaxWidth:maxTextWidth]; } } - - selectedComponentPositionY = selectedComponent.position.y; - + + // Get the messageText frame in the cell content view (as the messageTextView may be inside a stackView and not directly a child of the tableViewCell) + UITextView *messageTextView = roomBubbleTableViewCell.messageTextView; + CGRect messageTextViewFrame = [messageTextView convertRect:messageTextView.bounds toView:roomBubbleTableViewCell.contentView]; + if (textMessageHeight > 0) { selectedComponentHeight = textMessageHeight; } else { - selectedComponentHeight = roomBubbleTableViewCell.frame.size.height - selectedComponentPositionY; + // if we don't have a height, use the messageTextView height without the text container vertical insets to stay aligned with the text. + selectedComponentHeight = CGRectGetHeight(messageTextViewFrame) - messageTextView.textContainerInset.top - messageTextView.textContainerInset.bottom; } - // Force the textView used underneath to layout its frame properly - [roomBubbleTableViewCell setNeedsLayout]; - [roomBubbleTableViewCell layoutIfNeeded]; - - selectedComponenContentViewYOffset = roomBubbleTableViewCell.messageTextView.frame.origin.y; + // Get the vertical position of the messageTextView relative to the contentView + selectedComponenContentViewYOffset = CGRectGetMinY(messageTextViewFrame); + + // Get the position of the component inside the messageTextView + selectedComponentPositionY = selectedComponent.position.y; } - + if (roomBubbleTableViewCell.attachmentView || roomBubbleTableViewCell.messageTextView) { CGFloat x = 0; @@ -801,8 +812,7 @@ - (IBAction)onReceiptContainerTap:(UITapGestureRecognizer *)sender - (void)addTickView:(UIView *)tickView atIndex:(NSInteger)index { - CGRect componentFrame = [self componentFrameInContentViewForIndex: index]; - + CGRect componentFrame = [self componentFrameInContentViewForIndex:index]; tickView.frame = CGRectMake(self.contentView.bounds.size.width - tickView.frame.size.width - 2 * PlainRoomCellLayoutConstants.readReceiptsViewRightMargin, CGRectGetMaxY(componentFrame) - tickView.frame.size.height, tickView.frame.size.width, tickView.frame.size.height); [self.contentView addSubview:tickView]; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h index e934567b7c..df9d12900a 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h @@ -144,6 +144,15 @@ */ - (CGFloat)rawTextHeight:(NSAttributedString*)attributedText; +/** + Return the raw height of the provided text by removing any vertical margin/inset and constraining the width. + + @param attributedText the attributed text to measure + @param maxTextViewWidth the maximum text width + @return the computed height + */ +- (CGFloat)rawTextHeight:(NSAttributedString*)attributedText withMaxWidth:(CGFloat)maxTextViewWidth; + /** Return the content size of a text view initialized with the provided attributed text. CAUTION: This method runs only on main thread. diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m index 53b084c3a5..c9a13d9797 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -500,23 +500,34 @@ - (NSInteger)bubbleComponentIndexForEventId:(NSString *)eventId // Return the raw height of the provided text by removing any margin - (CGFloat)rawTextHeight: (NSAttributedString*)attributedText +{ + return [self rawTextHeight:attributedText withMaxWidth:_maxTextViewWidth]; +} + +// Return the raw height of the provided text by removing any vertical margin/inset and constraining the width. +- (CGFloat)rawTextHeight: (NSAttributedString*)attributedText withMaxWidth:(CGFloat)maxTextViewWidth { __block CGSize textSize; if ([NSThread currentThread] != [NSThread mainThread]) { dispatch_sync(dispatch_get_main_queue(), ^{ - textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + textSize = [self textContentSize:attributedText removeVerticalInset:YES maxTextViewWidth:maxTextViewWidth]; }); } else { - textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + textSize = [self textContentSize:attributedText removeVerticalInset:YES maxTextViewWidth:maxTextViewWidth]; } return textSize.height; } - (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset +{ + return [self textContentSize:attributedText removeVerticalInset:removeVerticalInset maxTextViewWidth:_maxTextViewWidth]; +} + +- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset maxTextViewWidth:(CGFloat)maxTextViewWidth { static UITextView* measurementTextView = nil; static UITextView* measurementTextViewWithoutInset = nil; @@ -536,7 +547,7 @@ - (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInse // Select the right text view for measurement UITextView *selectedTextView = (removeVerticalInset ? measurementTextViewWithoutInset : measurementTextView); - selectedTextView.frame = CGRectMake(0, 0, _maxTextViewWidth, 0); + selectedTextView.frame = CGRectMake(0, 0, maxTextViewWidth, 0); selectedTextView.attributedText = attributedText; // Force the layout manager to layout the text, fixes problems starting iOS 16 diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift index de15e91d38..f3f00f12f4 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift @@ -35,6 +35,15 @@ class TextMessageOutgoingWithoutSenderInfoBubbleCell: TextMessageBaseBubbleCell, self.textMessageContentView?.bubbleBackgroundView?.backgroundColor = theme.roomCellOutgoingBubbleBackgroundColor } + override func render(_ cellData: MXKCellData!) { + // This cell displays an outgoing message without any sender information. + // However, we need to set the following properties to our cellData, otherwise, to make room for the timestamp, a whitespace could be added when calculating the position of the components. + // If we don't, the component frame calculation will not work for this cell. + (cellData as? RoomBubbleCellData)?.shouldHideSenderName = false + (cellData as? RoomBubbleCellData)?.shouldHideSenderInformation = false + super.render(cellData) + } + // MARK: - Private private func setupBubbleConstraints() { diff --git a/changelog.d/pr-7512.bugfix b/changelog.d/pr-7512.bugfix new file mode 100644 index 0000000000..1c6d3a98d1 --- /dev/null +++ b/changelog.d/pr-7512.bugfix @@ -0,0 +1 @@ +Fix the position of the send confirmation icon. From 3cdbc26aed968dbfd87cbaa096f14254652a5193 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 19 Apr 2023 10:45:40 +0200 Subject: [PATCH 05/51] Add basic slash commands support to UserSuggestion module --- Riot/Modules/Room/RoomViewController.m | 8 ++ Riot/Modules/Room/RoomViewController.swift | 16 ++++ .../WysiwygInputToolbarView.swift | 4 + .../UserSuggestionCoordinator.swift | 42 ++++++++- .../UserSuggestionCoordinatorBridge.swift | 5 + .../Service/UserSuggestionService.swift | 93 ++++++++++++++----- .../UserSuggestionServiceProtocol.swift | 11 ++- .../UserSuggestion/UserSuggestionModels.swift | 16 +++- .../UserSuggestionScreenState.swift | 13 ++- .../UserSuggestionViewModel.swift | 18 +++- .../View/UserSuggestionList.swift | 17 ++-- .../View/UserSuggestionListItem.swift | 43 +++++---- 12 files changed, 226 insertions(+), 60 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 70b8d974c9..57a431d2ed 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -8076,6 +8076,14 @@ - (void)userSuggestionCoordinatorBridgeDidRequestMentionForRoom:(UserSuggestionC [self.inputToolbarView pasteText:[UserSuggestionID.room stringByAppendingString:@" "]]; } +- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator + didRequestCommand:(NSString *)command + textTrigger:(NSString *)textTrigger +{ + [self removeTriggerTextFromComposer:textTrigger]; + [self setCommand:command]; +} + - (void)removeTriggerTextFromComposer:(NSString *)textTrigger { RoomInputToolbarView *toolbar = (RoomInputToolbarView *)self.inputToolbarView; diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 3fec13de94..c94111be44 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -58,6 +58,22 @@ extension RoomViewController { } } + @objc func setCommand(_ command: String) { + if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled { + wysiwygInputToolbar.command(command) + wysiwygInputToolbar.becomeFirstResponder() + } else { + guard let attributedText = inputToolbarView.attributedTextMessage else { return } + + let newAttributedString = NSMutableAttributedString(attributedString: attributedText) + newAttributedString.append(NSAttributedString(string: "\(command) ", + attributes: [.font: inputToolbarView.defaultFont])) + + inputToolbarView.attributedTextMessage = newAttributedString + inputToolbarView.becomeFirstResponder() + } + } + /// Send the formatted text message and its raw counterpart to the room /// diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index f3fc1111bd..5700909fac 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -195,6 +195,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp name: member.displayname, mentionType: .user) } + + func command(_ command: String) { + self.wysiwygViewModel.setCommand(name: command) + } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index a2156cd89a..1999e6c072 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -23,6 +23,7 @@ import WysiwygComposer protocol UserSuggestionCoordinatorDelegate: AnyObject { func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) + func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) } @@ -52,6 +53,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { private var userSuggestionService: UserSuggestionServiceProtocol private var userSuggestionViewModel: UserSuggestionViewModelProtocol private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider + private var commandProvider: UserSuggestionCoordinatorCommandProvider private var cancellables = Set() @@ -69,7 +71,8 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { self.parameters = parameters roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider) + commandProvider = UserSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) let view = UserSuggestionList(viewModel: viewModel.context) @@ -90,11 +93,11 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { return } - guard let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first else { - return + if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { + self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0 == identifier }).first { + self.delegate?.userSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.userSuggestionService.currentTextTrigger) } - - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) } } @@ -199,3 +202,32 @@ private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderPr roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } } } + +private class UserSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { + private let room: MXRoom + private let userID: String + + var commands: [String] = [] + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + func updateWithPowerLevels() { + // TODO: filter commands in terms of user power level ? + } + + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + self.commands = [ + "/ban", + "/invite", + "/join", + "/me" + ] + + // TODO: get real data + commands(self.commands.map { CommandsProviderCommand(name: $0) }) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index 0d1f6795e6..ba1bc75ca8 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -20,6 +20,7 @@ import Foundation protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject { func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) func userSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinatorBridge, textTrigger: String?) + func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) } @@ -68,6 +69,10 @@ extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { delegate?.userSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) } + func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { + delegate?.userSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) + } + func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { delegate?.userSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift index a790e28458..76d41e700d 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift @@ -24,6 +24,10 @@ struct RoomMembersProviderMember { var avatarUrl: String } +struct CommandsProviderCommand { + var name: String +} + class UserSuggestionID: NSObject { /// A special case added for suggesting `@room` mentions. @objc static let room = "@room" @@ -34,26 +38,35 @@ protocol RoomMembersProviderProtocol { func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) } +protocol CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) +} + struct UserSuggestionServiceItem: UserSuggestionItemProtocol { let userId: String let displayName: String? let avatarUrl: String? } +struct CommandSuggestionServiceItem: CommandSuggestionItemProtocol { + let name: String +} + class UserSuggestionService: UserSuggestionServiceProtocol { // MARK: - Properties // MARK: Private private let roomMemberProvider: RoomMembersProviderProtocol + private let commandProvider: CommandsProviderProtocol - private var suggestionItems: [UserSuggestionItemProtocol] = [] + private var suggestionItems: [SuggestionItem] = [] private let currentTextTriggerSubject = CurrentValueSubject(nil) private var cancellables = Set() // MARK: Public - var items = CurrentValueSubject<[UserSuggestionItemProtocol], Never>([]) + var items = CurrentValueSubject<[SuggestionItem], Never>([]) var currentTextTrigger: String? { currentTextTriggerSubject.value @@ -61,8 +74,11 @@ class UserSuggestionService: UserSuggestionServiceProtocol { // MARK: - Setup - init(roomMemberProvider: RoomMembersProviderProtocol, shouldDebounce: Bool = true) { + init(roomMemberProvider: RoomMembersProviderProtocol, + commandProvider: CommandsProviderProtocol, + shouldDebounce: Bool = true) { self.roomMemberProvider = roomMemberProvider + self.commandProvider = commandProvider if shouldDebounce { currentTextTriggerSubject @@ -83,7 +99,7 @@ class UserSuggestionService: UserSuggestionServiceProtocol { guard let textMessage = textMessage, textMessage.count > 0, let lastComponent = textMessage.components(separatedBy: .whitespaces).last, - lastComponent.prefix(while: { $0 == "@" }).count == 1 // Partial username should start with one and only one "@" character + lastComponent.prefix(while: { $0 == "@" || $0 == "/" }).count == 1 // Partial username should start with one and only one "@" character else { items.send([]) currentTextTriggerSubject.send(nil) @@ -94,13 +110,22 @@ class UserSuggestionService: UserSuggestionServiceProtocol { } func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - guard let suggestionPattern, suggestionPattern.key == .at else { + guard let suggestionPattern else { items.send([]) currentTextTriggerSubject.send(nil) return } - currentTextTriggerSubject.send("@" + suggestionPattern.text) + switch suggestionPattern.key { + case .at: + currentTextTriggerSubject.send("@" + suggestionPattern.text) + case .hash: + // No room suggestion support yet + items.send([]) + currentTextTriggerSubject.send(nil) + case .slash: + currentTextTriggerSubject.send("/" + suggestionPattern.text) + } } // MARK: - Private @@ -109,24 +134,48 @@ class UserSuggestionService: UserSuggestionServiceProtocol { guard var partialName = textTrigger else { return } - - partialName.removeFirst() // remove the '@' prefix - - roomMemberProvider.fetchMembers { [weak self] members in - guard let self = self else { - return + + switch partialName.first { + case "@": + partialName.removeFirst() // remove the '@' prefix + + roomMemberProvider.fetchMembers { [weak self] members in + guard let self = self else { + return + } + + self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in + SuggestionItem.user(value: UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) + } + + self.items.send(self.suggestionItems.filter { item in + guard case let .user(userSuggestion) = item else { return false } + + let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) + let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) + + return (containedInUsername || containedInDisplayName) + }) } - - self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in - UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl) + case "/": + // TODO: send all commands if only text is "/" + partialName.removeFirst() + + commandProvider.fetchCommands { [weak self] commands in + guard let self else { return } + + self.suggestionItems = commands.map { command in + SuggestionItem.command(value: CommandSuggestionServiceItem(name: command.name)) + } + + self.items.send(self.suggestionItems.filter { item in + guard case let .command(commandSuggestion) = item else { return false } + + return commandSuggestion.name.lowercased().contains(partialName.lowercased()) + }) } - - self.items.send(self.suggestionItems.filter { userSuggestion in - let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) - - return (containedInUsername || containedInDisplayName) - }) + default: + return } } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift index 43006dbed9..4b5787cff4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift @@ -24,8 +24,17 @@ protocol UserSuggestionItemProtocol: Avatarable { var avatarUrl: String? { get } } +protocol CommandSuggestionItemProtocol { + var name: String { get } +} + +enum SuggestionItem { + case command(value: CommandSuggestionItemProtocol) + case user(value: UserSuggestionItemProtocol) +} + protocol UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never> { get } + var items: CurrentValueSubject<[SuggestionItem], Never> { get } var currentTextTrigger: String? { get } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift index d4e984f886..dbaaf92950 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift @@ -24,10 +24,18 @@ enum UserSuggestionViewModelResult { case selectedItemWithIdentifier(String) } -struct UserSuggestionViewStateItem: Identifiable { - let id: String - let avatar: AvatarInputProtocol? - let displayName: String? +enum UserSuggestionViewStateItem: Identifiable { + case command(name: String) + case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) + + var id: String { + switch self { + case .command(let name): + return name + case .user(let id, _, _): + return id + } + } } struct UserSuggestionViewState: BindableState { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift index 0a9395fa56..95aea9dbe6 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift @@ -27,7 +27,7 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { } var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self) + let service = UserSuggestionService(roomMemberProvider: self, commandProvider: self) let listViewModel = UserSuggestionViewModel(userSuggestionService: service) let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in @@ -60,3 +60,14 @@ extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { } } } + +extension MockUserSuggestionScreenState: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands([ + CommandsProviderCommand(name: "/ban"), + CommandsProviderCommand(name: "/invite"), + CommandsProviderCommand(name: "/join"), + CommandsProviderCommand(name: "/me") + ]) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 3999447b7e..68d573bdf9 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -40,14 +40,28 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo self.userSuggestionService = userSuggestionService let items = userSuggestionService.items.value.map { suggestionItem in - UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.displayName) + switch suggestionItem { + case .command(let commandSuggestionItem): + return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) + case .user(let userSuggestionItem): + return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, + avatar: userSuggestionItem, + displayName: userSuggestionItem.displayName) + } } super.init(initialViewState: UserSuggestionViewState(items: items)) userSuggestionService.items.sink { [weak self] items in self?.state.items = items.map { item in - UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName) + switch item { + case .command(let commandSuggestionItem): + return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) + case .user(let userSuggestionItem): + return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, + avatar: userSuggestionItem, + displayName: userSuggestionItem.displayName) + } } }.store(in: &cancellables) } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift index e509a58b3f..fe0c217618 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift @@ -51,9 +51,12 @@ struct UserSuggestionList: View { EmptyView() } else { ZStack { - UserSuggestionListItem(avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), - displayName: "Prototype", - userId: "Prototype") + UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + id: "Prototype", + avatar: AvatarInput(mxContentUri: "", + matrixItemId: "", + displayName: "Prototype"), + displayName: "Prototype")) .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() if showBackgroundShadow { @@ -76,12 +79,8 @@ struct UserSuggestionList: View { Button { viewModel.send(viewAction: .selectedItem(item)) } label: { - UserSuggestionListItem( - avatar: item.avatar, - displayName: item.displayName, - userId: item.id - ) - .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) + UserSuggestionListItem(content: item) + .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) } } .listStyle(PlainListStyle()) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift index 862e7573d6..0175c2abe0 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift @@ -25,26 +25,33 @@ struct UserSuggestionListItem: View { // MARK: Public - let avatar: AvatarInputProtocol? - let displayName: String? - let userId: String + let content: UserSuggestionViewStateItem var body: some View { HStack { - if let avatar = avatar { - AvatarImage(avatarData: avatar, size: .medium) - } - VStack(alignment: .leading) { - Text(displayName ?? "") + switch content { + case .command(let name): + Text(name) .font(theme.fonts.body) .foregroundColor(theme.colors.primaryContent) - .accessibility(identifier: "displayNameText") - .lineLimit(1) - Text(userId) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.tertiaryContent) - .accessibility(identifier: "userIdText") + .accessibility(identifier: "nameText") .lineLimit(1) + case .user(let userId, let avatar, let displayName): + if let avatar = avatar { + AvatarImage(avatarData: avatar, size: .medium) + } + VStack(alignment: .leading) { + Text(displayName ?? "") + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "displayNameText") + .lineLimit(1) + Text(userId) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "userIdText") + .lineLimit(1) + } } } } @@ -54,7 +61,11 @@ struct UserSuggestionListItem: View { struct UserSuggestionHeader_Previews: PreviewProvider { static var previews: some View { - UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org") - .environmentObject(AvatarViewModel.withMockedServices()) + UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + id: "@alice:matrix.org", + avatar: MockAvatarInput.example, + displayName: "Alice" + )) + .environmentObject(AvatarViewModel.withMockedServices()) } } From 6d981004ed2cba87a71bfeff5486af93e106ba40 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 19 Apr 2023 14:22:21 +0200 Subject: [PATCH 06/51] Rename `UserSuggestion` module as `CompletionSuggestion` --- Riot/Modules/Room/RoomViewController.h | 2 +- Riot/Modules/Room/RoomViewController.m | 56 ++++++------ Riot/Modules/Room/RoomViewController.xib | 24 ++---- .../Views/InputToolbar/RoomInputToolbarView.h | 4 +- .../WysiwygInputToolbarView.swift | 2 +- .../Modules/Common/Mock/MockAppScreens.swift | 2 +- .../CompletionSuggestionModels.swift} | 12 +-- .../CompletionSuggestionScreenState.swift} | 16 ++-- .../CompletionSuggestionViewModel.swift | 77 +++++++++++++++++ ...mpletionSuggestionViewModelProtocol.swift} | 10 +-- .../CompletionSuggestionCoordinator.swift} | 86 +++++++++---------- ...ompletionSuggestionCoordinatorBridge.swift | 79 +++++++++++++++++ .../CompletionSuggestionService.swift} | 26 +++--- ...CompletionSuggestionServiceProtocol.swift} | 16 ++-- .../UI/CompletionSuggestionUITests.swift} | 6 +- .../CompletionSuggestionServiceTests.swift} | 60 +++++++++---- .../View/CompletionSuggestionList.swift} | 12 +-- .../View/CompletionSuggestionListItem.swift} | 8 +- .../CompletionSuggestionListWithInput.swift} | 14 +-- .../Composer/MockComposerScreenState.swift | 8 +- .../Room/Composer/Model/ComposerModels.swift | 8 +- .../Modules/Room/Composer/View/Composer.swift | 8 +- .../UserSuggestionCoordinatorBridge.swift | 79 ----------------- .../UserSuggestionViewModel.swift | 77 ----------------- 24 files changed, 353 insertions(+), 339 deletions(-) rename RiotSwiftUI/Modules/Room/{UserSuggestion/UserSuggestionModels.swift => CompletionSuggestion/CompletionSuggestionModels.swift} (76%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/UserSuggestionScreenState.swift => CompletionSuggestion/CompletionSuggestionScreenState.swift} (75%) create mode 100644 RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift rename RiotSwiftUI/Modules/Room/{UserSuggestion/UserSuggestionViewModelProtocol.swift => CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift} (67%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Coordinator/UserSuggestionCoordinator.swift => CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift} (59%) create mode 100644 RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift rename RiotSwiftUI/Modules/Room/{UserSuggestion/Service/UserSuggestionService.swift => CompletionSuggestion/Service/CompletionSuggestionService.swift} (80%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Service/UserSuggestionServiceProtocol.swift => CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift} (71%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Test/UI/UserSuggestionUITests.swift => CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift} (79%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift => CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift} (61%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/View/UserSuggestionList.swift => CompletionSuggestion/View/CompletionSuggestionList.swift} (92%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/View/UserSuggestionListItem.swift => CompletionSuggestion/View/CompletionSuggestionListItem.swift} (89%) rename RiotSwiftUI/Modules/Room/{UserSuggestion/View/UserSuggestionListWithInput.swift => CompletionSuggestion/View/CompletionSuggestionListWithInput.swift} (75%) delete mode 100644 RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift delete mode 100644 RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 072a882a69..6cc25bcfe4 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -61,7 +61,7 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; // The preview header @property (weak, nonatomic, nullable) IBOutlet UIView *previewHeaderContainer; @property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *previewHeaderContainerHeightConstraint; -@property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *userSuggestionContainerHeightConstraint; +@property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *completionSuggestionContainerHeightConstraint; // The jump to last unread banner @property (weak, nonatomic, nullable) IBOutlet UIView *jumpToLastUnreadBannerContainer; diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 57a431d2ed..38f71f8d16 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -97,7 +97,7 @@ @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, CompletionSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate, RoomInputToolbarViewDelegate, ComposerCreateActionListBridgePresenterDelegate> { // The preview header @@ -223,8 +223,8 @@ @interface RoomViewController () - + - - + @@ -13,6 +12,8 @@ + + @@ -32,8 +33,6 @@ - - @@ -48,20 +47,20 @@ - + - + - + - + @@ -237,11 +236,6 @@ - - - - - diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index df71790bed..5bbdeaa51f 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -22,7 +22,7 @@ @class RoomInputToolbarView; @class LinkActionWrapper; @class SuggestionPatternWrapper; -@class UserSuggestionViewModelContextWrapper; +@class CompletionSuggestionViewModelContextWrapper; /** Destination of the message in the composer @@ -84,7 +84,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; -- (UserSuggestionViewModelContextWrapper *)userSuggestionContext; +- (CompletionSuggestionViewModelContextWrapper *)completionSuggestionContext; - (MXMediaManager *)mediaManager; diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 5700909fac..9bc02c21ec 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -223,7 +223,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp let composer = Composer( viewModel: viewModel.context, wysiwygViewModel: wysiwygViewModel, - userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context, + completionSuggestionSharedContext: toolbarViewDelegate.completionSuggestionContext().context, resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 91bf25a51f..0afe12c024 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -51,7 +51,7 @@ enum MockAppScreens { MockStaticLocationViewingScreenState.self, MockLocationSharingScreenState.self, MockAnalyticsPromptScreenState.self, - MockUserSuggestionScreenState.self, + MockCompletionSuggestionScreenState.self, MockPollEditFormScreenState.self, MockSpaceCreationEmailInvitesScreenState.self, MockSpaceSettingsScreenState.self, diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift similarity index 76% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift index dbaaf92950..91fc4ffeb0 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -16,15 +16,15 @@ import Foundation -enum UserSuggestionViewAction { - case selectedItem(UserSuggestionViewStateItem) +enum CompletionSuggestionViewAction { + case selectedItem(CompletionSuggestionViewStateItem) } -enum UserSuggestionViewModelResult { +enum CompletionSuggestionViewModelResult { case selectedItemWithIdentifier(String) } -enum UserSuggestionViewStateItem: Identifiable { +enum CompletionSuggestionViewStateItem: Identifiable { case command(name: String) case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) @@ -38,6 +38,6 @@ enum UserSuggestionViewStateItem: Identifiable { } } -struct UserSuggestionViewState: BindableState { - var items: [UserSuggestionViewStateItem] +struct CompletionSuggestionViewState: BindableState { + var items: [CompletionSuggestionViewStateItem] } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift similarity index 75% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index 95aea9dbe6..1427c3f3fe 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -17,32 +17,32 @@ import Foundation import SwiftUI -enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { +enum MockCompletionSuggestionScreenState: MockScreenState, CaseIterable { case multipleResults private static var members: [RoomMembersProviderMember]! var screenType: Any.Type { - UserSuggestionList.self + CompletionSuggestionList.self } var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self, commandProvider: self) - let listViewModel = UserSuggestionViewModel(userSuggestionService: service) + let service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self) + let listViewModel = CompletionSuggestionViewModel(completionSuggestionService: service) - let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in + let viewModel = CompletionSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in service.processTextMessage(textMessage) } return ( [service, listViewModel], - AnyView(UserSuggestionListWithInput(viewModel: viewModel) + AnyView(CompletionSuggestionListWithInput(viewModel: viewModel) .environmentObject(AvatarViewModel.withMockedServices())) ) } } -extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { +extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { var canMentionRoom: Bool { false } func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { @@ -61,7 +61,7 @@ extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { } } -extension MockUserSuggestionScreenState: CommandsProviderProtocol { +extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ CommandsProviderCommand(name: "/ban"), diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift new file mode 100644 index 0000000000..01c881970c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -0,0 +1,77 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +typealias CompletionSuggestionViewModelType = StateStoreViewModel + +class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, CompletionSuggestionViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let completionSuggestionService: CompletionSuggestionServiceProtocol + + // MARK: Public + + var sharedContext: CompletionSuggestionViewModelType.Context { + return self.context + } + + var completion: ((CompletionSuggestionViewModelResult) -> Void)? + + // MARK: - Setup + + init(completionSuggestionService: CompletionSuggestionServiceProtocol) { + self.completionSuggestionService = completionSuggestionService + + let items = completionSuggestionService.items.value.map { suggestionItem in + switch suggestionItem { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + + super.init(initialViewState: CompletionSuggestionViewState(items: items)) + + completionSuggestionService.items.sink { [weak self] items in + self?.state.items = items.map { item in + switch item { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + }.store(in: &cancellables) + } + + // MARK: - Public + + override func process(viewAction: CompletionSuggestionViewAction) { + switch viewAction { + case .selectedItem(let item): + completion?(.selectedItemWithIdentifier(item.id)) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift similarity index 67% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift index 33aa5bb795..d7c51909f1 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift @@ -16,10 +16,10 @@ import Foundation -protocol UserSuggestionViewModelProtocol { - /// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple - /// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` +protocol CompletionSuggestionViewModelProtocol { + /// Defines a shared context providing the ability to use a single `CompletionSuggestionViewModel` for multiple + /// `CompletionSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` /// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload the data. - var sharedContext: UserSuggestionViewModelType.Context { get } - var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } + var sharedContext: CompletionSuggestionViewModelType.Context { get } + var completion: ((CompletionSuggestionViewModelResult) -> Void)? { get set } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift similarity index 59% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 1999e6c072..8da2356fdd 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -20,40 +20,40 @@ import SwiftUI import UIKit import WysiwygComposer -protocol UserSuggestionCoordinatorDelegate: AnyObject { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) +protocol CompletionSuggestionCoordinatorDelegate: AnyObject { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) } -struct UserSuggestionCoordinatorParameters { +struct CompletionSuggestionCoordinatorParameters { let mediaManager: MXMediaManager let room: MXRoom let userID: String } -/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c. -final class UserSuggestionViewModelContextWrapper: NSObject { - let context: UserSuggestionViewModelType.Context +/// Wrapper around `CompletionSuggestionViewModelType.Context` to pass it through obj-c. +final class CompletionSuggestionViewModelContextWrapper: NSObject { + let context: CompletionSuggestionViewModelType.Context - init(context: UserSuggestionViewModelType.Context) { + init(context: CompletionSuggestionViewModelType.Context) { self.context = context } } -final class UserSuggestionCoordinator: Coordinator, Presentable { +final class CompletionSuggestionCoordinator: Coordinator, Presentable { // MARK: - Properties // MARK: Private - private let parameters: UserSuggestionCoordinatorParameters + private let parameters: CompletionSuggestionCoordinatorParameters - private var userSuggestionHostingController: UIHostingController - private var userSuggestionService: UserSuggestionServiceProtocol - private var userSuggestionViewModel: UserSuggestionViewModelProtocol - private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider - private var commandProvider: UserSuggestionCoordinatorCommandProvider + private var completionSuggestionHostingController: UIHostingController + private var completionSuggestionService: CompletionSuggestionServiceProtocol + private var completionSuggestionViewModel: CompletionSuggestionViewModelProtocol + private var roomMemberProvider: CompletionSuggestionCoordinatorRoomMemberProvider + private var commandProvider: CompletionSuggestionCoordinatorCommandProvider private var cancellables = Set() @@ -63,57 +63,57 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { var childCoordinators: [Coordinator] = [] var completion: (() -> Void)? - weak var delegate: UserSuggestionCoordinatorDelegate? + weak var delegate: CompletionSuggestionCoordinatorDelegate? // MARK: - Setup - init(parameters: UserSuggestionCoordinatorParameters) { + init(parameters: CompletionSuggestionCoordinatorParameters) { self.parameters = parameters - roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - commandProvider = UserSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) + roomMemberProvider = CompletionSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) + commandProvider = CompletionSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + completionSuggestionService = CompletionSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - userSuggestionViewModel = viewModel - userSuggestionHostingController = VectorHostingController(rootView: view) + completionSuggestionViewModel = viewModel + completionSuggestionHostingController = VectorHostingController(rootView: view) - userSuggestionViewModel.completion = { [weak self] result in + completionSuggestionViewModel.completion = { [weak self] result in guard let self = self else { return } switch result { case .selectedItemWithIdentifier(let identifier): - if identifier == UserSuggestionID.room { - self.delegate?.userSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.userSuggestionService.currentTextTrigger) + if identifier == CompletionSuggestionUserID.room { + self.delegate?.completionSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.completionSuggestionService.currentTextTrigger) return } if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) + self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) } else if let command = self.commandProvider.commands.filter({ $0 == identifier }).first { - self.delegate?.userSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.userSuggestionService.currentTextTrigger) + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.completionSuggestionService.currentTextTrigger) } } } - userSuggestionService.items.sink { [weak self] _ in + completionSuggestionService.items.sink { [weak self] _ in guard let self = self else { return } - self.delegate?.userSuggestionCoordinator(self, + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) }.store(in: &cancellables) } func processTextMessage(_ textMessage: String) { - userSuggestionService.processTextMessage(textMessage) + completionSuggestionService.processTextMessage(textMessage) } func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - userSuggestionService.processSuggestionPattern(suggestionPattern) + completionSuggestionService.processSuggestionPattern(suggestionPattern) } // MARK: - Public @@ -121,18 +121,18 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { func start() { } func toPresentable() -> UIViewController { - userSuggestionHostingController + completionSuggestionHostingController } - func sharedContext() -> UserSuggestionViewModelContextWrapper { - UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + CompletionSuggestionViewModelContextWrapper(context: completionSuggestionViewModel.sharedContext) } // MARK: - Private private func calculateViewHeight() -> CGFloat { - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) let controller = VectorHostingController(rootView: view) @@ -156,7 +156,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { } } -private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { +private class CompletionSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { private let room: MXRoom private let userID: String @@ -194,7 +194,7 @@ private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderPr self.roomMembers = joinedMembers members(self.roomMembersToProviderMembers(joinedMembers)) } failure: { error in - MXLog.error("[UserSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) + MXLog.error("[CompletionSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) } } @@ -203,7 +203,7 @@ private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderPr } } -private class UserSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { +private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { private let room: MXRoom private let userID: String diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift new file mode 100644 index 0000000000..83a9ed94c4 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift @@ -0,0 +1,79 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@objc +protocol CompletionSuggestionCoordinatorBridgeDelegate: AnyObject { + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinatorBridge, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) +} + +@objcMembers +final class CompletionSuggestionCoordinatorBridge: NSObject { + private var _completionSuggestionCoordinator: Any? + fileprivate var completionSuggestionCoordinator: CompletionSuggestionCoordinator { + _completionSuggestionCoordinator as! CompletionSuggestionCoordinator + } + + weak var delegate: CompletionSuggestionCoordinatorBridgeDelegate? + + init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { + let parameters = CompletionSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) + let completionSuggestionCoordinator = CompletionSuggestionCoordinator(parameters: parameters) + _completionSuggestionCoordinator = completionSuggestionCoordinator + + super.init() + + completionSuggestionCoordinator.delegate = self + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionCoordinator.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { + completionSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) + } + + func toPresentable() -> UIViewController? { + completionSuggestionCoordinator.toPresentable() + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + completionSuggestionCoordinator.sharedContext() + } +} + +extension CompletionSuggestionCoordinatorBridge: CompletionSuggestionCoordinatorDelegate { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) + } + + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { + delegate?.completionSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift similarity index 80% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 76d41e700d..0353b63d43 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -28,7 +28,7 @@ struct CommandsProviderCommand { var name: String } -class UserSuggestionID: NSObject { +class CompletionSuggestionUserID: NSObject { /// A special case added for suggesting `@room` mentions. @objc static let room = "@room" } @@ -42,17 +42,17 @@ protocol CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) } -struct UserSuggestionServiceItem: UserSuggestionItemProtocol { +struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol { let userId: String let displayName: String? let avatarUrl: String? } -struct CommandSuggestionServiceItem: CommandSuggestionItemProtocol { +struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { let name: String } -class UserSuggestionService: UserSuggestionServiceProtocol { +class CompletionSuggestionService: CompletionSuggestionServiceProtocol { // MARK: - Properties // MARK: Private @@ -60,13 +60,13 @@ class UserSuggestionService: UserSuggestionServiceProtocol { private let roomMemberProvider: RoomMembersProviderProtocol private let commandProvider: CommandsProviderProtocol - private var suggestionItems: [SuggestionItem] = [] + private var suggestionItems: [CompletionSuggestionItem] = [] private let currentTextTriggerSubject = CurrentValueSubject(nil) private var cancellables = Set() // MARK: Public - var items = CurrentValueSubject<[SuggestionItem], Never>([]) + var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) var currentTextTrigger: String? { currentTextTriggerSubject.value @@ -93,7 +93,7 @@ class UserSuggestionService: UserSuggestionServiceProtocol { } } - // MARK: - UserSuggestionServiceProtocol + // MARK: - CompletionSuggestionServiceProtocol func processTextMessage(_ textMessage: String?) { guard let textMessage = textMessage, @@ -145,14 +145,14 @@ class UserSuggestionService: UserSuggestionServiceProtocol { } self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in - SuggestionItem.user(value: UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) + CompletionSuggestionItem.user(value: CompletionSuggestionServiceUserItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) } self.items.send(self.suggestionItems.filter { item in - guard case let .user(userSuggestion) = item else { return false } + guard case let .user(completionSuggestionUserItem) = item else { return false } - let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(partialName.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(partialName.lowercased()) return (containedInUsername || containedInDisplayName) }) @@ -165,7 +165,7 @@ class UserSuggestionService: UserSuggestionServiceProtocol { guard let self else { return } self.suggestionItems = commands.map { command in - SuggestionItem.command(value: CommandSuggestionServiceItem(name: command.name)) + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name)) } self.items.send(self.suggestionItems.filter { item in @@ -184,6 +184,6 @@ extension Array where Element == RoomMembersProviderMember { /// Returns the array with an additional member that represents an `@room` mention. func withRoom(_ canMentionRoom: Bool) -> Self { guard canMentionRoom else { return self } - return self + [RoomMembersProviderMember(userId: UserSuggestionID.room, displayName: "Everyone", avatarUrl: "")] + return self + [RoomMembersProviderMember(userId: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift similarity index 71% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 4b5787cff4..4586e12944 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -18,23 +18,23 @@ import Combine import Foundation import WysiwygComposer -protocol UserSuggestionItemProtocol: Avatarable { +protocol CompletionSuggestionUserItemProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } } -protocol CommandSuggestionItemProtocol { +protocol CompletionSuggestionCommandItemProtocol { var name: String { get } } -enum SuggestionItem { - case command(value: CommandSuggestionItemProtocol) - case user(value: UserSuggestionItemProtocol) +enum CompletionSuggestionItem { + case command(value: CompletionSuggestionCommandItemProtocol) + case user(value: CompletionSuggestionUserItemProtocol) } -protocol UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[SuggestionItem], Never> { get } +protocol CompletionSuggestionServiceProtocol { + var items: CurrentValueSubject<[CompletionSuggestionItem], Never> { get } var currentTextTrigger: String? { get } @@ -44,7 +44,7 @@ protocol UserSuggestionServiceProtocol { // MARK: Avatarable -extension UserSuggestionItemProtocol { +extension CompletionSuggestionUserItemProtocol { var mxContentUri: String? { avatarUrl } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift similarity index 79% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift index f44744a9c1..5ec9d4b9b4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift @@ -17,9 +17,9 @@ import RiotSwiftUI import XCTest -class UserSuggestionUITests: MockScreenTestCase { - func testUserSuggestionScreen() throws { - app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title) +class CompletionSuggestionUITests: MockScreenTestCase { + func testCompletionSuggestionScreen() throws { + app.goToScreenWithIdentifier(MockCompletionSuggestionScreenState.multipleResults.title) let firstButton = app.buttons["displayNameText-userIdText"].firstMatch XCTAssert(firstButton.waitForExistence(timeout: 10)) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift similarity index 61% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift index 7ae0bfa39e..636ba3355c 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -19,51 +19,53 @@ import XCTest @testable import RiotSwiftUI -class UserSuggestionServiceTests: XCTestCase { - var service: UserSuggestionService! +class CompletionSuggestionServiceTests: XCTestCase { + var service: CompletionSuggestionService! var canMentionRoom = false override func setUp() { - service = UserSuggestionService(roomMemberProvider: self, shouldDebounce: false) + service = CompletionSuggestionService(roomMemberProvider: self, + commandProvider: self, + shouldDebounce: false) canMentionRoom = false } func testAlice() { service.processTextMessage("@Al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@ice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@Alice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") service.processTextMessage("@alice:matrix.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") } func testBob() { service.processTextMessage("@ob") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") service.processTextMessage("@ob:") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") service.processTextMessage("@b:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") } func testBoth() { service.processTextMessage("@:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") service.processTextMessage("@.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") } func testEmptyResult() { @@ -117,18 +119,18 @@ class UserSuggestionServiceTests: XCTestCase { } func testRoomWithPower() { - // Given a user without the power to mention a room. + // Given a user with the power to mention a room. canMentionRoom = true - // Given a user without the power to mention a room. + // Given a user with the power to mention a room. service.processTextMessage("@ro") // Then the completion for a room mention should be shown. - XCTAssertEqual(service.items.value.first?.userId, UserSuggestionID.room) + XCTAssertEqual(service.items.value.first?.asUser?.userId, CompletionSuggestionUserID.room) } } -extension UserSuggestionServiceTests: RoomMembersProviderProtocol { +extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { let users = [("Alice", "@alice:matrix.org"), ("Bob", "@bob:matrix.org")] @@ -138,3 +140,23 @@ extension UserSuggestionServiceTests: RoomMembersProviderProtocol { }) } } + +extension CompletionSuggestionServiceTests: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + let commandList = ["/ban", "/invite", "/join", "/me"] + + commands(commandList.map { command in + CommandsProviderCommand(name: command) + }) + } +} + +extension CompletionSuggestionItem { + var asUser: CompletionSuggestionUserItemProtocol? { + if case let .user(value) = self { return value } else { return nil } + } + + var asCommand: CompletionSuggestionCommandItemProtocol? { + if case let .command(value) = self { return value } else { return nil } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift similarity index 92% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift index fe0c217618..02aef8a1f0 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift @@ -16,7 +16,7 @@ import SwiftUI -struct UserSuggestionList: View { +struct CompletionSuggestionList: View { private enum Constants { static let topPadding: CGFloat = 8.0 static let listItemPadding: CGFloat = 4.0 @@ -43,7 +43,7 @@ struct UserSuggestionList: View { // MARK: Public - @ObservedObject var viewModel: UserSuggestionViewModel.Context + @ObservedObject var viewModel: CompletionSuggestionViewModel.Context var showBackgroundShadow: Bool = true var body: some View { @@ -51,7 +51,7 @@ struct UserSuggestionList: View { EmptyView() } else { ZStack { - UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", @@ -79,7 +79,7 @@ struct UserSuggestionList: View { Button { viewModel.send(viewAction: .selectedItem(item)) } label: { - UserSuggestionListItem(content: item) + CompletionSuggestionListItem(content: item) .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) } } @@ -134,8 +134,8 @@ private struct BackgroundView: View { // MARK: - Previews -struct UserSuggestion_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestion_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift similarity index 89% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift index 0175c2abe0..c30ec5d89e 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -16,7 +16,7 @@ import SwiftUI -struct UserSuggestionListItem: View { +struct CompletionSuggestionListItem: View { // MARK: - Properties // MARK: Private @@ -25,7 +25,7 @@ struct UserSuggestionListItem: View { // MARK: Public - let content: UserSuggestionViewStateItem + let content: CompletionSuggestionViewStateItem var body: some View { HStack { @@ -59,9 +59,9 @@ struct UserSuggestionListItem: View { // MARK: - Previews -struct UserSuggestionHeader_Previews: PreviewProvider { +struct CompletionSuggestionHeader_Previews: PreviewProvider { static var previews: some View { - UserSuggestionListItem(content: UserSuggestionViewStateItem.user( + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( id: "@alice:matrix.org", avatar: MockAvatarInput.example, displayName: "Alice" diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift similarity index 75% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 176be8ec41..0b1dd8e8a4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -16,24 +16,24 @@ import SwiftUI -struct UserSuggestionListWithInputViewModel { - let listViewModel: UserSuggestionViewModel +struct CompletionSuggestionListWithInputViewModel { + let listViewModel: CompletionSuggestionViewModel let callback: (String) -> Void } -struct UserSuggestionListWithInput: View { +struct CompletionSuggestionListWithInput: View { // MARK: - Properties // MARK: Private // MARK: Public - var viewModel: UserSuggestionListWithInputViewModel + var viewModel: CompletionSuggestionListWithInputViewModel @State private var inputText = "" var body: some View { VStack(spacing: 0.0) { - UserSuggestionList(viewModel: viewModel.listViewModel.context) + CompletionSuggestionList(viewModel: viewModel.listViewModel.context) TextField("Search for user", text: $inputText) .background(Color.white) .onChange(of: inputText, perform: viewModel.callback) @@ -48,8 +48,8 @@ struct UserSuggestionListWithInput: View { // MARK: - Previews -struct UserSuggestionListWithInput_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestionListWithInput_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 8b5327b14d..79322b78a5 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,7 +29,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel - let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) + let completionSuggestionViewModel = MockCompletionSuggestionViewModel(initialViewState: CompletionSuggestionViewState(items: [])) let bindings = ComposerBindings(focused: false) switch self { @@ -67,7 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, - userSuggestionSharedContext: userSuggestionViewModel.context, + completionSuggestionSharedContext: completionSuggestionViewModel.context, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -82,6 +82,4 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { } } -private final class MockUserSuggestionViewModel: UserSuggestionViewModelType { - -} +private final class MockCompletionSuggestionViewModel: CompletionSuggestionViewModelType { } diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 6f7bab1652..33d73ef4a2 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -257,11 +257,11 @@ final class SuggestionPatternWrapper: NSObject { } } -final class UserSuggestionViewModelWrapper: NSObject { - let userSuggestionViewModel: UserSuggestionViewModel +final class CompletionSuggestionViewModelWrapper: NSObject { + let completionSuggestionViewModel: CompletionSuggestionViewModel - init(_ userSuggestionViewModel: UserSuggestionViewModel) { - self.userSuggestionViewModel = userSuggestionViewModel + init(_ completionSuggestionViewModel: CompletionSuggestionViewModel) { + self.completionSuggestionViewModel = completionSuggestionViewModel super.init() } } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index e4317a2759..a74b0bb4d6 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,7 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel - private let userSuggestionSharedContext: UserSuggestionViewModelType.Context + private let completionSuggestionSharedContext: CompletionSuggestionViewModelType.Context private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -223,13 +223,13 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, - userSuggestionSharedContext: UserSuggestionViewModelType.Context, + completionSuggestionSharedContext: CompletionSuggestionViewModelType.Context, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { self.viewModel = viewModel self.wysiwygViewModel = wysiwygViewModel - self.userSuggestionSharedContext = userSuggestionSharedContext + self.completionSuggestionSharedContext = completionSuggestionSharedContext self.resizeAnimationDuration = resizeAnimationDuration self.sendMessageAction = sendMessageAction self.showSendMediaActions = showSendMediaActions @@ -256,7 +256,7 @@ struct Composer: View { } } if wysiwygViewModel.maximised { - UserSuggestionList(viewModel: userSuggestionSharedContext, showBackgroundShadow: false) + CompletionSuggestionList(viewModel: completionSuggestionSharedContext, showBackgroundShadow: false) } } .frame(height: composerHeight) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift deleted file mode 100644 index ba1bc75ca8..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -@objc -protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject { - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinatorBridge, textTrigger: String?) - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) -} - -@objcMembers -final class UserSuggestionCoordinatorBridge: NSObject { - private var _userSuggestionCoordinator: Any? - fileprivate var userSuggestionCoordinator: UserSuggestionCoordinator { - _userSuggestionCoordinator as! UserSuggestionCoordinator - } - - weak var delegate: UserSuggestionCoordinatorBridgeDelegate? - - init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { - let parameters = UserSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) - let userSuggestionCoordinator = UserSuggestionCoordinator(parameters: parameters) - _userSuggestionCoordinator = userSuggestionCoordinator - - super.init() - - userSuggestionCoordinator.delegate = self - } - - func processTextMessage(_ textMessage: String) { - userSuggestionCoordinator.processTextMessage(textMessage) - } - - func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { - userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) - } - - func toPresentable() -> UIViewController? { - userSuggestionCoordinator.toPresentable() - } - - func sharedContext() -> UserSuggestionViewModelContextWrapper { - userSuggestionCoordinator.sharedContext() - } -} - -extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) - } - - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) - } - - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) - } - - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { - delegate?.userSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift deleted file mode 100644 index 68d573bdf9..0000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import SwiftUI - -typealias UserSuggestionViewModelType = StateStoreViewModel - -class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewModelProtocol { - // MARK: - Properties - - // MARK: Private - - private let userSuggestionService: UserSuggestionServiceProtocol - - // MARK: Public - - var sharedContext: UserSuggestionViewModelType.Context { - return self.context - } - - var completion: ((UserSuggestionViewModelResult) -> Void)? - - // MARK: - Setup - - init(userSuggestionService: UserSuggestionServiceProtocol) { - self.userSuggestionService = userSuggestionService - - let items = userSuggestionService.items.value.map { suggestionItem in - switch suggestionItem { - case .command(let commandSuggestionItem): - return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) - case .user(let userSuggestionItem): - return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, - avatar: userSuggestionItem, - displayName: userSuggestionItem.displayName) - } - } - - super.init(initialViewState: UserSuggestionViewState(items: items)) - - userSuggestionService.items.sink { [weak self] items in - self?.state.items = items.map { item in - switch item { - case .command(let commandSuggestionItem): - return UserSuggestionViewStateItem.command(name: commandSuggestionItem.name) - case .user(let userSuggestionItem): - return UserSuggestionViewStateItem.user(id: userSuggestionItem.userId, - avatar: userSuggestionItem, - displayName: userSuggestionItem.displayName) - } - } - }.store(in: &cancellables) - } - - // MARK: - Public - - override func process(viewAction: UserSuggestionViewAction) { - switch viewAction { - case .selectedItem(let item): - completion?(.selectedItemWithIdentifier(item.id)) - } - } -} From d29145748e483bfb5599f06944f9cac80e35f558 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 19 Apr 2023 15:32:30 +0200 Subject: [PATCH 07/51] Display additional command content in suggestion list --- .../CompletionSuggestionModels.swift | 4 +-- .../CompletionSuggestionScreenState.swift | 16 +++++++++--- .../CompletionSuggestionViewModel.swift | 12 +++++++-- .../CompletionSuggestionCoordinator.swift | 26 +++++++++++++------ .../Service/CompletionSuggestionService.swift | 8 ++++-- .../CompletionSuggestionServiceProtocol.swift | 2 ++ .../View/CompletionSuggestionListItem.swift | 26 ++++++++++++++----- 7 files changed, 70 insertions(+), 24 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift index 91fc4ffeb0..8476834b90 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -25,12 +25,12 @@ enum CompletionSuggestionViewModelResult { } enum CompletionSuggestionViewStateItem: Identifiable { - case command(name: String) + case command(name: String, parametersFormat: String, description: String) case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) var id: String { switch self { - case .command(let name): + case .command(let name, _, _): return name case .user(let id, _, _): return id diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index 1427c3f3fe..81d6e20889 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -64,10 +64,18 @@ extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ - CommandsProviderCommand(name: "/ban"), - CommandsProviderCommand(name: "/invite"), - CommandsProviderCommand(name: "/join"), - CommandsProviderCommand(name: "/me") + CommandsProviderCommand(name: "/ban", + parametersFormat: " [reason]", + description: "Bans user with given id"), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room"), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address"), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action") ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift index 01c881970c..0c9c0215c3 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -42,7 +42,11 @@ class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, Completi let items = completionSuggestionService.items.value.map { suggestionItem in switch suggestionItem { case .command(let completionSuggestionCommandItem): - return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) case .user(let completionSuggestionUserItem): return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, avatar: completionSuggestionUserItem, @@ -56,7 +60,11 @@ class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, Completi self?.state.items = items.map { item in switch item { case .command(let completionSuggestionCommandItem): - return CompletionSuggestionViewStateItem.command(name: completionSuggestionCommandItem.name) + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) case .user(let completionSuggestionUserItem): return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, avatar: completionSuggestionUserItem, diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 8da2356fdd..f2dab2dabe 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -95,8 +95,8 @@ final class CompletionSuggestionCoordinator: Coordinator, Presentable { if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) - } else if let command = self.commandProvider.commands.filter({ $0 == identifier }).first { - self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command, textTrigger: self.completionSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0.name == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.name, textTrigger: self.completionSuggestionService.currentTextTrigger) } } } @@ -207,7 +207,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr private let room: MXRoom private let userID: String - var commands: [String] = [] + var commands: [(name: String, parametersFormat: String, description: String)] = [] init(room: MXRoom, userID: String) { self.room = room @@ -221,13 +221,23 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { self.commands = [ - "/ban", - "/invite", - "/join", - "/me" + (name: "/ban", + parametersFormat: " [reason]", + description: "Bans user with given id"), + (name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room"), + (name: "/join", + parametersFormat: "", + description: "Joins room with given address"), + (name: "/me", + parametersFormat: "", + description: "Displays action") ] // TODO: get real data - commands(self.commands.map { CommandsProviderCommand(name: $0) }) + commands(self.commands.map { CommandsProviderCommand(name: $0.name, + parametersFormat: $0.parametersFormat, + description: $0.description) }) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 0353b63d43..5adf4f3c5b 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -25,7 +25,9 @@ struct RoomMembersProviderMember { } struct CommandsProviderCommand { - var name: String + let name: String + let parametersFormat: String + let description: String } class CompletionSuggestionUserID: NSObject { @@ -50,6 +52,8 @@ struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { let name: String + let parametersFormat: String + let description: String } class CompletionSuggestionService: CompletionSuggestionServiceProtocol { @@ -165,7 +169,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { guard let self else { return } self.suggestionItems = commands.map { command in - CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name)) + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name, parametersFormat: command.parametersFormat, description: command.description)) } self.items.send(self.suggestionItems.filter { item in diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 4586e12944..3930c59d16 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -26,6 +26,8 @@ protocol CompletionSuggestionUserItemProtocol: Avatarable { protocol CompletionSuggestionCommandItemProtocol { var name: String { get } + var parametersFormat: String { get } + var description: String { get } } enum CompletionSuggestionItem { diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift index c30ec5d89e..95f81fb75b 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -30,12 +30,26 @@ struct CompletionSuggestionListItem: View { var body: some View { HStack { switch content { - case .command(let name): - Text(name) - .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - .accessibility(identifier: "nameText") - .lineLimit(1) + case .command(let name, let parametersFormat, let description): + VStack(alignment: .leading) { + HStack { + Text(name) + .font(theme.fonts.body.bold()) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "nameText") + .lineLimit(1) + Text(parametersFormat) + .font(theme.fonts.body.italic()) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "parametersFormatText") + .lineLimit(1) + } + Text(description) + .font(theme.fonts.body) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "descriptionText") + .lineLimit(1) + } case .user(let userId, let avatar, let displayName): if let avatar = avatar { AvatarImage(avatarData: avatar, size: .medium) From 01024598f8ac8419121844f6849a8772bb053b35 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 12:10:03 +0200 Subject: [PATCH 08/51] Rework `MXKSlashCommands` to a more Swift-friendly form and use it in suggestion module --- Riot/Modules/MatrixKit/MatrixKit.h | 2 - .../MatrixKit/Models/Room/MXKRoomDataSource.m | 4 +- .../MatrixKit/Models/Room/MXKSlashCommands.h | 34 ------ .../MatrixKit/Models/Room/MXKSlashCommands.m | 30 ------ .../Models/Room/MXKSlashCommands.swift | 101 ++++++++++++++++++ .../Room/DataSources/RoomDataSource.swift | 2 +- Riot/Modules/Room/MXKRoomViewController.m | 43 ++++---- Riot/Modules/Room/RoomViewController.m | 6 +- .../CompletionSuggestionCoordinator.swift | 77 +++++++++---- .../View/CompletionSuggestionListItem.swift | 1 - 10 files changed, 185 insertions(+), 115 deletions(-) delete mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h delete mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift diff --git a/Riot/Modules/MatrixKit/MatrixKit.h b/Riot/Modules/MatrixKit/MatrixKit.h index 2bb02223bf..ce6ea5f1e2 100644 --- a/Riot/Modules/MatrixKit/MatrixKit.h +++ b/Riot/Modules/MatrixKit/MatrixKit.h @@ -145,5 +145,3 @@ #import "MXKCountryPickerViewController.h" #import "MXKLanguagePickerViewController.h" - -#import "MXKSlashCommands.h" diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index 0998122aef..a69f504cc0 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -31,8 +31,6 @@ #import "MXKAppSettings.h" -#import "MXKSlashCommands.h" - #import "GeneratedInterface-Swift.h" const BOOL USE_THREAD_TIMELINE = YES; @@ -316,7 +314,7 @@ - (instancetype)initWithRoomId:(NSString *)roomId andMatrixSession:(MXSession *) _filterMessagesWithURL = NO; - emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote]; + emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]; // Set default data and view classes // Cell data diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h deleted file mode 100644 index ef9c717832..0000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - Copyright 2018 New Vector Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -@import Foundation; - -/** - Slash commands used to perform actions from a room. - */ - -FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeDisplayName; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdEmote; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdJoinRoom; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdPartRoom; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdInviteUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdKickUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdBanUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdUnbanUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdSetUserPowerLevel; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdResetUserPowerLevel; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeRoomTopic; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdDiscardSession; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m deleted file mode 100644 index e9d483d9b8..0000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m +++ /dev/null @@ -1,30 +0,0 @@ -/* - Copyright 2018 New Vector Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -#import "MXKSlashCommands.h" - -NSString *const kMXKSlashCmdChangeDisplayName = @"/nick"; -NSString *const kMXKSlashCmdEmote = @"/me"; -NSString *const kMXKSlashCmdJoinRoom = @"/join"; -NSString *const kMXKSlashCmdPartRoom = @"/part"; -NSString *const kMXKSlashCmdInviteUser = @"/invite"; -NSString *const kMXKSlashCmdKickUser = @"/kick"; -NSString *const kMXKSlashCmdBanUser = @"/ban"; -NSString *const kMXKSlashCmdUnbanUser = @"/unban"; -NSString *const kMXKSlashCmdSetUserPowerLevel = @"/op"; -NSString *const kMXKSlashCmdResetUserPowerLevel = @"/deop"; -NSString *const kMXKSlashCmdChangeRoomTopic = @"/topic"; -NSString *const kMXKSlashCmdDiscardSession = @"/discardsession"; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift new file mode 100644 index 0000000000..faae85e945 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -0,0 +1,101 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@objc final class MXKSlashCommandsHelper: NSObject { + @objc static func commandNameFor(_ slashCommand: MXKSlashCommand) -> String { + slashCommand.cmd + } + + @objc static func commandUsageFor(_ slashCommand: MXKSlashCommand) -> String { + "Usage: \(slashCommand.cmd) \(slashCommand.parametersFormat)" + } +} + +@objc enum MXKSlashCommand: Int, CaseIterable { + case changeDisplayName + case emote + case joinRoom + case partRoom + case inviteUser + case kickUser + case banUser + case unbanUser + case setUserPowerLevel + case resetUserPowerLevel + case changeRoomTopic + case discardSession + + var cmd: String { + switch self { + case .changeDisplayName: + return "/nick" + case .emote: + return "/me" + case .joinRoom: + return "/join" + case .partRoom: + return "/part" + case .inviteUser: + return "/invite" + case .kickUser: + return "/kick" + case .banUser: + return "/ban" + case .unbanUser: + return "/unban" + case .setUserPowerLevel: + return "/op" + case .resetUserPowerLevel: + return "/deop" + case .changeRoomTopic: + return "/topic" + case .discardSession: + return "/discardsession" + } + } + + // Note: not localized for consistancy, as commands are in english + // also translating these parameters could lead to inconsistency in + // the UI in case of languages with otherlength translation. + var parametersFormat: String { + switch self { + case .changeDisplayName: + return "" + case .emote: + return "" + case .joinRoom: + return "" + case .partRoom: + return "[]" + case .inviteUser: + return "" + case .kickUser: + return " []" + case .banUser: + return " []" + case .unbanUser: + return "" + case .setUserPowerLevel: + return " " + case .resetUserPowerLevel: + return "" + case .changeRoomTopic: + return "" + case .discardSession: + return "" + } + } +} diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.swift b/Riot/Modules/Room/DataSources/RoomDataSource.swift index 281a7a046a..89cbabe42f 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.swift +++ b/Riot/Modules/Room/DataSources/RoomDataSource.swift @@ -19,7 +19,7 @@ import Foundation extension RoomDataSource { // MARK: - Private Constants private enum Constants { - static let emoteMessageSlashCommandPrefix = String(format: "%@ ", kMXKSlashCmdEmote) + static let emoteMessageSlashCommandPrefix = String(format: "%@ ", MXKSlashCommand.emote.cmd) } // MARK: - NSAttributedString Sending diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index 7c014e0182..2e55c4771d 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -39,7 +39,6 @@ #import "MXKEncryptionKeysImportView.h" #import "NSBundle+MatrixKit.h" -#import "MXKSlashCommands.h" #import "MXKSwiftHeader.h" #import "MXKPreviewViewController.h" @@ -1284,8 +1283,14 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string // TODO: display an alert with the cmd usage in case of error or unrecognized cmd. NSString *cmdUsage; + + NSString* kMXKSlashCmdChangeDisplayName = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeDisplayName]; + NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; + NSString* kMXKSlashCmdPartRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandPartRoom]; + NSString* kMXKSlashCmdChangeRoomTopic = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeRoomTopic]; + - if ([cmd isEqualToString:kMXKSlashCmdEmote]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // send message as an emote [self sendTextMessage:string]; @@ -1320,7 +1325,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /nick "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeDisplayName]; } } else if ([string hasPrefix:kMXKSlashCmdJoinRoom]) @@ -1355,7 +1360,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /join "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } } else if ([string hasPrefix:kMXKSlashCmdPartRoom]) @@ -1413,7 +1418,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /part []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandPartRoom]; } } else if ([string hasPrefix:kMXKSlashCmdChangeRoomTopic]) @@ -1445,10 +1450,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /topic "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeRoomTopic]; } } - else if ([string hasPrefix:kMXKSlashCmdDiscardSession]) + else if ([string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandDiscardSession]]) { [roomDataSource.mxSession.crypto discardOutboundGroupSessionForRoomWithRoomId:roomDataSource.roomId onComplete:^{ MXLogDebug(@"[MXKRoomVC] Manually discarded outbound group session"); @@ -1470,7 +1475,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string userId = nil; } - if ([cmd isEqualToString:kMXKSlashCmdInviteUser]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandInviteUser]]) { if (userId) { @@ -1489,10 +1494,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /invite "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandInviteUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdKickUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandKickUser]]) { if (userId) { @@ -1524,10 +1529,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /kick []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandKickUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdBanUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandBanUser]]) { if (userId) { @@ -1559,10 +1564,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /ban []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandBanUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdUnbanUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandUnbanUser]]) { if (userId) { @@ -1581,10 +1586,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /unban "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandUnbanUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdSetUserPowerLevel]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandSetUserPowerLevel]]) { // Retrieve power level NSString *powerLevel = nil; @@ -1617,10 +1622,10 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /op "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandSetUserPowerLevel]; } } - else if ([cmd isEqualToString:kMXKSlashCmdResetUserPowerLevel]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandResetUserPowerLevel]]) { if (userId) { @@ -1639,7 +1644,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /deop "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandResetUserPowerLevel]; } } else diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 38f71f8d16..274e7d4375 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -1281,6 +1281,8 @@ - (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string { // Override the default behavior for `/join` command in order to open automatically the joined room + + NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; if ([string hasPrefix:kMXKSlashCmdJoinRoom]) { @@ -1317,7 +1319,7 @@ - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string else { // Display cmd usage in text input as placeholder - self.inputToolbarView.placeholder = @"Usage: /join "; + self.inputToolbarView.placeholder = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } return YES; } @@ -5237,7 +5239,7 @@ - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedT if (readyToSend) { BOOL isMessageAHandledCommand = NO; // "/me" command is supported with Pills in RoomDataSource. - if (![attributedTextMessage.string hasPrefix:kMXKSlashCmdEmote]) + if (![attributedTextMessage.string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // Other commands currently work with identifiers (e.g. ban, invite, op, etc). NSString *message; diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index f2dab2dabe..8669e812f4 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -95,8 +95,8 @@ final class CompletionSuggestionCoordinator: Coordinator, Presentable { if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) - } else if let command = self.commandProvider.commands.filter({ $0.name == identifier }).first { - self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.name, textTrigger: self.completionSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0.cmd == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.cmd, textTrigger: self.completionSuggestionService.currentTextTrigger) } } } @@ -207,7 +207,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr private let room: MXRoom private let userID: String - var commands: [(name: String, parametersFormat: String, description: String)] = [] + var commands = MXKSlashCommand.allCases init(room: MXRoom, userID: String) { self.room = room @@ -216,28 +216,59 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr } func updateWithPowerLevels() { - // TODO: filter commands in terms of user power level ? + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + + // Note: for now only filter out `/op` and `/deop` (same as Element-Web), + // but we could use power level for ban/invite/etc to filter further. + let adminOnlyCommands: [MXKSlashCommand] = [.setUserPowerLevel, .resetUserPowerLevel] + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + + if RoomPowerLevel(rawValue: userPowerLevel) != .admin { + self.commands = self.commands.filter { + !adminOnlyCommands.contains($0) + } + } + } } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - self.commands = [ - (name: "/ban", - parametersFormat: " [reason]", - description: "Bans user with given id"), - (name: "/invite", - parametersFormat: "", - description: "Invites user with given id to current room"), - (name: "/join", - parametersFormat: "", - description: "Joins room with given address"), - (name: "/me", - parametersFormat: "", - description: "Displays action") - ] - - // TODO: get real data - commands(self.commands.map { CommandsProviderCommand(name: $0.name, - parametersFormat: $0.parametersFormat, - description: $0.description) }) + commands(self.commands.map { CommandsProviderCommand( + name: $0.cmd, + parametersFormat: $0.parametersFormat, + description: $0.description + )}) + } +} + +private extension MXKSlashCommand { + // TODO: L10N + var description: String { + switch self { + case .changeDisplayName: + return "Changes your display nickname" + case .emote: + return "Displays action" + case .joinRoom: + return "Joins room with given address" + case .partRoom: + return "Leave room" + case .inviteUser: + return "Invites user with given id to current room" + case .kickUser: + return "Removes user with given id from this room" + case .banUser: + return "Bans user with given id" + case .unbanUser: + return "Unbans user with given id" + case .setUserPowerLevel: + return "Define the power level of a user" + case .resetUserPowerLevel: + return "Deops user with given id" + case .changeRoomTopic: + return "Sets the room topic" + case .discardSession: + return "Forces the current outbound group session in an encrypted room to be discarded" + } } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift index 95f81fb75b..4a16161897 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -48,7 +48,6 @@ struct CompletionSuggestionListItem: View { .font(theme.fonts.body) .foregroundColor(theme.colors.tertiaryContent) .accessibility(identifier: "descriptionText") - .lineLimit(1) } case .user(let userId, let avatar, let displayName): if let avatar = avatar { From 670064085ca707d4e37df74988b94583fac8fe9d Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 14:12:55 +0200 Subject: [PATCH 09/51] Display all commands when a single slash is entered --- .../Service/CompletionSuggestionService.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 5adf4f3c5b..b16efc137c 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -162,21 +162,29 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { }) } case "/": - // TODO: send all commands if only text is "/" partialName.removeFirst() commandProvider.fetchCommands { [weak self] commands in guard let self else { return } self.suggestionItems = commands.map { command in - CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem(name: command.name, parametersFormat: command.parametersFormat, description: command.description)) + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( + name: command.name, + parametersFormat: command.parametersFormat, + description: command.description + )) } - self.items.send(self.suggestionItems.filter { item in - guard case let .command(commandSuggestion) = item else { return false } + if partialName.isEmpty { + // A single `/` will display all available commands. + self.items.send(self.suggestionItems) + } else { + self.items.send(self.suggestionItems.filter { item in + guard case let .command(commandSuggestion) = item else { return false } - return commandSuggestion.name.lowercased().contains(partialName.lowercased()) - }) + return commandSuggestion.name.lowercased().contains(partialName.lowercased()) + }) + } } default: return From 1e98305012a793cd4dbc9ac957e1d52aff70bf52 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 15:43:20 +0200 Subject: [PATCH 10/51] Rework `CompletionSuggestionService` text trigger --- .../Service/CompletionSuggestionService.swift | 75 ++++++++++++------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index b16efc137c..86a99370fa 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -65,7 +65,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { private let commandProvider: CommandsProviderProtocol private var suggestionItems: [CompletionSuggestionItem] = [] - private let currentTextTriggerSubject = CurrentValueSubject(nil) + private let currentTextTriggerSubject = CurrentValueSubject(nil) private var cancellables = Set() // MARK: Public @@ -73,7 +73,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) var currentTextTrigger: String? { - currentTextTriggerSubject.value + currentTextTriggerSubject.value?.asString() } // MARK: - Setup @@ -88,11 +88,11 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { currentTextTriggerSubject .debounce(for: 0.5, scheduler: RunLoop.main) .removeDuplicates() - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } .store(in: &cancellables) } else { currentTextTriggerSubject - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } .store(in: &cancellables) } } @@ -101,16 +101,14 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { func processTextMessage(_ textMessage: String?) { guard let textMessage = textMessage, - textMessage.count > 0, - let lastComponent = textMessage.components(separatedBy: .whitespaces).last, - lastComponent.prefix(while: { $0 == "@" || $0 == "/" }).count == 1 // Partial username should start with one and only one "@" character + let textTrigger = textMessage.currentTextTrigger else { items.send([]) currentTextTriggerSubject.send(nil) return } - currentTextTriggerSubject.send(lastComponent) + currentTextTriggerSubject.send(textTrigger) } func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { @@ -122,27 +120,23 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { switch suggestionPattern.key { case .at: - currentTextTriggerSubject.send("@" + suggestionPattern.text) + currentTextTriggerSubject.send(TextTrigger(key: .at, text: suggestionPattern.text)) case .hash: // No room suggestion support yet items.send([]) currentTextTriggerSubject.send(nil) case .slash: - currentTextTriggerSubject.send("/" + suggestionPattern.text) + currentTextTriggerSubject.send(TextTrigger(key: .slash, text: suggestionPattern.text)) } } // MARK: - Private - private func fetchAndFilterMembersForTextTrigger(_ textTrigger: String?) { - guard var partialName = textTrigger else { - return - } - - switch partialName.first { - case "@": - partialName.removeFirst() // remove the '@' prefix + private func fetchAndFilterSuggestionsForTextTrigger(_ textTrigger: TextTrigger?) { + guard let textTrigger else { return } + switch textTrigger.key { + case .at: roomMemberProvider.fetchMembers { [weak self] members in guard let self = self else { return @@ -155,15 +149,13 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { self.items.send(self.suggestionItems.filter { item in guard case let .user(completionSuggestionUserItem) = item else { return false } - let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(partialName.lowercased()) + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(textTrigger.text.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(textTrigger.text.lowercased()) return (containedInUsername || containedInDisplayName) }) } - case "/": - partialName.removeFirst() - + case .slash: commandProvider.fetchCommands { [weak self] commands in guard let self else { return } @@ -175,19 +167,17 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { )) } - if partialName.isEmpty { + if textTrigger.text.isEmpty { // A single `/` will display all available commands. self.items.send(self.suggestionItems) } else { self.items.send(self.suggestionItems.filter { item in guard case let .command(commandSuggestion) = item else { return false } - return commandSuggestion.name.lowercased().contains(partialName.lowercased()) + return commandSuggestion.name.lowercased().contains(textTrigger.text.lowercased()) }) } } - default: - return } } } @@ -199,3 +189,34 @@ extension Array where Element == RoomMembersProviderMember { return self + [RoomMembersProviderMember(userId: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] } } + +private enum SuggestionKey: Character { + case at = "@" + case slash = "/" +} + +private struct TextTrigger: Equatable { + let key: SuggestionKey + let text: String + + func asString() -> String { + return String(key.rawValue) + text + } +} + +private extension String { + // Returns current completion suggestion for a text message, if any. + var currentTextTrigger: TextTrigger? { + let components = self.components(separatedBy: .whitespaces) + guard var lastComponent = components.last, + lastComponent.count > 0, + let suggestionKey = SuggestionKey(rawValue: lastComponent.removeFirst()), + // If a second character exists and is the same as the key it shouldn't trigger. + lastComponent.first != suggestionKey.rawValue, + // Slash commands should be displayed only if there is a single component + !(suggestionKey == .slash && components.count > 1) + else { return nil } + + return TextTrigger(key: suggestionKey, text: lastComponent) + } +} From 46f191c33b2d29a095d3e33be8de8f43ef24e8f1 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 16:01:17 +0200 Subject: [PATCH 11/51] Re-enable unit tests and fix a few lint warnings --- .../CompletionSuggestionScreenState.swift | 2 +- .../CompletionSuggestionViewModel.swift | 2 +- .../CompletionSuggestionCoordinator.swift | 9 ++------- .../Service/CompletionSuggestionService.swift | 4 ++-- .../CompletionSuggestionServiceTests.swift | 19 ++++++++++++++----- .../View/CompletionSuggestionList.swift | 11 +++-------- 6 files changed, 23 insertions(+), 24 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index 81d6e20889..b78c255755 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -65,7 +65,7 @@ extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ CommandsProviderCommand(name: "/ban", - parametersFormat: " [reason]", + parametersFormat: " []", description: "Bans user with given id"), CommandsProviderCommand(name: "/invite", parametersFormat: "", diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift index 0c9c0215c3..53d2c69757 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -29,7 +29,7 @@ class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, Completi // MARK: Public var sharedContext: CompletionSuggestionViewModelType.Context { - return self.context + context } var completion: ((CompletionSuggestionViewModelResult) -> Void)? diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 8669e812f4..102994636b 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -103,8 +103,7 @@ final class CompletionSuggestionCoordinator: Coordinator, Presentable { completionSuggestionService.items.sink { [weak self] _ in guard let self = self else { return } - self.delegate?.completionSuggestionCoordinator(self, - didUpdateViewHeight: self.calculateViewHeight()) + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) }.store(in: &cancellables) } @@ -233,11 +232,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - commands(self.commands.map { CommandsProviderCommand( - name: $0.cmd, - parametersFormat: $0.parametersFormat, - description: $0.description - )}) + commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description) }) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 86a99370fa..09b229ec43 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -200,14 +200,14 @@ private struct TextTrigger: Equatable { let text: String func asString() -> String { - return String(key.rawValue) + text + String(key.rawValue) + text } } private extension String { // Returns current completion suggestion for a text message, if any. var currentTextTrigger: TextTrigger? { - let components = self.components(separatedBy: .whitespaces) + let components = components(separatedBy: .whitespaces) guard var lastComponent = components.last, lastComponent.count > 0, let suggestionKey = SuggestionKey(rawValue: lastComponent.removeFirst()), diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift index 636ba3355c..18283bfb35 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -143,11 +143,20 @@ extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { extension CompletionSuggestionServiceTests: CommandsProviderProtocol { func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - let commandList = ["/ban", "/invite", "/join", "/me"] - - commands(commandList.map { command in - CommandsProviderCommand(name: command) - }) + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id"), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room"), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address"), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action") + ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift index 02aef8a1f0..cf8e34e023 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift @@ -30,7 +30,7 @@ struct CompletionSuggestionList: View { to the list items in order to be as close as possible as the `UITableView` display. */ - @available (iOS 16.0, *) + @available(iOS 16.0, *) static let collectionViewPaddingCorrection: CGFloat = -5.0 } @@ -44,19 +44,14 @@ struct CompletionSuggestionList: View { // MARK: Public @ObservedObject var viewModel: CompletionSuggestionViewModel.Context - var showBackgroundShadow: Bool = true + var showBackgroundShadow = true var body: some View { if viewModel.viewState.items.isEmpty { EmptyView() } else { ZStack { - CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( - id: "Prototype", - avatar: AvatarInput(mxContentUri: "", - matrixItemId: "", - displayName: "Prototype"), - displayName: "Prototype")) + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user(id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), displayName: "Prototype")) .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() if showBackgroundShadow { From 6e855584c8e07a4404b6e4e90683b1047fa91004 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 16:43:36 +0200 Subject: [PATCH 12/51] Move room admin condition to be usable in UnitTests and add tests --- .../CompletionSuggestionScreenState.swift | 22 +++- .../CompletionSuggestionCoordinator.swift | 24 ++-- .../Service/CompletionSuggestionService.swift | 11 +- .../CompletionSuggestionServiceTests.swift | 105 +++++++++++++++++- .../CompletionSuggestionListWithInput.swift | 4 +- 5 files changed, 144 insertions(+), 22 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift index b78c255755..5bdd720886 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -62,20 +62,34 @@ extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { } extension MockCompletionSuggestionScreenState: CommandsProviderProtocol { + var isRoomAdmin: Bool { false } + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { commands([ CommandsProviderCommand(name: "/ban", parametersFormat: " []", - description: "Bans user with given id"), + description: "Bans user with given id", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/invite", parametersFormat: "", - description: "Invites user with given id to current room"), + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/join", parametersFormat: "", - description: "Joins room with given address"), + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), CommandsProviderCommand(name: "/me", parametersFormat: "", - description: "Displays action") + description: "Displays action", + requiresAdminPowerLevel: false) ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 102994636b..6c868ce4c0 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -207,6 +207,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr private let userID: String var commands = MXKSlashCommand.allCases + var isRoomAdmin = false init(room: MXRoom, userID: String) { self.room = room @@ -218,21 +219,13 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr room.state { [weak self] state in guard let self, let powerLevels = state?.powerLevels else { return } - // Note: for now only filter out `/op` and `/deop` (same as Element-Web), - // but we could use power level for ban/invite/etc to filter further. - let adminOnlyCommands: [MXKSlashCommand] = [.setUserPowerLevel, .resetUserPowerLevel] let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - - if RoomPowerLevel(rawValue: userPowerLevel) != .admin { - self.commands = self.commands.filter { - !adminOnlyCommands.contains($0) - } - } + isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin } } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { - commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description) }) + commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description, requiresAdminPowerLevel: $0.requiresAdminPowerLevel) }) } } @@ -266,4 +259,15 @@ private extension MXKSlashCommand { return "Forces the current outbound group session in an encrypted room to be discarded" } } + + // Note: for now only filter out `/op` and `/deop` (same as Element-Web), + // but we could use power level for ban/invite/etc to filter further. + var requiresAdminPowerLevel: Bool { + switch self { + case .setUserPowerLevel, .resetUserPowerLevel: + return true + default: + return false + } + } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift index 09b229ec43..5ded36c2cd 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -28,6 +28,7 @@ struct CommandsProviderCommand { let name: String let parametersFormat: String let description: String + let requiresAdminPowerLevel: Bool } class CompletionSuggestionUserID: NSObject { @@ -41,6 +42,7 @@ protocol RoomMembersProviderProtocol { } protocol CommandsProviderProtocol { + var isRoomAdmin: Bool { get } func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) } @@ -159,7 +161,7 @@ class CompletionSuggestionService: CompletionSuggestionServiceProtocol { commandProvider.fetchCommands { [weak self] commands in guard let self else { return } - self.suggestionItems = commands.map { command in + self.suggestionItems = commands.filtered(isRoomAdmin: self.commandProvider.isRoomAdmin).map { command in CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( name: command.name, parametersFormat: command.parametersFormat, @@ -190,6 +192,13 @@ extension Array where Element == RoomMembersProviderMember { } } +extension Array where Element == CommandsProviderCommand { + func filtered(isRoomAdmin: Bool) -> Self { + guard !isRoomAdmin else { return self } + return filter { !$0.requiresAdminPowerLevel } + } +} + private enum SuggestionKey: Character { case at = "@" case slash = "/" diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift index 18283bfb35..90542868d4 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -22,14 +22,18 @@ import XCTest class CompletionSuggestionServiceTests: XCTestCase { var service: CompletionSuggestionService! var canMentionRoom = false + var isRoomAdmin = false override func setUp() { service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self, shouldDebounce: false) canMentionRoom = false + isRoomAdmin = false } - + + // MARK: - User suggestions + func testAlice() { service.processTextMessage("@Al") XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") @@ -128,6 +132,85 @@ class CompletionSuggestionServiceTests: XCTestCase { // Then the completion for a room mention should be shown. XCTAssertEqual(service.items.value.first?.asUser?.userId, CompletionSuggestionUserID.room) } + + // MARK: - Command suggestions + + func testJoin() { + service.processTextMessage("/jo") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/joi") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/join") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/oin") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + } + + func testInvite() { + service.processTextMessage("/inv") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/invite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/vite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + } + + func testMultipleResults() { + service.processTextMessage("/in") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/invite", "/join"] + ) + } + + func testDoubleSlashDontTrigger() { + service.processTextMessage("//") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testNonLeadingSlashCommandDontTrigger() { + service.processTextMessage("test /joi") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreNotAvailable() { + isRoomAdmin = false + + service.processTextMessage("/op") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreAvailable() { + isRoomAdmin = true + + service.processTextMessage("/op") + XCTAssertEqual(service.items.value.compactMap { $0.asCommand?.name }, ["/op", "/deop"]) + } + + func testDisplayAllCommandsAsStandardUser() { + isRoomAdmin = false + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/me"] + ) + } + + func testDisplayAllCommandsAsAdmin() { + isRoomAdmin = true + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/op", "/deop", "/me"] + ) + } } extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { @@ -146,16 +229,28 @@ extension CompletionSuggestionServiceTests: CommandsProviderProtocol { commands([ CommandsProviderCommand(name: "/ban", parametersFormat: " []", - description: "Bans user with given id"), + description: "Bans user with given id", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/invite", parametersFormat: "", - description: "Invites user with given id to current room"), + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), CommandsProviderCommand(name: "/join", parametersFormat: "", - description: "Joins room with given address"), + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), CommandsProviderCommand(name: "/me", parametersFormat: "", - description: "Displays action") + description: "Displays action", + requiresAdminPowerLevel: false) ]) } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 0b1dd8e8a4..223b4fbc61 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -34,13 +34,13 @@ struct CompletionSuggestionListWithInput: View { var body: some View { VStack(spacing: 0.0) { CompletionSuggestionList(viewModel: viewModel.listViewModel.context) - TextField("Search for user", text: $inputText) + TextField("Search for user/command", text: $inputText) .background(Color.white) .onChange(of: inputText, perform: viewModel.callback) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding([.leading, .trailing]) .onAppear { - inputText = "@-" // Make the list show all available mock results + inputText = "@-" // Make the list show all available user mock results } } } From 07ba5638c441aa84b883b3c41952a9493d341f39 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 16:50:44 +0200 Subject: [PATCH 13/51] Add changelog --- changelog.d/7493.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7493.feature diff --git a/changelog.d/7493.feature b/changelog.d/7493.feature new file mode 100644 index 0000000000..075a7f6a2b --- /dev/null +++ b/changelog.d/7493.feature @@ -0,0 +1 @@ +Add composer suggestions for slash commands From fb5d65e834eb96eaea433b50e9fcb95d718d84fd Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 17:09:02 +0200 Subject: [PATCH 14/51] L10N --- Riot/Assets/en.lproj/Vector.strings | 14 ++++++ Riot/Generated/Strings.swift | 48 +++++++++++++++++++ .../CompletionSuggestionCoordinator.swift | 25 +++++----- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index c1099f168a..38cded4a34 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -614,6 +614,20 @@ Tap the + to start adding people."; "room_join_group_call" = "Join"; "room_no_privileges_to_create_group_call" = "You need to be an admin or a moderator to start a call."; +// Room commands descriptions +"room_command_change_display_name_description" = "Changes your display nickname"; +"room_command_emote_description" = "Displays action"; +"room_command_join_room_description" = "Joins room with given address"; +"room_command_part_room_description" = "Leave room"; +"room_command_invite_user_description" = "Invites user with given id to current room"; +"room_command_kick_user_description" = "Removes user with given id from this room"; +"room_command_ban_user_description" = "Bans user with given id"; +"room_command_unban_user_description" = "Unbans user with given id"; +"room_command_set_user_power_level_description" = "Define the power level of a user"; +"room_command_reset_user_power_level_description" = "Deops user with given id"; +"room_command_change_room_topic_description" = "Sets the room topic"; +"room_command_discard_session_description" = "Forces the current outbound group session in an encrypted room to be discarded"; + // MARK: Threads "room_thread_title" = "Thread"; "thread_copy_link_to_thread" = "Copy link to thread"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 0cdd03d2d7..e48764f1dd 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5211,6 +5211,54 @@ public class VectorL10n: NSObject { public static var roomAvatarViewAccessibilityLabel: String { return VectorL10n.tr("Vector", "room_avatar_view_accessibility_label") } + /// Bans user with given id + public static var roomCommandBanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_ban_user_description") + } + /// Changes your display nickname + public static var roomCommandChangeDisplayNameDescription: String { + return VectorL10n.tr("Vector", "room_command_change_display_name_description") + } + /// Sets the room topic + public static var roomCommandChangeRoomTopicDescription: String { + return VectorL10n.tr("Vector", "room_command_change_room_topic_description") + } + /// Forces the current outbound group session in an encrypted room to be discarded + public static var roomCommandDiscardSessionDescription: String { + return VectorL10n.tr("Vector", "room_command_discard_session_description") + } + /// Displays action + public static var roomCommandEmoteDescription: String { + return VectorL10n.tr("Vector", "room_command_emote_description") + } + /// Invites user with given id to current room + public static var roomCommandInviteUserDescription: String { + return VectorL10n.tr("Vector", "room_command_invite_user_description") + } + /// Joins room with given address + public static var roomCommandJoinRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_join_room_description") + } + /// Removes user with given id from this room + public static var roomCommandKickUserDescription: String { + return VectorL10n.tr("Vector", "room_command_kick_user_description") + } + /// Leave room + public static var roomCommandPartRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_part_room_description") + } + /// Deops user with given id + public static var roomCommandResetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_reset_user_power_level_description") + } + /// Define the power level of a user + public static var roomCommandSetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_set_user_power_level_description") + } + /// Unbans user with given id + public static var roomCommandUnbanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_unban_user_description") + } /// You need permission to manage conference call in this room public static var roomConferenceCallNoPower: String { return VectorL10n.tr("Vector", "room_conference_call_no_power") diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index 6c868ce4c0..c02df825fe 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -230,33 +230,32 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr } private extension MXKSlashCommand { - // TODO: L10N var description: String { switch self { case .changeDisplayName: - return "Changes your display nickname" + return VectorL10n.roomCommandChangeDisplayNameDescription case .emote: - return "Displays action" + return VectorL10n.roomCommandEmoteDescription case .joinRoom: - return "Joins room with given address" + return VectorL10n.roomCommandJoinRoomDescription case .partRoom: - return "Leave room" + return VectorL10n.roomCommandPartRoomDescription case .inviteUser: - return "Invites user with given id to current room" + return VectorL10n.roomCommandInviteUserDescription case .kickUser: - return "Removes user with given id from this room" + return VectorL10n.roomCommandKickUserDescription case .banUser: - return "Bans user with given id" + return VectorL10n.roomCommandBanUserDescription case .unbanUser: - return "Unbans user with given id" + return VectorL10n.roomCommandUnbanUserDescription case .setUserPowerLevel: - return "Define the power level of a user" + return VectorL10n.roomCommandSetUserPowerLevelDescription case .resetUserPowerLevel: - return "Deops user with given id" + return VectorL10n.roomCommandResetUserPowerLevelDescription case .changeRoomTopic: - return "Sets the room topic" + return VectorL10n.roomCommandChangeRoomTopicDescription case .discardSession: - return "Forces the current outbound group session in an encrypted room to be discarded" + return VectorL10n.roomCommandDiscardSessionDescription } } From 0bcd27abb5af206932c1f22215d4b6e75c8420b9 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 20 Apr 2023 17:12:09 +0200 Subject: [PATCH 15/51] Fix comment typo --- Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift index faae85e945..7498b57696 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -67,7 +67,7 @@ } } - // Note: not localized for consistancy, as commands are in english + // Note: not localized for consistency, as commands are in english // also translating these parameters could lead to inconsistency in // the UI in case of languages with otherlength translation. var parametersFormat: String { From 1de6e769c81626ff8509a4081ecce606a8c07bb5 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 09:11:35 +0200 Subject: [PATCH 16/51] Fix missing self in closure --- .../Coordinator/CompletionSuggestionCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift index c02df825fe..4196da77a8 100644 --- a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -220,7 +220,7 @@ private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderPr guard let self, let powerLevels = state?.powerLevels else { return } let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin + self.isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin } } From bf1ea7d8552aa6276dd2d5667b44709e27ba6c79 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 09:30:56 +0200 Subject: [PATCH 17/51] Fix `RoomInputToolbarTextView` pills flushing --- .../RoomInputToolbarTextView.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift index 47d981c866..eabd68c9e3 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift @@ -23,6 +23,7 @@ class RoomInputToolbarTextView: UITextView { private var heightConstraint: NSLayoutConstraint! + private var pillViews = [UIView]() weak var toolbarDelegate: RoomInputToolbarTextViewDelegate? @@ -51,12 +52,18 @@ class RoomInputToolbarTextView: UITextView { } override var text: String! { + willSet { + flushPills() + } didSet { updateUI() } } override var attributedText: NSAttributedString! { + willSet { + flushPills() + } didSet { updateUI() } @@ -162,3 +169,17 @@ class RoomInputToolbarTextView: UITextView { delegate.onTouchUp(inside: delegate.rightInputToolbarButton) } } + +extension RoomInputToolbarTextView: PillViewFlusher { + func registerPillView(_ pillView: UIView) { + pillViews.append(pillView) + } + + private func flushPills() { + for view in pillViews { + view.alpha = 0.0 + view.removeFromSuperview() + } + pillViews.removeAll() + } +} From 4856038fd99ffeef52c7404c72e76a4ee01d28d2 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 13:47:15 +0200 Subject: [PATCH 18/51] Fix typo --- Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift index 7498b57696..54ab1ab3c5 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -69,7 +69,7 @@ // Note: not localized for consistency, as commands are in english // also translating these parameters could lead to inconsistency in - // the UI in case of languages with otherlength translation. + // the UI in case of languages with overlength translation. var parametersFormat: String { switch self { case .changeDisplayName: From 05cb486d54e42307b8d247ee36d7c1480a1dbc8a Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 21 Apr 2023 17:13:03 +0200 Subject: [PATCH 19/51] Fix sending command with Pills through RTE --- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 +++ .../MXKRoomInputToolbarView.h | 8 ++++++ Riot/Modules/Room/RoomViewController.m | 21 ++++++++++++++++ Riot/Modules/Room/RoomViewController.swift | 2 +- .../WysiwygInputToolbarView.swift | 25 ++++++++++++++++++- 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 38cded4a34..1aadc203f7 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -627,6 +627,7 @@ Tap the + to start adding people."; "room_command_reset_user_power_level_description" = "Deops user with given id"; "room_command_change_room_topic_description" = "Sets the room topic"; "room_command_discard_session_description" = "Forces the current outbound group session in an encrypted room to be discarded"; +"room_command_error_unknown_command" = "Invalid or unhandled command"; // MARK: Threads "room_thread_title" = "Thread"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index e48764f1dd..db052a786b 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5231,6 +5231,10 @@ public class VectorL10n: NSObject { public static var roomCommandEmoteDescription: String { return VectorL10n.tr("Vector", "room_command_emote_description") } + /// Invalid or unhandled command + public static var roomCommandErrorUnknownCommand: String { + return VectorL10n.tr("Vector", "room_command_error_unknown_command") + } /// Invites user with given id to current room public static var roomCommandInviteUserDescription: String { return VectorL10n.tr("Vector", "room_command_invite_user_description") diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index e366ae2393..abd67ec7e0 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -102,6 +102,14 @@ typedef enum : NSUInteger */ - (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText; +/** + Tells the delegate that the user wants to send a command. + + @param toolbarView the room input toolbar view. + @param commandText the command to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText; + /** Tells the delegate that the user wants to display the send media actions. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 274e7d4375..7646a135e7 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5190,6 +5190,27 @@ - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendFormattedTe }]; } +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText +{ + // Create before sending the message in case of a discussion (direct chat) + MXWeakify(self); + [self createDiscussionIfNeeded:^(BOOL readyToSend) { + MXStrongifyAndReturnIfNil(self); + + if (readyToSend) { + if (![self sendAsIRCStyleCommandIfPossible:commandText]) + { + // Display an error for unknown command + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil + message:[VectorL10n roomCommandErrorUnknownCommand] + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; + } + } + }]; +} + - (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView { NSMutableArray *actionItems = [NSMutableArray new]; diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index c94111be44..727ca8f806 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -107,7 +107,7 @@ extension RoomViewController { "event_id": eventModified.eventId ]) }) - } else if !self.send(asIRCStyleCommandIfPossible: rawTextMsg) { + } else { roomDataSource.sendFormattedTextMessage(rawTextMsg, html: htmlMsg) { response in switch response { case .success: diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 9bc02c21ec..ad488897fc 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -338,7 +338,30 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } private func sendWysiwygMessage(content: WysiwygComposerContent) { - delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + if content.markdown.prefix(while: { $0 == "/" }).count == 1 { + let commandText: String + if content.markdown.hasPrefix(MXKSlashCommand.emote.cmd) { + // `/me` command works with markdown content + commandText = content.markdown + } else if #available(iOS 15.0, *) { + // Other commands should see pills replaced by matrix identifiers + commandText = PillsFormatter.stringByReplacingPills(in: self.wysiwygViewModel.textView.attributedText, mode: .identifier) + } else { + // Without Pills support, just use the raw text for command + commandText = self.wysiwygViewModel.textView.text + } + + // Fix potential command failures due to trailing characters + // or NBSP that are not properly handled by the command interpreter + let sanitizedCommand = commandText + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: String.nbsp, with: " ") + + delegate?.roomInputToolbarView?(self, sendCommand: sanitizedCommand) + } else { + delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + } + if isMaximised { minimise() } From b249532e86e5cf65fc24114e5b44e05d9668e5e5 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Tue, 25 Apr 2023 17:36:06 +0200 Subject: [PATCH 20/51] Fix: Remove the matrix id from the notice display name changed event --- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 ++++ .../MatrixKit/Utils/EventFormatter/MXKEventFormatter.m | 2 +- changelog.d/7517.change | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog.d/7517.change diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 1aadc203f7..679041963f 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2984,6 +2984,7 @@ To enable access, tap Settings> Location and select Always"; "notice_avatar_url_changed" = "%@ changed their avatar"; "notice_display_name_set" = "%@ set their display name to %@"; "notice_display_name_changed_from" = "%@ changed their display name from %@ to %@"; +"notice_display_name_changed_to" = "%@ changed their display name to %@"; "notice_display_name_removed" = "%@ removed their display name"; "notice_topic_changed" = "%@ changed the topic to \"%@\"."; "notice_room_name_changed" = "%@ changed the room name to %@."; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index db052a786b..90aff5d8f7 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -3899,6 +3899,10 @@ public class VectorL10n: NSObject { public static func noticeDisplayNameChangedFromByYou(_ p1: String, _ p2: String) -> String { return VectorL10n.tr("Vector", "notice_display_name_changed_from_by_you", p1, p2) } + /// %@ changed their display name to %@ + public static func noticeDisplayNameChangedTo(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "notice_display_name_changed_to", p1, p2) + } /// %@ removed their display name public static func noticeDisplayNameRemoved(_ p1: String) -> String { return VectorL10n.tr("Vector", "notice_display_name_removed", p1) diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m index e2a9e4a01e..19fae141d2 100644 --- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -571,7 +571,7 @@ - (NSAttributedString *)attributedStringFromEvent:(MXEvent*)event } else { - displayText = [VectorL10n noticeDisplayNameChangedFrom:event.sender :prevDisplayname :displayname]; + displayText = [VectorL10n noticeDisplayNameChangedTo:prevDisplayname :displayname]; } } } diff --git a/changelog.d/7517.change b/changelog.d/7517.change new file mode 100644 index 0000000000..f436629479 --- /dev/null +++ b/changelog.d/7517.change @@ -0,0 +1 @@ +Timeline: Remove the matrix ID displayed when someone has changed its display name. From 27fd56d46b4e9d042b5b18ca901c240e04600f43 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Tue, 25 Apr 2023 09:48:11 +0200 Subject: [PATCH 21/51] Fix: allow to render a TimelinePoll even if the poll is loading --- .../MatrixSDK/PollHistoryService.swift | 1 + .../Coordinator/TimelinePollCoordinator.swift | 15 +++++++++++--- .../TimelinePoll/TimelinePollModels.swift | 7 +++++++ .../TimelinePollScreenState.swift | 17 ++++++++++++++++ .../TimelinePoll/TimelinePollViewModel.swift | 6 +++++- .../TimelinePollViewModelProtocol.swift | 1 + .../TimelinePoll/View/TimelinePollView.swift | 20 ++++++++++++++++--- changelog.d/7497.bugfix | 1 + 8 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 changelog.d/7497.bugfix diff --git a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift index 7f6d8c5f6c..79784c9d80 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift @@ -170,6 +170,7 @@ private extension PollHistoryService { do { newContext.pollAggregator = try PollAggregator(session: room.mxSession, room: room, pollEvent: pollStartEvent, delegate: self) + newContext.pollAggregator?.reloadPollData() } catch { pollAggregationContexts.removeValue(forKey: eventId) } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index 3214fae658..1cbfc148b5 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -77,6 +77,8 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } } .store(in: &cancellables) + + pollAggregator.reloadPollData() } // MARK: - Public @@ -109,13 +111,20 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) { viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll)) + viewModel.updateWithPollState(.loaded) } - func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { } + func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { + viewModel.updateWithPollState(.loading) + } - func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { } + func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { + viewModel.updateWithPollState(.loaded) + } - func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { } + func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { + viewModel.updateWithPollState(.invalidStartEvent) + } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift index 0ee87c55f9..6439157dd8 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift @@ -37,6 +37,12 @@ enum TimelinePollEventType { case ended } +enum TimelinePollState { + case loading + case loaded + case invalidStartEvent +} + struct TimelinePollAnswerOption: Identifiable { var id: String var text: String @@ -99,6 +105,7 @@ struct TimelinePollViewState: BindableState { } struct TimelinePollViewStateBindings { + var pollState: TimelinePollState var alertInfo: AlertInfo? } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift index 8c70b21e3e..aea09ade23 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift @@ -23,6 +23,9 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { case openUndisclosed case closedUndisclosed case closedPollEnded + case loading + case invalidStartEvent + case withAlert var screenType: Any.Type { TimelinePollDetails.self @@ -47,6 +50,20 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { let viewModel = TimelinePollViewModel(timelinePollDetails: poll) + switch self { + case .loading: + viewModel.updateWithPollState(.loading) + case .invalidStartEvent: + viewModel.updateWithPollState(.invalidStartEvent) + default: + viewModel.updateWithPollState(.loaded) + } + + if self == .withAlert { + viewModel.showAnsweringFailure() + } + + return ([viewModel], AnyView(TimelinePollView(viewModel: viewModel.context))) } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift index a86862cf4b..da98d26a24 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift @@ -31,7 +31,7 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - Setup init(timelinePollDetails: TimelinePollDetails) { - super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings())) + super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings(pollState: .loading))) } // MARK: - Public @@ -58,6 +58,10 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro state.poll = pollDetails } + func updateWithPollState(_ pollState: TimelinePollState) { + state.bindings.pollState = pollState + } + func showAnsweringFailure() { state.bindings.alertInfo = AlertInfo(id: .failedSubmittingAnswer, title: VectorL10n.pollTimelineVoteNotRegisteredTitle, diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift index 492f7f7a30..f4e0e5a20a 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift @@ -21,6 +21,7 @@ protocol TimelinePollViewModelProtocol { var completion: ((TimelinePollViewModelResult) -> Void)? { get set } func updateWithPollDetails(_ pollDetails: TimelinePollDetails) + func updateWithPollState(_ pollState: TimelinePollState) func showAnsweringFailure() func showClosingFailure() } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift index 2109a0e8ad..fb1af9b2b7 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift @@ -28,6 +28,23 @@ struct TimelinePollView: View { @ObservedObject var viewModel: TimelinePollViewModel.Context var body: some View { + Group { + switch viewModel.pollState { + case .loading: + TimelinePollMessageView(message: "loading...") + case .loaded: + pollContent + case .invalidStartEvent: + TimelinePollMessageView(message: VectorL10n.pollTimelineReplyEndedPoll) + } + } + .alert(item: $viewModel.alertInfo) { info in + info.alert + } + } + + @ViewBuilder + private var pollContent: some View { let poll = viewModel.viewState.poll VStack(alignment: .leading, spacing: 16.0) { @@ -61,9 +78,6 @@ struct TimelinePollView: View { } .padding([.horizontal, .top], 2.0) .padding([.bottom]) - .alert(item: $viewModel.alertInfo) { info in - info.alert - } } private var totalVotesString: String { diff --git a/changelog.d/7497.bugfix b/changelog.d/7497.bugfix new file mode 100644 index 0000000000..a8558b8430 --- /dev/null +++ b/changelog.d/7497.bugfix @@ -0,0 +1 @@ +Poll: The timeline sometimes displayed closed polls in the wrong order. From 61e22845bd3f26c38a698043d48f18ff0c5daf51 Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Wed, 26 Apr 2023 12:58:18 +0200 Subject: [PATCH 22/51] Disable accessibility for emojis during verification --- .../Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift b/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift index e609f6b387..df8b3359e1 100644 --- a/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift +++ b/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift @@ -24,4 +24,9 @@ class VerifyEmojiCollectionViewCell: UICollectionViewCell, Reusable, Themable { func update(theme: Theme) { name.textColor = theme.textPrimaryColor } + + override func awakeFromNib() { + super.awakeFromNib() + emoji.isAccessibilityElement = false + } } From 7a8374ed395550698bb53a38c7eb96f7cc0217ad Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Wed, 26 Apr 2023 13:03:06 +0200 Subject: [PATCH 23/51] Add changelog.d file --- changelog.d/pr-7521.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/pr-7521.bugfix diff --git a/changelog.d/pr-7521.bugfix b/changelog.d/pr-7521.bugfix new file mode 100644 index 0000000000..3cedf12d48 --- /dev/null +++ b/changelog.d/pr-7521.bugfix @@ -0,0 +1 @@ +Disable accessibility for emojis during session verification. \ No newline at end of file From ee2623906a904cd9e25dce3c3418a5d183854b18 Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Wed, 26 Apr 2023 15:36:29 +0200 Subject: [PATCH 24/51] Fix accessibility in SetPinCoordinatorBridgePresenter --- .../Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift b/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift index c381b76eb7..6c4e69d147 100644 --- a/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift +++ b/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift @@ -91,6 +91,10 @@ final class SetPinCoordinatorBridgePresenter: NSObject { } func presentWithMainAppWindow(_ window: UIWindow) { + // Prevents the VoiceOver reading accessible content when the PIN screen is on top + // Calling `makeKeyAndVisible` in `dismissWithMainAppWindow(_:)` restores the visibility state. + window.isHidden = true + let pinCoordinatorWindow = UIWindow(frame: window.bounds) let setPinCoordinator = SetPinCoordinator(session: self.session, viewMode: self.viewMode, pinCodePreferences: .shared) From 2f5d1799fb540af382e5bea162b27e3a1b97fbcb Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Wed, 26 Apr 2023 15:46:43 +0200 Subject: [PATCH 25/51] Remove accessibility from placeholder button --- .../EnterPinCodeViewController.storyboard | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard index e3f675ca60..ab5ef64829 100644 --- a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard +++ b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard @@ -1,26 +1,27 @@ - + - + + - + - + @@ -44,20 +45,20 @@ - + @@ -97,7 +98,7 @@ - + @@ -106,7 +107,7 @@ - + @@ -124,12 +125,12 @@ - + - - - - - - - - - + @@ -312,6 +316,7 @@ + @@ -323,7 +328,6 @@ - @@ -350,5 +354,8 @@ + + + From fda718396982b3965f8969e920af40b48942bceb Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Wed, 26 Apr 2023 15:55:19 +0200 Subject: [PATCH 26/51] Add changelog.d file --- changelog.d/pr-7522.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/pr-7522.bugfix diff --git a/changelog.d/pr-7522.bugfix b/changelog.d/pr-7522.bugfix new file mode 100644 index 0000000000..0bd4e5b530 --- /dev/null +++ b/changelog.d/pr-7522.bugfix @@ -0,0 +1 @@ +Fix accessibility when entering the PIN to unlock the app. From c98b45c93878be92a82186c5e87138f5fce2b3d4 Mon Sep 17 00:00:00 2001 From: Kat Gerasimova Date: Tue, 25 Apr 2023 11:03:51 +0100 Subject: [PATCH 27/51] Update triage for labelled issues Modernise actions from graphql to use new actions. Remove automation for Delight, WTF, FTUE, voice message and message bubble boards. --- .github/workflows/triage-move-labelled.yml | 248 ++------------------- 1 file changed, 21 insertions(+), 227 deletions(-) diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 291360fd26..130326b3d9 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -53,23 +53,10 @@ jobs: contains(github.event.issue.labels.*.name, 'O-Frequent')) || contains(github.event.issue.labels.*.name, 'A11y')) steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc0sUA" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/18 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} add_product_issues_to_project: name: X-Needs-Product to Design project board @@ -77,138 +64,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'X-Needs-Product') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AAg6N" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - Delight_issues_to_board: - name: Spaces issues to Delight project board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'Team: Delight') || - contains(github.event.issue.labels.*.name, 'Z-AppLayout') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc1HvQ" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - move_voice-message_issues: - name: A-Voice Messages to voice message board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'A-Voice Messages') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc2KCw" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - move_message_bubble_issues: - name: A-Message-Bubbles to Message bubble board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc3m-g" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - move_FTUE_issues: - name: Z-FTUE to FTUE board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'Z-FTUE') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AAqVx" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - move_WTF_issues: - name: Z-WTF to WTF board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'Z-WTF') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AArk0" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/28 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ex_plorers: name: Add labelled issues to X-Plorer project @@ -216,23 +75,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: Element X Feature') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4ALoFY" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/73 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features1: name: Add labelled issues to PS features team 1 @@ -245,23 +91,10 @@ jobs: (contains(github.event.issue.labels.*.name, 'A-Session-Mgmt') && contains(github.event.issue.labels.*.name, 'A-User-Settings')) steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AHJKF" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/56 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features2: name: Add labelled issues to PS features team 2 @@ -270,23 +103,10 @@ jobs: contains(github.event.issue.labels.*.name, 'A-DM-Start') || contains(github.event.issue.labels.*.name, 'A-Broadcast') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AHJKd" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/58 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features3: name: Add labelled issues to PS features team 3 @@ -294,23 +114,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AHJKW" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/57 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} voip: name: Add labelled issues to VoIP project board @@ -318,20 +125,7 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: VoIP') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4ABMIk" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/41 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} From 6776a8f1bcbfd07406943bdfaf9c1e7c0e9cdb03 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Wed, 26 Apr 2023 15:31:07 +0200 Subject: [PATCH 28/51] Fix: TimelinePoll code refactoring --- Riot/Assets/en.lproj/Vector.strings | 2 + Riot/Generated/Strings.swift | 4 + .../MockPollHistoryDetailScreenState.swift | 2 +- .../MatrixSDK/PollHistoryService.swift | 10 +- .../Coordinator/TimelinePollCoordinator.swift | 29 +++-- .../Unit/TimelinePollViewModelTests.swift | 115 +++++++++++------- .../TimelinePoll/TimelinePollModels.swift | 9 +- .../TimelinePollScreenState.swift | 9 +- .../TimelinePoll/TimelinePollViewModel.swift | 51 ++++---- .../TimelinePollViewModelProtocol.swift | 3 +- .../TimelinePoll/View/TimelinePollView.swift | 26 ++-- 11 files changed, 143 insertions(+), 117 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 1aadc203f7..41b2b4a548 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2405,6 +2405,8 @@ Tap the + to start adding people."; "poll_timeline_reply_ended_poll" = "Ended poll"; +"poll_timeline_loading" = "Loading..."; + // MARK: - Location sharing "location_sharing_title" = "Location"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index db052a786b..cd5a22877a 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -4923,6 +4923,10 @@ public class VectorL10n: NSObject { public static var pollTimelineEndedText: String { return VectorL10n.tr("Vector", "poll_timeline_ended_text") } + /// Loading... + public static var pollTimelineLoading: String { + return VectorL10n.tr("Vector", "poll_timeline_loading") + } /// Please try again public static var pollTimelineNotClosedSubtitle: String { return VectorL10n.tr("Vector", "poll_timeline_not_closed_subtitle") diff --git a/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift b/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift index 09a8fb3c7d..9c57bdabb9 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift @@ -48,7 +48,7 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable { } var screenView: ([Any], AnyView) { - let timelineViewModel = TimelinePollViewModel(timelinePollDetails: poll) + let timelineViewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(poll)) let viewModel = PollHistoryDetailViewModel(poll: poll) return ([viewModel], AnyView(PollHistoryDetail(viewModel: viewModel.context, contentPoll: TimelinePollView(viewModel: timelineViewModel.context)))) diff --git a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift index 79784c9d80..9425cc2d4d 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift @@ -170,7 +170,6 @@ private extension PollHistoryService { do { newContext.pollAggregator = try PollAggregator(session: room.mxSession, room: room, pollEvent: pollStartEvent, delegate: self) - newContext.pollAggregator?.reloadPollData() } catch { pollAggregationContexts.removeValue(forKey: eventId) } @@ -210,13 +209,14 @@ extension PollHistoryService: PollAggregatorDelegate { func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { } func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { - guard let context = pollAggregationContexts[aggregator.poll.id], context.published == false else { + + guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published == false else { return } context.published = true - let newPoll: TimelinePollDetails = .init(poll: aggregator.poll, represent: .started) + let newPoll: TimelinePollDetails = .init(poll: poll, represent: .started) if context.isLivePoll { livePollsSubject.send(newPoll) @@ -226,9 +226,9 @@ extension PollHistoryService: PollAggregatorDelegate { } func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) { - guard let context = pollAggregationContexts[aggregator.poll.id], context.published else { + guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published else { return } - updatesSubject.send(.init(poll: aggregator.poll, represent: .started)) + updatesSubject.send(.init(poll: poll, represent: .started)) } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index 1cbfc148b5..e2202524b2 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -32,7 +32,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel private let parameters: TimelinePollCoordinatorParameters private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>() - private var pollAggregator: PollAggregator + private var pollAggregator: PollAggregator! private(set) var viewModel: TimelinePollViewModelProtocol! private var cancellables = Set() @@ -46,10 +46,9 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel init(parameters: TimelinePollCoordinatorParameters) throws { self.parameters = parameters - try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent) - pollAggregator.delegate = self + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading) + try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent, delegate: self) - viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll)) viewModel.completion = { [weak self] result in guard let self = self else { return } @@ -77,8 +76,6 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } } .store(in: &cancellables) - - pollAggregator.reloadPollData() } // MARK: - Public @@ -94,11 +91,11 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } func canEndPoll() -> Bool { - pollAggregator.poll.isClosed == false + pollAggregator.poll?.isClosed == false } func canEditPoll() -> Bool { - pollAggregator.poll.isClosed == false && pollAggregator.poll.totalAnswerCount == 0 + pollAggregator.poll?.isClosed == false && pollAggregator.poll?.totalAnswerCount == 0 } func endPoll() { @@ -110,20 +107,22 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel // MARK: - PollAggregatorDelegate func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) { - viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll)) - viewModel.updateWithPollState(.loaded) + if let poll = aggregator.poll { + viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll))) + } } - func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { - viewModel.updateWithPollState(.loading) - } + func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { } func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { - viewModel.updateWithPollState(.loaded) + guard let poll = aggregator.poll else { + return + } + viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll))) } func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { - viewModel.updateWithPollState(.invalidStartEvent) + viewModel.updateWithPollDetailsState(.errored) } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift index a36a7d0925..2fd2b032fa 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift @@ -41,111 +41,134 @@ class TimelinePollViewModelTests: XCTestCase { hasBeenEdited: false, hasDecryptionError: false) - viewModel = TimelinePollViewModel(timelinePollDetails: timelinePoll) + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(timelinePoll)) context = viewModel.context } func testInitialState() { - XCTAssertEqual(context.viewState.poll.answerOptions.count, 3) - XCTAssertFalse(context.viewState.poll.closed) - XCTAssertEqual(context.viewState.poll.type, .disclosed) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions.count, 3) + XCTAssertEqual(context.viewState.pollState.poll?.closed, false) + XCTAssertEqual(context.viewState.pollState.poll?.type, .disclosed) } func testSingleSelectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testSingleReselectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testMultipleSelectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) } func testMultipleReselectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) } func testClosedSelection() { - viewModel.state.poll.closed = true + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.closed = true + viewModel.updateWithPollDetailsState(.loaded(poll)) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testSingleSelectionOnMax2Allowed() { - viewModel.state.poll.maxAllowedSelections = 2 + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.maxAllowedSelections = 2 + viewModel.updateWithPollDetailsState(.loaded(poll)) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testSingleReselectionOnMax2Allowed() { - viewModel.state.poll.maxAllowedSelections = 2 + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.maxAllowedSelections = 2 + viewModel.updateWithPollDetailsState(.loaded(poll)) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testMultipleSelectionOnMax2Allowed() { - viewModel.state.poll.maxAllowedSelections = 2 - + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.maxAllowedSelections = 2 + viewModel.updateWithPollDetailsState(.loaded(poll)) + context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) context.send(viewAction: .selectAnswerOptionWithIdentifier("2")) - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) context.send(viewAction: .selectAnswerOptionWithIdentifier("2")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) + } +} + +private extension TimelinePollDetailsState { + var poll: TimelinePollDetails? { + switch self { + case .loaded(let poll): + return poll + default: + return nil + } } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift index 6439157dd8..6b2d52c788 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift @@ -37,10 +37,10 @@ enum TimelinePollEventType { case ended } -enum TimelinePollState { +enum TimelinePollDetailsState { case loading - case loaded - case invalidStartEvent + case loaded(TimelinePollDetails) + case errored } struct TimelinePollAnswerOption: Identifiable { @@ -100,12 +100,11 @@ struct TimelinePollDetails { extension TimelinePollDetails: Identifiable { } struct TimelinePollViewState: BindableState { - var poll: TimelinePollDetails + var pollState: TimelinePollDetailsState var bindings: TimelinePollViewStateBindings } struct TimelinePollViewStateBindings { - var pollState: TimelinePollState var alertInfo: AlertInfo? } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift index aea09ade23..c81d786839 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift @@ -48,22 +48,21 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { hasBeenEdited: false, hasDecryptionError: false) - let viewModel = TimelinePollViewModel(timelinePollDetails: poll) + let viewModel: TimelinePollViewModel switch self { case .loading: - viewModel.updateWithPollState(.loading) + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading) case .invalidStartEvent: - viewModel.updateWithPollState(.invalidStartEvent) + viewModel = TimelinePollViewModel(timelinePollDetailsState: .errored) default: - viewModel.updateWithPollState(.loaded) + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(poll)) } if self == .withAlert { viewModel.showAnsweringFailure() } - return ([viewModel], AnyView(TimelinePollView(viewModel: viewModel.context))) } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift index da98d26a24..26ac65a681 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift @@ -30,8 +30,8 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - Setup - init(timelinePollDetails: TimelinePollDetails) { - super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings(pollState: .loading))) + init(timelinePollDetailsState: TimelinePollDetailsState) { + super.init(initialViewState: TimelinePollViewState(pollState: timelinePollDetailsState, bindings: TimelinePollViewStateBindings())) } // MARK: - Public @@ -40,11 +40,11 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro switch viewAction { // Update local state. An update will be pushed from the coordinator once sent. case .selectAnswerOptionWithIdentifier(let identifier): - guard !state.poll.closed else { + // only if the poll is ready and not closed + guard case let .loaded(poll) = state.pollState, !poll.closed else { return } - - if state.poll.maxAllowedSelections == 1 { + if poll.maxAllowedSelections == 1 { updateSingleSelectPollLocalState(selectedAnswerIdentifier: identifier, callback: completion) } else { updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion) @@ -54,12 +54,8 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - TimelinePollViewModelProtocol - func updateWithPollDetails(_ pollDetails: TimelinePollDetails) { - state.poll = pollDetails - } - - func updateWithPollState(_ pollState: TimelinePollState) { - state.bindings.pollState = pollState + func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState) { + state.pollState = pollDetailsState } func showAnsweringFailure() { @@ -77,33 +73,40 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - Private func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) { - state.poll.answerOptions.updateEach { answerOption in + guard case var .loaded(poll) = state.pollState else { return } + + var pollAnswerOptions = poll.answerOptions + pollAnswerOptions.updateEach { answerOption in if answerOption.selected { answerOption.selected = false answerOption.count = UInt(max(0, Int(answerOption.count) - 1)) - state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1)) + poll.totalAnswerCount = UInt(max(0, Int(poll.totalAnswerCount) - 1)) } if answerOption.id == selectedAnswerIdentifier { answerOption.selected = true answerOption.count += 1 - state.poll.totalAnswerCount += 1 + poll.totalAnswerCount += 1 } } - + poll.answerOptions = pollAnswerOptions + state.pollState = .loaded(poll) informCoordinatorOfSelectionUpdate(state: state, callback: callback) } func updateMultiSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) { - let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true } + guard case .loaded(var poll) = state.pollState else { return } + + let selectedAnswerOptions = poll.answerOptions.filter { $0.selected == true } let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0 - if !isDeselecting, selectedAnswerOptions.count >= state.poll.maxAllowedSelections { + if !isDeselecting, selectedAnswerOptions.count >= poll.maxAllowedSelections { return } - state.poll.answerOptions.updateEach { answerOption in + var pollAnswerOptions = poll.answerOptions + pollAnswerOptions.updateEach { answerOption in if answerOption.id != selectedAnswerIdentifier { return } @@ -111,22 +114,24 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro if answerOption.selected { answerOption.selected = false answerOption.count = UInt(max(0, Int(answerOption.count) - 1)) - state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1)) + poll.totalAnswerCount = UInt(max(0, Int(poll.totalAnswerCount) - 1)) } else { answerOption.selected = true answerOption.count += 1 - state.poll.totalAnswerCount += 1 + poll.totalAnswerCount += 1 } } - + poll.answerOptions = pollAnswerOptions + state.pollState = .loaded(poll) informCoordinatorOfSelectionUpdate(state: state, callback: callback) } func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) { - let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in + guard case .loaded(let poll) = state.pollState else { return } + + let selectedIdentifiers = poll.answerOptions.compactMap { answerOption in answerOption.selected ? answerOption.id : nil } - callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers)) } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift index f4e0e5a20a..ade6814389 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift @@ -20,8 +20,7 @@ protocol TimelinePollViewModelProtocol { var context: TimelinePollViewModelType.Context { get } var completion: ((TimelinePollViewModelResult) -> Void)? { get set } - func updateWithPollDetails(_ pollDetails: TimelinePollDetails) - func updateWithPollState(_ pollState: TimelinePollState) + func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState) func showAnsweringFailure() func showClosingFailure() } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift index fb1af9b2b7..52533288cb 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift @@ -29,12 +29,12 @@ struct TimelinePollView: View { var body: some View { Group { - switch viewModel.pollState { + switch viewModel.viewState.pollState { case .loading: - TimelinePollMessageView(message: "loading...") - case .loaded: - pollContent - case .invalidStartEvent: + TimelinePollMessageView(message: VectorL10n.pollTimelineLoading) + case .loaded(let poll): + pollContent(poll) + case .errored: TimelinePollMessageView(message: VectorL10n.pollTimelineReplyEndedPoll) } } @@ -44,9 +44,7 @@ struct TimelinePollView: View { } @ViewBuilder - private var pollContent: some View { - let poll = viewModel.viewState.poll - + private func pollContent(_ poll: TimelinePollDetails) -> some View { VStack(alignment: .leading, spacing: 16.0) { if poll.representsPollEndedEvent { Text(VectorL10n.pollTimelineEndedText) @@ -57,7 +55,7 @@ struct TimelinePollView: View { Text(poll.question) .font(theme.fonts.bodySB) .foregroundColor(theme.colors.primaryContent) + - Text(editedText) + Text(editedText(poll)) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) @@ -71,7 +69,7 @@ struct TimelinePollView: View { .disabled(poll.closed) .fixedSize(horizontal: false, vertical: true) - Text(totalVotesString) + Text(totalVotesString(poll)) .lineLimit(2) .font(theme.fonts.footnote) .foregroundColor(theme.colors.tertiaryContent) @@ -80,9 +78,7 @@ struct TimelinePollView: View { .padding([.bottom]) } - private var totalVotesString: String { - let poll = viewModel.viewState.poll - + private func totalVotesString(_ poll: TimelinePollDetails) -> String { if poll.hasDecryptionError, poll.totalAnswerCount > 0 { return VectorL10n.pollTimelineDecryptionError } @@ -109,8 +105,8 @@ struct TimelinePollView: View { } } - private var editedText: String { - viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : "" + private func editedText(_ poll: TimelinePollDetails) -> String { + poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : "" } } From c9e9bd016abd1340e115040da4f75216bf70aa50 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Thu, 27 Apr 2023 14:55:40 +0200 Subject: [PATCH 29/51] Update RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift Co-authored-by: Alfonso Grillo --- .../Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift index 9425cc2d4d..c4471844e3 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift @@ -209,7 +209,6 @@ extension PollHistoryService: PollAggregatorDelegate { func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { } func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { - guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published == false else { return } From bc3c1b2c4708e1cb3c36d20afc92b07d9e1799a1 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Fri, 28 Apr 2023 15:47:51 +0200 Subject: [PATCH 30/51] Fix a flickering issue when the timeline datasource is reloaded. --- .../MatrixKit/Models/Room/MXKRoomDataSource.m | 19 +++++++------------ changelog.d/7523.bugfix | 1 + 2 files changed, 8 insertions(+), 12 deletions(-) create mode 100644 changelog.d/7523.bugfix diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index a69f504cc0..033aa93618 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -456,11 +456,6 @@ - (void)limitMemoryUsage:(NSInteger)maxBubbleNb } - (void)reset -{ - [self resetNotifying:YES]; -} - -- (void)resetNotifying:(BOOL)notify { if (roomDidFlushDataNotificationObserver) { @@ -556,12 +551,6 @@ - (void)resetNotifying:(BOOL)notify } _serverSyncEventCount = 0; - - // Notify the delegate to reload its tableview - if (notify && self.delegate) - { - [self.delegate dataSource:self didCellChange:nil]; - } } - (void)reload @@ -575,10 +564,16 @@ - (void)reloadNotifying:(BOOL)notify [self setState:MXKDataSourceStatePreparing]; - [self resetNotifying:notify]; + [self reset]; // Reload [self didMXSessionStateChange]; + + // Notify the delegate to refresh the tableview + if (notify && self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } } - (void)destroy diff --git a/changelog.d/7523.bugfix b/changelog.d/7523.bugfix new file mode 100644 index 0000000000..bc5cf31a71 --- /dev/null +++ b/changelog.d/7523.bugfix @@ -0,0 +1 @@ +Fix a flickering issue when the timeline datasource is reloaded. From 9d5faae7634d3124a66747bc09084d0520b63803 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Tue, 2 May 2023 11:11:51 +0200 Subject: [PATCH 31/51] Fix application crashing when opening a thread with RTE enabled --- Riot/Modules/Room/RoomViewController.m | 6 ++++++ changelog.d/7530.bugfix | 1 + 2 files changed, 7 insertions(+) create mode 100644 changelog.d/7530.bugfix diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 7646a135e7..799f1f7184 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -1194,6 +1194,12 @@ + (Class) mainToolbarClass - (void)updateRoomInputToolbarViewClassIfNeeded { Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; + + // If RTE is enabled, delay the toolbar setup until `completionSuggestionCoordinator` is ready. + if (roomInputToolbarViewClass == WysiwygInputToolbarView.class && _completionSuggestionCoordinator == nil) + { + return; + } BOOL shouldDismissContextualMenu = NO; diff --git a/changelog.d/7530.bugfix b/changelog.d/7530.bugfix new file mode 100644 index 0000000000..5733a8d814 --- /dev/null +++ b/changelog.d/7530.bugfix @@ -0,0 +1 @@ +Fix application crashing when opening a thread with RTE enabled From ff9f1c66226786ac6a3ada6ac334889c949f3636 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Tue, 2 May 2023 11:44:23 +0200 Subject: [PATCH 32/51] Update room input toolbar when `CompletionSuggestionCoordinator` is initialised --- Riot/Modules/Room/RoomViewController.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 799f1f7184..a18de9aa13 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -1094,6 +1094,8 @@ - (void)displayRoom:(MXKRoomDataSource *)dataSource _completionSuggestionCoordinator.delegate = self; [self setupCompletionSuggestionViewIfNeeded]; + + [self updateRoomInputToolbarViewClassIfNeeded]; [self updateTopBanners]; } From 5cf28f4fc28572531ce3c204d6ef1d717b922544 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Fri, 3 Mar 2023 10:02:57 +0100 Subject: [PATCH 33/51] Secrets recovery: fix an issue preventing the release of SecureBackupSetupCoordinator --- .../Secrets/Recover/SecretsRecoveryCoordinator.swift | 7 ++++--- .../Setup/SecureBackupSetupCoordinator.swift | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift b/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift index d36ee995ef..817414956a 100644 --- a/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift +++ b/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift @@ -121,11 +121,12 @@ final class SecretsRecoveryCoordinator: SecretsRecoveryCoordinatorType { private func showSecureBackupSetup(checkKeyBackup: Bool) { let coordinator = SecureBackupSetupCoordinator(session: self.session, checkKeyBackup: checkKeyBackup, navigationRouter: self.navigationRouter, cancellable: self.cancellable) coordinator.delegate = self - coordinator.start() - - self.navigationRouter.push(coordinator.toPresentable(), animated: true, popCompletion: { [weak self] in + // Fix: calling coordinator.start() will update the navigationRouter without a popCompletion + coordinator.start(popCompletion: { [weak self] in self?.remove(childCoordinator: coordinator) }) + // Fix: do not push the presentable from the coordinator to the navigation router as this has already been done by coordinator.start(). + // Also, coordinator.toPresentable() returns a navigation controller, which cannot be pushed into a navigation router. self.add(childCoordinator: coordinator) } } diff --git a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift index 53a03e3595..0cb6339450 100644 --- a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift +++ b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift @@ -73,15 +73,19 @@ final class SecureBackupSetupCoordinator: SecureBackupSetupCoordinatorType { // MARK: - Public methods func start() { + start(popCompletion: nil) + } + + func start(popCompletion: (() -> Void)?) { let rootViewController = self.createIntro() if self.navigationRouter.modules.isEmpty == false { - self.navigationRouter.push(rootViewController, animated: true, popCompletion: nil) + self.navigationRouter.push(rootViewController, animated: true, popCompletion: popCompletion) } else { - self.navigationRouter.setRootModule(rootViewController) + self.navigationRouter.setRootModule(rootViewController, popCompletion: popCompletion) } } - + func toPresentable() -> UIViewController { return self.navigationRouter .toPresentable() From 0240e35cca977880cb1ef6031a83be81187702b1 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Mon, 3 Apr 2023 13:50:35 +0200 Subject: [PATCH 34/51] =?UTF-8?q?Fix:=20don=E2=80=99t=20allow=20to=20reset?= =?UTF-8?q?=20secrets=20if=20it=20is=20already=20in=20progress.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift | 2 +- Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift | 1 + .../Secrets/Reset/SecretsResetViewController.swift | 6 ++++++ Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift | 9 ++++++++- Riot/Modules/Secrets/Reset/SecretsResetViewState.swift | 1 + changelog.d/pr-7404.bugfix | 1 + 6 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 changelog.d/pr-7404.bugfix diff --git a/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift b/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift index 6c72ebe5d9..bd9740ad59 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift @@ -94,11 +94,11 @@ extension SecretsResetCoordinator: SecretsResetViewModelCoordinatorDelegate { extension SecretsResetCoordinator: ReauthenticationCoordinatorDelegate { func reauthenticationCoordinatorDidComplete(_ coordinator: ReauthenticationCoordinatorType, withAuthenticationParameters authenticationParameters: [String: Any]?) { - self.secretsResetViewModel.process(viewAction: .authenticationInfoEntered(authenticationParameters ?? [:])) } func reauthenticationCoordinatorDidCancel(_ coordinator: ReauthenticationCoordinatorType) { + self.secretsResetViewModel.process(viewAction: .authenticationCancelled) self.remove(childCoordinator: coordinator) } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift index aa135b5fef..5b960342a4 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift @@ -22,6 +22,7 @@ import Foundation enum SecretsResetViewAction { case loadData case reset + case authenticationCancelled case authenticationInfoEntered(_ authInfo: [String: Any]) case cancel } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift index 6008a1fb09..fccbfb6e35 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift @@ -132,6 +132,8 @@ final class SecretsResetViewController: UIViewController { self.renderLoading() case .resetDone: self.renderLoaded() + case .resetCancelled: + self.renderCancelled() case .error(let error): self.render(error: error) } @@ -145,6 +147,10 @@ final class SecretsResetViewController: UIViewController { self.activityPresenter.removeCurrentActivityIndicator(animated: true) } + private func renderCancelled() { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + } + private func render(error: Error) { self.activityPresenter.removeCurrentActivityIndicator(animated: true) self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift index 2e8e7604ce..62b0c686fb 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift @@ -49,6 +49,8 @@ final class SecretsResetViewModel: SecretsResetViewModelType { break case .reset: self.askAuthentication() + case .authenticationCancelled: + self.authenticationCancelled() case .authenticationInfoEntered(let authParameters): self.resetSecrets(with: authParameters) case .cancel: @@ -68,7 +70,6 @@ final class SecretsResetViewModel: SecretsResetViewModelType { } MXLog.debug("[SecretsResetViewModel] resetSecrets") - self.update(viewState: .resetting) crossSigning.setup(withAuthParams: authParameters, success: { [weak self] in guard let self = self else { return @@ -96,7 +97,13 @@ final class SecretsResetViewModel: SecretsResetViewModelType { } private func askAuthentication() { + self.update(viewState: .resetting) + let setupCrossSigningRequest = self.crossSigningService.setupCrossSigningRequest() self.coordinatorDelegate?.secretsResetViewModel(self, needsToAuthenticateWith: setupCrossSigningRequest) } + + private func authenticationCancelled() { + self.update(viewState: .resetCancelled) + } } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift index b7cb0acb8a..128f90b190 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift @@ -22,5 +22,6 @@ import Foundation enum SecretsResetViewState { case resetting case resetDone + case resetCancelled case error(Error) } diff --git a/changelog.d/pr-7404.bugfix b/changelog.d/pr-7404.bugfix new file mode 100644 index 0000000000..58609a160a --- /dev/null +++ b/changelog.d/pr-7404.bugfix @@ -0,0 +1 @@ +Fix an issue where the Secrets Reset screen would open twice. From 4dcf8918b809a2f19d8ca053dd7cc8571d121bcc Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Tue, 2 May 2023 15:26:32 +0200 Subject: [PATCH 35/51] Fix the frame of the marker view highlighting an event --- .../MXKRoomBubbleTableViewCell+Riot.m | 48 +++++-------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m index 907dd5ff22..9164a61d74 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m @@ -256,40 +256,18 @@ - (void)markComponent:(NSUInteger)componentIndex if (componentIndex < bubbleComponents.count) { - MXKRoomBubbleComponent *component = bubbleComponents[componentIndex]; - - // Define the marker frame - CGFloat markPosY = component.position.y + self.msgTextViewTopConstraint.constant; - - NSInteger mostRecentComponentIndex = bubbleComponents.count - 1; - if ([bubbleData isKindOfClass:RoomBubbleCellData.class]) - { - mostRecentComponentIndex = ((RoomBubbleCellData*)bubbleData).mostRecentComponentIndex; - } - - // Compute the mark height. - // Use the rest of the cell height by default. - CGFloat markHeight = self.contentView.frame.size.height - markPosY; - if (componentIndex != mostRecentComponentIndex) + CGRect componentFrame = [self componentFrameInContentViewForIndex:componentIndex]; + if (CGRectIsEmpty(componentFrame)) { - // There is another component (with display) after this component in the cell. - // Stop the marker height to the top of this component. - for (NSInteger index = componentIndex + 1; index < bubbleComponents.count; index ++) - { - MXKRoomBubbleComponent *nextComponent = bubbleComponents[index]; - - if (nextComponent.attributedTextMessage) - { - markHeight = nextComponent.position.y - component.position.y; - break; - } - } + return; } - UIView *markerView = [[UIView alloc] initWithFrame:CGRectMake(VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_X, - markPosY, - VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_WIDTH, - markHeight)]; + CGRect markerFrame = CGRectMake(VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_X, + CGRectGetMinY(componentFrame), + VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_WIDTH, + CGRectGetHeight(componentFrame)); + + UIView *markerView = [[UIView alloc] initWithFrame:markerFrame]; markerView.backgroundColor = ThemeService.shared.theme.tintColor; [markerView setTranslatesAutoresizingMaskIntoConstraints:NO]; @@ -303,28 +281,28 @@ - (void)markComponent:(NSUInteger)componentIndex toItem:self.contentView attribute:NSLayoutAttributeLeading multiplier:1.0 - constant:VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_X]; + constant:CGRectGetMinX(markerFrame)]; NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:markerView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeTop multiplier:1.0 - constant:markPosY]; + constant:CGRectGetMinY(markerFrame)]; NSLayoutConstraint *widthConstraint = [NSLayoutConstraint constraintWithItem:markerView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 - constant:VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_WIDTH]; + constant:CGRectGetWidth(markerFrame)]; NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:markerView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 - constant:markHeight]; + constant:CGRectGetHeight(markerFrame)]; // Available on iOS 8 and later [NSLayoutConstraint activateConstraints:@[leftConstraint, topConstraint, widthConstraint, heightConstraint]]; From b9b421f6bb0c10a6d22c5a005c93ddff5f85753a Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Wed, 3 May 2023 11:43:02 +0200 Subject: [PATCH 36/51] Fix: highlighting an event removes the highlighting of the previous event. --- Riot/Modules/Room/RoomViewController.m | 36 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index a18de9aa13..592495ddab 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -7504,23 +7504,47 @@ - (void)highlightAndDisplayEvent:(NSString *)eventId completion:(void (^)(void)) return; } + NSMutableArray *rowsToReload = [[NSMutableArray alloc] init]; + // Get the current hightlighted event because we will need to reload it + NSString *currentHiglightedEventId = self.customizedRoomDataSource.highlightedEventId; + if (currentHiglightedEventId) + { + NSInteger currentHiglightedRow = [self.roomDataSource indexOfCellDataWithEventId:currentHiglightedEventId]; + if (currentHiglightedRow != NSNotFound) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:currentHiglightedRow inSection:0]; + if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) + { + [rowsToReload addObject:indexPath]; + } + } + } + self.customizedRoomDataSource.highlightedEventId = eventId; + // Add the new highligted event to the list of rows to reload NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; - if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) + BOOL indexPathIsVisible = [[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]; + if (indexPathIsVisible) { - [self.bubblesTableView reloadRowsAtIndexPaths:@[indexPath] + [rowsToReload addObject:indexPath]; + } + + // Reload rows + if (rowsToReload.count > 0) + { + [self.bubblesTableView reloadRowsAtIndexPaths:rowsToReload withRowAnimation:UITableViewRowAnimationNone]; - [self.bubblesTableView scrollToRowAtIndexPath:indexPath - atScrollPosition:UITableViewScrollPositionMiddle - animated:YES]; } - else if ([self.bubblesTableView vc_hasIndexPath:indexPath]) + + // Scroll to the newly highlighted row + if (indexPathIsVisible || [self.bubblesTableView vc_hasIndexPath:indexPath]) { [self.bubblesTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionMiddle animated:YES]; } + if (completion) { completion(); From 5a85793da8307ae6e5a59a4087639c6215796d38 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Wed, 3 May 2023 11:49:54 +0200 Subject: [PATCH 37/51] Add changelog file --- changelog.d/7526.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7526.bugfix diff --git a/changelog.d/7526.bugfix b/changelog.d/7526.bugfix new file mode 100644 index 0000000000..7adb60cc08 --- /dev/null +++ b/changelog.d/7526.bugfix @@ -0,0 +1 @@ +Fix the position of the marker highlighting an event. From 524cbccc4d71c514b4f35c595e40df3752204919 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Thu, 20 Apr 2023 14:31:26 +0200 Subject: [PATCH 38/51] VoiceBroadcast: Play a sound to notify the user when VB is pause due to an error. --- Riot/Assets/Sounds/vberror.mp3 | Bin 0 -> 9497 bytes .../MatrixKitAssets.bundle/Sounds/vberror.mp3 | Bin 0 -> 9497 bytes .../MatrixKit/MatrixKit-Bridging-Header.h | 1 + .../VoiceBroadcastRecorderService.swift | 35 +++++++++++++++++- changelog.d/7504.change | 1 + 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 Riot/Assets/Sounds/vberror.mp3 create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 create mode 100644 changelog.d/7504.change diff --git a/Riot/Assets/Sounds/vberror.mp3 b/Riot/Assets/Sounds/vberror.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..14c710595b54f5a340c2e67d1b4f8938f9e7b560 GIT binary patch literal 9497 zcmd6tXH*kWyYD9?gwR7TfD@3~*jx@+B!XRiel!kWFG-~K;sPhw)Ai~wlE&d$-%5xPnP z0Ngg%`|?%y5HG;O@0v%5yZ2=gHTmNrsw#4NewRh`uJ{FdiYRF)0d}^whR_{s&>iOP zVcx2Ed1ZM;Jf1e&e+Ky1>c3z9Kd&Z1-oem6*`Xg<01$r-Ff(&-@bU@?31Ki&QgU+2 z%F62Mr%o9dn3$NHK5cJ*_Uu_A(c3#P@Y1DASFhf>6&;(^~G=d&= zgrRM>4bTT;Nbv(N)l^u4oVZ4B)ESF2c6-q;^hb&LMJJ?00+6F~OfQ5Urh59M`x~jQWvQ&zZ#^Tq z6X5(-SJ{OszY3{P-l7R5~ASA$v zV43LRjg!|cly%@R)x>n*0?gq4=b`a4k}V#g7^iZmbwAevwL zYyhKf53h&GJD}Z;;pPp8Dv|PwZn4XkfkdOb6_ZxiT9L#n8;kFf$~CZOYQY1;e|6G7 zEj{NhMg(Vh@ox*^D&>s7ys%%0?2bM2Piu4&MJLMeVO)hf@2u*~U(3kg)ahf(KA?pX zI>yp>Dli=@5nK$5DwH*~_;)@Y59~N1=m+ibaqqkqn|M1;(qb>~B$6vTHE?XX@6_LbeaHtR|YT&42 z05i^95bi{VE!Z)v6uMN38~-9AmbPGtMnB{SX?>Jh*LOEzv1ooiPf1qV(A0b!D$8%P_amkH)44I z5-m^hdd{vZIb9_$1^mUDCy6JP^=;>IFxe_hH{%MxV9&SD4&eHW%9Cy&wNW_|2LL)E zXcU6(Fee`K;JICQ0L7=n)I7@q zyA3G#eRmfpIstyafCS{GJ2#08?4R$vM+0IaaXe-k`KjTA5`cwI0$r*|f|PZs zZztI~_Q_=+2Z+_QdElojvcS}FUeG|_LPYOC$L@j;?}_0fJNs`elF?+jiU1tw z%dCLMI{$X-UxOu9!QeMiy7eY`BgXuNOe471IrR2o?=$KQs5#_VmZISgcqFEye-;<| zG9_d)RxrJj8(NrH{`AII^TL$u%XDkl%M%}ld_Zsu1VaEEYlQ&oy?3EaErhcx+~2@a zaHY25B1y)f)R__HgB|Bywv;|=oAV}9`e8}^@KfEGz;VN|SEW@e@(&DFhlBq_Qj=09 z!DUAPrjx}F2-x@b;}`sowpSILrvYK2qO!OLmrXdI2*UhX0Hg#`NI_TZf+*mEl==pH ztg|kYClzf5T11=tyJj-LXA09TX8NUh(fb@um!;DDr$J>$NN50$a>>a2RgLkE_t6bK+ z&n`&RlX&){jO5`>(wAympVNTkNg4M9KFP#$lKEgJ!+@k!y!vMk@*CZkPI2F9K>M!i zSa0G`BSm!B#v{DRX>?eX+Oiq|O{us!y*bCvgsc`&5s|cRXfFKPoo`lgV(;`Gj)$bh z)p^c0s;^$|f&7rX6jRWc@5>J}RHxd}?ov~wb&KC8b+4!9zQ0F((g@mGd;xtb`P(?N zzuDd7Z4$RwdE(%B_B6VM4~muX-lfWLHG*%S2rE-G;`8F`7<6xhh^h1nqS2y1rMr() ztdo&o;2s>s1WxYn(tu6?9QOsSmd!aRP?oC)uzE;mKRoN3GQf@A`x{&WC5!0;3q=@W zX4iT#=j?LN-(E`N?uOHeT5xV0?Z6Ug3!qJ6`w#NpnRI^*7BaOn7PWD1>JA$)Q#l)7&VTCEO zHTQA*HgKfKiTGD~`KvgcS$fn{gQq2^S1Mey@bP*r>SHF<9dY2-KVs{{DT)Uv`h3MV z4%d*v0}$NbrU4m8!4d^+XBE&>e1%R~5D@*Wf-z~fy~aKEhbNGOqEsLf=;_FCEf-^R z-Cj)tebms)k0YX~t>iUg@NdQhG8#sRO4dN%53|<%JJEa~C<%~>s0W}%h`w0qCc`Q? zZhW=aO$}O3C#pj44W6V{cK|=aghC5TShveca2td_QOwF&$bU2K`n1{#<67#+8Cl-4 zZ5%TnPwlGX>}(TtFuqRp`W~%s6*R~2jSnV1AEd8C*J(hHI(@!!;&-$Nh3vdfwvB5O zX-%a8*^_FMS$YT)#B|>I@+^AHd8CNFMwB~-XmdxR6_IE)S%TB*AbfkJckBS_^Kn5% zP{9t2qm~f-RfBkJN2XhDI@$rpG6r`T!IfSTqFz7{$4b4U6e#3)uS1il{M2%2g@Rqsj-yXOPcRUdRmQ`OA zdjc)Huyk~`j+e37>6&{^P5!@4_oCiPY&~ArgtIe_QZVZEnf71e0YE6q$ylc@{S*zz z3l>FSwkC=YSVTqrU0{;daVRg1LVm(*MBKtRxF{j)I`x|m#=K%=MY(qjfVJC=lIcO& z0o1lel6?fvv-@eN8Mb2*cAJCk6NK?xJ}MRW>dT-8uKP>-8S(H>DM2HK-r1d^qTV>o zMd7dN-MsH`fRF<7FSz#ZTgfga-nK{KPc5HBiPkw0Mo(LZc|@;)u4#My-XwrdO3M@v z9JyT4k&qzN#e^HbjHT!N+$&CgSLdv*) z)Or!i*~3$;;RSX3h!=*sq>_r7>&bpw2p-oyL&-4*Uj-zpx6(+l`ln9jm2U9K6n5+* z0nkcbRH`>{`TAZ}j}1Z`F~ff?%3*tDQhLpR|{W<}~1*6M&C zfn~`2MAP-EpHF3VUqo*A1Hc)#J2fbH0+~%XD))5Hel8&=Y22b;QO)#`0|*sq7CC?% zCY9M*&QEGyQx0EW*tf*0w$bZQb+YF!i08=OZw4ZJ}4^VfUCNb5*)1?{a>5`LIBM5=C6Go1s{!rEX;hT;_-b!O52C%%xiiaS~p( zACYC611V00{hzEd_r?P3?U8m+`hL~m>3ShB>72@|975|O8qhsh#%XD*SraZ<5iyNk z;QTp+fuWp!lqe9N>aBvva_+HFjEWfAcr%M$i1-<&({1)jJar)T z=I4b|oyKvePd%){mUz|`KS{f6FZ(Mz1yzd~M;bw83*2l0EnVa;=NPQ!D1EQC5g5)6 zik5F;`*@`jeMeg+W*w}3kDLGHg5k;Fzt~FfsjVsIPZ&3S4o^uB>^!0aC`&;{s$lIV z0om)HaX~f$m#or>7U>O|EV{-=qCT+bwcnVPq5(CeZSd>2(UG`9gcAPX0kL_6{V^I) z>#d?$9Uw391B2`b#Mk6Hs|yVz)x@S3PrGg&zjA(tXzKa!N@lws?scM91u_hr0T&2A z-L_;?deSZo!-j+|tj5_CCUWrc${&v=Bs^D8{&qADQ2}a$e3{L4d!MD|u!UFbL>t7f zK1a7jE4#N33%8+UPYJ4~{1(m5ob80Vq|x9VQ27Q}e`+=3?Qr9j?{#Jc4+!V2em9&t zeo~H~sA3i+?k_y=x*%+$y$n)ETXrI!t(Y^qgdBZB5rKdVECdb}3M-l= z0clZ3mci|cpY%;$Ykjpt3kD5$Bi?aNXUX|4dGqR9Kgzk$u$5Fn>B>7($QQlLX4~fq$PkefA>;9 zZ~UN62Yy$s&)cb@R}oc@d_U26VMa65)XIjkOzaXpiWJiZ=MSJL2EkKrFQ4aD*6ii#)o1`>M3b!jmDsJ4S(%Y5Bip9?u%M z%=A%B2^5psnQF#9-rg_Zzv1-GuC~8u`ge=@Zlxp13`#B1fLsk$#b^q()?sD=v|xWYqm=?A=-zJ-T)$B}IX&6+@WoR;HDpM< zs0`+=1^V&RK78^ra|TlEcx2<3QE1PvybTVXTwDHp=~G%{-ccL-sjHR>?E?!{d>*nb zTZ5nEE`CYIqLE-DWZu47#S^t3H^wKYRvQ(MURCOIeN;2b8uvI%BjkRpS4oEOX-@P& zsjc(urx}nsv)*$sal|EOXQRI55v9VvN9!j$();?O3G;uQBX-3%2D&f)2dK!nCAv^~ zQ?`rm7XleXufYS*T&}E`r~~9`$Xj`aLz~$+)7*HrrDgZsuAkSJ&e7`gsIv*L$KEL8 z9>h)B7vGW1G+7(Dkv#z^v<)r2km39z;~wX3n~|%cUD->r>8(_K7$%YNNLZ9Hio!)@p0WrakdQ(g zhcR@g2uj;*?%?iU>c3~ps??A@w-E`|)P-V$vg$iG3)0MseP2dPzj=CQih1OL=rB_3 zWD9rz&AC^lk^ARuS8^5h@3`Z9vkXBfBVR!-#KWXB$mGlkdjotnvCckE&M>KxNBbjq z050!DOj3Ogc6)a}jIOJFWwW?2&hQ|o+gnh~IyLlOn1?Mk>Ak4$WL6x|JHqav1+`N+`mKE%ndPhd9q3*mAjtB8vL2=zSc>N7nkN!BqG7>oxjS$`wJ?? z)=8zhkM$l+8C;r*6HBhVFjQ%;UYLG|Eg7GAtuCj%GyY|bj~Dc~V(9ItKlcwYEqh8r zb=-NE46E<%-^2KeQs*nBi+*rvOwoWKg}jUo6_TVtr+58XLq)Dbmn+4H(11j~A04-9 z0r#M-cb9Koa^kPeQ+lPjJyjuE#?6)BlbJHGABD;R=8r1%(^B057+ml}p(%2*P&vPH zt+6?DKQ($9=W_Jx>AQoU#g*u}>Tmfw{WI*zfARD$m=g)j3f0{Gt+nQnTMr|~eL^%V zEyqK0{ncEIjbr-K)q8#QQQ>{uRQ)q`UQ1R!K_EDDfTQ#&bGno`v3+$J#%>MlUeF(Bhg|Lwp+pQ)hz%>DhmGOxUyE)s6%Pg?xnxn+pLeKT7pxGOBc=rdHa1zA23{^B|ECnPR^crb*$F6!6C`( zi3z`%P}4nELivjm(oe!tS@W+Owhuf4z8A$RZqk63 z**A=6S?)-P9OVyypm#O25P%e_qGXFh3bA3!@t@7~E;G}1@&UMj-MjHm;U|Y;{!lM2 zmhWN4(GBjCd1>^_xbAm+`jLiFSv<(Fb*P_vJF^4sP=EYM;}fojuSYcG6db`21TH@e z^tvB9d)pa%TrhC5Bh(Cwfo$C|r<(8f_C6;pxJz7SM;NQU3{etNt5LUlZ0!+aiDF*K zBSLxMfa*t$RCO(?ioK&XKQ^2%ZWhhfkI&N2U(+e)x}0eev8FrbMgv-7-w36}kaCEU z^eB|&5)3cVVyKo-ewengPz7FRGGERwvu#$AVTnORI|>R%#G<5ndag5N^RNN4jJ{SE z1=TZ_n2fZS4m}9m3lCT|J-Ia57a?V4_^|AAIj`Il<$Y{sw~-C|arwK#@bjE1DgDu( zp|}K}9%x(2nho9>9*!RBMfnM#744rdQ%+vt^LVRD7SGMqIj;=vgN>2XU}JDoLtoXu zlNY}JGjlsXvi?Zz56pP{lNp`$1@rJUqx&E2% zi4;@Zi?b6#ii%#PFBKKPbJoVcnKQ=J9MmTca$Uj~%cB7ufxD$u%mI_5Eeq=zp>JA1 zg_bPOA4RW>IMu|y2J0($K0C)Qe&}*(8&_yR$CV@~FVRq^V6ZI=DRhbOxEP8d)~12D zk31|r&K%EG20^I#qK)0RJy)fKd3|sO+ah@h9f5;f@d16fiHx9ed&$&rEVIRl^MV1f zT&whT{)cdXf4JrBcSy)fgp{%2CD_HkC8OGGP@YBp*{?qzUnFnYB}0?;DNr-}!ss_r zva6qc=I_eBhhWL+iP2!hFc>-F;QnL+3GzEAjQ@gNzt%#cyWr_c6>TD;OlwCm_+;%@ z>K6$aFVtv2>+BmDdWVHo+&7yBWVpafYkg2{#RX97GjO(FbinePbZ;c3VBSR1_$Yp| z`YilIQ`Soz%cv++ah8Xt$N7tvYkEZf5`oM>bk5ns?&ld877T*TJ84eT^@e9}9Za$J zba_f{myBEqy*K`Y@y1D0gO1H)N?fJB8D!81Js{qW2@gImUa*Q{)G2m$s;TylxAb z>*Ji=!U5sUckCQJ(0(P%!s#7?Jc@|IuK8B!!x&=UxM83}i=jh>kU@xX0jLfICsY=b zp|qXzC=%MhoyZx`qQI|uW5%iAwfUcJ?>9reUUJ1NE|`pQ9+PPQ9PIjfp>MCC@d6n} zTl*Pw5BU4*cW_4ejP2{;r5oqU%MA_6xYt$^{r8l^y{=Xou%F3ucX`naf+LaaVhzU2 zuKHC$*gKoLC-mUEbevCS+u5Xhq(xgN68L4bURtT^bX}GvD%ZP#RJU`hiGdG}>nTc{ z{}Y%e(S+X8!1r4khNc`5Nc<1b)&EKgGK4<>AQ?elPOC#^luB_TloVd-AvnJ$8hb$o zMm~Vf(d+AG^2b7@6Yu^oVp)<)EX+Sj-geeOZ;XZ%GMp+-vSFg}Y|2Cylwi&=Fyc#4t($MCxJp^bdX#p zLD(j!-JL(yvR(ZtN-#z%5?ULof_?wWRSIrwqfE}|6h58(eS_%(=Un4uog{H-os=Ye zU@iF8(>pa(k?YS+2EsrWGv^-g>zO>}~-;E$xFw*OtAJCeg}j8rn*=J1}_jIi+FM5XSq#TXp%{$pXoD z;B&jC*PBVqvT=C4z&#ME*lysz29Hmnr746=73DAJ4#l7K0xww9PoQP7wH$|^fkJaX^hc=*~YUdoO`y=h{*y z+%EZe*4JE=nO_vMH^xB*9-mLs2XufkNW;Ni*x;(`nkq)iO}a|`!8mcX(6%%uJI2^> zjriqE;K!2pu#`c_!u12Cai;Zpn0P|B|00V(60uJN9fE$3kPih?@t34n;$&`LV&m7a zZRH8;nE0pdZ_SUgNI>h$=3d;rTE+1eqqsI-c1rIg%EEGdCgafM(l+dAK#)QSQ0oH^ zqHkh{fI=vBP?kdrV)srw8a3Wp^?F^TS?6(zay7fnWk?z0$FA1L`a(eXX6;+WuIkr2 zJXsQMbQ6k@=X=ERCb49zRG^&hGam6>Ic~0NVR-m;5BvYWL07|~W7{f%&U74<-WR{0 z81*>1n%*)YrUFT>e42R!+Al9xTB-;4yKCD9lt(zP`3hdciOPwy+!be~0j;oagb--0 z&qbMp53~cGVWKFM6v9Xkpl1rud5AGY^s(@5ebfJISjGS1aR>x;3Pw zL7~nzk|o=ik^;FLkVg7nZ2tc=jRy2FZ9@{(2K$Kzj*D@3~*jx@+B!XRiel!kWFG-~K;sPhw)Ai~wlE&d$-%5xPnP z0Ngg%`|?%y5HG;O@0v%5yZ2=gHTmNrsw#4NewRh`uJ{FdiYRF)0d}^whR_{s&>iOP zVcx2Ed1ZM;Jf1e&e+Ky1>c3z9Kd&Z1-oem6*`Xg<01$r-Ff(&-@bU@?31Ki&QgU+2 z%F62Mr%o9dn3$NHK5cJ*_Uu_A(c3#P@Y1DASFhf>6&;(^~G=d&= zgrRM>4bTT;Nbv(N)l^u4oVZ4B)ESF2c6-q;^hb&LMJJ?00+6F~OfQ5Urh59M`x~jQWvQ&zZ#^Tq z6X5(-SJ{OszY3{P-l7R5~ASA$v zV43LRjg!|cly%@R)x>n*0?gq4=b`a4k}V#g7^iZmbwAevwL zYyhKf53h&GJD}Z;;pPp8Dv|PwZn4XkfkdOb6_ZxiT9L#n8;kFf$~CZOYQY1;e|6G7 zEj{NhMg(Vh@ox*^D&>s7ys%%0?2bM2Piu4&MJLMeVO)hf@2u*~U(3kg)ahf(KA?pX zI>yp>Dli=@5nK$5DwH*~_;)@Y59~N1=m+ibaqqkqn|M1;(qb>~B$6vTHE?XX@6_LbeaHtR|YT&42 z05i^95bi{VE!Z)v6uMN38~-9AmbPGtMnB{SX?>Jh*LOEzv1ooiPf1qV(A0b!D$8%P_amkH)44I z5-m^hdd{vZIb9_$1^mUDCy6JP^=;>IFxe_hH{%MxV9&SD4&eHW%9Cy&wNW_|2LL)E zXcU6(Fee`K;JICQ0L7=n)I7@q zyA3G#eRmfpIstyafCS{GJ2#08?4R$vM+0IaaXe-k`KjTA5`cwI0$r*|f|PZs zZztI~_Q_=+2Z+_QdElojvcS}FUeG|_LPYOC$L@j;?}_0fJNs`elF?+jiU1tw z%dCLMI{$X-UxOu9!QeMiy7eY`BgXuNOe471IrR2o?=$KQs5#_VmZISgcqFEye-;<| zG9_d)RxrJj8(NrH{`AII^TL$u%XDkl%M%}ld_Zsu1VaEEYlQ&oy?3EaErhcx+~2@a zaHY25B1y)f)R__HgB|Bywv;|=oAV}9`e8}^@KfEGz;VN|SEW@e@(&DFhlBq_Qj=09 z!DUAPrjx}F2-x@b;}`sowpSILrvYK2qO!OLmrXdI2*UhX0Hg#`NI_TZf+*mEl==pH ztg|kYClzf5T11=tyJj-LXA09TX8NUh(fb@um!;DDr$J>$NN50$a>>a2RgLkE_t6bK+ z&n`&RlX&){jO5`>(wAympVNTkNg4M9KFP#$lKEgJ!+@k!y!vMk@*CZkPI2F9K>M!i zSa0G`BSm!B#v{DRX>?eX+Oiq|O{us!y*bCvgsc`&5s|cRXfFKPoo`lgV(;`Gj)$bh z)p^c0s;^$|f&7rX6jRWc@5>J}RHxd}?ov~wb&KC8b+4!9zQ0F((g@mGd;xtb`P(?N zzuDd7Z4$RwdE(%B_B6VM4~muX-lfWLHG*%S2rE-G;`8F`7<6xhh^h1nqS2y1rMr() ztdo&o;2s>s1WxYn(tu6?9QOsSmd!aRP?oC)uzE;mKRoN3GQf@A`x{&WC5!0;3q=@W zX4iT#=j?LN-(E`N?uOHeT5xV0?Z6Ug3!qJ6`w#NpnRI^*7BaOn7PWD1>JA$)Q#l)7&VTCEO zHTQA*HgKfKiTGD~`KvgcS$fn{gQq2^S1Mey@bP*r>SHF<9dY2-KVs{{DT)Uv`h3MV z4%d*v0}$NbrU4m8!4d^+XBE&>e1%R~5D@*Wf-z~fy~aKEhbNGOqEsLf=;_FCEf-^R z-Cj)tebms)k0YX~t>iUg@NdQhG8#sRO4dN%53|<%JJEa~C<%~>s0W}%h`w0qCc`Q? zZhW=aO$}O3C#pj44W6V{cK|=aghC5TShveca2td_QOwF&$bU2K`n1{#<67#+8Cl-4 zZ5%TnPwlGX>}(TtFuqRp`W~%s6*R~2jSnV1AEd8C*J(hHI(@!!;&-$Nh3vdfwvB5O zX-%a8*^_FMS$YT)#B|>I@+^AHd8CNFMwB~-XmdxR6_IE)S%TB*AbfkJckBS_^Kn5% zP{9t2qm~f-RfBkJN2XhDI@$rpG6r`T!IfSTqFz7{$4b4U6e#3)uS1il{M2%2g@Rqsj-yXOPcRUdRmQ`OA zdjc)Huyk~`j+e37>6&{^P5!@4_oCiPY&~ArgtIe_QZVZEnf71e0YE6q$ylc@{S*zz z3l>FSwkC=YSVTqrU0{;daVRg1LVm(*MBKtRxF{j)I`x|m#=K%=MY(qjfVJC=lIcO& z0o1lel6?fvv-@eN8Mb2*cAJCk6NK?xJ}MRW>dT-8uKP>-8S(H>DM2HK-r1d^qTV>o zMd7dN-MsH`fRF<7FSz#ZTgfga-nK{KPc5HBiPkw0Mo(LZc|@;)u4#My-XwrdO3M@v z9JyT4k&qzN#e^HbjHT!N+$&CgSLdv*) z)Or!i*~3$;;RSX3h!=*sq>_r7>&bpw2p-oyL&-4*Uj-zpx6(+l`ln9jm2U9K6n5+* z0nkcbRH`>{`TAZ}j}1Z`F~ff?%3*tDQhLpR|{W<}~1*6M&C zfn~`2MAP-EpHF3VUqo*A1Hc)#J2fbH0+~%XD))5Hel8&=Y22b;QO)#`0|*sq7CC?% zCY9M*&QEGyQx0EW*tf*0w$bZQb+YF!i08=OZw4ZJ}4^VfUCNb5*)1?{a>5`LIBM5=C6Go1s{!rEX;hT;_-b!O52C%%xiiaS~p( zACYC611V00{hzEd_r?P3?U8m+`hL~m>3ShB>72@|975|O8qhsh#%XD*SraZ<5iyNk z;QTp+fuWp!lqe9N>aBvva_+HFjEWfAcr%M$i1-<&({1)jJar)T z=I4b|oyKvePd%){mUz|`KS{f6FZ(Mz1yzd~M;bw83*2l0EnVa;=NPQ!D1EQC5g5)6 zik5F;`*@`jeMeg+W*w}3kDLGHg5k;Fzt~FfsjVsIPZ&3S4o^uB>^!0aC`&;{s$lIV z0om)HaX~f$m#or>7U>O|EV{-=qCT+bwcnVPq5(CeZSd>2(UG`9gcAPX0kL_6{V^I) z>#d?$9Uw391B2`b#Mk6Hs|yVz)x@S3PrGg&zjA(tXzKa!N@lws?scM91u_hr0T&2A z-L_;?deSZo!-j+|tj5_CCUWrc${&v=Bs^D8{&qADQ2}a$e3{L4d!MD|u!UFbL>t7f zK1a7jE4#N33%8+UPYJ4~{1(m5ob80Vq|x9VQ27Q}e`+=3?Qr9j?{#Jc4+!V2em9&t zeo~H~sA3i+?k_y=x*%+$y$n)ETXrI!t(Y^qgdBZB5rKdVECdb}3M-l= z0clZ3mci|cpY%;$Ykjpt3kD5$Bi?aNXUX|4dGqR9Kgzk$u$5Fn>B>7($QQlLX4~fq$PkefA>;9 zZ~UN62Yy$s&)cb@R}oc@d_U26VMa65)XIjkOzaXpiWJiZ=MSJL2EkKrFQ4aD*6ii#)o1`>M3b!jmDsJ4S(%Y5Bip9?u%M z%=A%B2^5psnQF#9-rg_Zzv1-GuC~8u`ge=@Zlxp13`#B1fLsk$#b^q()?sD=v|xWYqm=?A=-zJ-T)$B}IX&6+@WoR;HDpM< zs0`+=1^V&RK78^ra|TlEcx2<3QE1PvybTVXTwDHp=~G%{-ccL-sjHR>?E?!{d>*nb zTZ5nEE`CYIqLE-DWZu47#S^t3H^wKYRvQ(MURCOIeN;2b8uvI%BjkRpS4oEOX-@P& zsjc(urx}nsv)*$sal|EOXQRI55v9VvN9!j$();?O3G;uQBX-3%2D&f)2dK!nCAv^~ zQ?`rm7XleXufYS*T&}E`r~~9`$Xj`aLz~$+)7*HrrDgZsuAkSJ&e7`gsIv*L$KEL8 z9>h)B7vGW1G+7(Dkv#z^v<)r2km39z;~wX3n~|%cUD->r>8(_K7$%YNNLZ9Hio!)@p0WrakdQ(g zhcR@g2uj;*?%?iU>c3~ps??A@w-E`|)P-V$vg$iG3)0MseP2dPzj=CQih1OL=rB_3 zWD9rz&AC^lk^ARuS8^5h@3`Z9vkXBfBVR!-#KWXB$mGlkdjotnvCckE&M>KxNBbjq z050!DOj3Ogc6)a}jIOJFWwW?2&hQ|o+gnh~IyLlOn1?Mk>Ak4$WL6x|JHqav1+`N+`mKE%ndPhd9q3*mAjtB8vL2=zSc>N7nkN!BqG7>oxjS$`wJ?? z)=8zhkM$l+8C;r*6HBhVFjQ%;UYLG|Eg7GAtuCj%GyY|bj~Dc~V(9ItKlcwYEqh8r zb=-NE46E<%-^2KeQs*nBi+*rvOwoWKg}jUo6_TVtr+58XLq)Dbmn+4H(11j~A04-9 z0r#M-cb9Koa^kPeQ+lPjJyjuE#?6)BlbJHGABD;R=8r1%(^B057+ml}p(%2*P&vPH zt+6?DKQ($9=W_Jx>AQoU#g*u}>Tmfw{WI*zfARD$m=g)j3f0{Gt+nQnTMr|~eL^%V zEyqK0{ncEIjbr-K)q8#QQQ>{uRQ)q`UQ1R!K_EDDfTQ#&bGno`v3+$J#%>MlUeF(Bhg|Lwp+pQ)hz%>DhmGOxUyE)s6%Pg?xnxn+pLeKT7pxGOBc=rdHa1zA23{^B|ECnPR^crb*$F6!6C`( zi3z`%P}4nELivjm(oe!tS@W+Owhuf4z8A$RZqk63 z**A=6S?)-P9OVyypm#O25P%e_qGXFh3bA3!@t@7~E;G}1@&UMj-MjHm;U|Y;{!lM2 zmhWN4(GBjCd1>^_xbAm+`jLiFSv<(Fb*P_vJF^4sP=EYM;}fojuSYcG6db`21TH@e z^tvB9d)pa%TrhC5Bh(Cwfo$C|r<(8f_C6;pxJz7SM;NQU3{etNt5LUlZ0!+aiDF*K zBSLxMfa*t$RCO(?ioK&XKQ^2%ZWhhfkI&N2U(+e)x}0eev8FrbMgv-7-w36}kaCEU z^eB|&5)3cVVyKo-ewengPz7FRGGERwvu#$AVTnORI|>R%#G<5ndag5N^RNN4jJ{SE z1=TZ_n2fZS4m}9m3lCT|J-Ia57a?V4_^|AAIj`Il<$Y{sw~-C|arwK#@bjE1DgDu( zp|}K}9%x(2nho9>9*!RBMfnM#744rdQ%+vt^LVRD7SGMqIj;=vgN>2XU}JDoLtoXu zlNY}JGjlsXvi?Zz56pP{lNp`$1@rJUqx&E2% zi4;@Zi?b6#ii%#PFBKKPbJoVcnKQ=J9MmTca$Uj~%cB7ufxD$u%mI_5Eeq=zp>JA1 zg_bPOA4RW>IMu|y2J0($K0C)Qe&}*(8&_yR$CV@~FVRq^V6ZI=DRhbOxEP8d)~12D zk31|r&K%EG20^I#qK)0RJy)fKd3|sO+ah@h9f5;f@d16fiHx9ed&$&rEVIRl^MV1f zT&whT{)cdXf4JrBcSy)fgp{%2CD_HkC8OGGP@YBp*{?qzUnFnYB}0?;DNr-}!ss_r zva6qc=I_eBhhWL+iP2!hFc>-F;QnL+3GzEAjQ@gNzt%#cyWr_c6>TD;OlwCm_+;%@ z>K6$aFVtv2>+BmDdWVHo+&7yBWVpafYkg2{#RX97GjO(FbinePbZ;c3VBSR1_$Yp| z`YilIQ`Soz%cv++ah8Xt$N7tvYkEZf5`oM>bk5ns?&ld877T*TJ84eT^@e9}9Za$J zba_f{myBEqy*K`Y@y1D0gO1H)N?fJB8D!81Js{qW2@gImUa*Q{)G2m$s;TylxAb z>*Ji=!U5sUckCQJ(0(P%!s#7?Jc@|IuK8B!!x&=UxM83}i=jh>kU@xX0jLfICsY=b zp|qXzC=%MhoyZx`qQI|uW5%iAwfUcJ?>9reUUJ1NE|`pQ9+PPQ9PIjfp>MCC@d6n} zTl*Pw5BU4*cW_4ejP2{;r5oqU%MA_6xYt$^{r8l^y{=Xou%F3ucX`naf+LaaVhzU2 zuKHC$*gKoLC-mUEbevCS+u5Xhq(xgN68L4bURtT^bX}GvD%ZP#RJU`hiGdG}>nTc{ z{}Y%e(S+X8!1r4khNc`5Nc<1b)&EKgGK4<>AQ?elPOC#^luB_TloVd-AvnJ$8hb$o zMm~Vf(d+AG^2b7@6Yu^oVp)<)EX+Sj-geeOZ;XZ%GMp+-vSFg}Y|2Cylwi&=Fyc#4t($MCxJp^bdX#p zLD(j!-JL(yvR(ZtN-#z%5?ULof_?wWRSIrwqfE}|6h58(eS_%(=Un4uog{H-os=Ye zU@iF8(>pa(k?YS+2EsrWGv^-g>zO>}~-;E$xFw*OtAJCeg}j8rn*=J1}_jIi+FM5XSq#TXp%{$pXoD z;B&jC*PBVqvT=C4z&#ME*lysz29Hmnr746=73DAJ4#l7K0xww9PoQP7wH$|^fkJaX^hc=*~YUdoO`y=h{*y z+%EZe*4JE=nO_vMH^xB*9-mLs2XufkNW;Ni*x;(`nkq)iO}a|`!8mcX(6%%uJI2^> zjriqE;K!2pu#`c_!u12Cai;Zpn0P|B|00V(60uJN9fE$3kPih?@t34n;$&`LV&m7a zZRH8;nE0pdZ_SUgNI>h$=3d;rTE+1eqqsI-c1rIg%EEGdCgafM(l+dAK#)QSQ0oH^ zqHkh{fI=vBP?kdrV)srw8a3Wp^?F^TS?6(zay7fnWk?z0$FA1L`a(eXX6;+WuIkr2 zJXsQMbQ6k@=X=ERCb49zRG^&hGam6>Ic~0NVR-m;5BvYWL07|~W7{f%&U74<-WR{0 z81*>1n%*)YrUFT>e42R!+Al9xTB-;4yKCD9lt(zP`3hdciOPwy+!be~0j;oagb--0 z&qbMp53~cGVWKFM6v9Xkpl1rud5AGY^s(@5ebfJISjGS1aR>x;3Pw zL7~nzk|o=ik^;FLkVg7nZ2tc=jRy2FZ9@{(2K$Kzj* URL? { + if let path = Bundle.main.path(forResource: soundName, ofType: "mp3") { + return URL(fileURLWithPath: path) + } else { + return Bundle.mxk_audioURLFromMXKAssetsBundle(withName: soundName) + } + } } diff --git a/changelog.d/7504.change b/changelog.d/7504.change new file mode 100644 index 0000000000..2fed9c438b --- /dev/null +++ b/changelog.d/7504.change @@ -0,0 +1 @@ +Add an audio alert when the voice broadcast recording is automatically paused From 693feddb8635370b1e86773a332ebd31f3a50eaa Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 3 May 2023 14:57:36 +0200 Subject: [PATCH 39/51] Fix partial text messages not being saved for each room with RTE enabled --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../MXKRoomInputToolbarView.h | 14 ++++++ .../MXKRoomInputToolbarView.m | 5 ++ Riot/Modules/Room/MXKRoomViewController.m | 2 +- Riot/Modules/Room/RoomViewController.m | 9 +++- .../WysiwygInputToolbarView.swift | 49 ++++++++++++++++--- .../MockComposerLinkActionScreenState.swift | 2 +- .../ComposerLinkActionViewModel.swift | 2 +- changelog.d/7535.bugfix | 1 + project.yml | 2 +- 10 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 changelog.d/7535.bugfix diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9870eb6da0..bf7d208dd6 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "758f226a92d6726ab626c1e78ecd183bdba77016", - "version" : "2.0.0" + "revision" : "ff5e8054da60212051cb0dec244500ca0f441bac", + "version" : "2.1.0" } }, { diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index abd67ec7e0..b18e936900 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -213,6 +213,15 @@ typedef enum : NSUInteger */ - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating; +/** + Tells the delegate that the partial content of the composer has changed + and should be stored to allow restoring it later if needed. + + @param toolbarView the room input toolbar view + @param partialAttributedTextMessage the partial content to store + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView shouldStorePartialContent:(NSAttributedString*)partialAttributedTextMessage; + @end /** @@ -390,6 +399,11 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; +/** + Sets the partial text message to apply to the current message composer. + */ +- (void)setPartialContent:(NSAttributedString *)attributedTextMessage; + /** Default font for the message composer. */ diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index d05cd9f53c..b5b15d4b85 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -1405,4 +1405,9 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return NO; } +- (void)setPartialContent:(NSAttributedString *)attributedTextMessage +{ + self.attributedTextMessage = attributedTextMessage; +} + @end diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index 2e55c4771d..b1a1bc18b0 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -360,7 +360,7 @@ - (void)viewDidAppear:(BOOL)animated { // Retrieve the potential message partially typed during last room display. // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) - inputToolbarView.attributedTextMessage = roomDataSource.partialAttributedTextMessage; + [inputToolbarView setPartialContent:roomDataSource.partialAttributedTextMessage]; } if (!hasAppearedOnce) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index a18de9aa13..cb3214684b 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -693,7 +693,7 @@ - (void)viewDidAppear:(BOOL)animated { // Retrieve the potential message partially typed during last room display. // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) - self.inputToolbarView.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage; + [self.inputToolbarView setPartialContent:self.roomDataSource.partialAttributedTextMessage]; } [self setMaximisedToolbarIsHiddenIfNeeded: NO]; @@ -5293,6 +5293,11 @@ - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedT }]; } +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView shouldStorePartialContent:(NSAttributedString *)partialAttributedTextMessage +{ + self.roomDataSource.partialAttributedTextMessage = partialAttributedTextMessage; +} + #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion @@ -6135,7 +6140,7 @@ - (void)goBackToLive if (self.saveProgressTextInput) { // Restore the potential message partially typed before jump to last unread messages. - self.inputToolbarView.attributedTextMessage = roomDataSource.partialAttributedTextMessage; + [self.inputToolbarView setPartialContent:roomDataSource.partialAttributedTextMessage]; } }; diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index ad488897fc..8a5c75618d 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -96,11 +96,17 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp // Note: this is only interactive in plain text mode. If RTE is enabled, // APIs from the composer view model should be used. get { - guard !self.textFormattingEnabled else { return nil } + guard !self.textFormattingEnabled else { + MXLog.failure("[WysiwygInputToolbarView] Trying to get attributedTextMessage in RTE mode") + return nil + } return self.wysiwygViewModel.textView.attributedText } set { - guard !self.textFormattingEnabled else { return } + guard !self.textFormattingEnabled else { + MXLog.failure("[WysiwygInputToolbarView] Trying to set attributedTextMessage in RTE mode") + return + } self.wysiwygViewModel.textView.attributedText = newValue } } @@ -174,6 +180,16 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp showKeyboard() } } + + override func setPartialContent(_ attributedTextMessage: NSAttributedString) { + let content: String + if #available(iOS 15.0, *) { + content = PillsFormatter.stringByReplacingPills(in: attributedTextMessage, mode: .markdown) + } else { + content = attributedTextMessage.string + } + self.wysiwygViewModel.setMarkdownContent(content) + } func showKeyboard() { self.viewModel.showKeyboard() @@ -191,7 +207,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } func mention(_ member: MXRoomMember) { - self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId), + self.wysiwygViewModel.setMention(url: MXTools.permalinkToUser(withUserId: member.userId), name: member.displayname, mentionType: .user) } @@ -281,12 +297,31 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp }, wysiwygViewModel.$plainTextContent - .dropFirst() .removeDuplicates() - .sink { [weak self] value in - guard let self else { return } - self.textMessage = value.string + .dropFirst() + .sink { [weak self] attributed in + // Note: filter out `plainTextMode` being off, as switching to RTE will trigger this + // publisher with empty content. This avoids saving the partial text message + // or trying to compute suggestion from this empty content. + guard let self, wysiwygViewModel.plainTextMode else { return } + self.textMessage = attributed.string self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self) + self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed) + }, + + wysiwygViewModel.$attributedContent + .removeDuplicates(by: { + $0.text == $1.text + }) + .dropFirst() + .sink { [weak self] _ in + // Note: filter out `plainTextMode` being on, as switching to plain text mode will trigger this + // publisher with empty content. This avoids saving the partial text message + // or trying to compute suggestion from this empty content. + guard let self, !self.wysiwygViewModel.plainTextMode else { return } + let markdown = self.wysiwygViewModel.content.markdown + let attributed = NSAttributedString(string: markdown, attributes: [.font: self.defaultFont]) + self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed) } ] diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift index 6bdc5ebc50..335ff3196e 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift @@ -33,7 +33,7 @@ enum MockComposerLinkActionScreenState: MockScreenState, CaseIterable { case .create: viewModel = .init(from: .create) case .edit: - viewModel = .init(from: .edit(link: "https://element.io")) + viewModel = .init(from: .edit(url: "https://element.io")) } return ( [viewModel], diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift index 3674172823..d16dd72129 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift @@ -36,7 +36,7 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos switch linkAction { case let .edit(link): initialViewState = .init( - linkAction: .edit(link: link), + linkAction: .edit(url: link), bindings: .init( text: "", linkUrl: link diff --git a/changelog.d/7535.bugfix b/changelog.d/7535.bugfix new file mode 100644 index 0000000000..f21ab863c2 --- /dev/null +++ b/changelog.d/7535.bugfix @@ -0,0 +1 @@ +Labs: Rich Text Editor: Fix partial text messages not being saved for each room diff --git a/project.yml b/project.yml index 6a207706dd..3922de6518 100644 --- a/project.yml +++ b/project.yml @@ -56,7 +56,7 @@ packages: branch: 0.0.1 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 2.0.0 + version: 2.1.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0 From 1e205474a5c30ef9f9f26378036617ca13d5085d Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 3 May 2023 15:49:58 +0200 Subject: [PATCH 40/51] Fix composer unit tests --- .../Test/Unit/ComposerLinkActionViewModelTests.swift | 6 +++--- .../Room/Composer/Test/Unit/ComposerViewModelTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift index 2407eccc4e..3fbb8d5647 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift @@ -54,7 +54,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase { func testEditDefaultState() { let link = "element.io" - setUp(with: .edit(link: link)) + setUp(with: .edit(url: link)) XCTAssertEqual(context.viewState.bindings.text, "") XCTAssertEqual(context.viewState.bindings.linkUrl, link) XCTAssertTrue(context.viewState.isSaveButtonDisabled) @@ -83,7 +83,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase { } func testRemoveAction() { - setUp(with: .edit(link: "element.io")) + setUp(with: .edit(url: "element.io")) var result: ComposerLinkActionViewModelResult! viewModel.callback = { value in result = value @@ -119,7 +119,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase { } func testSaveActionForEdit() { - setUp(with: .edit(link: "element.io")) + setUp(with: .edit(url: "element.io")) var result: ComposerLinkActionViewModelResult! viewModel.callback = { value in result = value diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift index e4d5b595da..c68cd77831 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift @@ -98,7 +98,7 @@ final class ComposerViewModelTests: XCTestCase { XCTAssertEqual(result, .linkTapped(LinkAction: .createWithText)) context.send(viewAction: .linkTapped(linkAction: .create)) XCTAssertEqual(result, .linkTapped(LinkAction: .create)) - context.send(viewAction: .linkTapped(linkAction: .edit(link: "https://element.io"))) - XCTAssertEqual(result, .linkTapped(LinkAction: .edit(link: "https://element.io"))) + context.send(viewAction: .linkTapped(linkAction: .edit(url: "https://element.io"))) + XCTAssertEqual(result, .linkTapped(LinkAction: .edit(url: "https://element.io"))) } } From 5559e1c2e58d438834a417d1b9ffd52f0a3e383c Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 3 May 2023 17:26:54 +0200 Subject: [PATCH 41/51] Add missing self in closure --- .../Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 8a5c75618d..edd951fd64 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -303,7 +303,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp // Note: filter out `plainTextMode` being off, as switching to RTE will trigger this // publisher with empty content. This avoids saving the partial text message // or trying to compute suggestion from this empty content. - guard let self, wysiwygViewModel.plainTextMode else { return } + guard let self, self.wysiwygViewModel.plainTextMode else { return } self.textMessage = attributed.string self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self) self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed) From a363a743cdb9b0fbdb247af288e8e60de3ec08d9 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Wed, 3 May 2023 10:35:55 +0200 Subject: [PATCH 42/51] Add logs to track a problem with the top left avatar disappearing --- Riot/Modules/Common/Avatar/AvatarView.swift | 9 +++++++++ Riot/Modules/Home/AllChats/AllChatsCoordinator.swift | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/Riot/Modules/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/AvatarView.swift index a3f46a5aa7..a1ead57dbf 100644 --- a/Riot/Modules/Common/Avatar/AvatarView.swift +++ b/Riot/Modules/Common/Avatar/AvatarView.swift @@ -103,12 +103,17 @@ class AvatarView: UIView, Themable { func updateAvatarImageView(with viewData: AvatarViewDataProtocol) { guard let avatarImageView = self.avatarImageView else { + MXLog.warning("[AvatarView] avatar not updated because avatarImageView is nil.") return } let (defaultAvatarImage, defaultAvatarImageContentMode) = viewData.fallbackImageParameters() ?? (nil, .scaleAspectFill) updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode) + if defaultAvatarImage == nil { + MXLog.warning("[AvatarView] defaultAvatarImage is nil") + } + if let avatarUrl = viewData.avatarUrl { avatarImageView.setImageURI(avatarUrl, withType: nil, @@ -118,6 +123,10 @@ class AvatarView: UIView, Themable { previewImage: defaultAvatarImage, mediaManager: viewData.mediaManager) updateAvatarContentMode(contentMode: .scaleAspectFill) + + if avatarImageView.frame.size.width < 8 || avatarImageView.frame.size.height < 8 { + MXLog.warning("[AvatarView] small avatarImageView frame: \(avatarImageView.frame)") + } } else { updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode) } diff --git a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift index 89c8edc2c9..8765adb05a 100644 --- a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift +++ b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift @@ -387,7 +387,11 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } private func updateAvatarButtonItem() { + MXLog.info("[AllChatsCoordinator] updating avatar button item.") if let avatar = userAvatarViewData(from: currentMatrixSession) { + if avatarMenuView == nil { + MXLog.warning("[AllChatsCoordinator] updateAvatarButtonItem: avatarMenuView is nil.") + } avatarMenuView?.fill(with: avatar) avatarMenuButton?.setImage(nil, for: .normal) } else { From ed3a1b48e717b14a555c903c9910593f0df0cca0 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 9 May 2023 17:05:27 +0100 Subject: [PATCH 43/51] Use the app's language for accessibility. --- Riot/Modules/Application/LegacyAppDelegate.m | 1 + Riot/Modules/Settings/SettingsViewController.m | 1 + changelog.d/pr-7493.bugfix | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog.d/pr-7493.bugfix diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 8678f5ab8e..ab17eeac5c 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -390,6 +390,7 @@ - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions: } [NSBundle mxk_setLanguage:language]; [NSBundle mxk_setFallbackLanguage:@"en"]; + UIApplication.sharedApplication.accessibilityLanguage = language; if (BuildSettings.disableRightToLeftLayout) { diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 055841f3fe..be87bea3b8 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -4158,6 +4158,7 @@ - (void)languagePickerViewController:(MXKLanguagePickerViewController *)language || (language == nil && [NSBundle mxk_language])) { [NSBundle mxk_setLanguage:language]; + UIApplication.sharedApplication.accessibilityLanguage = language; // Store user settings NSUserDefaults *sharedUserDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; diff --git a/changelog.d/pr-7493.bugfix b/changelog.d/pr-7493.bugfix new file mode 100644 index 0000000000..b486878b50 --- /dev/null +++ b/changelog.d/pr-7493.bugfix @@ -0,0 +1 @@ +Make sure to use the chosen language for the VoiceOver voice too. From 6e251b7b9350b5bc1353cf236bb2378adc3e9bfa Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Tue, 9 May 2023 09:53:42 +0200 Subject: [PATCH 44/51] Feat: add a flag in the build settings to force the user to define a homeserver. --- Config/BuildSettings.swift | 9 +++++++-- .../AuthenticationCoordinator.swift | 14 ++++++++++++-- .../Legacy/AuthenticationViewController.m | 18 ++++++++++++++++-- .../Common/AuthenticationModels.swift | 4 ++++ ...henticationServerSelectionCoordinator.swift | 8 +++++++- changelog.d/pr-7541.change | 1 + 6 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 changelog.d/pr-7541.change diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index e8c129619d..1b25717083 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -98,10 +98,15 @@ final class BuildSettings: NSObject { // MARK: - Server configuration - // Default servers proposed on the authentication screen + // Force the user to set a homeserver instead of using the default one + static let forceHomeserverSelection = false + + // Default server proposed on the authentication screen static let serverConfigDefaultHomeserverUrlString = "https://matrix.org" - static let serverConfigDefaultIdentityServerUrlString = "https://vector.im" + // Default identity server + static let serverConfigDefaultIdentityServerUrlString = "https://vector.im" + static let serverConfigSygnalAPIUrlString = "https://matrix.org/_matrix/push/v1/notify" diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index a245147cdc..6d5250497f 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -130,9 +130,19 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } let flow: AuthenticationFlow = initialScreen == .login ? .login : .register + + // Use the homeserver defined by a provisioningLink or by the user (if none is set, the default one will be used) + let homeserverAddress = authenticationService.provisioningLink?.homeserverUrl ?? authenticationService.state.homeserver.addressFromUser + + // Check if the user must select a server + if BuildSettings.forceHomeserverSelection, homeserverAddress == nil { + showServerSelectionScreen(for: flow) + return + } + do { - // Start the flow using the default server (or a provisioning link if set). - try await authenticationService.startFlow(flow) + // Start the flow (if homeserverAddress is nil, the default server will be used). + try await authenticationService.startFlow(flow, for: homeserverAddress) } catch { MXLog.error("[AuthenticationCoordinator] start: Failed to start, showing server selection.") showServerSelectionScreen(for: flow) diff --git a/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m b/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m index 3f5cfaf679..c0605d8131 100644 --- a/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m @@ -132,7 +132,14 @@ - (void)viewDidLoad target:self action:@selector(onButtonPressed:)]; - self.defaultHomeServerUrl = RiotSettings.shared.homeserverUrlString; + if (BuildSettings.forceHomeserverSelection) + { + self.defaultHomeServerUrl = nil; + } + else + { + self.defaultHomeServerUrl = RiotSettings.shared.homeserverUrlString; + } self.defaultIdentityServerUrl = RiotSettings.shared.identityServerUrlString; @@ -1207,7 +1214,14 @@ - (void)setCustomServerFieldsVisible:(BOOL)isVisible [self saveCustomServerInputs]; // Restore default configuration - [self setHomeServerTextFieldText:self.defaultHomeServerUrl]; + if (BuildSettings.forceHomeserverSelection) + { + [self setHomeServerTextFieldText:nil]; + } + else + { + [self setHomeServerTextFieldText:self.defaultHomeServerUrl]; + } [self setIdentityServerTextFieldText:self.defaultIdentityServerUrl]; [self.customServersTickButton setImage:AssetImages.selectionUntick.image forState:UIControlStateNormal]; diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index e59aa01893..34d7adb905 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift @@ -86,6 +86,10 @@ class HomeserverAddress: NSObject { /// - Ensure the address contains a scheme, otherwise make it `https`. /// - Remove any trailing slashes. static func sanitized(_ address: String) -> String { + guard !address.isEmpty else { + // prevent prefixing an empty string with "https:" + return address + } var address = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if !address.contains("://") { diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index c5d521701c..13308c2622 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -57,7 +57,13 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { self.parameters = parameters let homeserver = parameters.authenticationService.state.homeserver - let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.displayableAddress, + let homeserverAddress: String + if BuildSettings.forceHomeserverSelection, homeserver.addressFromUser == nil { + homeserverAddress = "" + } else { + homeserverAddress = homeserver.displayableAddress + } + let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserverAddress, flow: parameters.authenticationService.state.flow, hasModalPresentation: parameters.hasModalPresentation) let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context) diff --git a/changelog.d/pr-7541.change b/changelog.d/pr-7541.change new file mode 100644 index 0000000000..0e0c71fa67 --- /dev/null +++ b/changelog.d/pr-7541.change @@ -0,0 +1 @@ +Add a flag in the build settings to force the user to define a homeserver instead of using the default one. From 0c8c1ceffc60a00dc1a46478f167d8ead5181bd7 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 9 May 2023 19:12:30 +0100 Subject: [PATCH 45/51] Fix voiceover order of room creation header and message composer. --- .../RoomCreationIntroCell.swift | 5 +++++ .../RoomCreationIntroCellContentView.swift | 2 ++ .../Views/InputToolbar/RoomInputToolbarView.m | 17 +++++++++++++++++ changelog.d/pr-7543.bugfix | 1 + 4 files changed, 25 insertions(+) create mode 100644 changelog.d/pr-7543.bugfix diff --git a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift index 9bccbddf96..33a9c3d2b9 100644 --- a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift +++ b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift @@ -164,6 +164,11 @@ class RoomCreationIntroCell: MXKRoomBubbleTableViewCell { roomCellContentView.didTapAddParticipants = { [weak self] in self?.notifyDelegate(with: RoomCreationIntroCell.tapOnAddParticipants) } + + self.accessibilityElements = [roomCellContentView.roomAvatarView as Any, + roomCellContentView.titleLabel as Any, + roomCellContentView.informationLabel as Any, + roomCellContentView.addParticipantsContainerView as Any] } diff --git a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift index 809cf46764..e664c66ab2 100644 --- a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift +++ b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift @@ -69,8 +69,10 @@ final class RoomCreationIntroCellContentView: UIView, NibLoadable, Themable { self.addParticipantsButton.layer.masksToBounds = true self.addParticipantsButton.addTarget(self, action: #selector(socialButtonAction(_:)), for: .touchUpInside) + self.addParticipantsButton.accessibilityLabel = VectorL10n.roomIntroCellAddParticipantsAction self.addParticipantsLabel.text = VectorL10n.roomIntroCellAddParticipantsAction + self.addParticipantsLabel.isAccessibilityElement = false self.roomAvatarView.showCameraBadgeOnFallbackImage = true } diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index 2cead382a0..0d058fefc3 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -70,6 +70,8 @@ - (void)awakeFromNib _sendMode = RoomInputToolbarViewSendModeSend; self.inputContextViewHeightConstraint.constant = 0; + self.inputContextLabel.isAccessibilityElement = NO; + self.inputContextButton.isAccessibilityElement = NO; [self.rightInputToolbarButton setTitle:nil forState:UIControlStateNormal]; [self.rightInputToolbarButton setTitle:nil forState:UIControlStateHighlighted]; @@ -252,6 +254,10 @@ - (void)updateToolbarButtonLabelWithPreviousMode:(RoomInputToolbarViewSendMode)p break; } + // Hide the context items from VoiceOver when the context view is "hidden". + self.inputContextLabel.isAccessibilityElement = self.inputContextViewHeightConstraint.constant > 0; + self.inputContextButton.isAccessibilityElement = self.inputContextViewHeightConstraint.constant > 0; + [self.rightInputToolbarButton setImage:buttonImage forState:UIControlStateNormal]; if (self.maxHeight && updatedHeight > self.maxHeight) @@ -477,11 +483,22 @@ - (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView [self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor], [self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor], [self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]]; + + // The voice message toolbar is taller than the input toolbar so the record button is read + // out before the other subviews. Fix this by manually adding the elements in the right order. + self.accessibilityElements = @[self.attachMediaButton, + self.actionsBar, + self.inputContextLabel, + self.inputContextButton, + self.textView, + self.rightInputToolbarButton, + self.voiceMessageToolbarView]; } else { [self.voiceMessageToolbarView removeFromSuperview]; _voiceMessageToolbarView = nil; + self.accessibilityElements = nil; } } @end diff --git a/changelog.d/pr-7543.bugfix b/changelog.d/pr-7543.bugfix new file mode 100644 index 0000000000..6a56590cd9 --- /dev/null +++ b/changelog.d/pr-7543.bugfix @@ -0,0 +1 @@ +Fix voiceover order of room creation header and message composer. From eae51b398b7fbf9ab4fdcfe8e332467c3787c5f0 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Thu, 11 May 2023 09:45:04 +0200 Subject: [PATCH 46/51] Fix: apply the changes requested in the PR review --- Config/BuildSettings.swift | 6 +++--- .../Modules/Authentication/AuthenticationCoordinator.swift | 7 ++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 1b25717083..f58f969c10 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -98,13 +98,13 @@ final class BuildSettings: NSObject { // MARK: - Server configuration - // Force the user to set a homeserver instead of using the default one + /// Force the user to set a homeserver instead of using the default one static let forceHomeserverSelection = false - // Default server proposed on the authentication screen + /// Default server proposed on the authentication screen static let serverConfigDefaultHomeserverUrlString = "https://matrix.org" - // Default identity server + /// Default identity server static let serverConfigDefaultIdentityServerUrlString = "https://vector.im" static let serverConfigSygnalAPIUrlString = "https://matrix.org/_matrix/push/v1/notify" diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 6d5250497f..295a591f73 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -131,18 +131,15 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc let flow: AuthenticationFlow = initialScreen == .login ? .login : .register - // Use the homeserver defined by a provisioningLink or by the user (if none is set, the default one will be used) - let homeserverAddress = authenticationService.provisioningLink?.homeserverUrl ?? authenticationService.state.homeserver.addressFromUser - // Check if the user must select a server - if BuildSettings.forceHomeserverSelection, homeserverAddress == nil { + if BuildSettings.forceHomeserverSelection, authenticationService.provisioningLink?.homeserverUrl == nil { showServerSelectionScreen(for: flow) return } do { // Start the flow (if homeserverAddress is nil, the default server will be used). - try await authenticationService.startFlow(flow, for: homeserverAddress) + try await authenticationService.startFlow(flow) } catch { MXLog.error("[AuthenticationCoordinator] start: Failed to start, showing server selection.") showServerSelectionScreen(for: flow) From d628a03ef4396664eece5d48d52bc5b807a83d18 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Thu, 11 May 2023 18:15:35 +0200 Subject: [PATCH 47/51] Fix: text color of the last event description was incorrect. --- .../Categories/NSAttributedString+Theme.swift | 64 +++++++++++++++++++ Riot/Managers/Theme/ThemeService.swift | 2 +- .../Recents/Views/RecentTableViewCell.m | 3 +- Riot/Utils/EventFormatter.m | 20 +++++- Riot/Utils/ThemeColorResolver.swift | 48 ++++++++++++++ changelog.d/pr-7545.bugfix | 1 + 6 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 Riot/Categories/NSAttributedString+Theme.swift create mode 100644 Riot/Utils/ThemeColorResolver.swift create mode 100644 changelog.d/pr-7545.bugfix diff --git a/Riot/Categories/NSAttributedString+Theme.swift b/Riot/Categories/NSAttributedString+Theme.swift new file mode 100644 index 0000000000..9a0e01c936 --- /dev/null +++ b/Riot/Categories/NSAttributedString+Theme.swift @@ -0,0 +1,64 @@ +// +// 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. +// + +import Foundation + +/// Custom NSAttributedString.Key to specify the theme +let themeIdentifierAttributeName = NSAttributedString.Key("ThemeIdentifier") +/// Custom NSAttributedString.Key to specify a theme color by its name +let themeColorNameAttributeName = NSAttributedString.Key("ThemeColorName") + +extension NSAttributedString { + /// Fix foreground color attributes if this attributed string contains the `themeIdentifierAttributeName` and `foregroundColorNameAttributeName` attributes + /// - Returns: a new attributed string with updated colors + @objc func fixForegroundColor() -> NSAttributedString { + let activeTheme = ThemeService.shared().theme + + // Check if a theme is defined for this attributed string + var needUpdate = false + self.vc_enumerateAttribute(themeIdentifierAttributeName) { (themeIdentifier: String, range: NSRange, _) in + needUpdate = themeIdentifier != activeTheme.identifier + } + + guard needUpdate else { + return self + } + + // Build a new attributedString with the proper colors if possible + let mutableAttributedString = NSMutableAttributedString(attributedString: self) + mutableAttributedString.vc_enumerateAttribute(themeColorNameAttributeName) { (colorName: String, range: NSRange, _) in + if let color = ThemeColorResolver.getColorByName(colorName) { + mutableAttributedString.addAttribute(.foregroundColor, value: color, range: range) + } + } + return mutableAttributedString + } +} + +extension NSMutableAttributedString { + /// Adds a theme color name attribute + /// - Parameters: + /// - colorName: color name + /// - range:range for this attribute + @objc func addThemeColorNameAttribute(_ colorName: String, range: NSRange) { + self.addAttribute(themeColorNameAttributeName, value: colorName, range: range) + } + + /// Adds a theme identifier attribute + @objc func addThemeIdentifierAttribute() { + self.addAttribute(themeIdentifierAttributeName, value: ThemeService.shared().theme.identifier, range: .init(location: 0, length: length)) + } +} diff --git a/Riot/Managers/Theme/ThemeService.swift b/Riot/Managers/Theme/ThemeService.swift index 209812111c..3ce421d019 100644 --- a/Riot/Managers/Theme/ThemeService.swift +++ b/Riot/Managers/Theme/ThemeService.swift @@ -23,5 +23,5 @@ extension ThemeService { return nil } return ThemeIdentifier(rawValue: themeId) - } + } } diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m index a219963337..afd3f5c880 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m @@ -81,7 +81,8 @@ - (void)render:(MXKCellData *)cellData // Manage lastEventAttributedTextMessage optional property if (!roomCellData.roomSummary.spaceChildInfo && [roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) { - self.lastEventDescription.attributedText = roomCellData.lastEventAttributedTextMessage; + // Attempt to correct the attributed string colors to match the current theme + self.lastEventDescription.attributedText = [roomCellData.lastEventAttributedTextMessage fixForegroundColor]; } else { diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 278d902a57..d25655a226 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -573,8 +573,13 @@ - (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary { // Force the default text color for the last message (cancel highlighted message color) NSMutableAttributedString *lastEventDescription = [[NSMutableAttributedString alloc] initWithAttributedString:summary.lastMessage.attributedText]; - [lastEventDescription addAttribute:NSForegroundColorAttributeName value:ThemeService.shared.theme.textSecondaryColor - range:NSMakeRange(0, lastEventDescription.length)]; + NSRange range = NSMakeRange(0, lastEventDescription.length); + [lastEventDescription addAttribute:NSForegroundColorAttributeName + value:ThemeService.shared.theme.colors.secondaryContent + range:range]; + [lastEventDescription addThemeColorNameAttribute:@"secondaryContent" range:range]; + [lastEventDescription addThemeIdentifierAttribute]; + summary.lastMessage.attributedText = lastEventDescription; } @@ -670,9 +675,11 @@ - (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary NSAttributedString *attachmentString = nil; UIColor *textColor; + NSString *colorIdentifier; if (isStoppedVoiceBroadcast) { - textColor = ThemeService.shared.theme.textSecondaryColor; + textColor = ThemeService.shared.theme.colors.secondaryContent; + colorIdentifier = @"secondaryContent"; NSString *senderDisplayName; if ([stateEvent.stateKey isEqualToString:session.myUser.userId]) { @@ -688,6 +695,7 @@ - (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary else { textColor = ThemeService.shared.theme.colors.alert; + colorIdentifier = @"alert"; UIImage *liveImage = AssetImages.voiceBroadcastLive.image; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; @@ -717,6 +725,12 @@ - (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary } [lastMessage addAttribute:NSForegroundColorAttributeName value:textColor range:NSMakeRange(0, lastMessage.length)]; + if (colorIdentifier) + { + [lastMessage addThemeColorNameAttribute:colorIdentifier range:NSMakeRange(0, lastMessage.length)]; + [lastMessage addThemeIdentifierAttribute]; + } + summary.lastMessage.attributedText = lastMessage; return YES; diff --git a/Riot/Utils/ThemeColorResolver.swift b/Riot/Utils/ThemeColorResolver.swift new file mode 100644 index 0000000000..c0010c97df --- /dev/null +++ b/Riot/Utils/ThemeColorResolver.swift @@ -0,0 +1,48 @@ +// +// 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. +// + +import Foundation + +/// Utility struct to get a theme color by its name +struct ThemeColorResolver { + private static var theme: Theme? + private static var colorsTable: [String: UIColor] = [:] + private static let queue = DispatchQueue(label: "io.element.ThemeColorResolver.queue", qos: .userInteractive) + + private static func setTheme(theme: Theme) { + queue.sync { + guard self.theme?.identifier != theme.identifier else { + return + } + self.theme = theme + colorsTable = [:] + let mirror = Mirror(reflecting: theme.colors) + for child in mirror.children { + if let colorName = child.label { + colorsTable[colorName] = child.value as? UIColor + } + } + } + } + + /// Finds a color by its name in the current theme colors + /// - Parameter name: color name + /// - Returns: the corresponding color or nil + static func getColorByName(_ name: String) -> UIColor? { + setTheme(theme: ThemeService.shared().theme) + return colorsTable[name] + } +} diff --git a/changelog.d/pr-7545.bugfix b/changelog.d/pr-7545.bugfix new file mode 100644 index 0000000000..a2f30eb67e --- /dev/null +++ b/changelog.d/pr-7545.bugfix @@ -0,0 +1 @@ +Fix: The last event description text color now matches the active theme. From d59cb33d1c8f03146c58751b85504d230e8f4d89 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 12 May 2023 18:30:20 +0200 Subject: [PATCH 48/51] Disable removing mention/command text trigger with RTE enabled --- Riot/Modules/Room/RoomViewController.m | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index cc108baa3c..17253279be 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -8147,6 +8147,14 @@ - (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBr - (void)removeTriggerTextFromComposer:(NSString *)textTrigger { RoomInputToolbarView *toolbar = (RoomInputToolbarView *)self.inputToolbarView; + Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; + + // RTE handles removing the text trigger by itself. + if (roomInputToolbarViewClass == WysiwygInputToolbarView.class && RiotSettings.shared.enableWysiwygTextFormatting) + { + return; + } + if (toolbar && textTrigger.length) { NSMutableAttributedString *attributedTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:toolbar.attributedTextMessage]; [[attributedTextMessage mutableString] replaceOccurrencesOfString:textTrigger From 5e9209bcdc6466056cd72042e9353d49a71a772b Mon Sep 17 00:00:00 2001 From: aringenbach Date: Fri, 12 May 2023 18:34:32 +0200 Subject: [PATCH 49/51] Fix mention pills display in thread list --- .../Views/Cell/ThreadTableViewCell.swift | 8 ++-- .../Views/Cell/ThreadTableViewCell.xib | 37 ++++++++++--------- changelog.d/7322.bugfix | 1 + 3 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 changelog.d/7322.bugfix diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift index b99eec0c35..be4ed85dc5 100644 --- a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift @@ -35,7 +35,7 @@ class ThreadTableViewCell: UITableViewCell { @IBOutlet private weak var rootMessageAvatarView: UserAvatarView! @IBOutlet private weak var rootMessageSenderLabel: UILabel! - @IBOutlet private weak var rootMessageContentLabel: UILabel! + @IBOutlet private weak var rootMessageContentTextView: UITextView! @IBOutlet private weak var lastMessageTimeLabel: UILabel! @IBOutlet private weak var summaryView: ThreadSummaryView! @IBOutlet private weak var notificationStatusView: ThreadNotificationStatusView! @@ -61,7 +61,7 @@ class ThreadTableViewCell: UITableViewCell { if let rootMessageText = model.rootMessageText { updateRootMessageContentAttributes(rootMessageText, color: rootMessageColor) } else { - rootMessageContentLabel.attributedText = nil + rootMessageContentTextView.attributedText = nil } lastMessageTimeLabel.text = model.lastMessageTime if let summaryModel = model.summaryModel { @@ -83,7 +83,7 @@ class ThreadTableViewCell: UITableViewCell { mutable.addAttributes([ .foregroundColor: color ], range: NSRange(location: 0, length: mutable.length)) - rootMessageContentLabel.attributedText = mutable + rootMessageContentTextView.attributedText = mutable } } @@ -97,7 +97,7 @@ extension ThreadTableViewCell: Themable { Self.usernameColorGenerator.update(theme: theme) updateRootMessageSenderColor() rootMessageAvatarView.backgroundColor = .clear - if let attributedText = rootMessageContentLabel.attributedText { + if let attributedText = rootMessageContentTextView.attributedText { updateRootMessageContentAttributes(attributedText, color: rootMessageColor) } lastMessageTimeLabel.textColor = theme.colors.secondaryContent diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib index f9c881396a..3014cd7114 100644 --- a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib @@ -1,9 +1,9 @@ - + - + @@ -11,14 +11,14 @@ - + - + @@ -27,7 +27,7 @@ - + @@ -51,13 +51,13 @@ - - + + + @@ -68,20 +68,20 @@ - + - - + - + + @@ -89,7 +89,7 @@ - + @@ -97,6 +97,9 @@ + + + diff --git a/changelog.d/7322.bugfix b/changelog.d/7322.bugfix new file mode 100644 index 0000000000..b13925fa32 --- /dev/null +++ b/changelog.d/7322.bugfix @@ -0,0 +1 @@ +Fix mention pills display in thread list From c1dc7c93da3e5602ebdd1af67e345ae109733ee6 Mon Sep 17 00:00:00 2001 From: Element CI Date: Tue, 16 May 2023 15:46:01 +0300 Subject: [PATCH 50/51] changelog.d: Upgrade MatrixSDK version ([v0.26.10](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.10)). --- Podfile | 2 +- changelog.d/x-nolink-0.change | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/x-nolink-0.change diff --git a/Podfile b/Podfile index f4cbeda08c..52ce26306d 100644 --- a/Podfile +++ b/Podfile @@ -16,7 +16,7 @@ use_frameworks! # - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI # # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it -$matrixSDKVersion = '= 0.26.9' +$matrixSDKVersion = '= 0.26.10' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } diff --git a/changelog.d/x-nolink-0.change b/changelog.d/x-nolink-0.change new file mode 100644 index 0000000000..a52d295221 --- /dev/null +++ b/changelog.d/x-nolink-0.change @@ -0,0 +1 @@ +Upgrade MatrixSDK version ([v0.26.10](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.10)). \ No newline at end of file From da6c0fd24e6ced188228bd5b3e920b495045bd93 Mon Sep 17 00:00:00 2001 From: Element CI Date: Tue, 16 May 2023 15:46:02 +0300 Subject: [PATCH 51/51] version++ --- CHANGES.md | 31 +++++++++++++++++++++++++++++++ changelog.d/7322.bugfix | 1 - changelog.d/7493.feature | 1 - changelog.d/7497.bugfix | 1 - changelog.d/7504.change | 1 - changelog.d/7517.change | 1 - changelog.d/7523.bugfix | 1 - changelog.d/7526.bugfix | 1 - changelog.d/7530.bugfix | 1 - changelog.d/7535.bugfix | 1 - changelog.d/pr-7404.bugfix | 1 - changelog.d/pr-7493.bugfix | 1 - changelog.d/pr-7508.change | 1 - changelog.d/pr-7512.bugfix | 1 - changelog.d/pr-7521.bugfix | 1 - changelog.d/pr-7522.bugfix | 1 - changelog.d/pr-7541.change | 1 - changelog.d/pr-7543.bugfix | 1 - changelog.d/pr-7545.bugfix | 1 - changelog.d/x-nolink-0.change | 1 - 20 files changed, 31 insertions(+), 19 deletions(-) delete mode 100644 changelog.d/7322.bugfix delete mode 100644 changelog.d/7493.feature delete mode 100644 changelog.d/7497.bugfix delete mode 100644 changelog.d/7504.change delete mode 100644 changelog.d/7517.change delete mode 100644 changelog.d/7523.bugfix delete mode 100644 changelog.d/7526.bugfix delete mode 100644 changelog.d/7530.bugfix delete mode 100644 changelog.d/7535.bugfix delete mode 100644 changelog.d/pr-7404.bugfix delete mode 100644 changelog.d/pr-7493.bugfix delete mode 100644 changelog.d/pr-7508.change delete mode 100644 changelog.d/pr-7512.bugfix delete mode 100644 changelog.d/pr-7521.bugfix delete mode 100644 changelog.d/pr-7522.bugfix delete mode 100644 changelog.d/pr-7541.change delete mode 100644 changelog.d/pr-7543.bugfix delete mode 100644 changelog.d/pr-7545.bugfix delete mode 100644 changelog.d/x-nolink-0.change diff --git a/CHANGES.md b/CHANGES.md index 428bd30f7a..7e942b526b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,34 @@ +## Changes in 1.10.12 (2023-05-16) + +✨ Features + +- Add composer suggestions for slash commands ([#7493](https://github.com/vector-im/element-ios/issues/7493)) + +🙌 Improvements + +- Crypto: Deprecate MXLegacyCrypto ([#7508](https://github.com/vector-im/element-ios/pull/7508)) +- Add a flag in the build settings to force the user to define a homeserver instead of using the default one. ([#7541](https://github.com/vector-im/element-ios/pull/7541)) +- Upgrade MatrixSDK version ([v0.26.10](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.10)). +- Add an audio alert when the voice broadcast recording is automatically paused ([#7504](https://github.com/vector-im/element-ios/issues/7504)) +- Timeline: Remove the matrix ID displayed when someone has changed its display name. ([#7517](https://github.com/vector-im/element-ios/issues/7517)) + +🐛 Bugfixes + +- Fix an issue where the Secrets Reset screen would open twice. ([#7404](https://github.com/vector-im/element-ios/pull/7404)) +- Make sure to use the chosen language for the VoiceOver voice too. ([#7493](https://github.com/vector-im/element-ios/pull/7493)) +- Fix the position of the send confirmation icon. ([#7512](https://github.com/vector-im/element-ios/pull/7512)) +- Disable accessibility for emojis during session verification. ([#7521](https://github.com/vector-im/element-ios/pull/7521)) +- Fix accessibility when entering the PIN to unlock the app. ([#7522](https://github.com/vector-im/element-ios/pull/7522)) +- Fix voiceover order of room creation header and message composer. ([#7543](https://github.com/vector-im/element-ios/pull/7543)) +- Fix: The last event description text color now matches the active theme. ([#7545](https://github.com/vector-im/element-ios/pull/7545)) +- Fix mention pills display in thread list ([#7322](https://github.com/vector-im/element-ios/issues/7322)) +- Poll: The timeline sometimes displayed closed polls in the wrong order. ([#7497](https://github.com/vector-im/element-ios/issues/7497)) +- Fix a flickering issue when the timeline datasource is reloaded. ([#7523](https://github.com/vector-im/element-ios/issues/7523)) +- Fix the position of the marker highlighting an event. ([#7526](https://github.com/vector-im/element-ios/issues/7526)) +- Fix application crashing when opening a thread with RTE enabled ([#7530](https://github.com/vector-im/element-ios/issues/7530)) +- Labs: Rich Text Editor: Fix partial text messages not being saved for each room ([#7535](https://github.com/vector-im/element-ios/issues/7535)) + + ## Changes in 1.10.11 (2023-04-18) 🙌 Improvements diff --git a/changelog.d/7322.bugfix b/changelog.d/7322.bugfix deleted file mode 100644 index b13925fa32..0000000000 --- a/changelog.d/7322.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix mention pills display in thread list diff --git a/changelog.d/7493.feature b/changelog.d/7493.feature deleted file mode 100644 index 075a7f6a2b..0000000000 --- a/changelog.d/7493.feature +++ /dev/null @@ -1 +0,0 @@ -Add composer suggestions for slash commands diff --git a/changelog.d/7497.bugfix b/changelog.d/7497.bugfix deleted file mode 100644 index a8558b8430..0000000000 --- a/changelog.d/7497.bugfix +++ /dev/null @@ -1 +0,0 @@ -Poll: The timeline sometimes displayed closed polls in the wrong order. diff --git a/changelog.d/7504.change b/changelog.d/7504.change deleted file mode 100644 index 2fed9c438b..0000000000 --- a/changelog.d/7504.change +++ /dev/null @@ -1 +0,0 @@ -Add an audio alert when the voice broadcast recording is automatically paused diff --git a/changelog.d/7517.change b/changelog.d/7517.change deleted file mode 100644 index f436629479..0000000000 --- a/changelog.d/7517.change +++ /dev/null @@ -1 +0,0 @@ -Timeline: Remove the matrix ID displayed when someone has changed its display name. diff --git a/changelog.d/7523.bugfix b/changelog.d/7523.bugfix deleted file mode 100644 index bc5cf31a71..0000000000 --- a/changelog.d/7523.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix a flickering issue when the timeline datasource is reloaded. diff --git a/changelog.d/7526.bugfix b/changelog.d/7526.bugfix deleted file mode 100644 index 7adb60cc08..0000000000 --- a/changelog.d/7526.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix the position of the marker highlighting an event. diff --git a/changelog.d/7530.bugfix b/changelog.d/7530.bugfix deleted file mode 100644 index 5733a8d814..0000000000 --- a/changelog.d/7530.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix application crashing when opening a thread with RTE enabled diff --git a/changelog.d/7535.bugfix b/changelog.d/7535.bugfix deleted file mode 100644 index f21ab863c2..0000000000 --- a/changelog.d/7535.bugfix +++ /dev/null @@ -1 +0,0 @@ -Labs: Rich Text Editor: Fix partial text messages not being saved for each room diff --git a/changelog.d/pr-7404.bugfix b/changelog.d/pr-7404.bugfix deleted file mode 100644 index 58609a160a..0000000000 --- a/changelog.d/pr-7404.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix an issue where the Secrets Reset screen would open twice. diff --git a/changelog.d/pr-7493.bugfix b/changelog.d/pr-7493.bugfix deleted file mode 100644 index b486878b50..0000000000 --- a/changelog.d/pr-7493.bugfix +++ /dev/null @@ -1 +0,0 @@ -Make sure to use the chosen language for the VoiceOver voice too. diff --git a/changelog.d/pr-7508.change b/changelog.d/pr-7508.change deleted file mode 100644 index dbe206b348..0000000000 --- a/changelog.d/pr-7508.change +++ /dev/null @@ -1 +0,0 @@ -Crypto: Deprecate MXLegacyCrypto diff --git a/changelog.d/pr-7512.bugfix b/changelog.d/pr-7512.bugfix deleted file mode 100644 index 1c6d3a98d1..0000000000 --- a/changelog.d/pr-7512.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix the position of the send confirmation icon. diff --git a/changelog.d/pr-7521.bugfix b/changelog.d/pr-7521.bugfix deleted file mode 100644 index 3cedf12d48..0000000000 --- a/changelog.d/pr-7521.bugfix +++ /dev/null @@ -1 +0,0 @@ -Disable accessibility for emojis during session verification. \ No newline at end of file diff --git a/changelog.d/pr-7522.bugfix b/changelog.d/pr-7522.bugfix deleted file mode 100644 index 0bd4e5b530..0000000000 --- a/changelog.d/pr-7522.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix accessibility when entering the PIN to unlock the app. diff --git a/changelog.d/pr-7541.change b/changelog.d/pr-7541.change deleted file mode 100644 index 0e0c71fa67..0000000000 --- a/changelog.d/pr-7541.change +++ /dev/null @@ -1 +0,0 @@ -Add a flag in the build settings to force the user to define a homeserver instead of using the default one. diff --git a/changelog.d/pr-7543.bugfix b/changelog.d/pr-7543.bugfix deleted file mode 100644 index 6a56590cd9..0000000000 --- a/changelog.d/pr-7543.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix voiceover order of room creation header and message composer. diff --git a/changelog.d/pr-7545.bugfix b/changelog.d/pr-7545.bugfix deleted file mode 100644 index a2f30eb67e..0000000000 --- a/changelog.d/pr-7545.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix: The last event description text color now matches the active theme. diff --git a/changelog.d/x-nolink-0.change b/changelog.d/x-nolink-0.change deleted file mode 100644 index a52d295221..0000000000 --- a/changelog.d/x-nolink-0.change +++ /dev/null @@ -1 +0,0 @@ -Upgrade MatrixSDK version ([v0.26.10](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.10)). \ No newline at end of file