diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 291360fd2..130326b3d 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 }} diff --git a/CHANGES.md b/CHANGES.md index 428bd30f7..7e942b526 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/Config/BuildSettings.swift b/Config/BuildSettings.swift index 8773f2b68..44c008e0b 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/Config/CommonConfiguration.swift b/Config/CommonConfiguration.swift index 4b6068a96..8e00b0cdc 100644 --- a/Config/CommonConfiguration.swift +++ b/Config/CommonConfiguration.swift @@ -91,8 +91,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 = BWIBuildSettings.shared.stunServerFallbackUrlString { callManager.fallbackSTUNServer = stunServerFallback } - } - - - // 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 - } - + } +} + +extension CommonConfiguration: MXCryptoV2MigrationDelegate { + var needsVerificationUpgrade: Bool { + get { + RiotSettings.shared.showVerificationUpgradeAlert + } + set { + RiotSettings.shared.showVerificationUpgradeAlert = newValue + } + } } diff --git a/Config/Configurable.swift b/Config/Configurable.swift index acfb97605..2f1c46a03 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/Podfile b/Podfile index f64282478..d2b805bb3 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/Podfile.lock b/Podfile.lock index f47ccb4f3..eae31a173 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -39,9 +39,9 @@ PODS: - LoggerAPI (1.9.200): - Logging (~> 1.1) - Logging (1.4.0) - - MatrixSDK (0.26.9): - - MatrixSDK/Core (= 0.26.9) - - MatrixSDK/Core (0.26.9): + - MatrixSDK (0.26.10): + - MatrixSDK/Core (= 0.26.10) + - MatrixSDK/Core (0.26.10): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) @@ -49,7 +49,7 @@ PODS: - OLMKit (~> 3.2.5) - Realm (= 10.27.0) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/JingleCallStack (0.26.9): + - MatrixSDK/JingleCallStack (0.26.10): - JitsiMeetSDKLite (= 7.0.1-lite) - MatrixSDK/Core - MatrixSDKCrypto (0.3.4) @@ -102,8 +102,8 @@ DEPENDENCIES: - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatrixSDK (= 0.26.9) - - MatrixSDK/JingleCallStack (= 0.26.9) + - MatrixSDK (= 0.26.10) + - MatrixSDK/JingleCallStack (= 0.26.10) - OLMKit - PostHog (~> 2.0.0) - ReadMoreTextView (~> 3.0.1) @@ -187,7 +187,7 @@ SPEC CHECKSUMS: libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b - MatrixSDK: 2f6222978156818cf4c6ba590762ade601ba72f9 + MatrixSDK: 68e39c246ff8d80c5788d5fc46e93fcbb24703fa MatrixSDKCrypto: ac805c22c24f79f349cdbfa065855c73a4c81b51 OLMKit: da115f16582e47626616874e20f7bb92222c7a51 PostHog: 660ec6c9d80cec17b685e148f17f6785a88b597d @@ -208,6 +208,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: a55fb48d3bef5f5e24fcaf8c39d1eae1ed8c1603 +PODFILE CHECKSUM: 4c82d7cddeb9c9b7a7adeaa2cd76d416117cd1a6 COCOAPODS: 1.11.3 diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 489a44a34..0c7580209 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,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/Assets/Sounds/vberror.mp3 b/Riot/Assets/Sounds/vberror.mp3 new file mode 100644 index 000000000..14c710595 Binary files /dev/null and b/Riot/Assets/Sounds/vberror.mp3 differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 8a42809d9..690743765 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -614,6 +614,21 @@ Tap the + to start adding people."; "room_join_group_call" = "Join"; "room_no_privileges_to_create_group_call" = "You need to be an admin or a moderator to start a call."; +// Room commands descriptions +"room_command_change_display_name_description" = "Changes your display nickname"; +"room_command_emote_description" = "Displays action"; +"room_command_join_room_description" = "Joins room with given address"; +"room_command_part_room_description" = "Leave room"; +"room_command_invite_user_description" = "Invites user with given id to current room"; +"room_command_kick_user_description" = "Removes user with given id from this room"; +"room_command_ban_user_description" = "Bans user with given id"; +"room_command_unban_user_description" = "Unbans user with given id"; +"room_command_set_user_power_level_description" = "Define the power level of a user"; +"room_command_reset_user_power_level_description" = "Deops user with given id"; +"room_command_change_room_topic_description" = "Sets the room topic"; +"room_command_discard_session_description" = "Forces the current outbound group session in an encrypted room to be discarded"; +"room_command_error_unknown_command" = "Invalid or unhandled command"; + // MARK: Threads "room_thread_title" = "Thread"; "thread_copy_link_to_thread" = "Copy link to thread"; @@ -807,9 +822,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 %@"; @@ -2393,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"; @@ -2972,6 +2986,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/Categories/MXBugReportRestClient+Riot.swift b/Riot/Categories/MXBugReportRestClient+Riot.swift index 55fb902d7..953180160 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/Categories/MXKRoomBubbleTableViewCell+Riot.m b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m index a7bfd69f1..9164a61d7 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m @@ -256,40 +256,18 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = 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]) + CGRect componentFrame = [self componentFrameInContentViewForIndex:componentIndex]; + if (CGRectIsEmpty(componentFrame)) { - 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) - { - // 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 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = 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]]; @@ -600,36 +578,47 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = } 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 +790,7 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = - (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/Categories/MXRoom+Riot.m b/Riot/Categories/MXRoom+Riot.m index 04538ba80..b81da1759 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 @@ { [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 d25cdee5f..324a7f369 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 c6a55a230..b2c1eeb40 100644 --- a/Riot/Categories/MXRoomSummary+Riot.m +++ b/Riot/Categories/MXRoomSummary+Riot.m @@ -33,32 +33,15 @@ - (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/Categories/NSAttributedString+Theme.swift b/Riot/Categories/NSAttributedString+Theme.swift new file mode 100644 index 000000000..9a0e01c93 --- /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/Generated/Strings.swift b/Riot/Generated/Strings.swift index 3f4a5f01f..46da50d12 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) @@ -4923,6 +4927,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") @@ -5211,6 +5219,58 @@ public class VectorL10n: NSObject { public static var roomAvatarViewAccessibilityLabel: String { return VectorL10n.tr("Vector", "room_avatar_view_accessibility_label") } + /// Bans user with given id + public static var roomCommandBanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_ban_user_description") + } + /// Changes your display nickname + public static var roomCommandChangeDisplayNameDescription: String { + return VectorL10n.tr("Vector", "room_command_change_display_name_description") + } + /// Sets the room topic + public static var roomCommandChangeRoomTopicDescription: String { + return VectorL10n.tr("Vector", "room_command_change_room_topic_description") + } + /// Forces the current outbound group session in an encrypted room to be discarded + public static var roomCommandDiscardSessionDescription: String { + return VectorL10n.tr("Vector", "room_command_discard_session_description") + } + /// Displays action + public static var roomCommandEmoteDescription: String { + return VectorL10n.tr("Vector", "room_command_emote_description") + } + /// Invalid or unhandled command + public static var roomCommandErrorUnknownCommand: String { + return VectorL10n.tr("Vector", "room_command_error_unknown_command") + } + /// Invites user with given id to current room + public static var roomCommandInviteUserDescription: String { + return VectorL10n.tr("Vector", "room_command_invite_user_description") + } + /// Joins room with given address + public static var roomCommandJoinRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_join_room_description") + } + /// Removes user with given id from this room + public static var roomCommandKickUserDescription: String { + return VectorL10n.tr("Vector", "room_command_kick_user_description") + } + /// Leave room + public static var roomCommandPartRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_part_room_description") + } + /// Deops user with given id + public static var roomCommandResetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_reset_user_power_level_description") + } + /// Define the power level of a user + public static var roomCommandSetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_set_user_power_level_description") + } + /// Unbans user with given id + public static var roomCommandUnbanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_unban_user_description") + } /// You need permission to manage conference call in this room public static var roomConferenceCallNoPower: String { return VectorL10n.tr("Vector", "room_conference_call_no_power") @@ -7647,18 +7707,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 +7723,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/Managers/Theme/ThemeService.swift b/Riot/Managers/Theme/ThemeService.swift index 209812111..3ce421d01 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/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index 5e5b20ece..d6f8b6fa3 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 78450551b..54933a7ab 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 b82c950b0..100c69c31 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -34,7 +34,6 @@ #import "ContactDetailsViewController.h" #import "BugReportViewController.h" -#import "RoomKeyRequestViewController.h" #import "DecryptionFailureTracker.h" #import "Tools.h" @@ -115,11 +114,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni id roomKeyRequestObserver; id roomKeyRequestCancellationObserver; - /** - If any the currently displayed sharing key dialog - */ - RoomKeyRequestViewController *roomKeyRequestViewController; - /** Incoming key verification requests observers */ @@ -401,6 +395,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } [NSBundle mxk_setLanguage:language]; [NSBundle mxk_setFallbackLanguage:@"en"]; + UIApplication.sharedApplication.accessibilityLanguage = language; if (BuildSettings.disableRightToLeftLayout) { @@ -1887,8 +1882,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // start the call service [self.callPresenter start]; - [self.configuration setupSettingsWhenLoadedFor:mxSession]; - // Register to user new device sign in notification [self registerUserDidSignInOnNewDeviceNotificationForSession:mxSession]; @@ -1897,8 +1890,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // Register to new key verification request [self registerNewRequestNotificationForSession:mxSession]; - [self checkLocalPrivateKeysInSession:mxSession]; - [self.pushNotificationService checkPushKitPushersInSession:mxSession]; } else if (mxSession.state == MXSessionStateRunning) @@ -2126,9 +2117,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // If any, disable the no VoIP support workaround [self disableNoVoIPOnMatrixSession:mxSession]; - // Disable listening of incoming key share requests - [self disableRoomKeyRequestObserver:mxSession]; - // Disable listening of incoming key verification requests [self disableIncomingKeyVerificationObserver:mxSession]; @@ -2369,9 +2357,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // Clear cache [self clearCache]; - // Reset Crypto SDK configuration (labs flag for which crypto module to use) - [CryptoSDKFeature.shared reset]; - // Reset key backup banner preferences [SecureBackupBannerPreferences.shared reset]; @@ -2485,11 +2470,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni case MXSessionStateSyncInProgress: // Stay in launching during the first server sync if the store is empty. isLaunching = (mainSession.rooms.count == 0 && launchAnimationContainerView); - - if (mainSession.crypto.crossSigning && mainSession.crypto.crossSigning.state == MXCrossSigningStateCrossSigningExists && [mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) - { - [(MXLegacyCrypto *)mainSession.crypto setOutgoingKeyRequestsEnabled:NO onComplete:nil]; - } break; case MXSessionStateRunning: self.clearingCache = NO; @@ -2549,7 +2529,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // This is the time to check existing requests MXLogDebug(@"[AppDelegate] handleAppState: Check pending verification requests"); - [self checkPendingRoomKeyRequests]; [self checkPendingIncomingKeyVerificationsInSession:mainSession]; // TODO: When we will have an application state, we will do all of this in a dedicated initialisation state @@ -2558,9 +2537,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni { MXLogDebug(@"[AppDelegate] handleAppState: Set up observers for the crypto module"); - // Enable listening of incoming key share requests - [self enableRoomKeyRequestObserver:mainSession]; - // Enable listening of incoming key verification requests [self enableIncomingKeyVerificationObserver:mainSession]; } @@ -2611,6 +2587,15 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [window addSubview:launchLoadingView]; } + MXSession *mainSession = self.mxSessions.firstObject; + LaunchLoadingView *launchLoadingView = [LaunchLoadingView instantiateWithStartupProgress:mainSession.startupProgress]; + + launchLoadingView.frame = window.bounds; + [launchLoadingView updateWithTheme:ThemeService.shared.theme]; + launchLoadingView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + [window addSubview:launchLoadingView]; + launchAnimationContainerView = launchLoadingView; [MXSDKOptions.sharedInstance.profiler startMeasuringTaskWithName:MXTaskProfileNameStartupLaunchScreen]; @@ -2718,38 +2703,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni #endif } -- (void)checkLocalPrivateKeysInSession:(MXSession*)mxSession -{ - if (![mxSession.crypto isKindOfClass:[MXLegacyCrypto class]]) - { - return; - } - MXLegacyCrypto *crypto = (MXLegacyCrypto *)mxSession.crypto; - - MXRecoveryService *recoveryService = mxSession.crypto.recoveryService; - NSUInteger keysCount = 0; - if ([recoveryService hasSecretWithSecretId:MXSecretId.keyBackup]) - { - keysCount++; - } - if ([recoveryService hasSecretWithSecretId:MXSecretId.crossSigningUserSigning]) - { - keysCount++; - } - if ([recoveryService hasSecretWithSecretId:MXSecretId.crossSigningSelfSigning]) - { - keysCount++; - } - - if ((keysCount > 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]; @@ -3663,173 +3616,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } - -#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 @@ -3987,12 +3773,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni - (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 6f31a5415..0ceed0374 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -782,12 +782,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/AuthenticationViewController.m b/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m index 6da9da77f..b4c3821ca 100644 --- a/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m @@ -1320,7 +1320,14 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; [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/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift index 5b13dc810..b0999be31 100644 --- a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift @@ -236,12 +236,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 214c76695..ffefd839a 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 3e8227e7c..680d330fa 100644 --- a/Riot/Modules/Call/CallViewController.m +++ b/Riot/Modules/Call/CallViewController.m @@ -370,28 +370,16 @@ CallAudioRouteMenuViewDelegate> { 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/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/AvatarView.swift index 0febb3f51..6ffade6c9 100644 --- a/Riot/Modules/Common/Avatar/AvatarView.swift +++ b/Riot/Modules/Common/Avatar/AvatarView.swift @@ -103,6 +103,7 @@ 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 } @@ -120,6 +121,10 @@ class AvatarView: UIView, Themable { 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, @@ -129,6 +134,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/Common/Recents/Views/RecentTableViewCell.m b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m index c98d5606e..4c0ff4342 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m @@ -81,7 +81,8 @@ // 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/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 000000000..275d74ffc --- /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/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift b/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h similarity index 54% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift rename to Riot/Modules/Encryption/RoomEncryptionTrustLevel.h index d4e984f88..a942f5360 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h @@ -1,5 +1,5 @@ -// -// Copyright 2021 New Vector Ltd +// +// 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. @@ -14,22 +14,12 @@ // limitations under the License. // -import Foundation - -enum UserSuggestionViewAction { - case selectedItem(UserSuggestionViewStateItem) -} - -enum UserSuggestionViewModelResult { - case selectedItemWithIdentifier(String) -} - -struct UserSuggestionViewStateItem: Identifiable { - let id: String - let avatar: AvatarInputProtocol? - let displayName: String? -} - -struct UserSuggestionViewState: BindableState { - var items: [UserSuggestionViewStateItem] -} +/** + 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/Modules/Home/AllChats/AllChatsCoordinator.swift b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift index f8ecbc8f1..371e26334 100644 --- a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift +++ b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift @@ -422,7 +422,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 { diff --git a/Riot/Modules/Home/AllChats/AllChatsViewController.swift b/Riot/Modules/Home/AllChats/AllChatsViewController.swift index 41e6481c4..bc083457c 100644 --- a/Riot/Modules/Home/AllChats/AllChatsViewController.swift +++ b/Riot/Modules/Home/AllChats/AllChatsViewController.swift @@ -1085,8 +1085,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/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift b/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift index e609f6b38..df8b3359e 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 + } } diff --git a/Riot/Modules/LaunchLoading/LaunchLoadingView.swift b/Riot/Modules/LaunchLoading/LaunchLoadingView.swift index 8398c659d..c4cdee422 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/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 new file mode 100644 index 000000000..14c710595 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 differ diff --git a/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h b/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h index 635a2037c..195729055 100644 --- a/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h +++ b/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h @@ -17,3 +17,4 @@ #import "MXKRoomBubbleCellData.h" #import "UserIndicatorCancel.h" #import "VoiceBroadcastInfo.h" +#import "MXKSoundPlayer.h" diff --git a/Riot/Modules/MatrixKit/MatrixKit.h b/Riot/Modules/MatrixKit/MatrixKit.h index 2bb02223b..ce6ea5f1e 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/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index 70cc2f71a..1cd659943 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -953,15 +953,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; [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/MatrixKit/Models/Room/MXKRoomBubbleCellData.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h index e934567b7..df9d12900 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 9e9cbfc15..f14e27c77 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -500,23 +500,34 @@ // 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; @@ -535,7 +546,7 @@ // 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/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index 0998122ae..033aa9361 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 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { _filterMessagesWithURL = NO; - emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote]; + emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]; // Set default data and view classes // Cell data @@ -458,11 +456,6 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { } - (void)reset -{ - [self resetNotifying:YES]; -} - -- (void)resetNotifying:(BOOL)notify { if (roomDidFlushDataNotificationObserver) { @@ -558,12 +551,6 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { } _serverSyncEventCount = 0; - - // Notify the delegate to reload its tableview - if (notify && self.delegate) - { - [self.delegate dataSource:self didCellChange:nil]; - } } - (void)reload @@ -577,10 +564,16 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { [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/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h deleted file mode 100644 index ef9c71783..000000000 --- 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 e9d483d9b..000000000 --- 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 000000000..54ab1ab3c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -0,0 +1,101 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@objc final class MXKSlashCommandsHelper: NSObject { + @objc static func commandNameFor(_ slashCommand: MXKSlashCommand) -> String { + slashCommand.cmd + } + + @objc static func commandUsageFor(_ slashCommand: MXKSlashCommand) -> String { + "Usage: \(slashCommand.cmd) \(slashCommand.parametersFormat)" + } +} + +@objc enum MXKSlashCommand: Int, CaseIterable { + case changeDisplayName + case emote + case joinRoom + case partRoom + case inviteUser + case kickUser + case banUser + case unbanUser + case setUserPowerLevel + case resetUserPowerLevel + case changeRoomTopic + case discardSession + + var cmd: String { + switch self { + case .changeDisplayName: + return "/nick" + case .emote: + return "/me" + case .joinRoom: + return "/join" + case .partRoom: + return "/part" + case .inviteUser: + return "/invite" + case .kickUser: + return "/kick" + case .banUser: + return "/ban" + case .unbanUser: + return "/unban" + case .setUserPowerLevel: + return "/op" + case .resetUserPowerLevel: + return "/deop" + case .changeRoomTopic: + return "/topic" + case .discardSession: + return "/discardsession" + } + } + + // Note: not localized for consistency, as commands are in english + // also translating these parameters could lead to inconsistency in + // the UI in case of languages with overlength translation. + var parametersFormat: String { + switch self { + case .changeDisplayName: + return "" + case .emote: + return "" + case .joinRoom: + return "" + case .partRoom: + return "[]" + case .inviteUser: + return "" + case .kickUser: + return " []" + case .banUser: + return " []" + case .unbanUser: + return "" + case .setUserPowerLevel: + return " " + case .resetUserPowerLevel: + return "" + case .changeRoomTopic: + return "" + case .discardSession: + return "" + } + } +} diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m index 9301854f0..f4f109ff5 100644 --- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -571,7 +571,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* } else { - displayText = [VectorL10n noticeDisplayNameChangedFrom:event.sender :prevDisplayname :displayname]; + displayText = [VectorL10n noticeDisplayNameChangedTo:prevDisplayname :displayname]; } } } diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index e366ae239..b18e93690 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. @@ -205,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 /** @@ -382,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 d05cd9f53..b5b15d4b8 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -1405,4 +1405,9 @@ NSString* MXKFileSizes_description(MXKFileSizes sizes) return NO; } +- (void)setPartialContent:(NSAttributedString *)attributedTextMessage +{ + self.attributedTextMessage = attributedTextMessage; +} + @end diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.swift b/Riot/Modules/Room/DataSources/RoomDataSource.swift index 281a7a046..89cbabe42 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 7c014e018..b1a1bc18b 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" @@ -361,7 +360,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; { // 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) @@ -1284,8 +1283,14 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; // 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; userId = nil; } - if ([cmd isEqualToString:kMXKSlashCmdInviteUser]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandInviteUser]]) { if (userId) { @@ -1489,10 +1494,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; 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 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /deop "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandResetUserPowerLevel]; } } else diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index b0c482917..3a4ae5f81 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -62,7 +62,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 66d0f3538..96f84bc20 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -100,7 +100,7 @@ static CGSize kThreadListBarButtonItemImageSize; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, CompletionSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate, RoomInputToolbarViewDelegate, ComposerCreateActionListBridgePresenterDelegate> { // The preview header @@ -226,8 +226,8 @@ static CGSize kThreadListBarButtonItemImageSize; @property (nonatomic, strong) ShareManager *shareManager; @property (nonatomic, strong) EventMenuBuilder *eventMenuBuilder; -@property (nonatomic, strong) UserSuggestionCoordinatorBridge *userSuggestionCoordinator; -@property (nonatomic, weak) IBOutlet UIView *userSuggestionContainerView; +@property (nonatomic, strong) CompletionSuggestionCoordinatorBridge *completionSuggestionCoordinator; +@property (nonatomic, weak) IBOutlet UIView *completionSuggestionContainerView; @property (nonatomic, readwrite) RoomDisplayConfiguration *displayConfiguration; @@ -431,7 +431,7 @@ static CGSize kThreadListBarButtonItemImageSize; [self setupActions]; - [self setupUserSuggestionViewIfNeeded]; + [self setupCompletionSuggestionViewIfNeeded]; [self.topBannersStackView vc_removeAllSubviews]; } @@ -713,7 +713,7 @@ static CGSize kThreadListBarButtonItemImageSize; { // 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]; @@ -1108,12 +1108,14 @@ static CGSize kThreadListBarButtonItemImageSize; [VoiceMessageMediaServiceProvider.sharedProvider setCurrentRoomSummary:dataSource.room.summary]; _voiceMessageController.roomId = dataSource.roomId; - _userSuggestionCoordinator = [[UserSuggestionCoordinatorBridge alloc] initWithMediaManager:self.roomDataSource.mxSession.mediaManager + _completionSuggestionCoordinator = [[CompletionSuggestionCoordinatorBridge alloc] initWithMediaManager:self.roomDataSource.mxSession.mediaManager room:dataSource.room userID:self.roomDataSource.mxSession.myUserId]; - _userSuggestionCoordinator.delegate = self; + _completionSuggestionCoordinator.delegate = self; - [self setupUserSuggestionViewIfNeeded]; + [self setupCompletionSuggestionViewIfNeeded]; + + [self updateRoomInputToolbarViewClassIfNeeded]; [self updateTopBanners]; } @@ -1214,6 +1216,12 @@ static CGSize kThreadListBarButtonItemImageSize; - (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; @@ -1301,6 +1309,8 @@ static CGSize kThreadListBarButtonItemImageSize; - (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]) { @@ -1337,7 +1347,7 @@ static CGSize kThreadListBarButtonItemImageSize; else { // Display cmd usage in text input as placeholder - self.inputToolbarView.placeholder = @"Usage: /join "; + self.inputToolbarView.placeholder = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } return YES; } @@ -2770,13 +2780,13 @@ static CGSize kThreadListBarButtonItemImageSize; } } -- (void)setupUserSuggestionViewIfNeeded +- (void)setupCompletionSuggestionViewIfNeeded { if(!self.isViewLoaded) { return; } - UIViewController *suggestionsViewController = self.userSuggestionCoordinator.toPresentable; + UIViewController *suggestionsViewController = self.completionSuggestionCoordinator.toPresentable; if (!suggestionsViewController) { @@ -2786,12 +2796,12 @@ static CGSize kThreadListBarButtonItemImageSize; [suggestionsViewController.view setTranslatesAutoresizingMaskIntoConstraints:NO]; [self addChildViewController:suggestionsViewController]; - [self.userSuggestionContainerView addSubview:suggestionsViewController.view]; + [self.completionSuggestionContainerView addSubview:suggestionsViewController.view]; - [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.userSuggestionContainerView.topAnchor], - [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.userSuggestionContainerView.leadingAnchor], - [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.userSuggestionContainerView.trailingAnchor], - [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.userSuggestionContainerView.bottomAnchor],]]; + [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.topAnchor], + [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.leadingAnchor], + [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.trailingAnchor], + [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.bottomAnchor],]]; [suggestionsViewController didMoveToParentViewController:self]; } @@ -5202,17 +5212,17 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView *)toolbarView { - [self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage]; + [self.completionSuggestionCoordinator processTextMessage:toolbarView.textMessage]; } - (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern { - [self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern]; + [self.completionSuggestionCoordinator processSuggestionPattern:suggestionPattern]; } -- (UserSuggestionViewModelContextWrapper *)userSuggestionContext +- (CompletionSuggestionViewModelContextWrapper *)completionSuggestionContext { - return [self.userSuggestionCoordinator sharedContext]; + return [self.completionSuggestionCoordinator sharedContext]; } - (MXMediaManager *)mediaManager @@ -5243,6 +5253,27 @@ static CGSize kThreadListBarButtonItemImageSize; }]; } +- (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]; @@ -5292,7 +5323,7 @@ static CGSize kThreadListBarButtonItemImageSize; 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; @@ -5317,6 +5348,11 @@ static CGSize kThreadListBarButtonItemImageSize; }]; } +- (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 @@ -6169,7 +6205,7 @@ static CGSize kThreadListBarButtonItemImageSize; 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]; } }; @@ -6421,21 +6457,10 @@ static CGSize kThreadListBarButtonItemImageSize; 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]; } }]]; @@ -7558,23 +7583,47 @@ static CGSize kThreadListBarButtonItemImageSize; 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(); @@ -8152,8 +8201,6 @@ static CGSize kThreadListBarButtonItemImageSize; [[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId]; } -#pragma mark - Bwi Measurements - - (void) finishTextMessageProfil:(PerformanceProfile*)profile { [profile stopMeasurement]; if( [profile isLogable] ) { @@ -8179,7 +8226,7 @@ static CGSize kThreadListBarButtonItemImageSize; #pragma mark - UserSuggestionCoordinatorBridgeDelegate -- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator +- (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didRequestMentionForMember:(MXRoomMember *)member textTrigger:(NSString *)textTrigger { @@ -8187,16 +8234,32 @@ static CGSize kThreadListBarButtonItemImageSize; [self mention:member]; } -- (void)userSuggestionCoordinatorBridgeDidRequestMentionForRoom:(UserSuggestionCoordinatorBridge *)coordinator +- (void)completionSuggestionCoordinatorBridgeDidRequestMentionForRoom:(CompletionSuggestionCoordinatorBridge *)coordinator textTrigger:(NSString *)textTrigger { [self removeTriggerTextFromComposer:textTrigger]; - [self.inputToolbarView pasteText:[UserSuggestionID.room stringByAppendingString:@" "]]; + [self.inputToolbarView pasteText:[CompletionSuggestionUserID.room stringByAppendingString:@" "]]; +} + +- (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator + didRequestCommand:(NSString *)command + textTrigger:(NSString *)textTrigger +{ + [self removeTriggerTextFromComposer:textTrigger]; + [self setCommand:command]; } - (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 @@ -8207,11 +8270,11 @@ static CGSize kThreadListBarButtonItemImageSize; } } -- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator didUpdateViewHeight:(CGFloat)height +- (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didUpdateViewHeight:(CGFloat)height { - if (self.userSuggestionContainerHeightConstraint.constant != height) + if (self.completionSuggestionContainerHeightConstraint.constant != height) { - self.userSuggestionContainerHeightConstraint.constant = height; + self.completionSuggestionContainerHeightConstraint.constant = height; [self.view layoutIfNeeded]; } diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 1a3b45b17..8c52a9619 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 /// @@ -91,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/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index b7a62a8bf..cdb656508 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -1,9 +1,8 @@ - + - - + @@ -13,6 +12,8 @@ + + @@ -32,8 +33,6 @@ - - @@ -48,20 +47,20 @@ - + - + - + - + @@ -237,11 +236,6 @@ - - - - - diff --git a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift index f490950df..2d66b1eb5 100644 --- a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift +++ b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift @@ -165,6 +165,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 8b3ebb708..83a073cdb 100644 --- a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift +++ b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift @@ -70,8 +70,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/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift index de15e91d3..f3f00f12f 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/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift index 47d981c86..eabd68c9e 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift @@ -23,6 +23,7 @@ class RoomInputToolbarTextView: UITextView { private var heightConstraint: NSLayoutConstraint! + private var pillViews = [UIView]() weak var toolbarDelegate: RoomInputToolbarTextViewDelegate? @@ -51,12 +52,18 @@ class RoomInputToolbarTextView: UITextView { } override var text: String! { + willSet { + flushPills() + } didSet { updateUI() } } override var attributedText: NSAttributedString! { + willSet { + flushPills() + } didSet { updateUI() } @@ -162,3 +169,17 @@ class RoomInputToolbarTextView: UITextView { delegate.onTouchUp(inside: delegate.rightInputToolbarButton) } } + +extension RoomInputToolbarTextView: PillViewFlusher { + func registerPillView(_ pillView: UIView) { + pillViews.append(pillView) + } + + private func flushPills() { + for view in pillViews { + view.alpha = 0.0 + view.removeFromSuperview() + } + pillViews.removeAll() + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index df71790be..5bbdeaa51 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/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index 0da9df3e6..960a18d82 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -70,6 +70,8 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; _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]; @@ -255,6 +257,10 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; 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) @@ -480,11 +486,22 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; [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/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index f3fc1111b..edd951fd6 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,10 +207,14 @@ 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) } + + func command(_ command: String) { + self.wysiwygViewModel.setCommand(name: command) + } // MARK: - Private @@ -219,7 +239,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 } @@ -277,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, 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) } ] @@ -334,7 +373,30 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } private func sendWysiwygMessage(content: WysiwygComposerContent) { - delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + if content.markdown.prefix(while: { $0 == "/" }).count == 1 { + let commandText: String + if content.markdown.hasPrefix(MXKSlashCommand.emote.cmd) { + // `/me` command works with markdown content + commandText = content.markdown + } else if #available(iOS 15.0, *) { + // Other commands should see pills replaced by matrix identifiers + commandText = PillsFormatter.stringByReplacingPills(in: self.wysiwygViewModel.textView.attributedText, mode: .identifier) + } else { + // Without Pills support, just use the raw text for command + commandText = self.wysiwygViewModel.textView.text + } + + // Fix potential command failures due to trailing characters + // or NBSP that are not properly handled by the command interpreter + let sanitizedCommand = commandText + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: String.nbsp, with: " ") + + delegate?.roomInputToolbarView?(self, sendCommand: sanitizedCommand) + } else { + delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + } + if isMaximised { minimise() } diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h deleted file mode 100644 index e9db3a583..000000000 --- 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 6f638bd78..000000000 --- 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/Secrets/Recover/SecretsRecoveryCoordinator.swift b/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift index 0c6a959fa..593f10139 100644 --- a/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift +++ b/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift @@ -122,11 +122,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/Secrets/Reset/SecretsResetCoordinator.swift b/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift index 402dab935..aa03a01ad 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 aa135b5fe..5b960342a 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 3559ac368..855cf7861 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift @@ -137,6 +137,8 @@ final class SecretsResetViewController: UIViewController { self.renderLoading() case .resetDone: self.renderLoaded() + case .resetCancelled: + self.renderCancelled() case .error(let error): self.render(error: error) } @@ -150,6 +152,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 2e8e7604c..62b0c686f 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 b7cb0acb8..128f90b19 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/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift index 1060d79ea..d58444206 100644 --- a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift +++ b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift @@ -74,12 +74,16 @@ 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) } } diff --git a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard index 774f4b22e..ab5ef6482 100644 --- a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard +++ b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard @@ -1,109 +1,109 @@ - - + + - + - + - - - + + + - - - + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + - + + - diff --git a/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift b/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift index c381b76eb..6c4e69d14 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) diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 7707a1cff..7c43a0dc3 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -228,8 +228,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) @@ -749,12 +748,6 @@ ChangePasswordCoordinatorBridgePresenterDelegate> if (BWIBuildSettings.shared.settingsScreenShowLabSettings) { Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS]; - /* bwi: disabled for our apps - 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]; @@ -771,9 +764,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> { [sectionLabs addRowWithTag:LABS_ENABLE_WYSIWYG_COMPOSER]; } - /* bwi: disabled for our apps - [sectionLabs addRowWithTag:LABS_ENABLE_VOICE_BROADCAST]; - */ + // bwi: disabled for our apps + if (BWIBuildSettings.shared.enableLabFeatureVoiceBroadcasts) + { + [sectionLabs addRowWithTag:LABS_ENABLE_VOICE_BROADCAST]; + } sectionLabs.headerTitle = [VectorL10n settingsLabs]; if (sectionLabs.hasAnyRows) { @@ -2935,18 +2930,6 @@ ChangePasswordCoordinatorBridgePresenterDelegate> [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; } } @@ -3819,30 +3802,6 @@ ChangePasswordCoordinatorBridgePresenterDelegate> 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; @@ -4715,6 +4674,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> || (language == nil && [NSBundle mxk_language])) { [NSBundle mxk_setLanguage:language]; + UIApplication.sharedApplication.accessibilityLanguage = language; // Store user settings NSUserDefaults *sharedUserDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift index b99eec0c3..be4ed85dc 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 f9c881396..3014cd711 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/Riot/Modules/UserDevices/UsersDevicesViewController.m b/Riot/Modules/UserDevices/UsersDevicesViewController.m index 3b5b8c9a8..fcd7bd567 100644 --- a/Riot/Modules/UserDevices/UsersDevicesViewController.m +++ b/Riot/Modules/UserDevices/UsersDevicesViewController.m @@ -273,22 +273,12 @@ - (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/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index ef7892ecf..d144ac1db 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/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index a84023879..a16a2857e 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -581,8 +581,13 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent { // 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; } @@ -678,9 +683,11 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent 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]) { @@ -696,6 +703,7 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent else { textColor = ThemeService.shared.theme.colors.alert; + colorIdentifier = @"alert"; UIImage *liveImage = AssetImages.voiceBroadcastLive.image; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; @@ -725,6 +733,12 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent } [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 000000000..c0010c97d --- /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/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift index 8a68f5b4d..2cf70a387 100644 --- a/RiotNSE/NotificationService.swift +++ b/RiotNSE/NotificationService.swift @@ -42,7 +42,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] = [:] @@ -269,13 +268,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 @@ -292,16 +290,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 22d0063be..0e1c74cf7 100644 --- a/RiotShareExtension/Shared/ShareManager.m +++ b/RiotShareExtension/Shared/ShareManager.m @@ -102,11 +102,6 @@ static MXSession *fakeSession; [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/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h b/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h index 9a7cf3af1..618849c4d 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 cc5c6853c..242a7e373 100644 --- a/RiotShareExtension/target.yml +++ b/RiotShareExtension/target.yml @@ -94,3 +94,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/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index e59aa0189..34d7adb90 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/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift index 9c9100087..9cf127bb7 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/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index f4ebbc004..b2f161cbf 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/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 91bf25a51..0afe12c02 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/CompletionSuggestion/CompletionSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift new file mode 100644 index 000000000..8476834b9 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -0,0 +1,43 @@ +// +// 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 + +enum CompletionSuggestionViewAction { + case selectedItem(CompletionSuggestionViewStateItem) +} + +enum CompletionSuggestionViewModelResult { + case selectedItemWithIdentifier(String) +} + +enum CompletionSuggestionViewStateItem: Identifiable { + case command(name: String, parametersFormat: String, description: String) + case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) + + var id: String { + switch self { + case .command(let name, _, _): + return name + case .user(let id, _, _): + return id + } + } +} + +struct CompletionSuggestionViewState: BindableState { + var items: [CompletionSuggestionViewStateItem] +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift new file mode 100644 index 000000000..5bdd72088 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -0,0 +1,95 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +enum MockCompletionSuggestionScreenState: MockScreenState, CaseIterable { + case multipleResults + + private static var members: [RoomMembersProviderMember]! + + var screenType: Any.Type { + CompletionSuggestionList.self + } + + var screenView: ([Any], AnyView) { + let service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self) + let listViewModel = CompletionSuggestionViewModel(completionSuggestionService: service) + + let viewModel = CompletionSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in + service.processTextMessage(textMessage) + } + + return ( + [service, listViewModel], + AnyView(CompletionSuggestionListWithInput(viewModel: viewModel) + .environmentObject(AvatarViewModel.withMockedServices())) + ) + } +} + +extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { + var canMentionRoom: Bool { false } + + func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { + if Self.members == nil { + Self.members = generateUsersWithCount(10) + } + + members(Self.members) + } + + private func generateUsersWithCount(_ count: UInt) -> [RoomMembersProviderMember] { + (0.. Void) { + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action", + requiresAdminPowerLevel: false) + ]) + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift new file mode 100644 index 000000000..53d2c6975 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -0,0 +1,85 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +typealias CompletionSuggestionViewModelType = StateStoreViewModel + +class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, CompletionSuggestionViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let completionSuggestionService: CompletionSuggestionServiceProtocol + + // MARK: Public + + var sharedContext: CompletionSuggestionViewModelType.Context { + context + } + + var completion: ((CompletionSuggestionViewModelResult) -> Void)? + + // MARK: - Setup + + init(completionSuggestionService: CompletionSuggestionServiceProtocol) { + self.completionSuggestionService = completionSuggestionService + + let items = completionSuggestionService.items.value.map { suggestionItem in + switch suggestionItem { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + + super.init(initialViewState: CompletionSuggestionViewState(items: items)) + + completionSuggestionService.items.sink { [weak self] items in + self?.state.items = items.map { item in + switch item { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + }.store(in: &cancellables) + } + + // MARK: - Public + + override func process(viewAction: CompletionSuggestionViewAction) { + switch viewAction { + case .selectedItem(let item): + completion?(.selectedItemWithIdentifier(item.id)) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift similarity index 67% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift index 33aa5bb79..d7c51909f 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift @@ -16,10 +16,10 @@ import Foundation -protocol UserSuggestionViewModelProtocol { - /// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple - /// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` +protocol CompletionSuggestionViewModelProtocol { + /// Defines a shared context providing the ability to use a single `CompletionSuggestionViewModel` for multiple + /// `CompletionSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` /// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload the data. - var sharedContext: UserSuggestionViewModelType.Context { get } - var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } + var sharedContext: CompletionSuggestionViewModelType.Context { get } + var completion: ((CompletionSuggestionViewModelResult) -> Void)? { get set } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift new file mode 100644 index 000000000..4196da77a --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -0,0 +1,272 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import SwiftUI +import UIKit +import WysiwygComposer + +protocol CompletionSuggestionCoordinatorDelegate: AnyObject { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) +} + +struct CompletionSuggestionCoordinatorParameters { + let mediaManager: MXMediaManager + let room: MXRoom + let userID: String +} + +/// Wrapper around `CompletionSuggestionViewModelType.Context` to pass it through obj-c. +final class CompletionSuggestionViewModelContextWrapper: NSObject { + let context: CompletionSuggestionViewModelType.Context + + init(context: CompletionSuggestionViewModelType.Context) { + self.context = context + } +} + +final class CompletionSuggestionCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: CompletionSuggestionCoordinatorParameters + + private var completionSuggestionHostingController: UIHostingController + private var completionSuggestionService: CompletionSuggestionServiceProtocol + private var completionSuggestionViewModel: CompletionSuggestionViewModelProtocol + private var roomMemberProvider: CompletionSuggestionCoordinatorRoomMemberProvider + private var commandProvider: CompletionSuggestionCoordinatorCommandProvider + + private var cancellables = Set() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: (() -> Void)? + + weak var delegate: CompletionSuggestionCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: CompletionSuggestionCoordinatorParameters) { + self.parameters = parameters + + roomMemberProvider = CompletionSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) + commandProvider = CompletionSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + completionSuggestionService = CompletionSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) + + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) + + completionSuggestionViewModel = viewModel + completionSuggestionHostingController = VectorHostingController(rootView: view) + + completionSuggestionViewModel.completion = { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .selectedItemWithIdentifier(let identifier): + if identifier == CompletionSuggestionUserID.room { + self.delegate?.completionSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.completionSuggestionService.currentTextTrigger) + return + } + + if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0.cmd == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.cmd, textTrigger: self.completionSuggestionService.currentTextTrigger) + } + } + } + + completionSuggestionService.items.sink { [weak self] _ in + guard let self = self else { return } + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) + }.store(in: &cancellables) + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionService.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + completionSuggestionService.processSuggestionPattern(suggestionPattern) + } + + // MARK: - Public + + func start() { } + + func toPresentable() -> UIViewController { + completionSuggestionHostingController + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + CompletionSuggestionViewModelContextWrapper(context: completionSuggestionViewModel.sharedContext) + } + + // MARK: - Private + + private func calculateViewHeight() -> CGFloat { + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) + + let controller = VectorHostingController(rootView: view) + guard let view = controller.view else { + return 0 + } + view.isHidden = true + + toPresentable().view.addSubview(view) + controller.didMove(toParent: toPresentable()) + + view.setNeedsLayout() + view.layoutIfNeeded() + + let result = view.intrinsicContentSize.height + + controller.didMove(toParent: nil) + view.removeFromSuperview() + + return result + } +} + +private class CompletionSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { + private let room: MXRoom + private let userID: String + + var roomMembers: [MXRoomMember] = [] + var canMentionRoom = false + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + /// Gets the power levels for the room to update suggestions accordingly. + func updateWithPowerLevels() { + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + let mentionRoomPowerLevel = powerLevels.minimumPowerLevel(forNotifications: kMXRoomPowerLevelNotificationsRoomKey, + defaultPower: kMXRoomPowerLevelNotificationsRoomDefault) + self.canMentionRoom = userPowerLevel >= mentionRoomPowerLevel + } + } + + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { + room.members { [weak self] roomMembers in + guard let self = self, let joinedMembers = roomMembers?.joinedMembers else { + return + } + self.roomMembers = joinedMembers + members(self.roomMembersToProviderMembers(joinedMembers)) + } lazyLoadedMembers: { [weak self] lazyRoomMembers in + guard let self = self, let joinedMembers = lazyRoomMembers?.joinedMembers else { + return + } + self.roomMembers = joinedMembers + members(self.roomMembersToProviderMembers(joinedMembers)) + } failure: { error in + MXLog.error("[CompletionSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) + } + } + + private func roomMembersToProviderMembers(_ roomMembers: [MXRoomMember]) -> [RoomMembersProviderMember] { + roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } + } +} + +private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { + private let room: MXRoom + private let userID: String + + var commands = MXKSlashCommand.allCases + var isRoomAdmin = false + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + func updateWithPowerLevels() { + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + self.isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin + } + } + + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description, requiresAdminPowerLevel: $0.requiresAdminPowerLevel) }) + } +} + +private extension MXKSlashCommand { + var description: String { + switch self { + case .changeDisplayName: + return VectorL10n.roomCommandChangeDisplayNameDescription + case .emote: + return VectorL10n.roomCommandEmoteDescription + case .joinRoom: + return VectorL10n.roomCommandJoinRoomDescription + case .partRoom: + return VectorL10n.roomCommandPartRoomDescription + case .inviteUser: + return VectorL10n.roomCommandInviteUserDescription + case .kickUser: + return VectorL10n.roomCommandKickUserDescription + case .banUser: + return VectorL10n.roomCommandBanUserDescription + case .unbanUser: + return VectorL10n.roomCommandUnbanUserDescription + case .setUserPowerLevel: + return VectorL10n.roomCommandSetUserPowerLevelDescription + case .resetUserPowerLevel: + return VectorL10n.roomCommandResetUserPowerLevelDescription + case .changeRoomTopic: + return VectorL10n.roomCommandChangeRoomTopicDescription + case .discardSession: + return VectorL10n.roomCommandDiscardSessionDescription + } + } + + // Note: for now only filter out `/op` and `/deop` (same as Element-Web), + // but we could use power level for ban/invite/etc to filter further. + var requiresAdminPowerLevel: Bool { + switch self { + case .setUserPowerLevel, .resetUserPowerLevel: + return true + default: + return false + } + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift new file mode 100644 index 000000000..83a9ed94c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift @@ -0,0 +1,79 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@objc +protocol CompletionSuggestionCoordinatorBridgeDelegate: AnyObject { + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinatorBridge, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) +} + +@objcMembers +final class CompletionSuggestionCoordinatorBridge: NSObject { + private var _completionSuggestionCoordinator: Any? + fileprivate var completionSuggestionCoordinator: CompletionSuggestionCoordinator { + _completionSuggestionCoordinator as! CompletionSuggestionCoordinator + } + + weak var delegate: CompletionSuggestionCoordinatorBridgeDelegate? + + init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { + let parameters = CompletionSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) + let completionSuggestionCoordinator = CompletionSuggestionCoordinator(parameters: parameters) + _completionSuggestionCoordinator = completionSuggestionCoordinator + + super.init() + + completionSuggestionCoordinator.delegate = self + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionCoordinator.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { + completionSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) + } + + func toPresentable() -> UIViewController? { + completionSuggestionCoordinator.toPresentable() + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + completionSuggestionCoordinator.sharedContext() + } +} + +extension CompletionSuggestionCoordinatorBridge: CompletionSuggestionCoordinatorDelegate { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) + } + + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { + delegate?.completionSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift new file mode 100644 index 000000000..5ded36c2c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -0,0 +1,231 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import WysiwygComposer + +struct RoomMembersProviderMember { + var userId: String + var displayName: String + var avatarUrl: String +} + +struct CommandsProviderCommand { + let name: String + let parametersFormat: String + let description: String + let requiresAdminPowerLevel: Bool +} + +class CompletionSuggestionUserID: NSObject { + /// A special case added for suggesting `@room` mentions. + @objc static let room = "@room" +} + +protocol RoomMembersProviderProtocol { + var canMentionRoom: Bool { get } + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) +} + +protocol CommandsProviderProtocol { + var isRoomAdmin: Bool { get } + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) +} + +struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol { + let userId: String + let displayName: String? + let avatarUrl: String? +} + +struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { + let name: String + let parametersFormat: String + let description: String +} + +class CompletionSuggestionService: CompletionSuggestionServiceProtocol { + // MARK: - Properties + + // MARK: Private + + private let roomMemberProvider: RoomMembersProviderProtocol + private let commandProvider: CommandsProviderProtocol + + private var suggestionItems: [CompletionSuggestionItem] = [] + private let currentTextTriggerSubject = CurrentValueSubject(nil) + private var cancellables = Set() + + // MARK: Public + + var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) + + var currentTextTrigger: String? { + currentTextTriggerSubject.value?.asString() + } + + // MARK: - Setup + + init(roomMemberProvider: RoomMembersProviderProtocol, + commandProvider: CommandsProviderProtocol, + shouldDebounce: Bool = true) { + self.roomMemberProvider = roomMemberProvider + self.commandProvider = commandProvider + + if shouldDebounce { + currentTextTriggerSubject + .debounce(for: 0.5, scheduler: RunLoop.main) + .removeDuplicates() + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } + .store(in: &cancellables) + } else { + currentTextTriggerSubject + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } + .store(in: &cancellables) + } + } + + // MARK: - CompletionSuggestionServiceProtocol + + func processTextMessage(_ textMessage: String?) { + guard let textMessage = textMessage, + let textTrigger = textMessage.currentTextTrigger + else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + currentTextTriggerSubject.send(textTrigger) + } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + guard let suggestionPattern else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + switch suggestionPattern.key { + case .at: + currentTextTriggerSubject.send(TextTrigger(key: .at, text: suggestionPattern.text)) + case .hash: + // No room suggestion support yet + items.send([]) + currentTextTriggerSubject.send(nil) + case .slash: + currentTextTriggerSubject.send(TextTrigger(key: .slash, text: suggestionPattern.text)) + } + } + + // MARK: - Private + + private func fetchAndFilterSuggestionsForTextTrigger(_ textTrigger: TextTrigger?) { + guard let textTrigger else { return } + + switch textTrigger.key { + case .at: + roomMemberProvider.fetchMembers { [weak self] members in + guard let self = self else { + return + } + + self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in + CompletionSuggestionItem.user(value: CompletionSuggestionServiceUserItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) + } + + self.items.send(self.suggestionItems.filter { item in + guard case let .user(completionSuggestionUserItem) = item else { return false } + + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(textTrigger.text.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(textTrigger.text.lowercased()) + + return (containedInUsername || containedInDisplayName) + }) + } + case .slash: + commandProvider.fetchCommands { [weak self] commands in + guard let self else { return } + + self.suggestionItems = commands.filtered(isRoomAdmin: self.commandProvider.isRoomAdmin).map { command in + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( + name: command.name, + parametersFormat: command.parametersFormat, + description: command.description + )) + } + + if textTrigger.text.isEmpty { + // A single `/` will display all available commands. + self.items.send(self.suggestionItems) + } else { + self.items.send(self.suggestionItems.filter { item in + guard case let .command(commandSuggestion) = item else { return false } + + return commandSuggestion.name.lowercased().contains(textTrigger.text.lowercased()) + }) + } + } + } + } +} + +extension Array where Element == RoomMembersProviderMember { + /// Returns the array with an additional member that represents an `@room` mention. + func withRoom(_ canMentionRoom: Bool) -> Self { + guard canMentionRoom else { return self } + return self + [RoomMembersProviderMember(userId: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] + } +} + +extension Array where Element == CommandsProviderCommand { + func filtered(isRoomAdmin: Bool) -> Self { + guard !isRoomAdmin else { return self } + return filter { !$0.requiresAdminPowerLevel } + } +} + +private enum SuggestionKey: Character { + case at = "@" + case slash = "/" +} + +private struct TextTrigger: Equatable { + let key: SuggestionKey + let text: String + + func asString() -> String { + String(key.rawValue) + text + } +} + +private extension String { + // Returns current completion suggestion for a text message, if any. + var currentTextTrigger: TextTrigger? { + let components = components(separatedBy: .whitespaces) + guard var lastComponent = components.last, + lastComponent.count > 0, + let suggestionKey = SuggestionKey(rawValue: lastComponent.removeFirst()), + // If a second character exists and is the same as the key it shouldn't trigger. + lastComponent.first != suggestionKey.rawValue, + // Slash commands should be displayed only if there is a single component + !(suggestionKey == .slash && components.count > 1) + else { return nil } + + return TextTrigger(key: suggestionKey, text: lastComponent) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift similarity index 65% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 43006dbed..3930c59d1 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -18,14 +18,25 @@ import Combine import Foundation import WysiwygComposer -protocol UserSuggestionItemProtocol: Avatarable { +protocol CompletionSuggestionUserItemProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } } -protocol UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never> { get } +protocol CompletionSuggestionCommandItemProtocol { + var name: String { get } + var parametersFormat: String { get } + var description: String { get } +} + +enum CompletionSuggestionItem { + case command(value: CompletionSuggestionCommandItemProtocol) + case user(value: CompletionSuggestionUserItemProtocol) +} + +protocol CompletionSuggestionServiceProtocol { + var items: CurrentValueSubject<[CompletionSuggestionItem], Never> { get } var currentTextTrigger: String? { get } @@ -35,7 +46,7 @@ protocol UserSuggestionServiceProtocol { // MARK: Avatarable -extension UserSuggestionItemProtocol { +extension CompletionSuggestionUserItemProtocol { var mxContentUri: String? { avatarUrl } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift similarity index 79% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift index f44744a9c..5ec9d4b9b 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift @@ -17,9 +17,9 @@ import RiotSwiftUI import XCTest -class UserSuggestionUITests: MockScreenTestCase { - func testUserSuggestionScreen() throws { - app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title) +class CompletionSuggestionUITests: MockScreenTestCase { + func testCompletionSuggestionScreen() throws { + app.goToScreenWithIdentifier(MockCompletionSuggestionScreenState.multipleResults.title) let firstButton = app.buttons["displayNameText-userIdText"].firstMatch XCTAssert(firstButton.waitForExistence(timeout: 10)) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift new file mode 100644 index 000000000..90542868d --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -0,0 +1,266 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import XCTest + +@testable import RiotSwiftUI + +class CompletionSuggestionServiceTests: XCTestCase { + var service: CompletionSuggestionService! + var canMentionRoom = false + var isRoomAdmin = false + + override func setUp() { + service = CompletionSuggestionService(roomMemberProvider: self, + commandProvider: self, + shouldDebounce: false) + canMentionRoom = false + isRoomAdmin = false + } + + // MARK: - User suggestions + + func testAlice() { + service.processTextMessage("@Al") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@al") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@ice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@alice:matrix.org") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + } + + func testBob() { + service.processTextMessage("@ob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + + service.processTextMessage("@ob:") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + + service.processTextMessage("@b:matrix") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + } + + func testBoth() { + service.processTextMessage("@:matrix") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") + + service.processTextMessage("@.org") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") + } + + func testEmptyResult() { + service.processTextMessage("Lorem ipsum idolor") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@@") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("alice@matrix.org") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testStuff() { + service.processTextMessage("@@") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testWhitespaces() { + service.processTextMessage("") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("\n") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" \n ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@A ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" @A ") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testRoomWithoutPower() { + // Given a user without the power to mention a room. + canMentionRoom = false + + // Given a user without the power to mention a room. + service.processTextMessage("@ro") + + // Then the completion for a room mention should not be shown. + XCTAssertTrue(service.items.value.isEmpty) + } + + func testRoomWithPower() { + // Given a user with the power to mention a room. + canMentionRoom = true + + // Given a user with the power to mention a room. + service.processTextMessage("@ro") + + // Then the completion for a room mention should be shown. + XCTAssertEqual(service.items.value.first?.asUser?.userId, CompletionSuggestionUserID.room) + } + + // MARK: - Command suggestions + + func testJoin() { + service.processTextMessage("/jo") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/joi") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/join") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/oin") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + } + + func testInvite() { + service.processTextMessage("/inv") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/invite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/vite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + } + + func testMultipleResults() { + service.processTextMessage("/in") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/invite", "/join"] + ) + } + + func testDoubleSlashDontTrigger() { + service.processTextMessage("//") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testNonLeadingSlashCommandDontTrigger() { + service.processTextMessage("test /joi") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreNotAvailable() { + isRoomAdmin = false + + service.processTextMessage("/op") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreAvailable() { + isRoomAdmin = true + + service.processTextMessage("/op") + XCTAssertEqual(service.items.value.compactMap { $0.asCommand?.name }, ["/op", "/deop"]) + } + + func testDisplayAllCommandsAsStandardUser() { + isRoomAdmin = false + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/me"] + ) + } + + func testDisplayAllCommandsAsAdmin() { + isRoomAdmin = true + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/op", "/deop", "/me"] + ) + } +} + +extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { + let users = [("Alice", "@alice:matrix.org"), + ("Bob", "@bob:matrix.org")] + + members(users.map { user in + RoomMembersProviderMember(userId: user.1, displayName: user.0, avatarUrl: "") + }) + } +} + +extension CompletionSuggestionServiceTests: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action", + requiresAdminPowerLevel: false) + ]) + } +} + +extension CompletionSuggestionItem { + var asUser: CompletionSuggestionUserItemProtocol? { + if case let .user(value) = self { return value } else { return nil } + } + + var asCommand: CompletionSuggestionCommandItemProtocol? { + if case let .command(value) = self { return value } else { return nil } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift similarity index 83% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift index e509a58b3..cf8e34e02 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift @@ -16,7 +16,7 @@ import SwiftUI -struct UserSuggestionList: View { +struct CompletionSuggestionList: View { private enum Constants { static let topPadding: CGFloat = 8.0 static let listItemPadding: CGFloat = 4.0 @@ -30,7 +30,7 @@ struct UserSuggestionList: View { to the list items in order to be as close as possible as the `UITableView` display. */ - @available (iOS 16.0, *) + @available(iOS 16.0, *) static let collectionViewPaddingCorrection: CGFloat = -5.0 } @@ -43,17 +43,15 @@ struct UserSuggestionList: View { // MARK: Public - @ObservedObject var viewModel: UserSuggestionViewModel.Context - var showBackgroundShadow: Bool = true + @ObservedObject var viewModel: CompletionSuggestionViewModel.Context + var showBackgroundShadow = true var body: some View { if viewModel.viewState.items.isEmpty { EmptyView() } else { ZStack { - UserSuggestionListItem(avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), - displayName: "Prototype", - userId: "Prototype") + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user(id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), displayName: "Prototype")) .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() if showBackgroundShadow { @@ -76,12 +74,8 @@ struct UserSuggestionList: View { Button { viewModel.send(viewAction: .selectedItem(item)) } label: { - UserSuggestionListItem( - avatar: item.avatar, - displayName: item.displayName, - userId: item.id - ) - .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) + CompletionSuggestionListItem(content: item) + .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) } } .listStyle(PlainListStyle()) @@ -135,8 +129,8 @@ private struct BackgroundView: View { // MARK: - Previews -struct UserSuggestion_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestion_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift new file mode 100644 index 000000000..4a1616189 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -0,0 +1,84 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct CompletionSuggestionListItem: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let content: CompletionSuggestionViewStateItem + + var body: some View { + HStack { + switch content { + case .command(let name, let parametersFormat, let description): + VStack(alignment: .leading) { + HStack { + Text(name) + .font(theme.fonts.body.bold()) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "nameText") + .lineLimit(1) + Text(parametersFormat) + .font(theme.fonts.body.italic()) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "parametersFormatText") + .lineLimit(1) + } + Text(description) + .font(theme.fonts.body) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "descriptionText") + } + case .user(let userId, let avatar, let displayName): + if let avatar = avatar { + AvatarImage(avatarData: avatar, size: .medium) + } + VStack(alignment: .leading) { + Text(displayName ?? "") + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "displayNameText") + .lineLimit(1) + Text(userId) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "userIdText") + .lineLimit(1) + } + } + } + } +} + +// MARK: - Previews + +struct CompletionSuggestionHeader_Previews: PreviewProvider { + static var previews: some View { + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( + id: "@alice:matrix.org", + avatar: MockAvatarInput.example, + displayName: "Alice" + )) + .environmentObject(AvatarViewModel.withMockedServices()) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift similarity index 70% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 176be8ec4..223b4fbc6 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -16,31 +16,31 @@ import SwiftUI -struct UserSuggestionListWithInputViewModel { - let listViewModel: UserSuggestionViewModel +struct CompletionSuggestionListWithInputViewModel { + let listViewModel: CompletionSuggestionViewModel let callback: (String) -> Void } -struct UserSuggestionListWithInput: View { +struct CompletionSuggestionListWithInput: View { // MARK: - Properties // MARK: Private // MARK: Public - var viewModel: UserSuggestionListWithInputViewModel + var viewModel: CompletionSuggestionListWithInputViewModel @State private var inputText = "" var body: some View { VStack(spacing: 0.0) { - UserSuggestionList(viewModel: viewModel.listViewModel.context) - TextField("Search for user", text: $inputText) + CompletionSuggestionList(viewModel: viewModel.listViewModel.context) + TextField("Search for user/command", text: $inputText) .background(Color.white) .onChange(of: inputText, perform: viewModel.callback) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding([.leading, .trailing]) .onAppear { - inputText = "@-" // Make the list show all available mock results + inputText = "@-" // Make the list show all available user mock results } } } @@ -48,8 +48,8 @@ struct UserSuggestionListWithInput: View { // MARK: - Previews -struct UserSuggestionListWithInput_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestionListWithInput_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift index 6bdc5ebc5..335ff3196 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/Test/Unit/ComposerLinkActionViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift index 2407eccc4..3fbb8d564 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/LinkAction/ViewModel/ComposerLinkActionViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift index 367417282..d16dd7212 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/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 8b5327b14..79322b78a 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 6f7bab165..33d73ef4a 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/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift index e4d5b595d..c68cd7783 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"))) } } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 278f5e447..30d88ca3d 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/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift b/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift index 2fa3ca831..26eaf6485 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift @@ -49,7 +49,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 7f6d8c5f6..c4471844e 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift @@ -209,13 +209,13 @@ 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) @@ -225,9 +225,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 94fe5b28a..e2202524b 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -29,11 +29,10 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel // MARK: Private - private let navigationRouter: NavigationRouterType? 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() @@ -44,25 +43,18 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel // MARK: - Setup - // FRROT show participants needs a navigation router as it is a button click that creates a new View - init(parameters: TimelinePollCoordinatorParameters, navigationRouter: NavigationRouterType? = nil) throws { + init(parameters: TimelinePollCoordinatorParameters) throws { self.parameters = parameters - self.navigationRouter = navigationRouter + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading) + try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent, delegate: self) - try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent) - pollAggregator.delegate = self - - viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll)) viewModel.completion = { [weak self] result in guard let self = self else { return } switch result { case .selectedAnswerOptionsWithIdentifiers(let identifiers): self.selectedAnswerIdentifiersSubject.send(identifiers) - case .showParticipants: - self.showParticipantsView() - } } @@ -99,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() { @@ -112,34 +104,26 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } } - func showParticipantsView() { - if let navigationRouter = navigationRouter { - let parameters = PollParticipantDetailsCoordinatorParameters(room: parameters.room, poll: pollAggregator.poll) - let coordinator = PollParticipantDetailsCoordinator(parameters: parameters) - - add(childCoordinator: coordinator) - - if navigationRouter.modules.isEmpty == false { - navigationRouter.push(coordinator, animated: true, popCompletion: nil) - } else { - navigationRouter.setRootModule(coordinator, popCompletion: nil) - } - - coordinator.start() - } - } - // MARK: - PollAggregatorDelegate func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) { - viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll)) + if let poll = aggregator.poll { + viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll))) + } } func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { } - func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { } + func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { + guard let poll = aggregator.poll else { + return + } + viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll))) + } - func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { } + func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { + viewModel.updateWithPollDetailsState(.errored) + } // MARK: - Private @@ -158,8 +142,7 @@ extension TimelinePollDetails { text: pollAnswerOption.text, count: pollAnswerOption.count, winner: pollAnswerOption.isWinner, - selected: pollAnswerOption.isCurrentUserSelection, - voters:pollAnswerOption.voters) + selected: pollAnswerOption.isCurrentUserSelection) } self.init(id: poll.id, @@ -169,7 +152,6 @@ extension TimelinePollDetails { startDate: poll.startDate, totalAnswerCount: poll.totalAnswerCount, type: poll.kind.timelinePollType, - showParticipants: poll.showParticipants, eventType: eventType, maxAllowedSelections: poll.maxAllowedSelections, hasBeenEdited: poll.hasBeenEdited, diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift index a36a7d092..2fd2b032f 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 aaf3e5d67..3c4d06e96 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift @@ -39,6 +39,12 @@ enum TimelinePollEventType { case ended } +enum TimelinePollDetailsState { + case loading + case loaded(TimelinePollDetails) + case errored +} + struct TimelinePollAnswerOption: Identifiable { var id: String var text: String @@ -99,7 +105,7 @@ struct TimelinePollDetails { extension TimelinePollDetails: Identifiable { } struct TimelinePollViewState: BindableState { - var poll: TimelinePollDetails + var pollState: TimelinePollDetailsState var bindings: TimelinePollViewStateBindings } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift index f5910edf8..9522e9fce 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 @@ -46,7 +49,20 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { hasBeenEdited: false, hasDecryptionError: false) - let viewModel = TimelinePollViewModel(timelinePollDetails: poll) + let viewModel: TimelinePollViewModel + + switch self { + case .loading: + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading) + case .invalidStartEvent: + viewModel = TimelinePollViewModel(timelinePollDetailsState: .errored) + default: + 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 0a0aa6a6d..ea093ac05 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())) + 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) @@ -56,8 +56,8 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - TimelinePollViewModelProtocol - func updateWithPollDetails(_ pollDetails: TimelinePollDetails) { - state.poll = pollDetails + func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState) { + state.pollState = pollDetailsState } func showAnsweringFailure() { @@ -75,33 +75,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 } @@ -109,22 +116,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 492f7f7a3..ade681438 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift @@ -20,7 +20,7 @@ protocol TimelinePollViewModelProtocol { var context: TimelinePollViewModelType.Context { get } var completion: ((TimelinePollViewModelResult) -> Void)? { get set } - func updateWithPollDetails(_ pollDetails: TimelinePollDetails) + 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 358e5752d..db37651bb 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift @@ -28,8 +28,23 @@ struct TimelinePollView: View { @ObservedObject var viewModel: TimelinePollViewModel.Context var body: some View { - let poll = viewModel.viewState.poll - + Group { + switch viewModel.viewState.pollState { + case .loading: + TimelinePollMessageView(message: VectorL10n.pollTimelineLoading) + case .loaded(let poll): + pollContent(poll) + case .errored: + TimelinePollMessageView(message: VectorL10n.pollTimelineReplyEndedPoll) + } + } + .alert(item: $viewModel.alertInfo) { info in + info.alert + } + } + + @ViewBuilder + private func pollContent(_ poll: TimelinePollDetails) -> some View { VStack(alignment: .leading, spacing: 16.0) { if poll.representsPollEndedEvent { Text(VectorL10n.pollTimelineEndedText) @@ -40,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) @@ -54,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) @@ -74,14 +89,9 @@ struct TimelinePollView: View { } .padding([.horizontal, .top], 2.0) .padding([.bottom]) - .alert(item: $viewModel.alertInfo) { info in - info.alert - } } - private var totalVotesString: String { - let poll = viewModel.viewState.poll - + private func totalVotesString(_ poll: TimelinePollDetails) -> String { if poll.hasDecryptionError, poll.totalAnswerCount > 0 { return VectorL10n.pollTimelineDecryptionError } @@ -108,8 +118,8 @@ struct TimelinePollView: View { } } - private var editedText: String { - viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : "" + private func editedText(_ poll: TimelinePollDetails) -> String { + poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : "" } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift deleted file mode 100644 index a2156cd89..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import Foundation -import SwiftUI -import UIKit -import WysiwygComposer - -protocol UserSuggestionCoordinatorDelegate: AnyObject { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) -} - -struct UserSuggestionCoordinatorParameters { - let mediaManager: MXMediaManager - let room: MXRoom - let userID: String -} - -/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c. -final class UserSuggestionViewModelContextWrapper: NSObject { - let context: UserSuggestionViewModelType.Context - - init(context: UserSuggestionViewModelType.Context) { - self.context = context - } -} - -final class UserSuggestionCoordinator: Coordinator, Presentable { - // MARK: - Properties - - // MARK: Private - - private let parameters: UserSuggestionCoordinatorParameters - - private var userSuggestionHostingController: UIHostingController - private var userSuggestionService: UserSuggestionServiceProtocol - private var userSuggestionViewModel: UserSuggestionViewModelProtocol - private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider - - private var cancellables = Set() - - // MARK: Public - - // Must be used only internally - var childCoordinators: [Coordinator] = [] - var completion: (() -> Void)? - - weak var delegate: UserSuggestionCoordinatorDelegate? - - // MARK: - Setup - - init(parameters: UserSuggestionCoordinatorParameters) { - self.parameters = parameters - - roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider) - - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - - userSuggestionViewModel = viewModel - userSuggestionHostingController = VectorHostingController(rootView: view) - - userSuggestionViewModel.completion = { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .selectedItemWithIdentifier(let identifier): - if identifier == UserSuggestionID.room { - self.delegate?.userSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.userSuggestionService.currentTextTrigger) - return - } - - guard let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first else { - return - } - - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) - } - } - - userSuggestionService.items.sink { [weak self] _ in - guard let self = self else { return } - self.delegate?.userSuggestionCoordinator(self, - didUpdateViewHeight: self.calculateViewHeight()) - }.store(in: &cancellables) - } - - func processTextMessage(_ textMessage: String) { - userSuggestionService.processTextMessage(textMessage) - } - - func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - userSuggestionService.processSuggestionPattern(suggestionPattern) - } - - // MARK: - Public - - func start() { } - - func toPresentable() -> UIViewController { - userSuggestionHostingController - } - - func sharedContext() -> UserSuggestionViewModelContextWrapper { - UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) - } - - // MARK: - Private - - private func calculateViewHeight() -> CGFloat { - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - - let controller = VectorHostingController(rootView: view) - guard let view = controller.view else { - return 0 - } - view.isHidden = true - - toPresentable().view.addSubview(view) - controller.didMove(toParent: toPresentable()) - - view.setNeedsLayout() - view.layoutIfNeeded() - - let result = view.intrinsicContentSize.height - - controller.didMove(toParent: nil) - view.removeFromSuperview() - - return result - } -} - -private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { - private let room: MXRoom - private let userID: String - - var roomMembers: [MXRoomMember] = [] - var canMentionRoom = false - - init(room: MXRoom, userID: String) { - self.room = room - self.userID = userID - updateWithPowerLevels() - } - - /// Gets the power levels for the room to update suggestions accordingly. - func updateWithPowerLevels() { - room.state { [weak self] state in - guard let self, let powerLevels = state?.powerLevels else { return } - let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - let mentionRoomPowerLevel = powerLevels.minimumPowerLevel(forNotifications: kMXRoomPowerLevelNotificationsRoomKey, - defaultPower: kMXRoomPowerLevelNotificationsRoomDefault) - self.canMentionRoom = userPowerLevel >= mentionRoomPowerLevel - } - } - - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { - room.members { [weak self] roomMembers in - guard let self = self, let joinedMembers = roomMembers?.joinedMembers else { - return - } - self.roomMembers = joinedMembers - members(self.roomMembersToProviderMembers(joinedMembers)) - } lazyLoadedMembers: { [weak self] lazyRoomMembers in - guard let self = self, let joinedMembers = lazyRoomMembers?.joinedMembers else { - return - } - self.roomMembers = joinedMembers - members(self.roomMembersToProviderMembers(joinedMembers)) - } failure: { error in - MXLog.error("[UserSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) - } - } - - private func roomMembersToProviderMembers(_ roomMembers: [MXRoomMember]) -> [RoomMembersProviderMember] { - roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift deleted file mode 100644 index 0d1f6795e..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -@objc -protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject { - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinatorBridge, textTrigger: String?) - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) -} - -@objcMembers -final class UserSuggestionCoordinatorBridge: NSObject { - private var _userSuggestionCoordinator: Any? - fileprivate var userSuggestionCoordinator: UserSuggestionCoordinator { - _userSuggestionCoordinator as! UserSuggestionCoordinator - } - - weak var delegate: UserSuggestionCoordinatorBridgeDelegate? - - init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { - let parameters = UserSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) - let userSuggestionCoordinator = UserSuggestionCoordinator(parameters: parameters) - _userSuggestionCoordinator = userSuggestionCoordinator - - super.init() - - userSuggestionCoordinator.delegate = self - } - - func processTextMessage(_ textMessage: String) { - userSuggestionCoordinator.processTextMessage(textMessage) - } - - func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { - userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) - } - - func toPresentable() -> UIViewController? { - userSuggestionCoordinator.toPresentable() - } - - func sharedContext() -> UserSuggestionViewModelContextWrapper { - userSuggestionCoordinator.sharedContext() - } -} - -extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) - } - - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) - } - - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { - delegate?.userSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift deleted file mode 100644 index a790e2845..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import Foundation -import WysiwygComposer - -struct RoomMembersProviderMember { - var userId: String - var displayName: String - var avatarUrl: String -} - -class UserSuggestionID: NSObject { - /// A special case added for suggesting `@room` mentions. - @objc static let room = "@room" -} - -protocol RoomMembersProviderProtocol { - var canMentionRoom: Bool { get } - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) -} - -struct UserSuggestionServiceItem: UserSuggestionItemProtocol { - let userId: String - let displayName: String? - let avatarUrl: String? -} - -class UserSuggestionService: UserSuggestionServiceProtocol { - // MARK: - Properties - - // MARK: Private - - private let roomMemberProvider: RoomMembersProviderProtocol - - private var suggestionItems: [UserSuggestionItemProtocol] = [] - private let currentTextTriggerSubject = CurrentValueSubject(nil) - private var cancellables = Set() - - // MARK: Public - - var items = CurrentValueSubject<[UserSuggestionItemProtocol], Never>([]) - - var currentTextTrigger: String? { - currentTextTriggerSubject.value - } - - // MARK: - Setup - - init(roomMemberProvider: RoomMembersProviderProtocol, shouldDebounce: Bool = true) { - self.roomMemberProvider = roomMemberProvider - - if shouldDebounce { - currentTextTriggerSubject - .debounce(for: 0.5, scheduler: RunLoop.main) - .removeDuplicates() - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } - .store(in: &cancellables) - } else { - currentTextTriggerSubject - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } - .store(in: &cancellables) - } - } - - // MARK: - UserSuggestionServiceProtocol - - func processTextMessage(_ textMessage: String?) { - guard let textMessage = textMessage, - textMessage.count > 0, - let lastComponent = textMessage.components(separatedBy: .whitespaces).last, - lastComponent.prefix(while: { $0 == "@" }).count == 1 // Partial username should start with one and only one "@" character - else { - items.send([]) - currentTextTriggerSubject.send(nil) - return - } - - currentTextTriggerSubject.send(lastComponent) - } - - func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { - guard let suggestionPattern, suggestionPattern.key == .at else { - items.send([]) - currentTextTriggerSubject.send(nil) - return - } - - currentTextTriggerSubject.send("@" + suggestionPattern.text) - } - - // MARK: - Private - - private func fetchAndFilterMembersForTextTrigger(_ textTrigger: String?) { - guard var partialName = textTrigger else { - return - } - - partialName.removeFirst() // remove the '@' prefix - - roomMemberProvider.fetchMembers { [weak self] members in - guard let self = self else { - return - } - - self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in - UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl) - } - - self.items.send(self.suggestionItems.filter { userSuggestion in - let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) - - return (containedInUsername || containedInDisplayName) - }) - } - } -} - -extension Array where Element == RoomMembersProviderMember { - /// Returns the array with an additional member that represents an `@room` mention. - func withRoom(_ canMentionRoom: Bool) -> Self { - guard canMentionRoom else { return self } - return self + [RoomMembersProviderMember(userId: UserSuggestionID.room, displayName: "Everyone", avatarUrl: "")] - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift deleted file mode 100644 index 7ae0bfa39..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import XCTest - -@testable import RiotSwiftUI - -class UserSuggestionServiceTests: XCTestCase { - var service: UserSuggestionService! - var canMentionRoom = false - - override func setUp() { - service = UserSuggestionService(roomMemberProvider: self, shouldDebounce: false) - canMentionRoom = false - } - - func testAlice() { - service.processTextMessage("@Al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@ice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@Alice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@alice:matrix.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - } - - func testBob() { - service.processTextMessage("@ob") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - - service.processTextMessage("@ob:") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - - service.processTextMessage("@b:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - } - - func testBoth() { - service.processTextMessage("@:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") - - service.processTextMessage("@.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") - } - - func testEmptyResult() { - service.processTextMessage("Lorem ipsum idolor") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@@") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("alice@matrix.org") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testStuff() { - service.processTextMessage("@@") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testWhitespaces() { - service.processTextMessage("") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("\n") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" \n ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@A ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" @A ") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testRoomWithoutPower() { - // Given a user without the power to mention a room. - canMentionRoom = false - - // Given a user without the power to mention a room. - service.processTextMessage("@ro") - - // Then the completion for a room mention should not be shown. - XCTAssertTrue(service.items.value.isEmpty) - } - - func testRoomWithPower() { - // Given a user without the power to mention a room. - canMentionRoom = true - - // Given a user without the power to mention a room. - service.processTextMessage("@ro") - - // Then the completion for a room mention should be shown. - XCTAssertEqual(service.items.value.first?.userId, UserSuggestionID.room) - } -} - -extension UserSuggestionServiceTests: RoomMembersProviderProtocol { - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { - let users = [("Alice", "@alice:matrix.org"), - ("Bob", "@bob:matrix.org")] - - members(users.map { user in - RoomMembersProviderMember(userId: user.1, displayName: user.0, avatarUrl: "") - }) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift deleted file mode 100644 index 0a9395fa5..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import SwiftUI - -enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { - case multipleResults - - private static var members: [RoomMembersProviderMember]! - - var screenType: Any.Type { - UserSuggestionList.self - } - - var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self) - let listViewModel = UserSuggestionViewModel(userSuggestionService: service) - - let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in - service.processTextMessage(textMessage) - } - - return ( - [service, listViewModel], - AnyView(UserSuggestionListWithInput(viewModel: viewModel) - .environmentObject(AvatarViewModel.withMockedServices())) - ) - } -} - -extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { - var canMentionRoom: Bool { false } - - func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { - if Self.members == nil { - Self.members = generateUsersWithCount(10) - } - - members(Self.members) - } - - private func generateUsersWithCount(_ count: UInt) -> [RoomMembersProviderMember] { - (0.. - -class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewModelProtocol { - // MARK: - Properties - - // MARK: Private - - private let userSuggestionService: UserSuggestionServiceProtocol - - // MARK: Public - - var sharedContext: UserSuggestionViewModelType.Context { - return self.context - } - - var completion: ((UserSuggestionViewModelResult) -> Void)? - - // MARK: - Setup - - init(userSuggestionService: UserSuggestionServiceProtocol) { - self.userSuggestionService = userSuggestionService - - let items = userSuggestionService.items.value.map { suggestionItem in - UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.displayName) - } - - super.init(initialViewState: UserSuggestionViewState(items: items)) - - userSuggestionService.items.sink { [weak self] items in - self?.state.items = items.map { item in - UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName) - } - }.store(in: &cancellables) - } - - // MARK: - Public - - override func process(viewAction: UserSuggestionViewAction) { - switch viewAction { - case .selectedItem(let item): - completion?(.selectedItemWithIdentifier(item.id)) - } - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift deleted file mode 100644 index 862e7573d..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -struct UserSuggestionListItem: View { - // MARK: - Properties - - // MARK: Private - - @Environment(\.theme) private var theme: ThemeSwiftUI - - // MARK: Public - - let avatar: AvatarInputProtocol? - let displayName: String? - let userId: String - - var body: some View { - HStack { - if let avatar = avatar { - AvatarImage(avatarData: avatar, size: .medium) - } - VStack(alignment: .leading) { - Text(displayName ?? "") - .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - .accessibility(identifier: "displayNameText") - .lineLimit(1) - Text(userId) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.tertiaryContent) - .accessibility(identifier: "userIdText") - .lineLimit(1) - } - } - } -} - -// MARK: - Previews - -struct UserSuggestionHeader_Previews: PreviewProvider { - static var previews: some View { - UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org") - .environmentObject(AvatarViewModel.withMockedServices()) - } -} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift index c4851e779..eb9530f36 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift @@ -146,7 +146,14 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } func resumeRecordingVoiceBroadcast() { - try? audioEngine.start() + do { + // If we paused the recording because of an error, playing a sound mostly changed the category so we need to set it back to .playAndRecord + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) + try audioEngine.start() + } catch { + MXLog.error("[VoiceBroadcastRecorderService] failed to resume recording", context: error) + return + } startTimer() voiceBroadcastService?.resumeVoiceBroadcast(success: { [weak self] _ in @@ -179,12 +186,19 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } func pauseOnErrorRecordingVoiceBroadcast() { + guard audioEngine.isRunning else { + return + } + audioEngine.pause() UIApplication.shared.isIdleTimerDisabled = false invalidateTimer() // Update state - self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .error) + serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .error) + + // Play a sound + playSound(soundName: "vberror") } // MARK: - Private @@ -389,4 +403,21 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } } } + + private func playSound(soundName: String, delay: TimeInterval = 1.0) { + if let audioFileUrl = audioURLWithName(soundName: soundName) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + MXLog.debug("[VoiceBroadcastRecorderService] Playing sound: \(audioFileUrl.absoluteString)") + MXKSoundPlayer.sharedInstance().playSound(at: audioFileUrl, repeat: false, vibrate: false, routeToBuiltInReceiver: false) + } + } + } + + private func audioURLWithName(soundName: String) -> 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/RiotTests/Experiments/CryptoSDKFeatureTests.swift b/RiotTests/Experiments/CryptoSDKFeatureTests.swift deleted file mode 100644 index a512b71c6..000000000 --- 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/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift b/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift new file mode 100644 index 000000000..f038ba5e2 --- /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" + } + } +} diff --git a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m index 34ebb66e9..5bc037790 100644 --- a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m +++ b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m @@ -117,12 +117,6 @@ 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/project.yml b/project.yml index 3c93ca513..1067da832 100644 --- a/project.yml +++ b/project.yml @@ -62,7 +62,7 @@ packages: maxVersion: 3.5.0 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