diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml index 5d9cfb3c8..e776173fa 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yml +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - Thank you for taking the time to propose a new feature or make a suggestion. + Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/vector-im/element-meta/discussions/new?category=ideas) - type: textarea id: usecase attributes: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 73f195af6..d5a9d105d 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -63,3 +63,5 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 + with: + flags: unittests diff --git a/.github/workflows/ci-ui-tests.yml b/.github/workflows/ci-ui-tests.yml index a5ba76e7b..39c90d509 100644 --- a/.github/workflows/ci-ui-tests.yml +++ b/.github/workflows/ci-ui-tests.yml @@ -1,9 +1,6 @@ name: UI Tests CI on: - push: - branches: [ develop ] - pull_request: workflow_dispatch: @@ -61,4 +58,6 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 + with: + flags: uitests \ No newline at end of file diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index f9c45d4f0..f2fe6125c 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -4,6 +4,7 @@ on: # Triggers the workflow on any pull request pull_request: + types: [ labeled, synchronized, opened, reopened ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -27,7 +28,11 @@ jobs: build: # Run job if secrets are available (not available for forks). needs: [check-secret] - if: needs.check-secret.outputs.out-key == 'true' + if: | + needs.check-secret.outputs.out-key == 'true' && + (github.event_name == 'push' || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'Trigger-PR-Build'))) + name: Release runs-on: macos-12 diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 0d6b6689a..632e8b538 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -17,7 +17,8 @@ jobs: contains(github.event.issue.labels.*.name, 'Z-IA') || contains(github.event.issue.labels.*.name, 'A-Themes-Custom') || contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || - contains(github.event.issue.labels.*.name, 'A-Tags') + contains(github.event.issue.labels.*.name, 'A-Tags') || + contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') steps: - uses: actions/github-script@v5 with: @@ -44,7 +45,13 @@ jobs: name: P1 X-Needs-Design to Design project board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'X-Needs-Design') + contains(github.event.issue.labels.*.name, 'X-Needs-Design') && + (contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'O-Occasional')) || + (contains(github.event.issue.labels.*.name, 'S-Major') && + 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 @@ -202,3 +209,105 @@ jobs: env: PROJECT_ID: "PN_kwDOAM0swc4AArk0" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + + ps_features1: + name: Add labelled issues to PS features team 1 + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'A-Polls') || + contains(github.event.issue.labels.*.name, 'A-Location-Sharing') || + (contains(github.event.issue.labels.*.name, 'A-Voice-Messages') && + !contains(github.event.issue.labels.*.name, 'A-Broadcast')) || + (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 + 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 }} + + ps_features2: + name: Add labelled issues to PS features team 2 + runs-on: ubuntu-latest + if: > + 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 + 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 }} + + ps_features3: + name: Add labelled issues to PS features team 3 + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') + steps: + - uses: octokit/graphql-action@v2.x + id: add_to_project + 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 }} + + voip: + name: Add labelled issues to VoIP project board + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'Team: VoIP') + steps: + - uses: octokit/graphql-action@v2.x + id: add_to_project + 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 }} diff --git a/.github/workflows/triage-priority-bugs.yml b/.github/workflows/triage-priority-bugs.yml index 9e1eee8ba..548cd6aaa 100644 --- a/.github/workflows/triage-priority-bugs.yml +++ b/.github/workflows/triage-priority-bugs.yml @@ -25,7 +25,7 @@ jobs: - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488 with: project: iOS App Team - column: P1 + column: "Important Issues & Topics (P1)" repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} P1_issues_to_crypto_team_workboard: diff --git a/CHANGES.md b/CHANGES.md index f7dc561ab..8f8e82c1e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,97 @@ +## Changes in 1.9.10 (2022-11-01) + +✨ Features + +- Changed the info in the background audio message player. ([#6870](https://github.com/vector-im/element-ios/pull/6870)) +- Added voice message support to the Rich Text Composer ([#6941](https://github.com/vector-im/element-ios/issues/6941)) + +🙌 Improvements + +- Improves external links interaction UX. ([#6936](https://github.com/vector-im/element-ios/pull/6936)) +- Verification: Deprecate legacy device-to-device verification ([#6937](https://github.com/vector-im/element-ios/pull/6937)) +- Crypto: Define MXCrypto and MXCrossSigning as protocols ([#6943](https://github.com/vector-im/element-ios/pull/6943)) +- Hide the old session list when the new device manager is enabled. ([#6999](https://github.com/vector-im/element-ios/pull/6999)) +- Upgrade MatrixSDK version ([v0.24.2](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.24.2)). +- Added a responsive placeholder text to the Rich Text Composer ([#6935](https://github.com/vector-im/element-ios/issues/6935)) +- Added the maximise/minimise toggle button to the Rich Text Composer ([#6954](https://github.com/vector-im/element-ios/issues/6954)) + +🐛 Bugfixes + +- Timeline: Fix layout for SwiftUI content views. ([#5326](https://github.com/vector-im/element-ios/issues/5326)) +- Updates the avatar image loading logics. ([#6847](https://github.com/vector-im/element-ios/issues/6847)) +- Fixes input text view height when containing multiple lines of text. ([#6849](https://github.com/vector-im/element-ios/issues/6849)) +- Fixed the placeholder flickering in the input toolbar when there is an height change. ([#6949](https://github.com/vector-im/element-ios/issues/6949)) + +🧱 Build + +- Add Z-Labs tag for rich text editor and update to the new label naming. ([#6996](https://github.com/vector-im/element-ios/pull/6996)) + +🚧 In development 🚧 + +- Device Manager: Multi-session selection. ([#6928](https://github.com/vector-im/element-ios/issues/6928)) + +Others + +- Updated templates readme file. ([#6925](https://github.com/vector-im/element-ios/issues/6925)) + + +## Changes in 1.9.9 (2022-10-18) + +✨ Features + +- Added RendezvousService and secure channel establishment implementation ([#6806](https://github.com/vector-im/element-ios/pull/6806)) +- Implemented login with QR code flows when scanning from mobile ([#6857](https://github.com/vector-im/element-ios/pull/6857)) + +🙌 Improvements + +- User agents: Ignore OS version for web based sessions (PSG-826). ([#6852](https://github.com/vector-im/element-ios/pull/6852)) +- Upgrade MatrixSDK version ([v0.24.1](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.24.1)). +- Display push toggle for sessions with m.local_notification_settings. events in account_data ([#6797](https://github.com/vector-im/element-ios/issues/6797)) +- CryptoV2: Incoming verification requests ([#6809](https://github.com/vector-im/element-ios/issues/6809)) +- Check enabled field in notification settings push toggles ([#6814](https://github.com/vector-im/element-ios/issues/6814)) +- Only use device type name as fallback for session display name ([#6820](https://github.com/vector-im/element-ios/issues/6820)) +- Include app name in default session display name ([#6828](https://github.com/vector-im/element-ios/issues/6828)) +- Tidy up TabBarCoordinator now that AllChatsCoordinator exists. ([#6853](https://github.com/vector-im/element-ios/issues/6853)) +- Sign Out: Add a SignOutFlowPresenter and use this in All Chats, Settings and the Device Manager. ([#6854](https://github.com/vector-im/element-ios/issues/6854)) +- Improved the Rich Text Editor to match design requirements. ([#6903](https://github.com/vector-im/element-ios/issues/6903)) + +🐛 Bugfixes + +- Filter out application section in session details if needed. ([#6898](https://github.com/vector-im/element-ios/pull/6898)) +- Rich text editor now supports interactive dismissal by dragging the timeline. ([#6919](https://github.com/vector-im/element-ios/pull/6919), [#6900](https://github.com/vector-im/element-ios/issues/6900)) +- Location sharing: removing the loader. ([#5571](https://github.com/vector-im/element-ios/issues/5571)) +- Element freezes after searching in a room. ([#6762](https://github.com/vector-im/element-ios/issues/6762)) +- Settings: Use regular titles for all of the sub-screens. ([#6804](https://github.com/vector-im/element-ios/issues/6804)) +- All Chats: Fix a header glitch when aborting a pop gesture. ([#6833](https://github.com/vector-im/element-ios/issues/6833)) +- Device manager: Fixes from x-platform testing. ([#6864](https://github.com/vector-im/element-ios/issues/6864)) +- All chats shows no rooms in the list. ([#6869](https://github.com/vector-im/element-ios/issues/6869)) +- Device Manager: Navigating to session overview goes to session details. ([#6877](https://github.com/vector-im/element-ios/issues/6877)) +- "Notifications on this device" not refreshed in user settings screen ([#6888](https://github.com/vector-im/element-ios/issues/6888)) +- Rich text editor now always focuses if field is tapped within the border. ([#6897](https://github.com/vector-im/element-ios/issues/6897)) +- Device Manger: Device client information not updated. ([#6904](https://github.com/vector-im/element-ios/issues/6904)) + +🧱 Build + +- Remove the (now unused) FFMPEG pod. ([#6419](https://github.com/vector-im/element-ios/issues/6419)) +- Update build tools from Cocoapods. ([#6886](https://github.com/vector-im/element-ios/issues/6886)) + +🚧 In development 🚧 + +- Device manager: Inactive sessions screen. ([#6786](https://github.com/vector-im/element-ios/issues/6786)) +- Device manager: Unverified sessions screen. ([#6801](https://github.com/vector-im/element-ios/issues/6801)) +- Device Manager: Add logout actions to UserSessionsOverview and UserSessionOverview ([#6802](https://github.com/vector-im/element-ios/issues/6802)) +- Device Manager: 'View all' button in other sessions list. ([#6817](https://github.com/vector-im/element-ios/issues/6817)) +- Device manager: Add UserSessionName and Rename actions to UserSessionsOverview and UserSessionOverview. ([#6823](https://github.com/vector-im/element-ios/issues/6823)) +- Device Manager: Filter sessions. ([#6838](https://github.com/vector-im/element-ios/issues/6838)) +- Device manager: Add verify device actions to UserSessionsOverview and UserSessionOverview. ([#6845](https://github.com/vector-im/element-ios/issues/6845)) +- Device manager: Identify inactive sessions. ([#6881](https://github.com/vector-im/element-ios/issues/6881)) + +Others + +- Expose AuthenticationRestClient async login token generation method ([#6827](https://github.com/vector-im/element-ios/pull/6827)) +- Use unstable prefixes for login with QR flows. ([#6899](https://github.com/vector-im/element-ios/pull/6899)) + + ## Changes in 1.9.8 (2022-10-04) 🙌 Improvements diff --git a/Config/AppConfiguration.swift b/Config/AppConfiguration.swift index fa0d8b533..2686a6048 100644 --- a/Config/AppConfiguration.swift +++ b/Config/AppConfiguration.swift @@ -35,7 +35,8 @@ class AppConfiguration: CommonConfiguration { // bwi: add additional event for nicknames MXKAppSettings.standard()?.addSupportedEventTypes([kWidgetMatrixEventTypeString, kWidgetModularEventTypeString, - BWIBuildSettings.shared.bwiUserLabelEventTypeString]) + BWIBuildSettings.shared.bwiUserLabelEventTypeString, + VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) // Hide undecryptable messages that were sent while the user was not in the room MXKAppSettings.standard()?.hidePreJoinedUndecryptableEvents = true diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 6afeeb210..3343a233f 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -16,5 +16,5 @@ // // Version -MARKETING_VERSION = 1.26.0 +MARKETING_VERSION = 2.0.0 CURRENT_PROJECT_VERSION = 20220714163152 diff --git a/Config/CommonConfiguration.swift b/Config/CommonConfiguration.swift index 6b9e5a4c5..81dbb2cff 100644 --- a/Config/CommonConfiguration.swift +++ b/Config/CommonConfiguration.swift @@ -172,7 +172,7 @@ class CommonConfiguration: NSObject, Configurable { func setupSettingsWhenLoaded(for matrixSession: MXSession) { // Do not warn for unknown devices. We have cross-signing now - matrixSession.crypto.warnOnUnknowDevices = false + (matrixSession.crypto as? MXLegacyCrypto)?.warnOnUnknowDevices = false } } diff --git a/Podfile b/Podfile index 69413f80c..e2c3c4585 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.24.0' +$matrixSDKVersion = '= 0.24.2' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } @@ -63,6 +63,7 @@ end def import_SwiftUI_pods pod 'Introspect', '~> 0.1' pod 'DSBottomSheet', '~> 0.3' + pod 'ZXingObjC', '~> 3.6.5' end abstract_target 'RiotPods' do @@ -85,8 +86,9 @@ abstract_target 'RiotPods' do pod 'zxcvbn-ios' # Tools - pod 'SwiftGen', '~> 6.3' - pod 'SwiftLint', '~> 0.44.0' + pod 'SwiftGen' + pod 'SwiftLint' + pod 'SwiftFormat/CLI' target "Riot" do import_MatrixSDK @@ -97,7 +99,6 @@ abstract_target 'RiotPods' do pod 'UICollectionViewRightAlignedLayout', '~> 0.0.3' pod 'UICollectionViewLeftAlignedLayout', '~> 1.0.2' pod 'KTCenterFlowLayout', '~> 1.3.1' - pod 'ZXingObjC', '~> 3.6.5' pod 'FlowCommoniOS', '~> 1.12.0' pod 'DTTJailbreakDetection', '~> 0.4.0' pod 'ReadMoreTextView', '~> 3.0.1' @@ -105,7 +106,6 @@ abstract_target 'RiotPods' do pod 'SwiftJWT', '~> 3.6.200' pod 'SideMenu', '~> 6.5' pod 'DSWaveformImage', '~> 6.1.1' - pod 'ffmpeg-kit-ios-audio', '4.5.1' pod 'FLEX', '~> 4.5.0', :configurations => ['Debug'], :inhibit_warnings => true @@ -198,6 +198,15 @@ post_install do |installer| config.build_settings['WARNING_CFLAGS'] ||= ['$(inherited)','-Wno-nullability-completeness'] config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['$(inherited)', '-Xcc', '-Wno-nullability-completeness'] end + + # Fix Xcode 14 resource bundle signing issues + # https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1259231655 + if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle" + target.build_configurations.each do |config| + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + end + end + end Dir.glob("#{installer.sandbox.target_support_files_root}/**/*.pch") do |item| diff --git a/Podfile.lock b/Podfile.lock index 7b651e674..0def66683 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -38,8 +38,6 @@ PODS: - DTFoundation/Core - DTFoundation/UIKit (1.7.18): - DTFoundation/Core - - DTTJailbreakDetection (0.4.0) - - ffmpeg-kit-ios-audio (4.5.1) - FLEX (4.5.0) - FlowCommoniOS (1.12.2) - GBDeviceInfo (6.6.0): @@ -57,12 +55,9 @@ PODS: - LoggerAPI (1.9.200): - Logging (~> 1.1) - Logging (1.4.0) - - MatomoTracker (7.4.1): - - MatomoTracker/Core (= 7.4.1) - - MatomoTracker/Core (7.4.1) - - MatrixSDK (0.24.0): - - MatrixSDK/Core (= 0.24.0) - - MatrixSDK/Core (0.24.0): + - MatrixSDK (0.24.2): + - MatrixSDK/Core (= 0.24.2) + - MatrixSDK/Core (0.24.2): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) @@ -70,12 +65,12 @@ PODS: - OLMKit (~> 3.2.5) - Realm (= 10.27.0) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/CryptoSDK (0.24.0): - - MatrixSDKCrypto (= 0.1.0) - - MatrixSDK/JingleCallStack (0.24.0): + - MatrixSDK/CryptoSDK (0.24.2): + - MatrixSDKCrypto (= 0.1.5) + - MatrixSDK/JingleCallStack (0.24.2): - JitsiMeetSDK (= 5.0.2) - MatrixSDK/Core - - MatrixSDKCrypto (0.1.0) + - MatrixSDKCrypto (0.1.5) - OLMKit (3.2.12): - OLMKit/olmc (= 3.2.12) - OLMKit/olmcpp (= 3.2.12) @@ -96,6 +91,7 @@ PODS: - Sentry/Core (7.15.0) - SideMenu (6.5.0) - SwiftBase32 (0.9.0) + - SwiftFormat/CLI (0.50.2) - SwiftGen (6.6.2) - SwiftJWT (3.6.200): - BlueCryptor (~> 1.0) @@ -103,7 +99,7 @@ PODS: - BlueRSA (~> 1.0) - KituraContracts (~> 1.2) - LoggerAPI (~> 1.7) - - SwiftLint (0.44.0) + - SwiftLint (0.49.1) - SwiftyBeaver (1.9.5) - UICollectionViewLeftAlignedLayout (1.0.2) - UICollectionViewRightAlignedLayout (0.0.3) @@ -119,8 +115,6 @@ DEPENDENCIES: - DSBottomSheet (~> 0.3) - DSWaveformImage (~> 6.1.1) - DTCoreText (~> 1.6.25) - - DTTJailbreakDetection (~> 0.4.0) - - ffmpeg-kit-ios-audio (= 4.5.1) - FLEX (~> 4.5.0) - FlowCommoniOS (~> 1.12.0) - GBDeviceInfo (~> 6.6.0) @@ -128,9 +122,8 @@ DEPENDENCIES: - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatomoTracker (~> 7.4.1) - - MatrixSDK (from `https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios-matrix-sdk`, tag `v0.24.0_bwi_dev`) - - MatrixSDK/JingleCallStack (from `https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios-matrix-sdk`, tag `v0.24.0_bwi_dev`) + - MatrixSDK (= 0.24.2) + - MatrixSDK/JingleCallStack (= 0.24.2) - OLMKit - PostHog (~> 1.4.4) - ReadMoreTextView (~> 3.0.1) @@ -138,9 +131,10 @@ DEPENDENCIES: - Sentry (~> 7.15.0) - SideMenu (~> 6.5) - SwiftBase32 (~> 0.9.0) - - SwiftGen (~> 6.3) + - SwiftFormat/CLI + - SwiftGen - SwiftJWT (~> 3.6.200) - - SwiftLint (~> 0.44.0) + - SwiftLint - UICollectionViewLeftAlignedLayout (~> 1.0.2) - UICollectionViewRightAlignedLayout (~> 0.0.3) - WeakDictionary (~> 2.0) @@ -158,8 +152,6 @@ SPEC REPOS: - DSWaveformImage - DTCoreText - DTFoundation - - DTTJailbreakDetection - - ffmpeg-kit-ios-audio - FLEX - FlowCommoniOS - GBDeviceInfo @@ -173,7 +165,7 @@ SPEC REPOS: - libPhoneNumber-iOS - LoggerAPI - Logging - - MatomoTracker + - MatrixSDK - MatrixSDKCrypto - OLMKit - PostHog @@ -183,6 +175,7 @@ SPEC REPOS: - Sentry - SideMenu - SwiftBase32 + - SwiftFormat - SwiftGen - SwiftJWT - SwiftLint @@ -197,17 +190,11 @@ EXTERNAL SOURCES: AnalyticsEvents: :branch: release/swift :git: https://github.com/matrix-org/matrix-analytics-events.git - MatrixSDK: - :git: https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios-matrix-sdk - :tag: v0.24.0_bwi_dev CHECKOUT OPTIONS: AnalyticsEvents: :commit: 53ad46ba1ea1ee8f21139dda3c351890846a202f :git: https://github.com/matrix-org/matrix-analytics-events.git - MatrixSDK: - :git: https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios-matrix-sdk - :tag: v0.24.0_bwi_dev SPEC CHECKSUMS: AFNetworking: 7864c38297c79aaca1500c33288e429c3451fdce @@ -220,8 +207,6 @@ SPEC CHECKSUMS: DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536 - DTTJailbreakDetection: 5e356c5badc17995f65a83ed9483f787a0057b71 - ffmpeg-kit-ios-audio: 662ce2064e56733ca7d8216705efbc38d9e1c3fe FLEX: e51461dd6f0bfb00643c262acdfea5d5d12c596b FlowCommoniOS: ca92071ab526dc89905495a37844fd7e78d1a7f2 GBDeviceInfo: ed0db16230d2fa280e1cbb39a5a7f60f6946aaec @@ -235,9 +220,8 @@ SPEC CHECKSUMS: libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b - MatomoTracker: 24a846c9d3aa76933183fe9d47fd62c9efa863fb - MatrixSDK: 196ae670143c5169ca9d02ff4d4a87f1e4dd8f08 - MatrixSDKCrypto: 4b9146d5ef484550341be056a164c6930038028e + MatrixSDK: 1b64384084050652fcffafdf8641200f1ab25060 + MatrixSDKCrypto: dcab554bc7157cad31c01fc1137cf5acb01959a4 OLMKit: da115f16582e47626616874e20f7bb92222c7a51 PostHog: 4b6321b521569092d4ef3a02238d9435dbaeb99f ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d @@ -246,9 +230,10 @@ SPEC CHECKSUMS: Sentry: 63ca44f5e0c8cea0ee5a07686b02e56104f41ef7 SideMenu: f583187d21c5b1dd04c72002be544b555a2627a2 SwiftBase32: 9399c25a80666dc66b51e10076bf591e3bbb8f17 + SwiftFormat: 710117321c55c82675c0dc03055128efbb13c38f SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c SwiftJWT: 88c412708f58c169d431d344c87bc79a87c830ae - SwiftLint: e96c0a8c770c7ebbc4d36c55baf9096bb65c4584 + SwiftLint: 32ee33ded0636d0905ef6911b2b67bbaeeedafa5 SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82 UICollectionViewLeftAlignedLayout: 830bf6fa5bab9f9b464f62e3384f9d2e00b3c0f6 UICollectionViewRightAlignedLayout: 823eef8c567eba4a44c21bc2ffcb0d0d5f361e2d @@ -256,6 +241,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 76f4286f10bb28fa143ae00ccb47a2bdd09e95a4 +PODFILE CHECKSUM: 96a971e076c61e54ae5bb7bf30ecba80563eeacf -COCOAPODS: 1.11.3 +COCOAPODS: 1.11.2 diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 719e0d9de..5d8d79436 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,14 @@ "version" : "5.12.2" } }, + { + "identity" : "matrix-wysiwyg-composer-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", + "state" : { + "revision" : "d5ef7054fb43924d5b92d5d627347ca2bc333717" + } + }, { "identity" : "ogg-swift", "kind" : "remoteSourceControl", diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json new file mode 100644 index 000000000..3c15fd8e3 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Secure connection.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg new file mode 100644 index 000000000..ffbcb3b30 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json new file mode 100644 index 000000000..f6d56c99d --- /dev/null +++ b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "exclamation_circle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg new file mode 100644 index 000000000..5d23e58d5 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold.png b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold.png new file mode 100644 index 000000000..186ab7a26 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold@2x.png b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold@2x.png new file mode 100644 index 000000000..0f57f9bd0 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold@3x.png b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold@3x.png new file mode 100644 index 000000000..040357f99 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Bold@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Contents.json new file mode 100644 index 000000000..224203f93 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Bold.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Bold.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Bold@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Bold@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code.png b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code.png new file mode 100644 index 000000000..65d16fb1a Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code@2x.png b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code@2x.png new file mode 100644 index 000000000..a6b6b2b42 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code@3x.png b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code@3x.png new file mode 100644 index 000000000..830f50d7e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Code@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Code.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Contents.json new file mode 100644 index 000000000..12e2e8f2c --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Code.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Code.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Code@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Code@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Contents.json b/Riot/Assets/Images.xcassets/Composer/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Contents.json new file mode 100644 index 000000000..4643bee9f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Indent increase.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Indent increase@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Indent increase@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase.png b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase.png new file mode 100644 index 000000000..7cfba0707 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase@2x.png b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase@2x.png new file mode 100644 index 000000000..7b1a16642 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase@3x.png b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase@3x.png new file mode 100644 index 000000000..46818f71c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Indent_increase.imageset/Indent increase@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Contents.json new file mode 100644 index 000000000..b261ad13d --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Italic.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Italic@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Italic@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic.png b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic.png new file mode 100644 index 000000000..9954f5b10 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic@2x.png b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic@2x.png new file mode 100644 index 000000000..fa1be861c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic@3x.png b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic@3x.png new file mode 100644 index 000000000..ac46fccca Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Italic.imageset/Italic@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Link.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Contents.json new file mode 100644 index 000000000..712a0658e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Link.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Link@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Link@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link.png b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link.png new file mode 100644 index 000000000..b2ca41015 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link@2x.png b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link@2x.png new file mode 100644 index 000000000..66e4e869f Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link@3x.png b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link@3x.png new file mode 100644 index 000000000..bb4a6fa05 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Link.imageset/Link@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Contents.json new file mode 100644 index 000000000..03c78408f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Numbered list.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Numbered list@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Numbered list@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list.png b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list.png new file mode 100644 index 000000000..b2798d0d5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list@2x.png b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list@2x.png new file mode 100644 index 000000000..8eee74056 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list@3x.png b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list@3x.png new file mode 100644 index 000000000..be93c4bad Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Numbered list.imageset/Numbered list@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Contents.json new file mode 100644 index 000000000..2c07b7fbc --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Quote.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Quote@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Quote@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote.png b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote.png new file mode 100644 index 000000000..5a03db995 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote@2x.png b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote@2x.png new file mode 100644 index 000000000..7461b0b17 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote@3x.png b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote@3x.png new file mode 100644 index 000000000..cef7775c9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Quote.imageset/Quote@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Contents.json new file mode 100644 index 000000000..f6b269190 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Strikethrough.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Strikethrough@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Strikethrough@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough.png b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough.png new file mode 100644 index 000000000..a06fdf80d Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough@2x.png b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough@2x.png new file mode 100644 index 000000000..f01b9b897 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough@3x.png b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough@3x.png new file mode 100644 index 000000000..22ccc6db5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Strikethrough.imageset/Strikethrough@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Contents.json new file mode 100644 index 000000000..c394abde4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Underlined.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Underlined@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Underlined@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined.png b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined.png new file mode 100644 index 000000000..ec6995c2c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined@2x.png b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined@2x.png new file mode 100644 index 000000000..b5ad0ec57 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined@3x.png b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined@3x.png new file mode 100644 index 000000000..b467bdc17 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/Underlined.imageset/Underlined@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list.png b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list.png new file mode 100644 index 000000000..8b235c331 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list@2x.png b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list@2x.png new file mode 100644 index 000000000..8e6e0f9e9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list@3x.png b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list@3x.png new file mode 100644 index 000000000..356357067 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Bullet list@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Contents.json new file mode 100644 index 000000000..7fe1ca95e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/bullet_list.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Bullet list.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Bullet list@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Bullet list@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Contents.json new file mode 100644 index 000000000..73db7a0bf --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Indent decrease.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Indent decrease@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Indent decrease@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease.png b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease.png new file mode 100644 index 000000000..bba698715 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease@2x.png b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease@2x.png new file mode 100644 index 000000000..12d52fb69 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease@3x.png b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease@3x.png new file mode 100644 index 000000000..b434d77c3 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/indent_decrease.imageset/Indent decrease@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/Contents.json new file mode 100644 index 000000000..7229787b4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "maximise_composer.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "maximise_composer@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "maximise_composer@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer.png b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer.png new file mode 100644 index 000000000..7cf08e08e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer@2x.png b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer@2x.png new file mode 100644 index 000000000..02bfab959 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer@3x.png b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer@3x.png new file mode 100644 index 000000000..5d90ebcd7 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/maximise_composer.imageset/maximise_composer@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/Contents.json new file mode 100644 index 000000000..e67839028 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "minimise_composer.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "minimise_composer@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "minimise_composer@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer.png b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer.png new file mode 100644 index 000000000..13c74aa38 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer@2x.png b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer@2x.png new file mode 100644 index 000000000..e2d4ab522 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer@3x.png b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer@3x.png new file mode 100644 index 000000000..072c505ad Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/minimise_composer.imageset/minimise_composer@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/Contents.json b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/Contents.json new file mode 100644 index 000000000..397c5d0dc --- /dev/null +++ b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "start_compose_module.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "start_compose_module@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "start_compose_module@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module.png b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module.png new file mode 100644 index 000000000..c30133af2 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module@2x.png b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module@2x.png new file mode 100644 index 000000000..30b5b0253 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module@3x.png b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module@3x.png new file mode 100644 index 000000000..fc5065a60 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Composer/start_compose_module.imageset/start_compose_module@3x.png differ diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/Contents.json new file mode 100644 index 000000000..81ee52eeb --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_filter.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/user_other_sessions_filter.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/user_other_sessions_filter.svg new file mode 100644 index 000000000..a2b8549a1 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/user_other_sessions_filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/Contents.json new file mode 100644 index 000000000..89113e4ef --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_filter_selected.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/user_other_sessions_filter_selected.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/user_other_sessions_filter_selected.svg new file mode 100644 index 000000000..f964fdd1c --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/user_other_sessions_filter_selected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/Contents.json new file mode 100644 index 000000000..f16dd6afe --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_inactive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/user_other_sessions_inactive.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/user_other_sessions_inactive.svg new file mode 100644 index 000000000..8603bbc31 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/user_other_sessions_inactive.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json new file mode 100644 index 000000000..64debb2e6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_unverified.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg new file mode 100644 index 000000000..738e3ed9c --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/Contents.json new file mode 100644 index 000000000..fd25f3b8e --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_verified.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/user_other_sessions_verified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/user_other_sessions_verified.svg new file mode 100644 index 000000000..793d65784 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/user_other_sessions_verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/Contents.json new file mode 100644 index 000000000..e3af9f053 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_session_list_item_inactive_session.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/user_session_list_item_inactive_session.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/user_session_list_item_inactive_session.svg new file mode 100644 index 000000000..5aba5a38b --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/user_session_list_item_inactive_session.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/Contents.json new file mode 100644 index 000000000..132fb8937 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_session_list_item_not_selected.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/user_session_list_item_not_selected.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/user_session_list_item_not_selected.svg new file mode 100644 index 000000000..7b73d0c6e --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/user_session_list_item_not_selected.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/Contents.json new file mode 100644 index 000000000..7c5fd8698 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_session_list_item_selected.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/user_session_list_item_selected.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/user_session_list_item_selected.svg new file mode 100644 index 000000000..13680d43a --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/user_session_list_item_selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_verification_unknown.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verification_unknown.imageset/Contents.json new file mode 100644 index 000000000..7c84236f4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verification_unknown.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "user_session_verification_unknown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_verification_unknown.imageset/user_session_verification_unknown.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verification_unknown.imageset/user_session_verification_unknown.svg new file mode 100644 index 000000000..3210e4185 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verification_unknown.imageset/user_session_verification_unknown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_live.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Actions/action_live.imageset/Contents.json new file mode 100644 index 000000000..062d578a0 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/action_live.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "action_live.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_live.imageset/action_live.svg b/Riot/Assets/Images.xcassets/Room/Actions/action_live.imageset/action_live.svg new file mode 100644 index 000000000..8b62b05b6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/action_live.imageset/action_live.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json index ead86edbb..04b38da3e 100644 --- a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "action_voice_message.png", + "filename" : "Microphone icon.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "action_voice_message@2x.png", + "filename" : "Microphone icon@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "action_voice_message@3x.png", + "filename" : "Microphone icon@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon.png new file mode 100644 index 000000000..8a6b3eb14 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon.png differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@2x.png new file mode 100644 index 000000000..5b404b74c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@3x.png new file mode 100644 index 000000000..520e22e94 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message.png deleted file mode 100644 index b969cb3aa..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@2x.png deleted file mode 100644 index 32c6236a6..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@3x.png deleted file mode 100644 index e8cc54c29..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@3x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json index 900874ca1..bc412b2cf 100644 --- a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "voice_message_record_button_recording.png", + "filename" : "Microphone asset.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "voice_message_record_button_recording@2x.png", + "filename" : "Microphone asset@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "voice_message_record_button_recording@3x.png", + "filename" : "Microphone asset@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset.png new file mode 100644 index 000000000..ffeb00aaf Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset.png differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@2x.png new file mode 100644 index 000000000..8582e2d23 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@3x.png new file mode 100644 index 000000000..e48d9a36b Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png deleted file mode 100644 index 5972e1272..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png deleted file mode 100644 index 802268ba0..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png deleted file mode 100644 index b1def35e1..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/Contents.json new file mode 100644 index 000000000..fa6650d1c --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_live.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/voice_broadcast_live.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/voice_broadcast_live.svg new file mode 100644 index 000000000..fd78cfc25 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/voice_broadcast_live.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/Contents.json new file mode 100644 index 000000000..4f275b2b0 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_pause.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/voice_broadcast_pause.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/voice_broadcast_pause.svg new file mode 100644 index 000000000..babd78716 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/voice_broadcast_pause.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/Contents.json new file mode 100644 index 000000000..6302334b3 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_play.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/voice_broadcast_play.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/voice_broadcast_play.svg new file mode 100644 index 000000000..65849ae58 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/voice_broadcast_play.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/Contents.json new file mode 100644 index 000000000..48ffc5e34 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_record.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/voice_broadcast_record.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/voice_broadcast_record.svg new file mode 100644 index 000000000..4ca9bd42c --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/voice_broadcast_record.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/Contents.json new file mode 100644 index 000000000..157748565 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_record_pause.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/voice_broadcast_record_pause.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/voice_broadcast_record_pause.svg new file mode 100644 index 000000000..ba12bc64c --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/voice_broadcast_record_pause.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/Contents.json new file mode 100644 index 000000000..8431bfd58 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_stop.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/voice_broadcast_stop.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/voice_broadcast_stop.svg new file mode 100644 index 000000000..1fed1640b --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/voice_broadcast_stop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Riot/Assets/bg.lproj/InfoPlist.strings b/Riot/Assets/bg.lproj/InfoPlist.strings index 4e548cf76..1da155eba 100644 --- a/Riot/Assets/bg.lproj/InfoPlist.strings +++ b/Riot/Assets/bg.lproj/InfoPlist.strings @@ -1,7 +1,9 @@ // Permissions usage explanations -"NSCameraUsageDescription" = "Камерата се използва, за да се правят снимки и видеа, както и да се водят видео разговори."; -"NSPhotoLibraryUsageDescription" = "Галерията се използва, за да се изпращат снимки и видеа."; -"NSMicrophoneUsageDescription" = "Микрофонът се използва, за да се правят видеа и да се водят разговори."; -"NSContactsUsageDescription" = "За да открие контакти използващи Matrix, Element може да изпрати имейл адресите и телефонните номера от телефонния указател към избрания от вас Matrix сървър за самоличност. Ако се поддържа, личните данни могат да бъдат хеширани преди изпращане - вижте политиката за поверителност на сървъра за самоличност за повече информация."; +"NSCameraUsageDescription" = "Камерата се използва, за да се водят видео разговори, както и да се правят и изпращат снимки и видеа."; +"NSPhotoLibraryUsageDescription" = "Разрешете достъп до снимките, за да можете да качвате снимки и видеа от галерията си."; +"NSMicrophoneUsageDescription" = "Element се нуждае от достъп до микрофона за да прави и приема обаждания, да снима видеа и да записва гласови съобщения."; +"NSContactsUsageDescription" = "Ще бъдат споделени със сървъра ви за самоличност за да ви помогне да откриете контактите си в Matrix."; "NSCalendarsUsageDescription" = "Вижте насрочените срещи в приложението."; "NSFaceIDUsageDescription" = "Използва се Face ID за достъп до приложението."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Когато споделяте местоположението си с хората, Element се нуждае от достъп за да им покаже карта."; +"NSLocationWhenInUseUsageDescription" = "Когато споделяте местоположението си с хората, Element се нуждае от достъп за да им покаже карта."; diff --git a/Riot/Assets/bg.lproj/Localizable.strings b/Riot/Assets/bg.lproj/Localizable.strings index 26cf616f0..1d4b738f2 100644 --- a/Riot/Assets/bg.lproj/Localizable.strings +++ b/Riot/Assets/bg.lproj/Localizable.strings @@ -67,3 +67,54 @@ /* A user added a Jitsi call to a room */ "GROUP_CALL_STARTED" = "Беше стартиран групов разговор"; + +/* A user's membership has updated in an unknown way */ +"USER_MEMBERSHIP_UPDATED" = "%@ обнови профила си"; + +/* A user has change their avatar */ +"USER_UPDATED_AVATAR" = "%@ смени аватара си"; + +/* A user has change their name to a new name which we don't know */ +"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ смени името си"; + +/** Membership Updates **/ + +/* A user has change their name to a new name */ +"USER_UPDATED_DISPLAYNAME" = "%@ смени името си на %@"; + +/* A user has reacted to a message, but the reaction content is unknown */ +"GENERIC_REACTION_FROM_USER" = "%@ изпрати реакция"; + +/** Reactions **/ + +/* A user has reacted to a message, including the reaction e.g. "Alice reacted 👍". */ +"REACTION_FROM_USER" = "%@ реагира с %@"; + +/* New file message from a specific person, not referencing a room. */ +"LOCATION_FROM_USER" = "%@ сподели местоположението си"; + +/* New file message from a specific person, not referencing a room. */ +"FILE_FROM_USER" = "%@ изпрати файл %@"; + +/* New voice message from a specific person, not referencing a room. */ +"VOICE_MESSAGE_FROM_USER" = "%@ изпрати гласово съобщение"; + +/* New audio message from a specific person, not referencing a room. */ +"AUDIO_FROM_USER" = "%@ изпрати аудио файл %@"; + +/* New video message from a specific person, not referencing a room. */ +"VIDEO_FROM_USER" = "%@ изпрати видео"; + +/** Media Messages **/ + +/* New image message from a specific person, not referencing a room. */ +"PICTURE_FROM_USER" = "%@ изпрати снимка"; + +/* New message reply from a specific person in a named room. */ +"REPLY_FROM_USER_IN_ROOM_TITLE" = "%@ отговори в %@"; + +/* New message reply from a specific person, not referencing a room. */ +"REPLY_FROM_USER_TITLE" = "%@ отговори"; +/** General **/ + +"Notification" = "Уведомление"; diff --git a/Riot/Assets/bg.lproj/Vector.strings b/Riot/Assets/bg.lproj/Vector.strings index 9c760b32a..a749304aa 100644 --- a/Riot/Assets/bg.lproj/Vector.strings +++ b/Riot/Assets/bg.lproj/Vector.strings @@ -1837,3 +1837,24 @@ "notice_declined_video_call" = "%@ отказа разговора"; "e2e_passphrase_too_short" = "Паролата е прекалено кратка (трябва да е дълга поне %d символа)"; "resume_call" = "Възобнови"; +"onboarding_splash_login_button_title" = "Вече имам акаунт"; + +// MARK: Onboarding +"onboarding_splash_register_button_title" = "Създай акаунт"; +"accessibility_button_label" = "бутон"; +"saving" = "Запазване"; + +// Activities +"loading" = "Зареждане"; +"invite_to" = "Покани в %@"; +"confirm" = "Потвърди"; +"edit" = "Редактирай"; +"suggest" = "Предложи"; +"add" = "Добави"; +"existing" = "Съществуващо"; +"new_word" = "Ново"; +"stop" = "Спри"; +"done" = "Готово"; +"open" = "Отвори"; +"joining" = "Присъединяване"; +"enable" = "Включи"; diff --git a/Riot/Assets/de.lproj/Vector.strings b/Riot/Assets/de.lproj/Vector.strings index 1942aa352..5e7c7dee1 100644 --- a/Riot/Assets/de.lproj/Vector.strings +++ b/Riot/Assets/de.lproj/Vector.strings @@ -13,7 +13,7 @@ "title_people" = "Personen"; "title_rooms" = "Räume"; "warning" = "Warnung"; -"remove" = "Entferne"; +"remove" = "Entfernen"; "start" = "Starte"; "create" = "Erstellen"; "on" = "An"; @@ -100,7 +100,7 @@ "search_messages" = "Nachrichten"; "search_people" = "Personen"; "search_files" = "Dateien"; -"search_default_placeholder" = "Suche"; +"search_default_placeholder" = "Suchen"; "search_people_placeholder" = "Suche nach Nutzer-ID, Name oder E-Mail"; "search_no_result" = "Keine Ergebnisse"; // Directory @@ -153,15 +153,15 @@ "room_two_users_are_typing" = "%@ und %@ tippen…"; "room_many_users_are_typing" = "%@, %@ und andere tippen…"; "room_message_placeholder" = "Nachricht senden (unverschlüsselt)…"; -"encrypted_room_message_placeholder" = "Verschlüsselte Nachricht…"; -"room_message_short_placeholder" = "Sende eine Nachricht…"; +"encrypted_room_message_placeholder" = "Verschlüsselte Nachricht senden …"; +"room_message_short_placeholder" = "Nachricht senden …"; "room_offline_notification" = "Verbindung zum Server wurde unterbrochen."; -"room_unsent_messages_notification" = "Nachrichten wurden nicht gesendet."; -"room_unsent_messages_unknown_devices_notification" = "Nachrichten wurden nicht gesendet, da unbekannte Sitzungen vorhanden waren."; +"room_unsent_messages_notification" = "Senden der Nachrichten fehlgeschlagen."; +"room_unsent_messages_unknown_devices_notification" = "Senden der Nachrichten aufgrund unbekannter Sitzungen fehlgeschlagen."; "room_prompt_resend" = "Alle erneut senden"; -"room_prompt_cancel" = "Alles abbrechen"; +"room_prompt_cancel" = "Alle abbrechen"; "room_resend_unsent_messages" = "Ungesendete Nachrichten erneut senden"; -"room_delete_unsent_messages" = "Lösche ungesendete Nachrichten"; +"room_delete_unsent_messages" = "Nicht gesendete Nachrichten löschen"; "room_event_action_copy" = "Kopieren"; "room_event_action_quote" = "Zitieren"; "room_event_action_more" = "Mehr"; @@ -301,7 +301,7 @@ "room_participants_action_unban" = "Entsperren"; "room_participants_action_set_default_power_level" = "Besondere Berechtigungen entziehen"; "room_participants_action_start_voice_call" = "Starte Sprach-Anruf"; -"room_ongoing_conference_call" = "Laufender Konferenz-Anruf. Trete bei als %@ oder %@."; +"room_ongoing_conference_call" = "Laufender Konferenzanruf. Tritt als %@ oder %@ bei."; "room_event_action_redact" = "Entfernen"; "room_warning_about_encryption" = "Ende-zu-Ende-Verschlüsselung ist in Beta und ist evtl. nicht zuverlässig.\n\nMan sollte noch nicht darauf vertrauen, dass die Daten sicher sind.\n\nGeräte werden Nachrichten von vor dem Beitritt des Raumes nicht entschlüsseln können.\n\nVerschlüsselte Nachrichten sind nicht lesbar in Anwendungen, die die Verschlüsselung noch nicht implementiert haben."; "unknown_devices_alert" = "Dieser Raum enthält unbekannte Sitzungen, die nicht verifiziert wurden.\nDas bedeutet, es gibt keine Garantie, dass sie den angegebenen Benutzern gehört.\nWir empfehlen eine Überprüfung für jedes Gerät, bevor du weitermachst. Du kannst die Nachricht auch ohne Verifizierung erneut senden."; @@ -411,14 +411,14 @@ "auth_home_server_placeholder" = "URL (z.B. https://matrix.org)"; "auth_identity_server_placeholder" = "URL (z. B. https://vector.im)"; "room_ongoing_conference_call_close" = "Schließen"; -"room_conference_call_no_power" = "Du brauchst die Berechtigung Konferenzgespräche in diesem Raum zu verwalten"; +"room_conference_call_no_power" = "Du bist nicht berechtigt, Konferenzgespräche in diesem Raum zu verwalten"; "settings_labs_create_conference_with_jitsi" = "Erstelle Konferenzgespräche mit Jitsi"; "call_already_displayed" = "Es existiert bereits ein Gespräch."; "call_jitsi_error" = "Konferenzgespräch konnte nicht betreten werden."; // Widget "widget_no_power_to_manage" = "Du brauchst die Berechtigung um Widgets in diesem Raum zu verwalten"; "widget_creation_failure" = "Widget-Erstellung fehlgeschlagen"; -"room_ongoing_conference_call_with_close" = "Laufendes Konferenzgespräch. Trete mit %@ oder %@ bei. %@ es."; +"room_ongoing_conference_call_with_close" = "Laufendes Konferenzgespräch. Tritt als %@ oder %@ bei. %@ es."; "settings_ui_theme" = "Thema"; "settings_ui_theme_auto" = "Auto"; "settings_ui_theme_light" = "Hell"; @@ -436,13 +436,13 @@ "call_incoming_voice" = "Eingehender Anruf…"; "call_incoming_video" = "Eingehender Videoanruf…"; // Widget Integration Manager -"widget_integration_need_to_be_able_to_invite" = "Du musst Benutzer einladen können um das zu tun."; +"widget_integration_need_to_be_able_to_invite" = "Du musst Benutzer einladen dürfen, um dies zu tun."; "widget_integration_unable_to_create" = "Erstellen des Widgets nicht möglich."; "widget_integration_failed_to_send_request" = "Senden der Anfrage fehlgeschlagen."; "widget_integration_room_not_recognised" = "Dieser Raum wurde nicht erkannt."; "widget_integration_positive_power_level" = "Berechtigungslevel muss eine positive Zahl sein."; "widget_integration_must_be_in_room" = "Du bist nicht in diesem Raum."; -"widget_integration_no_permission_in_room" = "Du hast keine Berechtigung dies in diesem Raum zu tun."; +"widget_integration_no_permission_in_room" = "Du bist nicht berechtigt, dies in diesem Raum zu tun."; "widget_integration_missing_room_id" = "room_id fehlt in der Anfrage."; "widget_integration_missing_user_id" = "user_id fehlt in der Anfrage."; "widget_integration_room_not_visible" = "Raum %@ ist nicht sichtbar."; @@ -502,7 +502,7 @@ // Group rooms "group_rooms_filter_rooms" = "Filtere Community-Räume"; "e2e_room_key_request_message_new_device" = "Du hast die neue Sitzung '%@' hinzugefügt, welche Verschlüsselungs-Schlüssel anfordert."; -"room_do_not_have_permission_to_post" = "Du hast keine Berechtigung Nachrichten in diesem Raum zu senden"; +"room_do_not_have_permission_to_post" = "Du bist nicht berechtigt, Nachrichten in diesem Raum zu senden"; "room_event_action_kick_prompt_reason" = "Grund für das Entfernen des Benutzers"; "room_event_action_ban_prompt_reason" = "Grund für die Verbannung der Person"; "room_action_send_photo_or_video" = "Foto oder Video senden"; @@ -532,8 +532,8 @@ "rerequest_keys_alert_title" = "Anfrage gesendet"; "rerequest_keys_alert_message" = "Bitte %@ auf einem anderen Gerät öffnen, das die Nachricht entschlüsseln kann, damit es die Schlüssel an diese Sitzung senden kann."; "room_message_reply_to_placeholder" = "Antwort senden (unverschlüsselt)…"; -"encrypted_room_message_reply_to_placeholder" = "Sende eine verschlüsselte Antwort…"; -"room_message_reply_to_short_placeholder" = "Sende eine Antwort…"; +"encrypted_room_message_reply_to_placeholder" = "Verschlüsselte Antwort senden …"; +"room_message_reply_to_short_placeholder" = "Antwort senden …"; "room_replacement_information" = "Dieser Raum wurde ersetzt und ist nicht länger aktiv."; "room_replacement_link" = "Die Konversation wird hier fortgesetzt."; "room_predecessor_information" = "Dieser Raum ist die Fortsetzung einer anderen Konversation."; @@ -568,10 +568,10 @@ "settings_key_backup_info_trust_signature_invalid_device_verified" = "Sicherungskopie hat eine ungültige Signatur von %@"; "settings_key_backup_info_trust_signature_invalid_device_unverified" = "Sicherungskopie hat eine ungültige Signatur von %@"; "settings_key_backup_button_create" = "Beginne Wiederherstellung mit Hilfe der Sicherheitskopie"; -"settings_key_backup_button_restore" = "Wiederherstellung mit Hilfe der Sicherheitskopie"; -"settings_key_backup_button_delete" = "Sicherheitskopie löschen"; +"settings_key_backup_button_restore" = "Von Sicherung wiederherstellen"; +"settings_key_backup_button_delete" = "Lösche Sicherung"; "settings_key_backup_button_use" = "Benutze Schlüssel Sicherheitskopie"; -"settings_key_backup_delete_confirmation_prompt_title" = "Sicherheitskopie löschen"; +"settings_key_backup_delete_confirmation_prompt_title" = "Lösche Sicherung"; "settings_key_backup_delete_confirmation_prompt_msg" = "Bist du Sicher? Damit gehen alle verschlüsselten Mitteilungen verloren wenn deine Schlüssel nicht anderweitig richtig gespeichert wurden."; "room_does_not_exist" = "%@ existiert nicht"; "key_backup_setup_title" = "Sicherheitskopie des Schlüssels"; @@ -628,7 +628,7 @@ "key_backup_setup_banner_subtitle" = "Beginne Schlüsselsicherung zu nutzen"; "key_backup_recover_banner_title" = "Verliere niemals verschlüsselte Nachrichten"; "key_backup_recover_banner_subtitle" = "Benutze Schlüsselsicherung"; -"sign_out_existing_key_backup_alert_title" = "Bist du sicher, dass du dich abmelden willst?"; +"sign_out_existing_key_backup_alert_title" = "Bist du sicher, dass du dich abmelden möchtest?"; "sign_out_existing_key_backup_alert_sign_out_action" = "Abmelden"; "sign_out_non_existing_key_backup_alert_title" = "Du verlierst den Zugriff auf deine verschlüsselten Nachrichten, wenn du dich jetzt abmeldest"; "sign_out_non_existing_key_backup_alert_setup_key_backup_action" = "Beginne Schlüsselsicherung zu nutzen"; @@ -636,7 +636,7 @@ "sign_out_non_existing_key_backup_sign_out_confirmation_alert_title" = "Du wirst deine verschlüsselten Nachrichten verlieren"; "sign_out_non_existing_key_backup_sign_out_confirmation_alert_message" = "Du verlierst den Zugriff auf deine verschlüsselten Nachrichten, es sei denn, du sicherst deine Schlüssel, bevor du dich abmeldest."; "sign_out_non_existing_key_backup_sign_out_confirmation_alert_sign_out_action" = "Abmelden"; -"sign_out_non_existing_key_backup_sign_out_confirmation_alert_backup_action" = "Sicherungskopie"; +"sign_out_non_existing_key_backup_sign_out_confirmation_alert_backup_action" = "Sicherung"; "sign_out_key_backup_in_progress_alert_title" = "Schlüsselsicherung läuft. Wenn du dich jetzt abmeldest, verlierst du den Zugriff auf deine verschlüsselten Nachrichten."; "sign_out_key_backup_in_progress_alert_discard_key_backup_action" = "Ich brauche meine verschlüsselten Nachrichten nicht"; "sign_out_key_backup_in_progress_alert_cancel_action" = "Ich werde warten"; @@ -646,7 +646,7 @@ "e2e_key_backup_wrong_version_button_settings" = "Einstellungen"; "e2e_key_backup_wrong_version_button_wasme" = "Das war ich"; "key_backup_setup_intro_manual_export_info" = "(Erweitert)"; -"key_backup_setup_intro_manual_export_action" = "Manueller Schlüssel-Export"; +"key_backup_setup_intro_manual_export_action" = "Schlüssel manuell exportieren"; // String for App Store "store_short_description" = "Sicherer, dezentralisierter Chat/VoIP"; "store_full_description" = "Element ist die neue Art von Kommunikations- und Kooperations-App, die:\n\n1. dir die Kontrolle gibt, deine Privatsphäre zu schützen\n2. dir die Kommunikation mit anderen Personen im Matrix-Netzwerk und darüber hinaus Integration in Apps wie Slack ermöglicht\n3. dich vor Werbung, Datenerfassung, Hintertüren und geschlossene Plattformen schützt\n4. dich durch Ende-zu-Ende-Verschlüsselung absichert und mit Quersignaturen andere überprüft\n\nElement unterscheidet sich grundlegend von anderen Kommunikations- und Kooperations-Diensten, da es dezentralisiert und Open-Source ist.\n\nElement lässt dir die Wahl, ob du einen eigenen Server betreibst oder einen bestehenden wählst, sodass du Datenschutz, Eigentum und Kontrolle über deine Daten und Konversationen hast. Du erhältst Zugriff auf ein offenes Netzwerk und bist nicht auf Element-Nutzer beschränkt. Und es ist sehr sicher.\n\nElement ist in der Lage, all dies zu tun, da es mit Matrix arbeitet – dem Standard für offene, dezentrale Kommunikation.\n\nMit Element hast du die Kontrolle, indem du auswählen kannst, bei wem deine Unterhaltungen liegen. In der Element-App kannst du verschiedene Betreiber auswählen:\n\n1. Hole dir ein kostenloses Konto auf dem öffentlichen Server von matrix.org\n2. Beherberge dein Konto selbst, indem du einen Server auf deiner eigenen Hardware betreibst\n3. Registriere ein Konto auf einem maßgeschneiderten Server, indem du einfach die Element-Matrix-Services abonnierst\n\nWarum Element?\n\nBESITZE DEINE DATEN: Du entscheidest, wo deine Daten und Nachrichten aufbewahrt werden sollen. Du besitzt und kontrollierst sie, nicht irgendein MEGAKONZERN, der deine Daten verwertet oder dritten Zugriff gewährt.\n\nOFFENE KOMMUNIKATION UND ZUSAMMENARBEIT: Du kannst mit allen anderen Mitgliedern des Matrix-Netzwerks schreiben, unabhängig davon, ob sie Element oder eine andere Matrix-App verwenden, selbst wenn sie eine andere Plattform wie beispielsweise Slack, IRC oder XMPP verwenden.\n\nSUPER SICHER: Echte Ende-zu-Ende-Verschlüsselung (nur diejenigen in der Konversation können Nachrichten entschlüsseln) und Quersignierung, um die Geräte der Konversationsteilnehmer zu überprüfen.\n\nVOLLSTÄNDIGE KOMMUNIKATION: Schreiben, Sprach- und Videoanrufe, Dateifreigabe, Bildschirmfreigabe und eine ganze Reihe von Integrationen, Bots und Widgets. Erschaffe Räume, Gemeinschaften, bleib in Kontakt und erledige Dinge.\n\nÜBERALL, WO DU BIST: Bleibe mit dem vollständig synchronisierten Nachrichtenverlauf auf all deinen Geräten und im Internet (unter https://element.io/app) unabhängig voneinander in Kontakt."; @@ -657,9 +657,9 @@ "room_event_action_edit" = "Bearbeiten"; "room_action_reply" = "Antworten"; "settings_labs_message_reaction" = "Mit einem Emoji reagieren"; -"settings_key_backup_button_connect" = "Verbinde diese Sitzung mit der Schlüsselsicherung"; +"settings_key_backup_button_connect" = "Verbinde diese Sitzung mit einer Schlüsselsicherung"; "event_formatter_message_edited_mention" = "(bearbeitet)"; -"key_backup_setup_intro_setup_connect_action_with_existing_backup" = "Schlüssel dieses Geräts sichern"; +"key_backup_setup_intro_setup_connect_action_with_existing_backup" = "Verbinde dieses Gerät mit einer Schlüsselsicherung"; "key_backup_recover_connent_banner_subtitle" = "Schlüssel dieser Sitzung sichern"; // MARK: - Device Verification "device_verification_title" = "Sitzung verifizieren"; @@ -743,8 +743,8 @@ "room_action_send_file" = "Datei senden"; "room_message_edits_history_title" = "Bearbeitungsverlauf"; // Widget -"widget_no_integrations_server_configured" = "Kein Integrationsserver konfiguriert"; -"widget_integrations_server_failed_to_connect" = "Verbindung zum Integrationsserver fehlgeschlagen"; +"widget_no_integrations_server_configured" = "Kein Integrations-Server konfiguriert"; +"widget_integrations_server_failed_to_connect" = "Verbindung zum Integrations-Server fehlgeschlagen"; "device_verification_security_advice" = "Für maximale Sicherheit empfehlen wir, dies persönlich zu tun oder ein anderes vertrauenswürdiges Kommunikationsmittel zu verwenden"; "device_verification_incoming_description_1" = "Überprüfe diese Sitzung, um sie als vertrauenswürdig zu markieren. Sitzungen von Partnern zu vertrauen gibt dir zusätzliche Sicherheit bei der Verwendung von Ende-zu-Ende verschlüsselten Nachrichten."; "device_verification_incoming_description_2" = "Wenn du diese Sitzung verifizierst, wird sie für dich und für dein Gegenüber als vertrauenswürdig gekennzeichnet."; @@ -821,7 +821,7 @@ "media_type_accessibility_video" = "Video"; "media_type_accessibility_location" = "Standort"; "media_type_accessibility_file" = "Datei"; -"media_type_accessibility_sticker" = "Aufkleber"; +"media_type_accessibility_sticker" = "Sticker"; "settings_identity_server_settings" = "IDENTITÄTSERVER"; "settings_three_pids_management_information_part1" = "Verwalte hier, mit welchen E-Mail-Adressen oder Telefonnummern du dich anmeldest, oder dein Konto wiederherstellen kannst. Kontrolliere, wer dich finden kann "; "settings_three_pids_management_information_part3" = "."; @@ -902,7 +902,7 @@ "room_participants_security_loading" = "Lade…"; "room_participants_security_information_room_not_encrypted" = "Nachrichten in diesem Raum sind nicht Ende-zu-Ende verschlüsselt."; "settings_security" = "SICHERHEIT"; -"settings_integrations_allow_description" = "Benutze einen Integrationsmanager (%@), um Bots, Bridges, Widgets und Aufkleberpakete zu verwalten.\n\nIntegrationsmanager erhalten Konfigurationsdaten und können Widgets verändern, Raum-Einladungen versenden sowie Berechtigungen in deinem Namen einstellen."; +"settings_integrations_allow_description" = "Nutze einen Integrationsassistenten (%@), um Bots, Brücken, Widgets und Sticker-Pakete zu verwalten.\n\nIntegrationsassistenten erhalten Konfigurationsdaten und können Widgets verändern, Raumeinladungen versenden sowie Berechtigungen in deinem Namen einstellen."; "settings_labs_enable_cross_signing" = "Aktiviere Cross-Signing, um deinen Gesprächspartner anstatt dessen Gerät zu verifizieren (in Entwicklung)"; // Security settings "security_settings_title" = "Sicherheit"; @@ -945,9 +945,9 @@ "key_verification_tile_request_incoming_title" = "Verifizierungsanfrage"; "key_verification_tile_request_outgoing_title" = "Verifizierung gesendet"; "key_verification_tile_request_status_data_loading" = "Daten laden…"; -"key_verification_tile_request_status_waiting" = "Warten…"; +"key_verification_tile_request_status_waiting" = "Warten …"; "key_verification_tile_request_status_expired" = "Abgelaufen"; -"key_verification_tile_request_status_cancelled_by_me" = "Du hast abgebrochen"; +"key_verification_tile_request_status_cancelled_by_me" = "Du brachst ab"; "key_verification_tile_request_status_cancelled" = "%@ hat abgebrochen"; "key_verification_tile_request_status_accepted" = "Du hast akzeptiert"; "key_verification_tile_request_incoming_approval_accept" = "Annehmen"; @@ -958,7 +958,7 @@ "user_verification_start_verify_action" = "Verifizierung starten"; "user_verification_start_information_part1" = "Für zusätzliche Sicherheit verifizieren "; "user_verification_start_information_part2" = " indem ein einmaliger Code auf beiden Geräten überprüft wird."; -"user_verification_start_waiting_partner" = "Warte auf %@…"; +"user_verification_start_waiting_partner" = "Warte auf %@ …"; "user_verification_start_additional_information" = "Um sicher zu sein, tut dies persönlich oder verwendet einen anderen Kommunikationsweg."; "user_verification_sessions_list_user_trust_level_trusted_title" = "Vertraut"; "user_verification_sessions_list_user_trust_level_warning_title" = "Warnung"; @@ -974,7 +974,7 @@ "user_verification_session_details_information_trusted_other_user_part2" = " verifiziert:"; "user_verification_session_details_information_untrusted_current_user" = "Verifiziere diese Sitzung, um sie als vertrauenswürdig zu markieren, und gewähren ihr Zugriff auf verschlüsselte Nachrichten:"; "user_verification_session_details_information_untrusted_other_user" = " hat sich in einer neuen Sitzung angemeldet:"; -"user_verification_session_details_additional_information_untrusted_other_user" = "Bis dieser Benutzer diese Sitzung vertraut, werden an und von ihm gesendete Nachrichten mit Warnungen gekennzeichnet. Alternativ kannst du dies manuell überprüfen."; +"user_verification_session_details_additional_information_untrusted_other_user" = "Bis dieser Benutzer dieser Sitzung vertraut, werden an und von ihm gesendete Nachrichten mit Warnungen gekennzeichnet. Alternativ kannst du dies manuell überprüfen."; "user_verification_session_details_additional_information_untrusted_current_user" = "Wenn du dich nicht zu dieser Sitzung angemeldet hast, ist dein Konto möglicherweise gefährdet."; "user_verification_session_details_verify_action_current_user" = "Interaktiv überprüfen"; "user_verification_session_details_verify_action_other_user" = "Manuell Verifizieren"; @@ -986,8 +986,8 @@ "device_verification_self_verify_alert_cancel_action" = "Das war ich nicht"; "device_verification_self_verify_start_verify_action" = "Überprüfung starten"; "device_verification_self_verify_start_information" = "Benutze diese Sitzung um deine Neue zu verifizieren. Erlaube Zugriff auf die verschlüsselten Nachrichten."; -"device_verification_self_verify_start_waiting" = "Warte…"; -"device_verification_self_verify_wait_title" = "vervollständige Sicherheit"; +"device_verification_self_verify_start_waiting" = "Warten …"; +"device_verification_self_verify_wait_title" = "Sicherheit vervollständigen"; "device_verification_self_verify_wait_information" = "Überprüfe diese Sitzung von einer anderen aus, um Zugriff auf die verschlüsselten Nachrichten zu erhalten.\n\nBenutze die neuest %@-Sitzung auf deinem anderen Gerät:"; "device_verification_self_verify_wait_waiting" = "warte…"; "skip" = "Überspringen"; @@ -1000,14 +1000,14 @@ "room_member_power_level_short_custom" = "Selbstdefiniert"; "security_settings_secure_backup" = "SICHERE SICHERHEITSKOPIE"; "security_settings_secure_backup_synchronise" = "Synchronisiere"; -"security_settings_secure_backup_delete" = "Backup löschen"; +"security_settings_secure_backup_delete" = "Lösche Sicherung"; "security_settings_crosssigning_info_ok" = "Quersignierung ist bereit zur Anwendung."; "security_settings_crosssigning_reset" = "Zurücksetzen"; "security_settings_coming_soon" = "Entschuldigung, diese Funktion ist noch nicht für %@ iOS verfügbar. Bitte nutze eine andere Matrix-Anwendung, um es einzurichten. %@ iOS wird es benutzen."; "security_settings_user_password_description" = "Bestätige deine Identität durch Eingabe des Passworts deines Matrix-Kontos"; // AuthenticatedSessionViewControllerFactory "authenticated_session_flow_not_supported" = "Diese App unterstützt nicht diese Authentifizierungsmethode für deinen Heimserver."; -"secure_key_backup_setup_intro_title" = "Sichere Datensicherung"; +"secure_key_backup_setup_intro_title" = "Verschlüsselte Sicherung"; "store_promotional_text" = "Privatsphäre-wahrende Kollaborations-App in einem offenen Netzwerk. Dezentral, um dir die Kontrolle zu geben. Keine Datenerfassung, keine Hintertüren und kein Zugriff durch Dritte."; "room_participants_action_security_status_complete_security" = "Vollständige Sicherheit"; "external_link_confirmation_title" = "Überprüfe diesen Link genau"; @@ -1024,17 +1024,17 @@ "event_formatter_widget_removed_by_you" = "Du hast das Widget entfernt: %@"; "event_formatter_jitsi_widget_added_by_you" = "Du hast eine VoIP-Konferenz hinzugefügt"; "event_formatter_jitsi_widget_removed_by_you" = "Du hast eine VoIP-Konferenz entfernt"; -"secure_key_backup_setup_intro_info" = "Absicherung um den Zugriffsverlust auf verschlüsselte Nachrichten und Daten zu verhindern, indem die Schlüssel für die Entschlüsselung auf dem Server gesichert werden."; +"secure_key_backup_setup_intro_info" = "Verhindere, den Zugriff auf verschlüsselte Nachrichten und Daten zu verlieren, indem du die Verschlüsselungs-Schlüssel auf deinem Server sicherst."; "secure_key_backup_setup_intro_use_security_key_title" = "Benutze einen Sicherheitsschlüssel"; -"secure_key_backup_setup_intro_use_security_key_info" = "Generiere einen Sicherheitsschlüssel, welcher z.B. in einer Passwortverwaltung oder in einem Tresor sicher aufbewahrt werden sollte."; +"secure_key_backup_setup_intro_use_security_key_info" = "Generiere einen Sicherheitsschlüssel, den du in einem Passwort-Manager oder Tresor sicher aufbewahren solltest."; "secure_key_backup_setup_intro_use_security_passphrase_title" = "Benutze Sicherungsphrase"; "secure_key_backup_setup_intro_use_security_passphrase_info" = "Gib eine geheime Phrase ein, die nur du kennst, um einen Schlüssel für die Sicherung zu generieren."; "secure_key_backup_setup_existing_backup_error_title" = "Eine Sicherheitskopie für Nachrichten existiert bereits"; "secure_key_backup_setup_existing_backup_error_info" = "Entsperre es, um es in der sicheren Datensicherung wiederzuverwenden, oder lösche es, um eine neue Nachrichtensicherung zu erstellen."; "secure_key_backup_setup_existing_backup_error_unlock_it" = "Entschlüsseln"; -"secure_key_backup_setup_cancel_alert_title" = "Sicher?"; -"secure_key_backup_setup_cancel_alert_message" = "Wenn du jetzt abbrichst und den Zugriff zu deinen Sitzungen verlierst, kannst du verschlüsselte Nachrichten & Daten verlieren.\n\nDu kannst auch eine Sicherung einrichten und deine Schlüssel in den Einstellungen verwalten."; -"secure_backup_setup_banner_title" = "Sichere Datensicherung"; +"secure_key_backup_setup_cancel_alert_title" = "Bist du sicher?"; +"secure_key_backup_setup_cancel_alert_message" = "Wenn du jetzt abbrichst und den Zugriff zu deinen Sitzungen verlierst, kannst du verschlüsselte Nachrichten und Daten verlieren.\n\nDu kannst auch eine Sicherung einrichten und deine Schlüssel in den Einstellungen verwalten."; +"secure_backup_setup_banner_title" = "Verschlüsselte Sicherung"; "secure_backup_setup_banner_subtitle" = "Absicherung gegen den Zugriffsverlust auf verschlüsselte Nachrichten und Daten"; // Recover from private key "key_backup_recover_from_private_key_info" = "Sicherung wird wiederhergestellt…"; @@ -1083,8 +1083,8 @@ "key_verification_verify_qr_code_other_scan_my_code_title" = "Hat dein Gegenüber den QR-Code erfolgreich gescannt?"; "key_verification_verify_qr_code_scan_other_code_success_title" = "Code erfolgreich überprüft!"; // Scanning -"key_verification_scan_confirmation_scanning_title" = "Fast da! Warten auf Bestätigung…"; -"key_verification_scan_confirmation_scanning_user_waiting_other" = "Warten auf %@…"; +"key_verification_scan_confirmation_scanning_title" = "Fast geschafft! Warte auf Bestätigung …"; +"key_verification_scan_confirmation_scanning_user_waiting_other" = "Warte auf %@ …"; "key_verification_scan_confirmation_scanning_device_waiting_other" = "Warte auf das andere Gerät…"; // Scanned "key_verification_scan_confirmation_scanned_title" = "Fast da!"; @@ -1166,7 +1166,7 @@ "biometrics_cant_unlocked_alert_message_x" = "Zum Entsperren nutze %@ oder melde dich erneut an und reaktiviere %@"; "biometrics_cant_unlocked_alert_message_login" = "Erneut anmelden"; "biometrics_cant_unlocked_alert_message_retry" = "Erneut probieren"; -"device_verification_self_verify_wait_recover_secrets_checking_availability" = "Nach anderen Überprüfungsfunktionen suchen ..."; +"device_verification_self_verify_wait_recover_secrets_checking_availability" = "Nach anderen Überprüfungsfunktionen suchen …"; "joined" = "Beigetreten"; "switch" = "Ändern"; "more" = "Mehr"; @@ -1178,14 +1178,14 @@ "searchable_directory_x_network" = "%@ Netzwerk"; "searchable_directory_search_placeholder" = "Name oder ID"; "create_room_title" = "Neuer Raum"; -"create_room_section_header_name" = "NAME"; +"create_room_section_header_name" = "Name"; "create_room_placeholder_name" = "Name"; "create_room_section_header_topic" = "THEMA (OPTIONAL)"; "create_room_placeholder_topic" = "Um was geht es in diesem Raum?"; "create_room_section_header_encryption" = "VERSCHLÜSSELUNG"; "create_room_enable_encryption" = "Verschlüsselung aktivieren"; "create_room_section_footer_encryption" = "Verschlüsselung kann im Nachhinein nicht deaktiviert werden."; -"create_room_section_header_type" = "BEITRITTSBERECHTIGTE"; +"create_room_section_header_type" = "Beitrittsberechtigte"; "create_room_type_private" = "Privater Raum (nur Eingeladene)"; "create_room_type_public" = "Öffentlicher Raum (jeder hat Zugriff)"; "create_room_section_footer_type" = "Personen können einen privaten Raum nur mit Einladung betreten."; @@ -1301,8 +1301,8 @@ "settings_show_NSFW_public_rooms" = "Öffentliche Räume mit anstößigen Inhalte anzeigen"; "room_open_dialpad" = "Wähltastatur"; "room_place_voice_call" = "Sprachanruf"; -"room_unsent_messages_cancel_message" = "Bist du dir sicher alle nicht gesendete Nachrichten in diesem Raum zu löschen?"; -"room_unsent_messages_cancel_title" = "Lösche nicht gesendete Nachrichten"; +"room_unsent_messages_cancel_message" = "Bist du dir sicher, dass du alle nicht gesendeten Nachrichten in diesem Raum löschen möchtest?"; +"room_unsent_messages_cancel_title" = "Nicht gesendete Nachrichten löschen"; "callbar_return" = "Zurück"; "callbar_only_multiple_paused" = "%@ pausierte Anrufe"; "callbar_only_single_paused" = "Pausierter Anruf"; @@ -1366,7 +1366,7 @@ // Success from secure backup "key_backup_setup_success_from_secure_backup_info" = "Deine Schlüssel werden gesichert."; -"security_settings_secure_backup_restore" = "Von Backup wiederherstellen"; +"security_settings_secure_backup_restore" = "Von Sicherung wiederherstellen"; "security_settings_secure_backup_reset" = "Zurücksetzen"; "security_settings_secure_backup_info_valid" = "Diese Sitzung sichert deine Schlüssel."; "security_settings_secure_backup_info_checking" = "Überprüfen…"; @@ -1483,7 +1483,7 @@ // Alert explaining what an identity server / integration manager is. "service_terms_modal_information_title_identity_server" = "Indentitätsserver"; -"service_terms_modal_description_integration_manager" = "Das erlaubt dir Bots, Bridges und Stickerpacks zu verwenden."; +"service_terms_modal_description_integration_manager" = "Dies wird dir die Verwendung von Bots, Brücken und Sticker-Paketen ermöglichen."; "service_terms_modal_description_identity_server" = "Dies erlaubt Personen, die deine Telefonnummer oder E-Mail in ihren Kontakten hat, dich zu finden."; "service_terms_modal_table_header_identity_server" = "NUTZUNGSBEDINGUNGEN IDENTITÄTSSERVER"; "service_terms_modal_table_header_integration_manager" = "NUTZUNGSBEDINGUNGEN INTEGRATIONSMANAGER"; @@ -1506,12 +1506,12 @@ "poll_edit_form_add_option" = "Option hinzufügen"; "poll_edit_form_option_number" = "Option %lu"; "poll_edit_form_question_or_topic" = "Frage oder Thematik"; -"room_event_action_end_poll" = "Umfrage beenden"; -"room_event_action_remove_poll" = "Umfrage entfernen"; +"room_event_action_end_poll" = "Abstimmung beenden"; +"room_event_action_remove_poll" = "Abstimmung entfernen"; // Mark: - Polls -"poll_edit_form_create_poll" = "Umfrage erstellen"; +"poll_edit_form_create_poll" = "Abstimmung erstellen"; "settings_labs_enabled_polls" = "Umfragen"; "share_extension_send_now" = "Jetzt senden"; "accessibility_button_label" = "Knopf"; @@ -1538,7 +1538,7 @@ "poll_edit_form_poll_question_or_topic" = "Frage oder Thema der Umfrage"; "poll_edit_form_input_placeholder" = "Schreib etwas"; "analytics_prompt_terms_link_upgrade" = "hier"; -"poll_timeline_not_closed_title" = "Fehler beim Beenden der Abstimmung"; +"poll_timeline_not_closed_title" = "Beenden der Abstimmung fehlgeschlagen"; "poll_timeline_vote_not_registered_subtitle" = "Wir konnten deine Stimme leider nicht erfassen. Versuche es bitte erneut"; "poll_timeline_total_final_results" = "Es wurden %lu Stimmen abgegeben"; "poll_timeline_total_final_results_one_vote" = "Es wurde 1 Stimme abgegeben"; @@ -1547,7 +1547,7 @@ "poll_timeline_not_closed_subtitle" = "Versuche es bitte erneut"; "poll_timeline_vote_not_registered_title" = "Stimme nicht erfasst"; "poll_edit_form_post_failure_subtitle" = "Versuche es bitte erneut"; -"poll_edit_form_post_failure_title" = "Fehler beim Senden der Abstimmung"; +"poll_edit_form_post_failure_title" = "Absenden der Abstimmung fehlgeschlagen"; "share_extension_low_quality_video_message" = "Für eine bessere Qualität sende es in %@ oder sende es in niedriger Qualität."; "share_extension_low_quality_video_title" = "Das Video wird in niedriger Qualität gesendet werden"; "analytics_prompt_stop" = "Teilen beenden"; @@ -1588,11 +1588,11 @@ "onboarding_splash_register_button_title" = "Konto erstellen"; "settings_enable_room_message_bubbles" = "Nachrichtenblasen"; "poll_edit_form_update_failure_subtitle" = "Bitte erneut versuchen"; -"poll_edit_form_poll_type" = "Umfragetyp"; -"poll_edit_form_poll_type_closed_description" = "Ergebnisse werden erst angezeigt, wenn du die Umfrage beendest"; -"poll_edit_form_poll_type_closed" = "Geschlossene Umfrage"; -"poll_edit_form_poll_type_open_description" = "Ergebnisse werden direkt nach Stimmabgabe angezeigt"; -"poll_edit_form_poll_type_open" = "Offene Umfrage"; +"poll_edit_form_poll_type" = "Abstimmungsart"; +"poll_edit_form_poll_type_closed_description" = "Die Ergebnisse werden erst sichtbar, sobald du die Umfrage beendest"; +"poll_edit_form_poll_type_closed" = "Abgeschlossene Abstimmung"; +"poll_edit_form_poll_type_open_description" = "Abstimmende können die Ergebnisse nach Stimmabgabe sehen"; +"poll_edit_form_poll_type_open" = "Laufende Abstimmung"; "poll_edit_form_update_failure_title" = "Aktualisierung der Umfrage fehlgeschlagen"; "threads_empty_tip" = "Hinweis: Tippe auf eine Nachricht und wähle „Thread“ um einen neuen zu starten."; "threads_empty_info_my" = "Antworte auf einen laufenden Thread oder tippe auf eine Nachricht und wähle „Thread“ um einen neuen zu starten."; @@ -1646,11 +1646,11 @@ // Login Screen "login_create_account" = "Konto erstellen:"; "login_server_url_placeholder" = "URL (z.B. https://matrix.org)"; -"login_home_server_title" = "Heimserver-URL:"; -"login_home_server_info" = "Dein Heimserver speichert alle deine Gespräche und Benutzerkontodaten"; -"login_identity_server_title" = "Identitätsserver-URL:"; -"login_identity_server_info" = "Matrix stellt Identitätsserver bereit, um feststellen zu können, welche E-Mail-Adressen, etc. zu welchen Matrix-IDs gehören. Momentan existiert nur https://vector.im."; -"login_user_id_placeholder" = "Matrix-ID (z.B. @bob:matrix.org oder bob)"; +"login_home_server_title" = "Heim-Server-Adresse:"; +"login_home_server_info" = "Dein Heim-Server speichert all deine Gespräche und Kontodaten"; +"login_identity_server_title" = "Identitätsserver-Adresse:"; +"login_identity_server_info" = "Matrix unterstützt Identitäts-Server, um zu ermitteln, welche E-Mail-Adressen etc. zu welchen Matrix-IDs gehören. Momentan existiert nur https://vector.im."; +"login_user_id_placeholder" = "Matrix-ID (z. B. @bob:matrix.org oder bob)"; "login_password_placeholder" = "Passwort"; "login_optional_field" = "optional"; "login_display_name_placeholder" = "Anzeigename (z.B. Peter Pan)"; @@ -1766,7 +1766,7 @@ "notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ hat den zukünftigen Verlauf für alle Raumteilnehmer ab deren Einladung sichtbar gemacht."; "notice_crypto_unable_to_decrypt" = "** Entschlüsselung nicht möglich: %@ **"; "notice_crypto_error_unknown_inbound_session_id" = "Die absendende Sitzung hat uns keine Schlüssel für diese Nachricht gesendet."; -"notice_sticker" = "Aufkleber"; +"notice_sticker" = "Sticker"; "notice_in_reply_to" = "Als Antwort auf"; // room display name "room_displayname_empty_room" = "Leerer Raum"; @@ -1804,10 +1804,10 @@ "room_event_encryption_info_device_id" = "ID\n"; "room_event_encryption_info_device_verification" = "Überprüfung\n"; "room_event_encryption_info_device_fingerprint" = "Ed25519-Fingerabdruck\n"; -"room_event_encryption_info_device_verified" = "Überprüft"; +"room_event_encryption_info_device_verified" = "Verifiziert"; "room_event_encryption_info_device_not_verified" = "NICHT verifiziert"; "room_event_encryption_info_device_blocked" = "auf schwarzer Liste"; -"room_event_encryption_info_verify" = "Überprüfe..."; +"room_event_encryption_info_verify" = "Verifiziere …"; "room_event_encryption_info_unverify" = "Verifizierung widerrufen"; "room_event_encryption_info_block" = "Blockieren"; "room_event_encryption_info_unblock" = "Blockierung aufheben"; @@ -1839,7 +1839,7 @@ "room_creation_alias_placeholder" = "(z.B. #foo:example.org)"; "room_creation_alias_placeholder_with_homeserver" = "(z.B. #foo%@)"; "room_creation_participants_title" = "Teilnehmer:"; -"room_creation_participants_placeholder" = "(z.B. @laura:heimserver1; @thomas:heimserver2...)"; +"room_creation_participants_placeholder" = "(z. B. @laura:heimserver1; @thomas:heimserver2 …)"; // Room "room_please_select" = "Bitte wähle einen Raum"; "room_error_join_failed_title" = "Konnte Raum nicht betreten"; @@ -1873,14 +1873,14 @@ "attachment_multiselection_size_prompt" = "Bilder senden als:"; "attachment_multiselection_original" = "Originalgröße"; "attachment_e2e_keys_file_prompt" = "Diese Datei enthält von einer Matrix-Anwendung exportierte Schlüssel.\nMöchtest du den Dateiinhalt sehen oder die Schlüssel importieren?"; -"attachment_e2e_keys_import" = "Importiere..."; +"attachment_e2e_keys_import" = "Importiere …"; // Contacts "contact_mx_users" = "Matrixbenutzer"; "contact_local_contacts" = "Lokale Kontakte"; // Groups // Search "search_no_results" = "Nichts gefunden"; -"search_searching" = "Suche wird durchgeführt..."; +"search_searching" = "Suche wird durchgeführt …"; // Time "format_time_s" = "s"; "format_time_m" = "m"; @@ -1912,9 +1912,9 @@ "power_level" = "Berechtigungsstufe"; "network_error_not_reachable" = "Bitte Netzwerkverbindung prüfen"; "user_id_placeholder" = "z. B.: @thomas:heimserver"; -"ssl_homeserver_url" = "Heimserver URL: %@"; +"ssl_homeserver_url" = "Heim-Server-Adresse: %@"; // Permissions -"camera_access_not_granted_for_call" = "Video-Anrufe benötigen Zugriff auf die Kamera, aber %@ hat keine Berechtigung"; +"camera_access_not_granted_for_call" = "Videoanrufe benötigen Zugriff auf die Kamera, aber %@ hat keine Berechtigung"; "microphone_access_not_granted_for_call" = "Anrufe benötigen Zugriff auf das Mikrofon, aber %@ hat keine Berechtigung"; "local_contacts_access_not_granted" = "Finden von Benutzern in lokalen Kontakten benötigt Zugriff auf die Kontakte, aber %@ hat keine Berechtigung"; "local_contacts_access_discovery_warning_title" = "Benutzer finden"; @@ -2074,7 +2074,7 @@ "notification_settings_people_join_leave_rooms" = "Benachrichtige, wenn Benutzer einen Raum betreten oder verlassen"; "notification_settings_receive_a_call" = "Benachrichtige, wenn ich einen Anruf erhalte"; "notification_settings_suppress_from_bots" = "Unterdrücke Benachrichtigungen von Bots"; -"notification_settings_by_default" = "Als Standard..."; +"notification_settings_by_default" = "Standardmäßig …"; "notification_settings_notify_all_other" = "Benachrichtige für alle anderen Nachrichten/Räume"; // gcm section // Settings keys @@ -2166,9 +2166,9 @@ "authentication_verify_email_input_message" = "%@ muss deinen Account verifizieren"; "authentication_cancel_flow_confirmation_message" = "Dein Account ist noch nicht angelegt. Registrierung wirklich abbrechen?"; "authentication_server_selection_generic_error" = "Unter dieser URL konnte kein Server gefunden werden. Bitte überprüfe die Eingabe."; -"authentication_server_selection_register_message" = "Wie ist die Adresse deines Servers? Der Server ist wie ein Zuhause für all deine Daten"; -"authentication_server_info_title_login" = "Wo deine Unterhaltungen zum Leben erwachen"; -"authentication_server_info_title" = "Wo deine Unterhaltungen zum Leben erwachen"; +"authentication_server_selection_register_message" = "Wie lautet die Adresse deines Servers? Dies ist eine Art Zuhause für all deine Daten"; +"authentication_server_info_title_login" = "Der zukünftige Ort deiner Gespräche"; +"authentication_server_info_title" = "Der zukünftige Ort deiner Gespräche"; "authentication_registration_username_footer" = "Du kannst dies später nicht mehr ändern"; // MARK: Authentication @@ -2213,9 +2213,9 @@ "authentication_forgot_password_input_message" = "%@ wird dir einen Bestätigungslink senden"; "authentication_forgot_password_input_title" = "Gib deine E-Mail-Adresse ein"; "authentication_verify_email_waiting_button" = "E-mail erneut senden"; -"authentication_server_selection_server_url" = "Homeserver-URL"; -"authentication_server_selection_login_message" = "Wie ist die Adresse deines Servers?"; -"authentication_server_selection_register_title" = "Wähle deinen Homeserver aus"; +"authentication_server_selection_server_url" = "Heim-Server-Adresse"; +"authentication_server_selection_login_message" = "Wie lautet die Adresse deines Servers?"; +"authentication_server_selection_register_title" = "Wähle deinen Heim-Server"; "authentication_verify_email_text_field_placeholder" = "E-Mail-Adresse"; "authentication_forgot_password_waiting_button" = "E-Mail erneut senden"; "authentication_verify_email_input_title" = "Gib deine E-Mail-Adresse ein"; @@ -2232,14 +2232,14 @@ "authentication_login_username" = "Nutzername / E-Mail-Adresse / Telefonnummer"; "authentication_login_title" = "Willkommen zurück!"; "authentication_server_selection_login_title" = "Mit Homeserver verbinden"; -"location_sharing_invalid_power_level_message" = "Du brauchst die richtigen Berechtigungen, um deinen Live-Standort in diesem Raum zu teilen."; -"location_sharing_invalid_power_level_title" = "Du hast keine Berechtigung deinen Live-Standort zu teilen"; +"location_sharing_invalid_power_level_message" = "Du benötigst die entsprechenden Berechtigungen, um deinen Echtzeit-Standort in diesem Raum freizugeben."; +"location_sharing_invalid_power_level_title" = "Dir fehlt die Berechtigung, deinen Echtzeit-Standort freigeben zu dürfen"; "authentication_choose_password_not_verified_message" = "Überprüfe deinen Posteingang"; "authentication_choose_password_not_verified_title" = "E-Mail Adresse nicht bestätigt"; -"message_reply_to_sender_sent_their_live_location" = "Live-Standort."; -"location_sharing_live_lab_promotion_activation" = "Aktiviere Live-Standortfreigabe"; -"location_sharing_live_lab_promotion_text" = "Bitte beachte: Dies ist eine experimentelle Funktion. Sie benutzt eine temporäre Implementation und ermöglicht, dass andere Personen in diesem Raum den Verlauf deines geteilten Standortes permanent sehen können."; -"location_sharing_live_lab_promotion_title" = "Live-Standort-Freigabe"; +"message_reply_to_sender_sent_their_live_location" = "Echtzeit-Standort."; +"location_sharing_live_lab_promotion_activation" = "Aktiviere Echtzeit-Standortfreigabe"; +"location_sharing_live_lab_promotion_text" = "Bitte beachte: Dies ist eine experimentelle Funktion und temporäre Implementation, die es anderen Personen in diesem Raum dauerhaft ermöglicht, deinen Standortfreigabeverlauf sehen zu können."; +"location_sharing_live_lab_promotion_title" = "Echtzeit-Standortfreigabe"; "room_info_back_button_title" = "Raum-Info"; "network_offline_message" = "Du bist offline, überprüfe deine Internetverbindung."; "network_offline_title" = "Du bist offline"; @@ -2265,44 +2265,44 @@ "location_sharing_allow_background_location_cancel_action" = "Nicht jetzt"; "location_sharing_allow_background_location_validate_action" = "Einstellungen"; "location_sharing_allow_background_location_title" = "Zugriff erlauben"; -"settings_labs_enable_live_location_sharing" = "Teilen des Live-Standortes - teile deinen aktuellen Standort (aktive Entwicklung, temporäre Standorte bleiben im Verlauf des Raums)"; -"location_sharing_live_stop_sharing_progress" = "Standort-Freigabe beenden"; -"location_sharing_live_stop_sharing_error" = "Teilen des Live-Standortes konnte nicht gestoppt werden"; +"settings_labs_enable_live_location_sharing" = "Echtzeit-Standortfreigabe – teile deinen aktuellen Standort (Aktive in Entwicklung und temporär verbleiben Standorte im Raumverlauf)"; +"location_sharing_live_stop_sharing_progress" = "Beende Standortfreigabe"; +"location_sharing_live_stop_sharing_error" = "Beenden der Echtzeit-Standortfreigabe fehlgeschlagen"; "location_sharing_live_no_user_locations_error_title" = "Keine Standorte verfügbar"; "location_sharing_live_timer_selector_long" = "für 8 Stunden"; "location_sharing_live_timer_selector_medium" = "für 1 Stunde"; "location_sharing_live_timer_selector_short" = "für 15 Minuten"; "location_sharing_live_timer_selector_title" = "Lege fest, wie lange dein genauer Standort für andere sichtbar ist."; -"location_sharing_live_error" = "Live-Standort fehlgeschlagen"; -"location_sharing_live_loading" = "Lade Live-Standort..."; -"location_sharing_live_timer_incoming" = "Live bis %@"; -"live_location_sharing_ended" = "Live-Standort beendet"; -"location_sharing_live_list_item_stop_sharing_action" = "Stop"; +"location_sharing_live_error" = "Echtzeit-Standort-Fehler"; +"location_sharing_live_loading" = "Lade Echtzeit-Standort …"; +"location_sharing_live_timer_incoming" = "Echtzeit bis %@"; +"live_location_sharing_ended" = "Echtzeit-Standort beendet"; +"location_sharing_live_list_item_stop_sharing_action" = "Beenden"; "location_sharing_live_list_item_current_user_display_name" = "Du"; "location_sharing_live_list_item_last_update_invalid" = "Letzter Standort unbekannt"; "location_sharing_live_list_item_last_update" = "Vor %@ aktualisiert"; "location_sharing_live_list_item_sharing_expired" = "Freigabe abgelaufen"; -"location_sharing_live_list_item_time_left" = "%@ hat verlassen"; +"location_sharing_live_list_item_time_left" = "%@ übrig"; "location_sharing_live_viewer_title" = "Standort"; -"location_sharing_live_map_callout_title" = "Standort teilen"; +"location_sharing_live_map_callout_title" = "Standort freigeben"; "settings_presence_offline_mode_description" = "Wenn diese Option aktiviert ist, wirst Du anderen Nutzer:innen immer als offline angezeigt, auch wenn Du die Anwendung verwendest."; "settings_presence_offline_mode" = "Offline-Modus"; "settings_presence" = "Präsenz"; "threads_discourage_information_2" = "\n\nWillst du Threads trotzdem aktivieren?"; "threads_beta_cancel" = "Nicht jetzt"; "threads_beta_enable" = "Probiere es aus"; -"threads_beta_information_link" = "Mehr Informationen"; +"threads_beta_information_link" = "Mehr erfahren"; "threads_beta_information" = "Organisiere Diskussionen mit Threads.\n\nThreads helfen, Konversationen zu folgen und beim Thema zu bleiben. "; "threads_beta_title" = "Threads"; "ignore_user" = "Nutzer:in ignorieren"; "location_sharing_pin_drop_share_title" = "Teile diesen Standort"; "location_sharing_static_share_title" = "Meinen aktuellen Standort schicken"; -"live_location_sharing_banner_stop" = "Stop"; -"live_location_sharing_banner_title" = "Live-Standort aktiviert"; +"live_location_sharing_banner_stop" = "Beenden"; +"live_location_sharing_banner_title" = "Echtzeit-Standort aktiviert"; // MARK: Live location sharing -"location_sharing_live_share_title" = "Teile Live-Standort"; +"location_sharing_live_share_title" = "Echtzeit-Standort freigeben"; "side_menu_coach_message" = "Wische nach rechts oder tippe, um alle Räume zu sehen"; "spaces_add_room_missing_permission_message" = "Du hast keine Berechtigung, Räume zu diesem Space hinzuzufügen."; "spaces_creation_in_one_space" = "in 1 Space"; @@ -2310,7 +2310,7 @@ "spaces_creation_in_spacename_plus_many" = "in %@ + %@ Spaces"; "spaces_creation_in_spacename_plus_one" = "in %@ + 1 Space"; "spaces_creation_in_spacename" = "in %@"; -"spaces_creation_post_process_inviting_users" = "Lade %@ Nutzer:innen ein"; +"spaces_creation_post_process_inviting_users" = "Lade %@ Benutzer ein"; "spaces_creation_post_process_adding_rooms" = "Füge %@ Räume hinzu"; "spaces_creation_post_process_creating_room" = "Erstelle %@"; "spaces_creation_post_process_uploading_avatar" = "Lade Profilbild hoch"; @@ -2330,8 +2330,8 @@ "spaces_creation_email_invites_email_title" = "E-Mail"; "spaces_creation_email_invites_message" = "Du kannst sie auch später einladen."; "spaces_creation_email_invites_title" = "Lade dein Team ein"; -"spaces_creation_new_rooms_support" = "Support"; -"spaces_creation_new_rooms_random" = "Zufällig"; +"spaces_creation_new_rooms_support" = "Unterstützung"; +"spaces_creation_new_rooms_random" = "Ohne Thema"; "spaces_creation_new_rooms_general" = "Allgemein"; "spaces_creation_new_rooms_room_name_title" = "Raumname"; "spaces_creation_new_rooms_title" = "Worüber werdet ihr reden?"; @@ -2365,7 +2365,7 @@ "spaces_explore_rooms_room_number" = "%@ Räume"; "spaces_create_space_title" = "Einen Space erstellen"; "spaces_add_space_title" = "Space erstellen"; -"space_invite_not_enough_permission" = "Du hast keine Berechtigung, Personen zu diesem Space einzuladen"; +"space_invite_not_enough_permission" = "Du hast keine Berechtigung, Personen in diesen Space einzuladen"; "room_invite_not_enough_permission" = "Du hast keine Berechtigung, Personen zu diesem Raum einzuladen"; "room_invite_to_room_option_detail" = "Sie werden kein Teil von %@ sein."; "room_invite_to_room_option_title" = "Nur zu diesem Raum"; @@ -2473,7 +2473,7 @@ "room_access_settings_screen_public_message" = "Sichtbar und zugänglich für jeden."; "room_access_settings_screen_restricted_message" = "Sichtbar und betretbar für jeden Nutzer in einem Space.\nDu wählst, für welche Spaces dies gilt."; "room_access_settings_screen_private_message" = "Nur sichtbar und betretbar für eingeladene Personen."; -"location_sharing_allow_background_location_message" = "Wenn du deinen Live-Standort teilen möchtest, benötigt Element den Standortzugriff auch im Hintergrund. Um den Zugriff zu ermöglichen, tippe auf Einstellungen > Standort und wähle ‘Immer‘ aus"; +"location_sharing_allow_background_location_message" = "Wenn du deinen Echtzeit-Standort freigeben möchtest, benötigt Element den Standortzugriff auch im Hintergrund. Um den Zugriff zu gewähren, tippe auf Einstellungen > Standort und wähle „Immer“"; "space_selector_empty_view_information" = "Spaces sind eine neue Möglichkeit, Räume und Personen zu gruppieren. Erstelle einen Space, um zu beginnen."; "all_chats_onboarding_title" = "Was ist neu"; "all_chats_onboarding_page_message3" = "Drücke auf dein Profil um uns Wissen zu lassen, was du denkst."; @@ -2491,7 +2491,7 @@ "all_chats_nothing_found_placeholder_message" = "Versuche, deine Suche anzupassen."; "all_chats_edit_layout_recents" = "Historie"; "all_chats_edit_layout" = "Layouteinstellungen"; -"spaces_creation_new_rooms_message" = "Wir werden für jedes Thema einen Raum erstellen."; +"spaces_creation_new_rooms_message" = "Wir werden für jedes einen Raum erstellen."; "create_room_section_footer_type_public" = "Sichtbar und betretbar für alle eingeladenen Personen, nicht nur jene, die sich im Space befinden."; // First item is client name and second item is session display name @@ -2504,11 +2504,11 @@ "all_chats_edit_layout_add_section_message" = "Abschnitt an Startseite für schnellen Zugriff anpinnen"; "all_chats_edit_layout_add_section_title" = "Abschnitt zur Startseite hinzufügen"; "device_name_desktop" = "%@ Desktop"; -"user_sessions_overview_current_session_section_title" = "AKTUELLE SITZUNG"; -"user_sessions_overview_other_sessions_section_title" = "ANDERE SITZUNGEN"; +"user_sessions_overview_current_session_section_title" = "Aktuelle Sitzung"; +"user_sessions_overview_other_sessions_section_title" = "Andere Sitzungen"; "device_name_unknown" = "Unbekannte Anwendung"; "device_name_mobile" = "%@ Mobil"; -"user_session_item_details" = "%@ · Neueste Aktivität %@"; +"user_session_item_details" = "%1$@ · %2$@"; "user_session_unverified_additional_info" = "Verifiziere deine aktuelle Sitzung für besonders sichere Kommunikation."; "user_session_verified_additional_info" = "Deine aktuelle Sitzung ist für sichere Kommunikation bereit."; "user_session_learn_more" = "Mehr erfahren"; @@ -2516,8 +2516,138 @@ "user_session_verify_action" = "Sitzung verifizieren"; "user_session_unverified_short" = "Nicht verifiziert"; "user_session_verified_short" = "Verifiziert"; -"user_session_unverified" = "Nicht verifizierte Sitzungen"; -"user_session_verified" = "Verifizierte Sitzungen"; +"user_session_unverified" = "Nicht verifizierte Sitzung"; +"user_session_verified" = "Verifizierte Sitzung"; "user_sessions_overview_other_sessions_section_info" = "Für bestmögliche Sicherheit verifiziere deine Sitzungen und melde dich von allen ab, die du nicht erkennst oder nutzt."; "settings_labs_enable_new_app_layout" = "Neues App-Layout"; "room_first_message_placeholder" = "Schreibe deine erste Nachricht …"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "Die Echtheit dieser verschlüsselten Nachricht kann auf diesem Gerät nicht garantiert werden."; +"user_session_overview_session_details_button_title" = "Sitzungsdetails"; +"user_session_overview_session_title" = "Sitzung"; +"user_session_overview_current_session_title" = "Aktuelle Sitzung"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Version"; +"user_session_details_application_name" = "Name"; +"user_session_details_device_os" = "Betriebssystem"; +"user_session_details_device_browser" = "Browser"; +"user_session_details_device_model" = "Modell"; +"user_session_details_device_ip_location" = "IP-Standort"; +"user_session_details_device_ip_address" = "IP-Adresse"; +"user_session_details_session_section_footer" = "Kopiere beliebige Daten, in dem du sie gedrückt hältst."; +"user_session_details_session_id" = "Sitzungs-ID"; +"user_session_details_session_name" = "Sitzungsname"; +"user_session_details_device_section_header" = "Gerät"; +"user_session_details_application_section_header" = "Anwendung"; +"user_session_details_session_section_header" = "Sitzung"; +"user_session_details_title" = "Sitzungsdetails"; +"user_session_push_notifications_message" = "Wenn aktiviert, wird diese Sitzung Push-Benachrichtigungen erhalten."; +"user_session_push_notifications" = "Push-Benachrichtigungen"; +"user_sessions_view_all_action" = "Alle anzeigen (%1$d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Erwäge, dich aus alten (90 Tage oder mehr), nicht mehr verwendeten Sitzungen abzumelden."; +"user_sessions_overview_security_recommendations_inactive_title" = "Inaktive Sitzungen"; +"user_sessions_overview_security_recommendations_unverified_info" = "Nicht verifizierte Sitzungen verifizieren oder abmelden."; +"user_sessions_overview_security_recommendations_unverified_title" = "Nicht verifizierte Sitzungen"; +"user_sessions_overview_security_recommendations_section_info" = "Verbessere deine Kontosicherheit, indem du diese Empfehlungen beherzigst."; +"user_sessions_overview_security_recommendations_section_title" = "Sicherheitsempfehlungen"; +"all_chats_user_menu_accessibility_label" = "Benutzermenü"; +"settings_labs_enable_new_client_info_feature" = "Bezeichnung, Version und URL der Anwendung registrieren, damit diese Sitzung in der Sitzungsverwaltung besser erkennbar ist"; +"settings_labs_enable_new_session_manager" = "Neue Sitzungsverwaltung"; +"authentication_qr_login_start_step2" = "Gehe zu Einstellungen -> Sicherheit und Privatsphäre"; +"authentication_qr_login_scan_subtitle" = "Positioniere den QR-Code innerhalb des Quadrats"; +"authentication_qr_login_display_step2" = "Wähle „Anmelden mit QR-Code“"; +"authentication_qr_login_scan_title" = "QR-Code einlesen"; +"authentication_qr_login_display_subtitle" = "Lese den folgenden QR-Code mit deinem abgemeldeten Gerät ein."; +"authentication_qr_login_start_need_alternative" = "Benötigst du eine andere Methode?"; +"authentication_qr_login_start_display_qr" = "QR-Code auf diesem Gerät anzeigen"; +"authentication_qr_login_start_step4" = "Wähle „Zeige QR-Code auf diesem Gerät“"; +"authentication_qr_login_display_title" = "Verbinde ein Gerät"; +"authentication_qr_login_start_step3" = "Wähle „Verbinde ein Gerät“"; +"authentication_qr_login_start_title" = "QR-Code einlesen"; +"authentication_login_with_qr" = "Anmelden mit QR-Code"; +"device_type_name_unknown" = "Unbekannt"; +"device_type_name_mobile" = "Mobil"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Desktop"; +"user_inactive_session_item_with_date" = "Inaktiv seit 90+ Tagen (%@)"; +"user_inactive_session_item" = "Inaktiv seit 90+ Tagen"; +"user_other_session_unverified_sessions_header_subtitle" = "Für besonders sichere Kommunikation verifiziere deine Sitzungen oder melde dich von ihnen ab, falls du sie nicht mehr identifizieren kannst."; +"user_other_session_security_recommendation_title" = "Sicherheitsempfehlung"; +"user_sessions_overview_link_device" = "Verbinde ein Gerät"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"sign_out_confirmation_message" = "Bist du sicher, dass du dich abmelden möchtest?"; + +// MARK: Sign out warning + +"sign_out" = "Abmelden"; +"manage_session_rename" = "Sitzung umbenennen"; +"authentication_qr_login_failure_retry" = "Erneut versuchen"; +"authentication_qr_login_failure_request_timed_out" = "Die Verbindung wurde nicht in der vorgeschriebenen Zeit abgeschlossen."; +"authentication_qr_login_failure_request_denied" = "Die Anfrage wurde auf dem anderen Gerät verweigert."; +"authentication_qr_login_failure_invalid_qr" = "QR-Code ist ungültig."; +"authentication_qr_login_failure_title" = "Verbindung gescheitert"; +"authentication_qr_login_loading_signed_in" = "Du bist nun mit deinem anderen Gerät angemeldet."; +"authentication_qr_login_loading_waiting_signin" = "Warte auf Geräteanmeldung."; +"authentication_qr_login_loading_connecting_device" = "Verbinde mit Gerät"; +"authentication_qr_login_confirm_alert" = "Bitte stelle sicher, dass du die Quelle dieses Codes kennst. Durch das Verbinden des Gerätes wirst du jemandem vollen Zugriff auf dein Konto gewähren."; +"authentication_qr_login_confirm_subtitle" = "Bestätige, dass der folgende Code mit dem auf deinem anderen Gerät übereinstimmt:"; +"authentication_qr_login_confirm_title" = "Sichere Verbindung aufgebaut"; +"authentication_qr_login_display_step1" = "Öffne Element auf deinem anderen Gerät"; +"authentication_qr_login_start_step1" = "Öffne Element auf deinem anderen Gerät"; +"authentication_qr_login_start_subtitle" = "Nutze die Kamera dieses Gerätes, um den auf deinem anderen Gerät angezeigten QR-Code einzulesen:"; +"wysiwyg_composer_start_action_text_formatting" = "Textformatierung"; +"wysiwyg_composer_start_action_camera" = "Kamera"; +"wysiwyg_composer_start_action_location" = "Standort"; +"wysiwyg_composer_start_action_polls" = "Umfragen"; +"wysiwyg_composer_start_action_attachments" = "Anhänge"; +"user_session_details_last_activity" = "Neueste Aktivität"; +"user_session_item_details_last_activity" = "Neueste Aktivität %@"; +"user_other_session_clear_filter" = "Filter zurücksetzen"; +"user_other_session_no_unverified_sessions" = "Keine unverifizierten Sitzungen gefunden."; +"user_other_session_no_verified_sessions" = "Keine verifizierten Sitzungen gefunden."; +"user_other_session_no_inactive_sessions" = "Keine inaktiven Sitzungen gefunden."; +"user_other_session_filter_menu_inactive" = "Inaktiv"; +"user_other_session_filter_menu_unverified" = "Nicht verifiziert"; +"user_other_session_filter_menu_verified" = "Verifiziert"; +"user_other_session_filter_menu_all" = "Alle Sitzungen"; +"user_other_session_verified_sessions_header_subtitle" = "Für bestmögliche Sicherheit, melde dich von allen Sitzungen ab, die du nicht erkennst oder nutzt."; +"user_other_session_current_session_details" = "Deine aktuelle Sitzung"; +"user_other_session_verified_additional_info" = "Diese Sitzung ist für sichere Kommunikation bereit."; +"user_other_session_unverified_additional_info" = "Für bestmögliche Sicherheit und Zuverlässigkeit verifiziere diese Sitzung oder melde sie ab."; +"user_session_verification_unknown_additional_info" = "Verifiziere deine aktuelle Sitzung, um den Verifizierungsstatus dieser Sitzung anzuzeigen."; +"user_session_verification_unknown_short" = "Unbekannt"; +"user_session_verification_unknown" = "Unbekannter Verifizierungsstatus"; +"manage_session_name_info_link" = "Mehr erfahren"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Sei dir bitte bewusst, dass Sitzungsnamen auch für Personen, mit denen du kommunizierst, sichtbar sind. %@"; +"manage_session_name_hint" = "Individuelle Sitzungsnamen können dir helfen, deine Geräte einfacher zu erkennen."; +"user_other_session_filter" = "Filtern"; +"wysiwyg_composer_format_action_strikethrough" = "Unterstrichen formatieren"; +"wysiwyg_composer_format_action_underline" = "Durchgestrichen formatieren"; +"wysiwyg_composer_format_action_italic" = "Kursiv formatieren"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Fett formatieren"; +"wysiwyg_composer_start_action_stickers" = "Sticker"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Fotobibliothek"; +"settings_labs_enable_wysiwyg_composer" = "Probiere den Rich-Text-Editor aus (bald auch mit Plain-Text-Modus)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Sprachübertragung"; +"voice_broadcast_already_in_progress_message" = "Du zeichnest bereits eine Sprachübertragung auf. Bitte beende die laufende Übertragung, um eine neue zu beginnen."; +"voice_broadcast_blocked_by_someone_else_message" = "Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest."; +"voice_broadcast_permission_denied_message" = "Du hast nicht die nötigen Berechtigungen, um eine Sprachübertragung in diesem Raum zu starten. Kontaktiere einen Raumadministrator, um deine Berechtigungen anzupassen."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Sprachübertragung kann nicht gestartet werden"; +"settings_labs_enable_voice_broadcast" = "Sprachübertragung (in aktiver Entwicklung)"; +"voice_broadcast_playback_loading_error" = "Wiedergabe der Sprachübertragung nicht möglich."; +"deselect_all" = "Alle abwählen"; +"user_other_session_menu_select_sessions" = "Sitzungen auswählen"; +"user_other_session_selected_count" = "%@ ausgewählt"; diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 9ff00a53d..6d9320f4a 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -19,3 +19,4 @@ // MARK: Onboarding Personalization WIP "image_picker_action_files" = "Choose from files"; +"voice_broadcast_in_timeline_title" = "Voice broadcast detected (under active development)"; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 571cf8959..1f04c58a2 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -156,6 +156,7 @@ "authentication_login_username" = "Username / Email / Phone"; "authentication_login_forgot_password" = "Forgot password"; "authentication_server_info_title_login" = "Where your conversations live"; +"authentication_login_with_qr" = "Sign in with QR code"; "authentication_server_selection_login_title" = "Connect to homeserver"; "authentication_server_selection_login_message" = "What is the address of your server?"; @@ -211,6 +212,37 @@ "authentication_recaptcha_title" = "Are you a human?"; +"authentication_qr_login_start_title" = "Scan QR code"; +"authentication_qr_login_start_subtitle" = "Use the camera on this device to scan the QR code shown on your other device:"; +"authentication_qr_login_start_step1" = "Open Element on your other device"; +"authentication_qr_login_start_step2" = "Go to Settings -> Security & Privacy"; +"authentication_qr_login_start_step3" = "Select ‘Link a device’"; +"authentication_qr_login_start_step4" = "Select ‘Show QR code on this device’"; +"authentication_qr_login_start_need_alternative" = "Need an alternative method?"; +"authentication_qr_login_start_display_qr" = "Show QR code on this device"; + +"authentication_qr_login_display_title" = "Link a device"; +"authentication_qr_login_display_subtitle" = "Scan the QR code below with your device that’s signed out."; +"authentication_qr_login_display_step1" = "Open Element on your other device"; +"authentication_qr_login_display_step2" = "Select ‘Sign in with QR code’"; + +"authentication_qr_login_scan_title" = "Scan QR code"; +"authentication_qr_login_scan_subtitle" = "Position the QR code in the square below"; + +"authentication_qr_login_confirm_title" = "Secure connection established"; +"authentication_qr_login_confirm_subtitle" = "Confirm that the code below matches with your other device:"; +"authentication_qr_login_confirm_alert" = "Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account."; + +"authentication_qr_login_loading_connecting_device" = "Connecting to device"; +"authentication_qr_login_loading_waiting_signin" = "Waiting for device to sign in."; +"authentication_qr_login_loading_signed_in" = "You are now signed in on your other device."; + +"authentication_qr_login_failure_title" = "Linking failed"; +"authentication_qr_login_failure_invalid_qr" = "QR code is invalid."; +"authentication_qr_login_failure_request_denied" = "The request was denied on the other device."; +"authentication_qr_login_failure_request_timed_out" = "The linking wasn’t completed in the required time."; +"authentication_qr_login_failure_retry" = "Try again"; + // MARK: Password Validation "password_validation_info_header" = "Your password should meet the criteria below:"; "password_validation_error_header" = "Given password does not meet the criteria below:"; @@ -765,6 +797,8 @@ Tap the + to start adding people."; "settings_labs_enable_new_session_manager" = "New session manager"; "settings_labs_enable_new_client_info_feature" = "Record the client name, version, and url to recognise sessions more easily in session manager"; "settings_labs_enable_new_app_layout" = "New Application Layout"; +"settings_labs_enable_wysiwyg_composer" = "Try out the rich text editor (plain text mode coming soon)"; +"settings_labs_enable_voice_broadcast" = "Voice broadcast (under active development)"; "settings_version" = "Version %@"; "settings_olm_version" = "Olm Version %@"; @@ -894,9 +928,14 @@ Tap the + to start adding people."; "manage_session_title" = "Manage session"; "manage_session_info" = "SESSION INFO"; "manage_session_name" = "Session name"; +"manage_session_name_hint" = "Custom session names can help you recognize your devices more easily."; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Please be aware that session names are also visible to people you communicate with. %@"; +"manage_session_name_info_link" = "Learn more"; "manage_session_trusted" = "Trusted by you"; "manage_session_not_trusted" = "Not trusted"; "manage_session_sign_out" = "Sign out of this session"; +"manage_session_rename" = "Rename session"; // User sessions management "user_sessions_settings" = "Manage sessions"; @@ -1449,6 +1488,9 @@ Tap the + to start adding people."; // MARK: Sign out warning +"sign_out" = "Sign out"; +"sign_out_confirmation_message" = "Are you sure you want to sign out?"; + "sign_out_existing_key_backup_alert_title" = "Are you sure you want to sign out?"; "sign_out_existing_key_backup_alert_sign_out_action" = "Sign out"; @@ -2147,6 +2189,13 @@ Tap the + to start adding people."; "voice_message_stop_locked_mode_recording" = "Tap on your recording to stop or listen"; "voice_message_lock_screen_placeholder" = "Voice message"; +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Can't start a new voice broadcast"; +"voice_broadcast_permission_denied_message" = "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions."; +"voice_broadcast_blocked_by_someone_else_message" = "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one."; +"voice_broadcast_already_in_progress_message" = "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one."; +"voice_broadcast_playback_loading_error" = "Unable to play this voice broadcast."; + // Mark: - Version check "version_check_banner_title_supported" = "We’re ending support for iOS %@"; @@ -2356,6 +2405,9 @@ To enable access, tap Settings> Location and select Always"; // MARK: User sessions management +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; + "user_sessions_overview_title" = "Sessions"; "user_sessions_overview_security_recommendations_section_title" = "Security recommendations"; @@ -2371,33 +2423,64 @@ To enable access, tap Settings> Location and select Always"; "user_sessions_overview_other_sessions_section_info" = "For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore."; "user_sessions_overview_current_session_section_title" = "Current session"; +"user_sessions_overview_link_device" = "Link a device"; "user_sessions_view_all_action" = "View all (%d)"; "user_session_verified" = "Verified session"; "user_session_unverified" = "Unverified session"; +"user_session_verification_unknown" = "Unknown verification status"; "user_session_verified_short" = "Verified"; "user_session_unverified_short" = "Unverified"; +"user_session_verification_unknown_short" = "Unknown"; "user_session_verify_action" = "Verify session"; "user_session_view_details" = "View details"; "user_session_learn_more" = "Learn more"; "user_session_verified_additional_info" = "Your current session is ready for secure messaging."; "user_session_unverified_additional_info" = "Verify your current session for enhanced secure messaging."; - +"user_session_verification_unknown_additional_info" = "Verify your current session to reveal this session's verification status."; +"user_other_session_unverified_additional_info" = "Verify or sign out from this session for best security and reliability."; +"user_other_session_verified_additional_info" = "This session is ready for secure messaging."; "user_session_push_notifications" = "Push notifications"; "user_session_push_notifications_message" = "When turned on, this session will receive push notifications."; +"user_other_session_security_recommendation_title" = "Security recommendation"; +"user_other_session_unverified_sessions_header_subtitle" = "Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore."; +"user_other_session_current_session_details" = "Your current session"; +"user_other_session_verified_sessions_header_subtitle" = "For best security, sign out from any session that you don’t recognize or use anymore."; + +"user_other_session_filter" = "Filter"; +"user_other_session_filter_menu_all" = "All sessions"; +"user_other_session_filter_menu_verified" = "Verified"; +"user_other_session_filter_menu_unverified" = "Unverified"; +"user_other_session_filter_menu_inactive" = "Inactive"; + +"user_other_session_no_inactive_sessions" = "No inactive sessions found."; +"user_other_session_no_verified_sessions" = "No verified sessions found."; +"user_other_session_no_unverified_sessions" = "No unverified sessions found."; +"user_other_session_clear_filter" = "Clear filter"; +"user_other_session_selected_count" = "%@ selected"; +"user_other_session_menu_select_sessions" = "Select sessions"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; -"user_session_item_details" = "%@ · Last activity %@"; +/* %1$@ will be the verification state and %2$@ will be user_session_item_details_verification_unknown or user_other_session_current_session_details */ +"user_session_item_details" = "%1$@ · %2$@"; +"user_session_item_details_last_activity" = "Last activity %@"; +"user_inactive_session_item" = "Inactive for 90+ days"; +"user_inactive_session_item_with_date" = "Inactive for 90+ days (%@)"; "device_name_desktop" = "%@ Desktop"; "device_name_web" = "%@ Web"; "device_name_mobile" = "%@ Mobile"; "device_name_unknown" = "Unknown client"; +"device_type_name_desktop" = "Desktop"; +"device_type_name_web" = "Web"; +"device_type_name_mobile" = "Mobile"; +"device_type_name_unknown" = "Unknown"; + "user_session_details_title" = "Session details"; "user_session_details_session_section_header" = "Session"; "user_session_details_application_section_header" = "Application"; @@ -2405,6 +2488,7 @@ To enable access, tap Settings> Location and select Always"; "user_session_details_session_name" = "Session name"; "user_session_details_session_id" = "Session ID"; "user_session_details_session_section_footer" = "Copy any data by tapping on it and holding it down."; +"user_session_details_last_activity" = "Last activity"; "user_session_details_device_ip_address" = "IP address"; "user_session_details_device_ip_location" = "IP location"; "user_session_details_device_model" = "Model"; @@ -2416,6 +2500,26 @@ To enable access, tap Settings> Location and select Always"; "user_session_overview_current_session_title" = "Current session"; "user_session_overview_session_title" = "Session"; "user_session_overview_session_details_button_title" = "Session details"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Photo Library"; +"wysiwyg_composer_start_action_stickers" = "Stickers"; +"wysiwyg_composer_start_action_attachments" = "Attachments"; +"wysiwyg_composer_start_action_polls" = "Polls"; +"wysiwyg_composer_start_action_location" = "Location"; +"wysiwyg_composer_start_action_camera" = "Camera"; +"wysiwyg_composer_start_action_text_formatting" = "Text Formatting"; +"wysiwyg_composer_start_action_voice_broadcast" = "Voice broadcast"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Apply bold format"; +"wysiwyg_composer_format_action_italic" = "Apply italic format"; +"wysiwyg_composer_format_action_underline" = "Apply strikethrough format"; +"wysiwyg_composer_format_action_strikethrough" = "Apply underline format"; + // MARK: - MatrixKit @@ -2488,6 +2592,7 @@ To enable access, tap Settings> Location and select Always"; "reset_to_default" = "Reset to default"; "resend_message" = "Resend the message"; "select_all" = "Select All"; +"deselect_all" = "Deselect All"; "cancel_upload" = "Cancel Upload"; "cancel_download" = "Cancel Download"; "show_details" = "Show Details"; diff --git a/Riot/Assets/et.lproj/Vector.strings b/Riot/Assets/et.lproj/Vector.strings index d84210c12..1dd7c1b42 100644 --- a/Riot/Assets/et.lproj/Vector.strings +++ b/Riot/Assets/et.lproj/Vector.strings @@ -2441,7 +2441,7 @@ "device_name_mobile" = "%@ Mobile"; "device_name_web" = "%@ Web"; "device_name_desktop" = "%@ Desktop"; -"user_session_item_details" = "%@ · Viimati kasutusel %@"; +"user_session_item_details" = "%@ · %@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2454,8 +2454,138 @@ "user_session_verified_short" = "Verifitseeritud"; "user_session_unverified" = "Verifitseerimata sessioon"; "user_session_verified" = "Verifitseeritud sessioon"; -"user_sessions_overview_current_session_section_title" = "PRAEGUNE SESSIOON"; +"user_sessions_overview_current_session_section_title" = "Praegune sessioon"; "user_sessions_overview_other_sessions_section_info" = "Parima turvalisuse nimel verifitseeri kõik oma sessioonid ning logi välja neist, mida sa enam ei kasuta."; -"user_sessions_overview_other_sessions_section_title" = "MUUD SESSIOONID"; +"user_sessions_overview_other_sessions_section_title" = "Muud sessioonid"; "settings_labs_enable_new_app_layout" = "Rakenduse uus paigutus"; "room_first_message_placeholder" = "Saada oma esimene sõnum…"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "Selle krüptitud sõnumi autentsus pole selles seadmes tagatud."; +"user_session_overview_session_details_button_title" = "Sessiooni teave"; +"user_session_overview_session_title" = "Sessioon"; +"user_session_overview_current_session_title" = "Praegune sessioon"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Versioon"; +"user_session_details_application_name" = "Nimi"; +"user_session_details_device_os" = "Operatsioonisüsteem"; +"user_session_details_device_browser" = "Brauser"; +"user_session_details_device_model" = "Mudel"; +"user_session_details_device_ip_location" = "IP-aadressi asukoht"; +"user_session_details_device_ip_address" = "IP-aadress"; +"user_session_details_session_section_footer" = "Pika vajutusega saad kopeerida andmeid."; +"user_session_details_session_id" = "Sessiooni tunnus"; +"user_session_details_session_name" = "Sessiooni nimi"; +"user_session_details_device_section_header" = "Seade"; +"user_session_details_application_section_header" = "Rakendus"; +"user_session_details_session_section_header" = "Sessioon"; +"user_session_details_title" = "Sessiooni teave"; +"user_session_push_notifications_message" = "Kui see valik on sisse lülitatud, siis see sessioon saab vastu võtta tõuketeavitusi."; +"user_session_push_notifications" = "Tõuketeavitused"; +"user_sessions_view_all_action" = "Näita kõiki (%d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Logi välja sellistest vanadest sessioonidest (vanemad kui 90 päeva), mida sa enam ei kasuta."; +"user_sessions_overview_security_recommendations_inactive_title" = "Mitteaktiivsed sessioonid"; +"user_sessions_overview_security_recommendations_unverified_info" = "Logi verifitseerimata sessioonidest välja või verifitseeri nad."; +"user_sessions_overview_security_recommendations_unverified_title" = "Verifitseerimata sessioonid"; +"user_sessions_overview_security_recommendations_section_info" = "Kui järgid neid soovitusi, siis sa parandad oma kasutajakonto turvalisust."; +"user_sessions_overview_security_recommendations_section_title" = "Turvalisusega seotud soovitused"; +"all_chats_user_menu_accessibility_label" = "Kasutajamenüü"; +"settings_labs_enable_new_client_info_feature" = "Sessioonide paremaks tuvastamiseks saad nüüd sessioonihalduris salvestada klientrakenduse nime, versiooni ja aadressi"; +"settings_labs_enable_new_session_manager" = "Uus sessioonihaldur"; +"authentication_qr_login_confirm_title" = "Turvaline ühendus on olemas"; +"authentication_qr_login_scan_subtitle" = "Joonda QR-kood allpool näidatud ruudu sisse"; +"authentication_qr_login_scan_title" = "Skaneeri QR-koodi"; +"authentication_qr_login_display_step2" = "Vali „Logi võrku QR-koodi abil“"; +"authentication_qr_login_display_step1" = "Ava Element oma teises seadmes"; +"authentication_qr_login_display_subtitle" = "Loe QR-koodi seadmega, kus sa oled Matrix'i võrgust välja loginud."; +"authentication_qr_login_display_title" = "Seo teise seadmega"; +"authentication_qr_login_start_display_qr" = "Näita selles seadmes QR-koodi"; +"authentication_qr_login_start_need_alternative" = "Kas soovid kasutada mõnda muud lahendust?"; +"authentication_qr_login_start_step4" = "Vali „Näita selles seadmes QR-koodi“"; +"authentication_qr_login_start_step3" = "Vali „Seo seade“"; +"authentication_qr_login_start_step2" = "Vali menüüst Seadistused -> Turvalisus ja privaatsus"; +"authentication_qr_login_start_step1" = "Ava Element mõnes oma muus seadmes"; +"authentication_qr_login_start_subtitle" = "Kasuta selle seadme kaamerat ja logi sisse teises seadmes kuvatud QR-koodi alusel:"; +"authentication_qr_login_start_title" = "Loe QR-koodi"; +"authentication_login_with_qr" = "Logi sisse QR-koodi abil"; +"wysiwyg_composer_format_action_strikethrough" = "Kasuta allajoonitud kirja"; +"wysiwyg_composer_format_action_underline" = "Kasuta läbijoonitud kirja"; +"wysiwyg_composer_format_action_italic" = "Kasuta kaldkirja"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Kasuta paksu kirja"; +"wysiwyg_composer_start_action_voice_broadcast" = "Ringhäälingukõne"; +"wysiwyg_composer_start_action_text_formatting" = "Tekstivorming"; +"wysiwyg_composer_start_action_camera" = "Kaamera"; +"wysiwyg_composer_start_action_location" = "Asukoht"; +"wysiwyg_composer_start_action_polls" = "Küsitlused"; +"wysiwyg_composer_start_action_attachments" = "Manused"; +"wysiwyg_composer_start_action_stickers" = "Kleepsud"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Fotode kogu"; +"user_session_details_last_activity" = "Viimati kasutusel"; +"device_type_name_unknown" = "Tundmatu seadmetüüp"; +"device_type_name_mobile" = "Mobiiltelefon"; +"device_type_name_web" = "Veebiliides"; +"device_type_name_desktop" = "Töölauarakendus"; +"user_inactive_session_item_with_date" = "Pole olnud kasutusel üle 90 päeva (%@)"; +"user_inactive_session_item" = "Pole olnud kasutusel üle 90 päeva"; +"user_session_item_details_last_activity" = "Viimati kasutusel %@"; +"user_other_session_clear_filter" = "Eemalda filter"; +"user_other_session_no_unverified_sessions" = "Verifitseerimata sessioone ei leidu."; +"user_other_session_no_verified_sessions" = "Verifitseeritud sessioone ei leidu."; +"user_other_session_no_inactive_sessions" = "Ei leidu sessioone, mis pole aktiivses kasutuses."; +"user_other_session_filter_menu_inactive" = "Pole pidevas kasutuses"; +"user_other_session_filter_menu_unverified" = "Verifitseerimata"; +"user_other_session_filter_menu_verified" = "Verifitseeritud"; +"user_other_session_filter_menu_all" = "Kõik sessioonid"; +"user_other_session_filter" = "Filtreeri"; +"user_other_session_verified_sessions_header_subtitle" = "Parima turvalisuse nimel logi välja neist sessioonidest, mida sa enam ei kasuta või ei tunne ära."; +"user_other_session_current_session_details" = "Sinu praegune sessioon"; +"user_other_session_unverified_sessions_header_subtitle" = "Turvalise sõnumvahetuse nimel verifitseeri kõik oma sessioonid ning logi neist välja, mida sa enam ei kasuta või ei tunne enam ära."; +"user_other_session_security_recommendation_title" = "Turvalisusega seotud soovitused"; +"user_other_session_verified_additional_info" = "See sessioon on valmis turvaliseks sõnumivahetuseks."; +"user_other_session_unverified_additional_info" = "Parima turvalisuse ja töökindluse nimel verifitseeri see sessioon või logi ta võrgust välja."; +"user_session_verification_unknown_additional_info" = "Selle sessiooni olekut ei saa tuvastada enne kui oled ta verifitseerinud."; +"user_session_verification_unknown_short" = "Teadmata olek"; +"user_session_verification_unknown" = "Verifitseerimise olek on määratlemata"; +"user_sessions_overview_link_device" = "Seo teise seadmega"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"voice_broadcast_playback_loading_error" = "Selle ringhäälingukõne esitamine ei õnnestu."; +"voice_broadcast_already_in_progress_message" = "Sa juba salvestad ringhäälingukõnet. Uue alustamiseks palun lõpeta eelmine salvestus."; +"voice_broadcast_blocked_by_someone_else_message" = "Keegi juba salvestab ringhäälingukõnet. Uue ringhäälingukõne salvestamiseks palun oota, kuni see teine ringhäälingukõne on lõppenud."; +"voice_broadcast_permission_denied_message" = "Sul pole piisavalt õigusi selles jututoas ringhäälingukõne algatamiseks. Õiguste lisamiseks palun võta ühendust jututoa haldajaga."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Uue ringhäälingukõne alustamine pole võimalik"; +"sign_out_confirmation_message" = "Kas sa oled kindel et soovid välja logida?"; + +// MARK: Sign out warning + +"sign_out" = "Logi välja"; +"manage_session_rename" = "Muuda sessiooni nime"; +"manage_session_name_info_link" = "Lisateave"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Palun arvesta, et sessioonide nimed on näha ka kõikidele osapooltele, kellega sa suhtled. %@"; +"manage_session_name_hint" = "Sinu enda kirjutatud sessiooninimede alusel on sul oma seadmeid lihtsam ära tunda."; +"settings_labs_enable_voice_broadcast" = "Ringhäälingukõne (aktiivses arenduses)"; +"settings_labs_enable_wysiwyg_composer" = "Proovi vormindatud teksti alusel töötavat tekstitoimetit (varsti lisandub ka vormindamata teksti režiim)"; +"authentication_qr_login_failure_retry" = "Proovi uuesti"; +"authentication_qr_login_failure_request_timed_out" = "Sidumine ei lõppenud etteantud aja jooksul."; +"authentication_qr_login_failure_request_denied" = "Teine seade lükkas päringu tagasi."; +"authentication_qr_login_failure_invalid_qr" = "QR-kood on vigane."; +"authentication_qr_login_failure_title" = "Seose loomine ei õnenstunud"; +"authentication_qr_login_loading_signed_in" = "Sa oled oma teises seadmes sisse loginud Matrix'i võrku."; +"authentication_qr_login_loading_waiting_signin" = "Ootame, et teine seade logiks võrku."; +"authentication_qr_login_loading_connecting_device" = "Loon ühendust seadmega"; +"authentication_qr_login_confirm_alert" = "Palun vaata, et sa kindlasti tead, kust see QR-kood kuvatakse. Sellisel viisil seadmete sidumisel sa annad oma kasutajakontole täiemahulise ligipääsu."; +"authentication_qr_login_confirm_subtitle" = "Kontrolli, et järgnev kood klapib teises seadmes kuvatava koodiga:"; +"deselect_all" = "Eemalda kõik valikud"; +"user_other_session_menu_select_sessions" = "Vali sessioonid"; +"user_other_session_selected_count" = "%@ valitud"; diff --git a/Riot/Assets/fa.lproj/Vector.strings b/Riot/Assets/fa.lproj/Vector.strings index 8ae59ee6b..fe51dcd78 100644 --- a/Riot/Assets/fa.lproj/Vector.strings +++ b/Riot/Assets/fa.lproj/Vector.strings @@ -1270,3 +1270,23 @@ "microphone_access_not_granted_for_voice_message" = "جهت ارسال پیام صوتی نیاز به دسترسی به میکروفون وجود دارد اما %@ دسترسی استفاده از آن را ندارد"; "e2e_passphrase_too_short" = "کلمه عبور بیش از حد کوتاه است (حداقل می‌بایست %d کاراکتر باشد)"; "message_reply_to_sender_sent_a_voice_message" = "یک پیام صوتی ارسال کنید."; +"onboarding_splash_page_1_title" = "صاحب گفتگوهای خود شوید."; +"onboarding_splash_login_button_title" = "من از قبل حساب کاربری دارم"; + +// MARK: Onboarding +"onboarding_splash_register_button_title" = "ساخت حساب کاربری"; +"accessibility_button_label" = "دکمه"; +"saving" = "در حال ذخیره"; + +// Activities +"loading" = "در حال بارگزاری"; +"invite_to" = "دعوت به %@"; +"confirm" = "تأیید"; +"edit" = "ویرایش"; +"suggest" = "پیشنهاد"; +"add" = "افزودن"; +"existing" = "خروج"; +"new_word" = "جدید"; +"stop" = "توقف"; +"joining" = "پیوستن"; +"enable" = "فعال"; diff --git a/Riot/Assets/fr.lproj/Vector.strings b/Riot/Assets/fr.lproj/Vector.strings index 3cf3a6a40..bb02d69cd 100644 --- a/Riot/Assets/fr.lproj/Vector.strings +++ b/Riot/Assets/fr.lproj/Vector.strings @@ -1044,7 +1044,7 @@ "security_settings_crosssigning_reset" = "Réinitialiser"; "security_settings_crosssigning_complete_security" = "Compléter la sécurité"; "security_settings_complete_security_alert_title" = "Améliorer la sécurité"; -"security_settings_complete_security_alert_message" = "Vous devriez d’abord améliorer la sécurité de votre session actuelle."; +"security_settings_complete_security_alert_message" = "Vous devriez d’abord améliorer la sécurité de la session courante."; "security_settings_coming_soon" = "Désolé, cette action n’est pas encore disponible dans %@ iOS. Utilisez un autre client Matrix pour le configurer. %@ iOS l’utilisera."; "device_verification_self_verify_wait_new_sign_in_title" = "Vérifier cette connexion"; "device_verification_self_verify_wait_additional_information" = "Ceci fonctionne avec %@ ou un autre client Matrix prenant en charge la signature croisée."; @@ -2506,3 +2506,102 @@ // User sessions management "user_sessions_settings" = "Gérer les sessions"; "invite_to" = "Inviter dans %@"; +"user_session_unverified_additional_info" = "Vérifiez cette session pour renforcer la sécurité de votre messagerie."; +"user_session_verified_additional_info" = "Cette session est prête pour la messagerie sécurisée."; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "L’authenticité de ce message chiffré ne peut être garantie sur cet appareil."; +"user_session_overview_session_details_button_title" = "Informations de session"; +"user_session_overview_session_title" = "Session"; +"user_session_overview_current_session_title" = "Session courante"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Version"; +"user_session_details_application_name" = "Nom"; +"user_session_details_device_os" = "Système d’exploitation"; +"user_session_details_device_browser" = "Navigateur"; +"user_session_details_device_model" = "Modèle"; +"user_session_details_device_ip_location" = "Emplacement de l’IP"; +"user_session_details_device_ip_address" = "Adresse IP"; +"user_session_details_session_section_footer" = "Copiez n’importe quelles données en faisant une pression longue dessus."; +"user_session_details_session_id" = "Identifiant de la session"; +"user_session_details_session_name" = "Nom de la session"; +"user_session_details_device_section_header" = "Appareil"; +"user_session_details_application_section_header" = "Application"; +"user_session_details_session_section_header" = "Session"; +"user_session_details_title" = "Informations de session"; +"device_type_name_unknown" = "Inconnu"; +"device_type_name_mobile" = "Mobile"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Bureau"; +"device_name_unknown" = "Client inconnu"; +"device_name_mobile" = "%@ Mobile"; +"device_name_web" = "%@ Web"; +"device_name_desktop" = "%@ Bureau"; +"user_inactive_session_item_with_date" = "Inactif depuis 90 jours ou plus (%@)"; +"user_inactive_session_item" = "Inactif depuis 90 jours ou plus"; +"user_session_item_details" = "%@ · Dernière activité %@"; + +// First item is client name and second item is session display name +"user_session_name" = "%@ : %@"; +"user_other_session_unverified_sessions_header_subtitle" = "Vérifiez vos sessions pour renforcer la sécurité de votre messagerie, ou déconnectez celles que vous ne reconnaissez ou utilisez plus."; +"user_other_session_security_recommendation_title" = "Recommandations de sécurité"; +"user_session_push_notifications_message" = "Lorsqu’activé, cette session recevra des notifications push."; +"user_session_push_notifications" = "Notifications push"; +"user_sessions_overview_current_session_section_title" = "Session courante"; +"user_session_learn_more" = "En apprendre plus"; +"user_session_view_details" = "Afficher les informations"; +"user_session_verify_action" = "Vérifier la session"; +"user_session_unverified_short" = "Non-vérifiée"; +"user_session_verified_short" = "Vérifiée"; +"user_session_unverified" = "Session non-vérifiée"; +"user_session_verified" = "Session vérifiée"; +"user_sessions_view_all_action" = "Tout afficher (%d)"; +"user_sessions_overview_link_device" = "Appairer un appareil"; +"user_sessions_overview_other_sessions_section_info" = "Pour une sécurité optimale, vérifiez vos sessions et déconnectez celles que vous de reconnaissez pas ou n’utilisez plus."; +"user_sessions_overview_other_sessions_section_title" = "Autres sessions"; +"user_sessions_overview_security_recommendations_inactive_info" = "Vous pourriez vouloir déconnecter les anciennes sessions que vous n’utilisez plus (depuis au moins 90 jours)."; +"user_sessions_overview_security_recommendations_inactive_title" = "Sessions inactives"; +"user_sessions_overview_security_recommendations_unverified_info" = "Vérifiez ou déconnectez les sessions non-vérifiées."; +"user_sessions_overview_security_recommendations_unverified_title" = "Sessions non-vérifiées"; +"user_sessions_overview_security_recommendations_section_info" = "Renforcez la sécurité de votre compte en suivant ces recommandations."; +"user_sessions_overview_security_recommendations_section_title" = "Recommandations de sécurité"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"all_chats_user_menu_accessibility_label" = "Menu utilisateur"; +"sign_out_confirmation_message" = "Êtes vous sûr de vouloir vous déconnecter ?"; + +// MARK: Sign out warning + +"sign_out" = "Déconnexion"; +"manage_session_rename" = "Renommer la session"; +"settings_labs_enable_new_app_layout" = "Nouvelle disposition"; +"settings_labs_enable_new_client_info_feature" = "Renseignez le nom du client, sa version, et son URL pour retrouvez vos sessions plus facilement dans le gestionnaire de sessions"; +"settings_labs_enable_new_session_manager" = "Nouveau gestionnaire de sessions"; +"room_first_message_placeholder" = "Envoyez votre premier message…"; +"authentication_qr_login_failure_retry" = "Réessayez"; +"authentication_qr_login_failure_request_timed_out" = "L’appairage n’a pas été effectué dans le temps imparti."; +"authentication_qr_login_failure_request_denied" = "La requête a été refusée sur l’autre appareil."; +"authentication_qr_login_failure_invalid_qr" = "Le QR code est invalide."; +"authentication_qr_login_failure_title" = "Échec de l’appairage"; +"authentication_qr_login_loading_signed_in" = "Vous êtes désormais connecté sur votre autre appareil."; +"authentication_qr_login_loading_waiting_signin" = "En attente de connexion de l’appareil."; +"authentication_qr_login_loading_connecting_device" = "Connexion à l’appareil"; +"authentication_qr_login_confirm_alert" = "Vérifiez l’origine de ce code. En appairant un appareil, vous lui fournissez un accès complet à votre compte."; +"authentication_qr_login_confirm_subtitle" = "Confirmez que le code ci-dessous correspond à celui sur votre autre appareil :"; +"authentication_qr_login_confirm_title" = "Connexion sécurisée établie"; +"authentication_qr_login_scan_subtitle" = "Placez le QR code dans le carré ci-dessous"; +"authentication_qr_login_scan_title" = "Scannez le QR code"; +"authentication_qr_login_display_step2" = "Sélectionnez « Se connecter avec un QR code »"; +"authentication_qr_login_display_step1" = "Ouvrez Element sur votre autre appareil"; +"authentication_qr_login_display_subtitle" = "Scannez le QR code ci-dessous avec l’appareil qui n’est pas connecté."; +"authentication_qr_login_display_title" = "Appairer un appareil"; +"authentication_qr_login_start_display_qr" = "Afficher le QR code sur cet appareil"; +"authentication_qr_login_start_need_alternative" = "Besoin d’une autre méthode ?"; +"authentication_qr_login_start_step4" = "Sélectionnez « Afficher le QR code sur cet appareil »"; +"authentication_qr_login_start_step3" = "Sélectionnez « Appairer un appareil »"; +"authentication_qr_login_start_step2" = "Allez dans Réglages -> Confidentialité et sécurité"; +"authentication_qr_login_start_step1" = "Ouvrez Element sur votre autre appareil"; +"authentication_qr_login_start_subtitle" = "Utilisez l’appareil photo de cet appareil pour scanner le QR code affiché sur votre autre appareil :"; +"authentication_qr_login_start_title" = "Scanner le QR code"; +"authentication_login_with_qr" = "Se connecter avec un QR code"; diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings index 87b74c5bb..1f72aab67 100644 --- a/Riot/Assets/hu.lproj/Vector.strings +++ b/Riot/Assets/hu.lproj/Vector.strings @@ -2489,7 +2489,7 @@ "device_name_mobile" = "%@ Mobil"; "device_name_desktop" = "%@ Alkalmazás"; "device_name_web" = "%@ Web"; -"user_session_item_details" = "%@ · Utolsó aktivitás %@"; +"user_session_item_details" = "%1$@ · %2$@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2502,8 +2502,138 @@ "user_session_verified_short" = "Hitelesített"; "user_session_unverified" = "Ellenőrizetlen munkamenet"; "user_session_verified" = "Ellenőrzött munkamenet"; -"user_sessions_overview_current_session_section_title" = "JELENLEGI MUNKAMENET"; +"user_sessions_overview_current_session_section_title" = "Jelenlegi munkamenet"; "user_sessions_overview_other_sessions_section_info" = "A legjobb biztonság érdekében ellenőrizd a munkameneteket, és jelentkezz ki minden olyan munkamenetből, melyet már nem ismersz fel vagy nem használsz."; -"user_sessions_overview_other_sessions_section_title" = "TOVÁBBI MUNKAMENETEK"; +"user_sessions_overview_other_sessions_section_title" = "További munkamenetek"; "settings_labs_enable_new_app_layout" = "Új alkalmazás kinézet"; "room_first_message_placeholder" = "Küld el az első üzenetedet…"; +"authentication_qr_login_confirm_title" = "Biztonságos kapcsolat beállítva"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "A titkosított üzenetek valódiságát ezen az eszközön nem lehet garantálni."; +"wysiwyg_composer_format_action_strikethrough" = "Aláhúzott"; +"wysiwyg_composer_format_action_underline" = "Áthúzott"; +"wysiwyg_composer_format_action_italic" = "Dőlt"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Félkövér"; +"wysiwyg_composer_start_action_text_formatting" = "Szöveg formázás"; +"wysiwyg_composer_start_action_camera" = "Kamera"; +"wysiwyg_composer_start_action_location" = "Földrajzi helyzet"; +"wysiwyg_composer_start_action_polls" = "Szavazások"; +"wysiwyg_composer_start_action_attachments" = "Mellékletek"; +"wysiwyg_composer_start_action_stickers" = "Matricák"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Fénykép könyvtár"; +"user_session_overview_session_details_button_title" = "Munkamenet információk"; +"user_session_overview_session_title" = "Munkamenet"; +"user_session_overview_current_session_title" = "Jelenlegi munkamenet"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Verzió"; +"user_session_details_application_name" = "Név"; +"user_session_details_device_os" = "Operációs rendszer"; +"user_session_details_device_browser" = "Böngésző"; +"user_session_details_device_model" = "Modell"; +"user_session_details_device_ip_location" = "Tartózkodási helyem"; +"user_session_details_device_ip_address" = "IP cím"; +"user_session_details_last_activity" = "Utolsó tevékenység"; +"user_session_details_session_section_footer" = "A másoláshoz koppints és tartsd rajta az ujjad."; +"user_session_details_session_id" = "Kapcsolat azonosító"; +"user_session_details_session_name" = "Munkamenet neve"; +"user_session_details_device_section_header" = "Eszköz"; +"user_session_details_application_section_header" = "Alkalmazás"; +"user_session_details_session_section_header" = "Munkamenet"; +"user_session_details_title" = "Munkamenet információk"; +"device_type_name_unknown" = "Ismeretlen"; +"device_type_name_mobile" = "Mobil"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Asztali"; +"user_inactive_session_item_with_date" = "90+ napja inaktív (%@)"; +"user_inactive_session_item" = "90+ napja inaktív"; +"user_session_item_details_last_activity" = "Utolsó tevékenység: %@"; +"user_other_session_clear_filter" = "Szűrő törlése"; +"user_other_session_no_unverified_sessions" = "Nincs ellenőrizetlen munkamenet."; +"user_other_session_no_verified_sessions" = "Nincs ellenőrzött munkamenet."; +"user_other_session_no_inactive_sessions" = "Nincs inaktív munkamenet."; +"user_other_session_filter_menu_inactive" = "Inaktív"; +"user_other_session_filter_menu_unverified" = "Ellenőrizetlen"; +"user_other_session_filter_menu_verified" = "Hitelesített"; +"user_other_session_filter_menu_all" = "Minden munkamenet"; +"user_other_session_filter" = "Szűrés"; +"user_other_session_verified_sessions_header_subtitle" = "A legjobb biztonság érdekében jelentkezz ki minden olyan munkamenetből, melyet már nem ismersz fel vagy nem használsz."; +"user_other_session_current_session_details" = "Jelenlegi munkamenet"; +"user_other_session_unverified_sessions_header_subtitle" = "Erősítse meg a munkameneteit a még biztonságosabb csevegéshez vagy jelentkezzen ki ezekből, ha nem ismeri fel vagy már nem használja őket."; +"user_other_session_security_recommendation_title" = "Biztonsági javaslat"; +"user_session_push_notifications_message" = "Ha be van kapcsolva az eszközre Push értesítések lesznek küldve."; +"user_session_push_notifications" = "Push értesítések"; +"user_other_session_verified_additional_info" = "Ez a munkamenet beállítva a biztonságos üzenetküldéshez."; +"user_other_session_unverified_additional_info" = "A jobb biztonság vagy megbízhatóság érdekében ellenőrizze vagy jelentkezzen ki ebből a munkamenetből."; +"user_session_verification_unknown_additional_info" = "Ellenőrizd a jelenlegi munkamenetedet, hogy ismert állapotba kerüljön."; +"user_session_verification_unknown_short" = "Ismeretlen"; +"user_session_verification_unknown" = "Ismeretlen ellenőrzési státusz"; +"user_sessions_view_all_action" = "Összes megtekintése (%d)"; +"user_sessions_overview_link_device" = "Eszköz összekötése"; +"user_sessions_overview_security_recommendations_inactive_info" = "Fontold meg, hogy kijelentkezel a régi munkamenetekből (90 napja vagy régebben használtál) amit már nem használsz."; +"user_sessions_overview_security_recommendations_inactive_title" = "Nem aktív munkamenetek"; +"user_sessions_overview_security_recommendations_unverified_info" = "Ellenőrizd vagy jelentkezz ki az ellenőrizetlen munkamenetekből."; +"user_sessions_overview_security_recommendations_unverified_title" = "Ellenőrizetlen munkamenetek"; +"user_sessions_overview_security_recommendations_section_info" = "Javítsa a fiókja biztonságát azzal, hogy követi a következő javaslatokat."; +"user_sessions_overview_security_recommendations_section_title" = "Biztonsági javaslatok"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"all_chats_user_menu_accessibility_label" = "Felhasználói menü"; +"sign_out_confirmation_message" = "Biztos, hogy ki akarsz lépni?"; + +// MARK: Sign out warning + +"sign_out" = "Kijelentkezés"; +"manage_session_rename" = "Munkamenet átnevezése"; +"manage_session_name_info_link" = "Tudj meg többet"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Fontos, hogy a munkamenet neve a kommunikációban résztvevők számára látható. %@"; +"manage_session_name_hint" = "Az egyedi munkamenet név segíthet az eszköz könnyebb felismerésében."; +"settings_labs_enable_wysiwyg_composer" = "Próbálja ki az új szövegbevitelt (hamarosan érkezik a sima szöveges üzemmód)"; +"settings_labs_enable_new_client_info_feature" = "Kliens neve, verziója és url felvétele a munkamenet könnyebb azonosításához a munkamenet kezelőben"; +"settings_labs_enable_new_session_manager" = "Új munkamenet kezelő"; +"authentication_qr_login_failure_retry" = "Próbáld újra"; +"authentication_qr_login_failure_request_timed_out" = "Az összekötés az elvárt időn belül nem fejeződött be."; +"authentication_qr_login_failure_request_denied" = "A kérést elutasították a másik eszközön."; +"authentication_qr_login_failure_invalid_qr" = "QR kód érvénytelen."; +"authentication_qr_login_failure_title" = "Összekötés sikertelen"; +"authentication_qr_login_loading_signed_in" = "Bejelentkeztél a másik eszközöddel."; +"authentication_qr_login_loading_waiting_signin" = "Várakozás a másik eszköz bejelentkezésére."; +"authentication_qr_login_loading_connecting_device" = "Csatlakozás az eszközhöz"; +"authentication_qr_login_confirm_alert" = "Győződj meg a kód eredetéről. Az eszközök összekötésével esetleg valakinek teljes hozzáférést adhatsz a fiókodhoz."; +"authentication_qr_login_confirm_subtitle" = "Erősítsd meg, hogy az alábbi kód megegyezik a másik eszközödön lévővel:"; +"authentication_qr_login_scan_subtitle" = "A QR kód legyen a négyzetben"; +"authentication_qr_login_scan_title" = "QR kód beolvasása"; +"authentication_qr_login_display_step2" = "Válaszd ezt: „Belépés QR kóddal”"; +"authentication_qr_login_display_step1" = "Nyisd meg az Elementet a másik eszközödön"; +"authentication_qr_login_display_subtitle" = "A kijelentkezett eszközzel olvasd be a QR kódot alább."; +"authentication_qr_login_display_title" = "Eszköz összekötése"; +"authentication_qr_login_start_display_qr" = "QR kód megjelenítése ezen az eszközön"; +"authentication_qr_login_start_need_alternative" = "Más módszer szükséges?"; +"authentication_qr_login_start_step4" = "Válaszd ezt: „QR kód megjelenítése ezen az eszközön”"; +"authentication_qr_login_start_step3" = "Válaszd ezt: „Eszköz összekötése”"; +"authentication_qr_login_start_step2" = "Menj a Beállítások -> Biztonság és Adatvédelem menübe"; +"authentication_qr_login_start_step1" = "Nyisd meg az Elementet a másik eszközödön"; +"authentication_qr_login_start_subtitle" = "Használd a kamerát ezen az eszközön a másik eszközödön megjelenő QR kód beolvasására:"; +"authentication_qr_login_start_title" = "QR kód beolvasása"; +"authentication_login_with_qr" = "Belépés QR kóddal"; +"settings_labs_enable_voice_broadcast" = "Hang közvetítés (aktív fejlesztés alatt)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Hang közvetítés"; +"voice_broadcast_playback_loading_error" = "A hang közvetítés nem játszható le."; +"voice_broadcast_already_in_progress_message" = "Egy hang közvetítés már folyamatban van. Először fejezd be a jelenlegi közvetítést egy új indításához."; +"voice_broadcast_blocked_by_someone_else_message" = "Valaki már elindított egy hang közvetítést. Várd meg a közvetítés végét az új indításához."; +"voice_broadcast_permission_denied_message" = "Nincs jogosultságod hang közvetítést indítani ebben a szobában. Vedd fel a kapcsolatot a szoba adminisztrátorával a szükséges jogosultság megszerzéséhez."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Az új hang közvetítés nem indítható el"; +"deselect_all" = "Semmit nem jelöl ki"; +"user_other_session_menu_select_sessions" = "Munkamenetek kiválasztása"; +"user_other_session_selected_count" = "%@ kiválasztva"; diff --git a/Riot/Assets/id.lproj/Vector.strings b/Riot/Assets/id.lproj/Vector.strings index d698f6115..be31a8bdb 100644 --- a/Riot/Assets/id.lproj/Vector.strings +++ b/Riot/Assets/id.lproj/Vector.strings @@ -70,7 +70,7 @@ // Titles "title_home" = "Beranda"; -"auth_email_validation_message" = "Silakan periksa surel Anda untuk melanjutkan pendaftaran"; +"auth_email_validation_message" = "Silakan periksa email Anda untuk melanjutkan pendaftaran"; "auth_use_server_options" = "Gunakan opsi server khusus (lanjutan)"; "auth_email_not_found" = "Gagal mengirim surel: Alamat email ini tidak ditemukan"; "auth_forgot_password_error_no_configured_identity_server" = "Tidak ada server identitas yang dikonfigurasikan: tambahkan satu untuk mengatur ulang kata sandi akun Matrix Anda."; @@ -89,9 +89,9 @@ "auth_add_phone_message_2" = "Atur telepon, dan nanti dapat ditemukan oleh orang-orang yang mengenal Anda secara opsional."; "auth_add_email_message_2" = "Tetapkan surel untuk pemulihan akun, dan nanti dapat ditemukan oleh orang-orang yang mengenal Anda secara opsional."; "auth_missing_password" = "Tidak ada kata sandi"; -"auth_invalid_phone" = "Ini tidak terlihat seperti nomor telepon yang valid"; -"auth_invalid_email" = "Ini tidak terlihat seperti surel yang valid"; -"auth_invalid_password" = "Kata sandi terlalu pendek (min 6)"; +"auth_invalid_phone" = "Ini tidak terlihat seperti nomor telepon yang absah"; +"auth_invalid_email" = "Ini tidak terlihat seperti alamat email yang absah"; +"auth_invalid_password" = "Kata sandi terlalu pendek (min. 6)"; "auth_invalid_user_name" = "Nama pengguna hanya dapat berisi huruf, angka, titik, tanda hubung, dan garis bawah"; "auth_send_reset_email" = "Kirim Reset Email"; "auth_submit" = "Kirim"; @@ -102,7 +102,7 @@ "joined" = "Bergabung"; "collapse" = "tutup"; "store_promotional_text" = "Aplikasi perpesanan dan kolaborasi yang menjaga privasi, pada jaringan terbuka. Terdesentralisasi untuk Anda kendali. Tidak ada penambangan data, tidak ada pintu belakang dan tidak ada akses pihak ketiga."; -"store_full_description" = "Element adalah aplikasi messenger dan kolaborasi tipe baru yang:\n\n1. Menempatkan Anda dalam kendali untuk mempertahankan privasi Anda\n2. Memungkinkan Anda berkomunikasi dengan siapa pun di jaringan Matrix, dan bahkan di luar dengan mengintegrasikan dengan aplikasi seperti Slack\n3. Melindungi Anda dari iklan, menambangan data, pintu belakang, dan taman berdinding\n4. Mengamankan Anda melalui enkripsi ujung-ke-ujung, dengan penandatanganan silang untuk memverifikasi orang lain\n\nElement benar-benar berbeda dari aplikasi perpesanan dan kolaborasi lain karena Element terdesentralisasi dan sumber terbuka.\n\nElement memungkinkan Anda host sendiri — atau memilih host — sehingga Anda memiliki privasi, kepemilikan, dan kontrol data dan obrolan Anda. Ini memberi Anda akses ke jaringan terbuka, jadi Anda tidak hanya terjebak berbicara dengan pengguna Element. Itu sangat aman.\n\nElement dapat melakukan semua ini karena beroperasi pada Matrix — standar untuk komunikasi terdesentralisasi terbuka.\n\nElement menempatkan Anda dalam kendali dengan membiarkan Anda memilih siapa yang menghost percakapan Anda. Dari aplikasi Element, Anda dapat memilih untuk menghost dengan cara yang berbeda:\n\n1. Dapatkan akun gratis pada server publik matrix.org\n2. Host sendiri akun Anda dengan menjalankan server pada perangkat keras Anda sendiri\n3. Mendaftar untuk akun di server khusus dengan hanya berlangganan platform hosting Element Matrix Services\n\nMengapa memilih Element?\n\nMILIKI DATA ANDA: Anda memutuskan di mana untuk menyimpan data dan pesan Anda. Anda memilikinya dan mengendalikannya, bukan perusahaan besar yang menambang data Anda atau memberikan akses ke pihak ketiga.\n\nPESAN DAN KOLABORASI TERBUKA: Anda dapat mengobrol dengan orang lain di jaringan Matrix, jika mereka menggunakan Element atau aplikasi Matrix lain, dan bahkan jika mereka menggunakan sistem perpesanan seperti Slack, IRC atau XMPP.\n\nSANGAT AMAN: Enkripsi ujung-ke-ujung yang nyata (hanya mereka yang dalam percakapan dapat mendekripsi pesan), dan penandatanganan silang untuk memverifikasi perangkat anggota obrolan.\n\nKOMUNIKASI LENGKAP: Perpesanan, panggilan suara dan video, pembagian file, pembagian layar dan banyak integrasi, bot dan widget. Buat ruangan, komunitas, tetap terhubung dan selesaikan hal-hal.\n\nDI MANA PUN ANDA BERADA: Tetap berkomunikasi di mana pun Anda berada dengan riwayat pesan yang sepenuhnya disinkronkan di semua perangkat Anda dan di web di https://app.element.io/."; +"store_full_description" = "Element adalah aplikasi perpesanan dan kolaborasi baru yang:\n\n1. Menempatkan Anda dalam kendali untuk mempertahankan privasi Anda\n2. Memungkinkan Anda berkomunikasi dengan siapa pun di jaringan Matrix, dan bahkan di luar dengan mengintegrasikan dengan aplikasi seperti Slack\n3. Melindungi Anda dari iklan, menambangan data, pintu belakang, dan taman berdinding\n4. Mengamankan Anda melalui enkripsi ujung ke ujung, dengan penandatanganan silang untuk memverifikasi orang lain\n\nElement benar-benar berbeda dari aplikasi perpesanan dan kolaborasi lain karena Element terdesentralisasi dan sumber terbuka.\n\nElement memungkinkan Anda host sendiri — atau memilih host — sehingga Anda memiliki privasi, kepemilikan, dan kontrol data dan obrolan Anda. Ini memberi Anda akses ke jaringan terbuka, jadi Anda tidak hanya terjebak berbicara dengan pengguna Element. Itu sangat aman.\n\nElement dapat melakukan semua ini karena beroperasi pada Matrix — standar untuk komunikasi terdesentralisasi terbuka.\n\nElement menempatkan Anda dalam kendali dengan membiarkan Anda memilih siapa yang menghost percakapan Anda. Dari aplikasi Element, Anda dapat memilih untuk menghost dengan cara yang berbeda:\n\n1. Dapatkan akun gratis pada server publik matrix.org\n2. Host sendiri akun Anda dengan menjalankan server pada perangkat keras Anda sendiri\n3. Mendaftar untuk akun di server khusus dengan hanya berlangganan platform hosting Element Matrix Services\n\nMengapa memilih Element?\n\nMILIKI DATA ANDA: Anda memutuskan di mana untuk menyimpan data dan pesan Anda. Anda memilikinya dan mengendalikannya, bukan perusahaan besar yang menambang data Anda atau memberikan akses ke pihak ketiga.\n\nPESAN DAN KOLABORASI TERBUKA: Anda dapat mengobrol dengan orang lain di jaringan Matrix, jika mereka menggunakan Element atau aplikasi Matrix lain, dan bahkan jika mereka menggunakan sistem perpesanan seperti Slack, IRC atau XMPP.\n\nSANGAT AMAN: Enkripsi ujung ke ujung yang nyata (hanya mereka yang dalam percakapan dapat mendekripsi pesan), dan penandatanganan silang untuk memverifikasi perangkat anggota obrolan.\n\nKOMUNIKASI LENGKAP: Perpesanan, panggilan suara dan video, pembagian file, pembagian layar dan banyak integrasi, bot dan widget. Buat ruangan, komunitas, tetap terhubung dan selesaikan hal-hal.\n\nDI MANA PUN ANDA BERADA: Tetap berkomunikasi di mana pun Anda berada dengan riwayat pesan yang sepenuhnya disinkronkan di semua perangkat Anda dan di web di https://app.element.io/."; // String for App Store "store_short_description" = "Obrolan/VoIP terdesentralisasi aman"; @@ -121,14 +121,14 @@ "settings_crypto_device_key" = "\nKunci sesi:\n"; "settings_crypto_device_id" = "\nID Sesi: "; "settings_crypto_device_name" = "Nama sesi: "; -"settings_add_3pid_invalid_password_message" = "Kredential tidak valid"; +"settings_add_3pid_invalid_password_message" = "Kredential tidak absah"; "settings_confirm_password" = "Konfirmasi kata sandi"; "settings_new_password" = "Kata sandi baru"; "settings_old_password" = "Kata sandi lama"; "settings_third_party_notices" = "Pemberitahuan Pihak Ketiga"; "settings_privacy_policy" = "Kebijakan Privasi"; "settings_version" = "Versi %@"; -"settings_labs_e2e_encryption" = "Enkripsi Ujung-ke-Ujung"; +"settings_labs_e2e_encryption" = "Enkripsi Ujung ke Ujung"; "settings_contacts_phonebook_country" = "Negara buku telepon"; "settings_integrations_allow_button" = "Kelola integrasi"; "settings_enable_callkit" = "Panggilan yang diintegrasi"; @@ -558,7 +558,7 @@ "directory_searching_title" = "Mencari direktori…"; "room_details_advanced_room_id" = "ID Ruangan:"; "room_details_banned_users_section" = "Pengguna yang dicekal"; -"room_details_flair_invalid_id_prompt_title" = "Format tidak valid"; +"room_details_flair_invalid_id_prompt_title" = "Format tidak absah"; "room_details_history_section_prompt_title" = "Peringatan privasi"; "room_details_direct_chat" = "Pesan Langsung"; "room_details_mute_notifs" = "Bisukan notifikasi"; @@ -627,11 +627,11 @@ "secrets_recovery_with_passphrase_information_default" = "Akses riwayat pesan terenkripsi Anda dan identitas penandatanganan silang Anda untuk memverifikasi sesi lain dengan memasukkan Frasa Keamanan Anda."; "user_verification_session_details_additional_information_untrusted_other_user" = "Hingga pengguna ini memercayai sesi ini, pesan yang dikirim ke dan dari sesi ini akan diberi label peringatan. Atau, Anda dapat memverifikasinya secara manual."; "user_verification_session_details_information_untrusted_current_user" = "Verifikasi sesi ini untuk menandainya sebagai tepercaya & memberikan akses ke pesan terenkripsi:"; -"user_verification_sessions_list_information" = "Pesan dengan pengguna ini di ruangan ini dienkripsi secara ujung-ke-ujung dan tidak dapat dibaca oleh pihak ketiga."; +"user_verification_sessions_list_information" = "Pesan dengan pengguna ini di ruangan ini dienkripsi secara ujung ke ujung dan tidak dapat dibaca oleh pihak ketiga."; // User -"key_verification_verified_user_information" = "Pesan dengan pengguna ini dienkripsi secara ujung-ke-ujung dan tidak dapat dibaca oleh pihak ketiga."; +"key_verification_verified_user_information" = "Pesan dengan pengguna ini dienkripsi secara ujung ke ujung dan tidak dapat dibaca oleh pihak ketiga."; "key_verification_verified_this_session_information" = "Anda sekarang dapat membaca pesan aman di perangkat ini, dan pengguna lain akan tahu bahwa mereka dapat mempercayainya."; "key_verification_verified_new_session_information" = "Anda sekarang dapat membaca pesan aman di perangkat baru Anda, dan pengguna lain akan tahu bahwa mereka dapat mempercayainya."; "key_verification_verified_other_session_information" = "Anda sekarang dapat membaca pesan terenkripsi di sesi Anda yang lain, dan pengguna lain akan tahu bahwa mereka dapat mempercayainya."; @@ -643,7 +643,7 @@ "device_verification_self_verify_start_information" = "Gunakan sesi ini untuk memverifikasi sesi Anda yang baru, memberikan akses ke pesan terenkripsi."; "device_verification_start_use_legacy" = "Tidak ada yang muncul? Belum semua klien mendukung verifikasi interaktif. Gunakan verifikasi warisan."; "device_verification_incoming_description_2" = "Memverifikasi sesi ini akan menandainya sebagai tepercaya, dan juga menandai sesi Anda sebagai terpercaya kepada pengguna yang lain."; -"device_verification_incoming_description_1" = "Verifikasi sesi ini untuk menandainya sebagai tepercaya. Mempercayai sesi pengguna memberi Anda ketenangan pikiran ekstra saat menggunakan enkripsi ujung-ke-ujung."; +"device_verification_incoming_description_1" = "Verifikasi sesi ini untuk menandainya sebagai tepercaya. Mempercayai sesi pengguna memberi Anda ketenangan pikiran lebih saat menggunakan enkripsi ujung ke ujung."; "sign_out_key_backup_in_progress_alert_title" = "Pencadangan kunci sedang berlangsung. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."; "sign_out_non_existing_key_backup_sign_out_confirmation_alert_message" = "Anda akan kehilangan akses ke pesan terenkripsi Anda kecuali jika Anda mencadangkan kunci Anda sebelum keluar."; "key_backup_recover_from_recovery_key_lost_recovery_key_action" = "Kehilangan Kunci Keamanan Anda dapat menyiapkan yang baru di pengaturan."; @@ -672,7 +672,7 @@ "e2e_key_backup_wrong_version" = "Cadangan kunci pesan aman baru telah terdeteksi.\n\nJika ini bukan Anda, atur Frasa Keamanan baru di Pengaturan."; // Crypto -"e2e_enabling_on_app_update" = "%@ sekarang mendukung enkripsi ujung-ke-ujung tetapi Anda harus masuk lagi untuk mengaktifkannya.\n\nAnda dapat melakukannya sekarang atau nanti di pengaturan aplikasi."; +"e2e_enabling_on_app_update" = "%@ sekarang mendukung enkripsi ujung ke ujung tetapi Anda harus masuk lagi untuk mengaktifkannya.\n\nAnda dapat melakukannya sekarang atau nanti di pengaturan aplikasi."; // Crash report "no_voip" = "%@ sedang memanggil Anda tetapi %@ belum mendukung panggilan.\nAnda dapat mengabaikan notifikasi ini dan jawab panggilannya di perangkat yang lain atau menolak panggilannya."; @@ -757,8 +757,8 @@ "group_participants_remove_prompt_msg" = "Apakah Anda yakin untuk mengeluarkan %@ dari grup ini?"; "group_participants_leave_prompt_msg" = "Apakah Anda yakin untuk meninggalkan grup ini?"; "room_details_fail_to_update_room_direct" = "Gagal untuk memperbarui detail ruangan ini"; -"room_details_flair_invalid_id_prompt_msg" = "%@ bukan pengenal yang valid untuk sebuah komunitas"; -"room_details_addresses_invalid_address_prompt_msg" = "%@ bukan format yang valid untuk sebuah alias"; +"room_details_flair_invalid_id_prompt_msg" = "%@ bukan pengenal yang absah untuk sebuah komunitas"; +"room_details_addresses_invalid_address_prompt_msg" = "%@ bukan format yang absah untuk sebuah alias"; "room_details_history_section_members_only" = "Anggota saha (sejak opsi ini dipilih)"; "room_details_access_section_no_address_warning" = "Untuk menautkan ke ruangan itu harus memiliki alamat"; "voice_message_stop_locked_mode_recording" = "Ketuk pada rekaman Anda untuk berhenti atau dengarkan"; @@ -807,7 +807,7 @@ "widget_sticker_picker_no_stickerpacks_alert" = "Saat ini Anda tidak mengaktifkan paket stiker apa pun."; "call_already_displayed" = "Sudah ada panggilan yang sedang berlangsung."; "camera_unavailable" = "Kamera tidak tersedia di perangkat Anda"; -"network_offline_prompt" = "Koneksi internetnya terlihat offline."; +"network_offline_prompt" = "Koneksi internet sepertinya luring."; "group_participants_invite_another_user" = "Cari/undang dengan ID Pengguna atau Nama"; "group_invitation_format" = "%@ telah mengundang Anda untuk bergabung ke komunitas ini"; "room_notifs_settings_manage_notifications" = "Anda dapat mengelola notifikasi di %@"; @@ -820,7 +820,7 @@ "room_details_access_section_anyone_for_dm" = "Siapa saja yang tahu tautannya, termasuk tamu"; "room_details_access_section_anyone" = "Siapa saja yang tahu tautannya ruangan, termasuk tamu"; "room_details_access_section_anyone_apart_from_guest_for_dm" = "Siapa saja yang tahu linknya, selain dari tamu"; -"identity_server_settings_alert_error_invalid_identity_server" = "%@ bukan server identitas yang valid."; +"identity_server_settings_alert_error_invalid_identity_server" = "%@ bukan server identitas yang absah."; "identity_server_settings_alert_no_terms_title" = "Server identitas tidak mempunyai kebijakan layanan"; "security_settings_user_password_description" = "Konfirmasi identitas Anda dengan memasukkan kata sandi akun Matrix Anda"; "security_settings_secure_backup_info_valid" = "Sesi ini mencadangkan kunci Anda."; @@ -864,7 +864,7 @@ "room_predecessor_link" = "Ketuk di sini untuk melihat pesan lama."; "room_many_users_are_typing" = "%@, %@ & lainnya sedang mengetik…"; "room_two_users_are_typing" = "%@ & %@ sedang mengetik…"; -"room_participants_security_information_room_not_encrypted_for_dm" = "Pesan di sini tidak terenkripsi secara ujung-ke-ujung."; +"room_participants_security_information_room_not_encrypted_for_dm" = "Pesan di sini tidak terenkripsi secara ujung ke ujung."; "room_participants_action_unignore" = "Tampilkan semua pesan dari penguna ini"; "room_participants_action_ignore" = "Sembunyikan semua pesan dari pengguna ini"; "find_your_contacts_title" = "Mulai dengan mendaftar kontak Anda"; @@ -1021,7 +1021,7 @@ "room_creation_private_room" = "Obrolan ini privat"; "social_login_button_title_sign_up" = "Daftar dengan %@"; "social_login_button_title_sign_in" = "Masuk dengan %@"; -"auth_autodiscover_invalid_response" = "Respons penemuan homeserver tidak valid"; +"auth_autodiscover_invalid_response" = "Respons penemuan homeserver tidak absah"; "pin_protection_settings_section_header_with_biometrics" = "PIN & %@"; "service_terms_modal_table_header_integration_manager" = "SYARAT PENGELOLA INTEGRASI"; "service_terms_modal_table_header_identity_server" = "SYARAT SERVER IDENTITAS"; @@ -1033,7 +1033,7 @@ "event_formatter_call_incoming_voice" = "Panggilan suara masuk"; // Room Notification Settings -"room_notifs_settings_notify_me_for" = "Beritahu saya untuk"; +"room_notifs_settings_notify_me_for" = "Beri tahu saya untuk"; "security_settings_secure_backup_restore" = "Pulihkan dari Cadangan"; "settings_contacts_enable_sync" = "Cari kontak Anda"; "settings_show_url_previews" = "Tampilkan tampilan website"; @@ -1041,7 +1041,7 @@ "settings_messages_containing_display_name" = "Nama tampilan saya"; "settings_encrypted_group_messages" = "Pesan grup terenkripsi"; "settings_encrypted_direct_messages" = "Pesan langsung terenkripsi"; -"settings_notify_me_for" = "Beritahu saya untuk"; +"settings_notify_me_for" = "Beri tahu saya untuk"; "settings_mentions_and_keywords" = "Sebutan dan Keyword"; "find_your_contacts_button_title" = "Cari kontak Anda"; "call_incoming_voice" = "Panggilan masuk…"; @@ -1111,7 +1111,7 @@ "key_verification_this_session_title" = "Verifikasi sesi ini"; "key_backup_recover_from_recovery_key_recovery_key_placeholder" = "Masukkan Kunci Keamanan"; "key_backup_recover_invalid_recovery_key_title" = "Kunci Keamanan Tidak Cocok"; -"key_backup_recover_invalid_passphrase_title" = "Frasa Sandi Tidak Benar"; +"key_backup_recover_invalid_passphrase_title" = "Frasa Keamanan Tidak Benar"; "key_backup_setup_success_from_recovery_key_make_copy_action" = "Buat sebuah Salinan"; "key_backup_setup_success_from_passphrase_save_recovery_key_action" = "Simpan Kunci Keamanan"; "key_backup_setup_passphrase_confirm_passphrase_invalid" = "frasa tidak cocok"; @@ -1164,7 +1164,7 @@ "room_details_copy_room_address" = "Salin Alamat Ruangan"; "room_details_copy_room_id" = "Salin ID Ruangan"; "room_details_addresses_disable_main_address_prompt_title" = "Peringatan alamat utama"; -"room_details_addresses_invalid_address_prompt_title" = "Format alias tidak valid"; +"room_details_addresses_invalid_address_prompt_title" = "Format alias tidak absah"; "room_details_new_address" = "Tambahkan alamat baru"; "identity_server_settings_alert_disconnect_title" = "Putuskan server identitas"; "identity_server_settings_alert_change_title" = "Ubah server identitas"; @@ -1223,7 +1223,7 @@ "room_participants_action_start_new_chat" = "Mulai obrolan baru"; "room_participants_action_leave" = "Tinggalkan ruangan ini"; "room_participants_filter_room_members" = "Filter anggota ruangan"; -"contacts_user_directory_offline_section" = "DIREKTORI PENGGUNA (offline)"; +"contacts_user_directory_offline_section" = "DIREKTORI PENGGUNA (luring)"; "contacts_address_book_permission_denied_alert_title" = "Kontak dinonaktifkan"; "contacts_address_book_no_contact" = "Tidak ada kontak lokal"; "contacts_address_book_matrix_users_toggle" = "Hanya pengguna Matrix"; @@ -1421,16 +1421,16 @@ "widget_integration_must_be_in_room" = "Anda tidak berada di ruangan ini."; "settings_devices_description" = "Nama publik sesi dapat dilihat oleh orang yang berkomunikasi dengan Anda"; "settings_key_backup_delete_confirmation_prompt_msg" = "Apakah Anda yakin? Anda akan kehilangan pesan terenkripsi jika kunci Anda tidak dicadangkan secara benar."; -"settings_key_backup_info_trust_signature_invalid_device_unverified" = "Cadangan memiliki tanda tangan yang tidak valid dari %@"; -"settings_key_backup_info_trust_signature_invalid_device_verified" = "Cadangan mempunyai tanda tangan yang tidak valid dari %@"; -"settings_key_backup_info_trust_signature_valid_device_verified" = "Cadangan mempunyai tanda tangan yang valid dari %@"; -"settings_key_backup_info_trust_signature_valid" = "Cadangan mempunyai tanda tangan yang valid dari sesi ini"; +"settings_key_backup_info_trust_signature_invalid_device_unverified" = "Cadangan memiliki tanda tangan yang tidak absah dari %@"; +"settings_key_backup_info_trust_signature_invalid_device_verified" = "Cadangan mempunyai tanda tangan yang tidak absah dari %@"; +"settings_key_backup_info_trust_signature_valid_device_verified" = "Cadangan mempunyai tanda tangan yang absah dari %@"; +"settings_key_backup_info_trust_signature_valid" = "Cadangan mempunyai tanda tangan yang absah dari sesi ini"; "settings_key_backup_info_trust_signature_unknown" = "Cadangan mempunyai tanda tangan dari sesi dengan ID: %@"; "settings_key_backup_info_not_valid" = "Sesi ini tidak mencadangkan kunci Anda, tetapi Anda memiliki cadangan yang ada yang dapat Anda pulihkan dan tambahkan untuk selanjutnya."; "settings_key_backup_info_valid" = "Sesi ini mencadangkan kunci Anda."; "settings_key_backup_info_signout_warning" = "Cadangkan kunci Anda sebelum keluar untuk mencegah kehilangannya."; "settings_key_backup_info_none" = "Kunci Anda tidak dicadangkan dari sesi ini."; -"settings_key_backup_info" = "Pesan terenkripsi diamankan dengan enkripsi ujung-ke-ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesannya."; +"settings_key_backup_info" = "Pesan terenkripsi diamankan dengan enkripsi ujung ke ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesannya."; "settings_labs_e2e_encryption_prompt_message" = "Untuk menyelesaikan enkripsi Anda harus masuk lagi."; "settings_contacts_enable_sync_description" = "Ini akan menggunakan server identitas Anda untuk menghubung Anda dengan kontak Anda, dan membantunya untuk menemukan Anda."; "settings_show_url_previews_description" = "Pratinjau akan ditampilkan di ruangan yang tidak dienkripsi."; @@ -1467,7 +1467,7 @@ // Chat "room_slide_to_end_group_call" = "Geser untuk mengakhiri panggilan untuk semuanya"; -"room_participants_security_information_room_not_encrypted" = "Pesan di ruangan ini tidak dienkripsi secara ujung-ke-ujung."; +"room_participants_security_information_room_not_encrypted" = "Pesan di ruangan ini tidak dienkripsi secara ujung ke ujung."; "room_participants_start_new_chat_error_using_user_email_without_identity_server" = "Tidak ada server identitas yang diatur, jadi Anda tidak dapat mengobrol dengan sebuah kontak menggunakan email."; "room_participants_invite_malformed_id" = "ID cacat. Seharusnya sebuah alamat email atau ID Matrix seperti '@pengguna:domain'"; "room_participants_invite_another_user" = "Cari/Undang dengan ID Pengguna, Nama atau email"; @@ -1525,11 +1525,11 @@ "settings_add_3pid_password_message" = "Untuk melanjutkan, mohon masukkan kata sandi akun Matrix Anda"; "settings_send_crash_report" = "Kirim crash & data penggunaan anonim"; "secure_key_backup_setup_cancel_alert_message" = "Jika Anda membatalkan sekarang, Anda mungkin kehilangan pesan & data terenkripsi jika Anda kehilangan akses ke login Anda.\n\nAnda juga dapat mengatur Cadangan Aman & kelola kunci Anda di Pengaturan."; -"room_participants_security_information_room_encrypted" = "Pesan di ruangan ini dienkripsi secara ujung-ke-ujung.\n\nPesan Anda diamankan dengan kunci dan hanya Anda dan penerima punya kunci uniknya untuk membukanya."; +"room_participants_security_information_room_encrypted" = "Pesan di ruangan ini dienkripsi secara ujung ke ujung.\n\nPesan Anda diamankan dengan kunci dan hanya Anda dan penerima punya kunci uniknya untuk membukanya."; "settings_callkit_info" = "Terima panggilan masuk di layar kunci Anda. Lihat panggilan %@ Anda di riwayat panggilan sistem. Jika iCloud diaktifkan, riwayat panggilannya akan dibagikan dengan Apple."; "settings_three_pids_management_information_part1" = "Kelola alamat email dan nomor telepon apa saja Anda dapat gunakan untuk masuk atau memulihkan akun Anda di sini. Atur siapa saja yang dapat menemukan Anda di "; -"settings_sign_out_e2e_warn" = "Anda akan kehilangan kunci enkripsi ujung-ke-ujung Anda. Ini berarti Anda tidak akan lagi dapat membaca pesan lama di ruangan terenkripsi di perangkat ini."; -"room_participants_security_information_room_encrypted_for_dm" = "Pesan di pesan langsung ini dienkripsi secara ujung-ke-ujung.\n\nPesan Anda diamankan dengan kunci dan hanya Anda dan penerima punya kunci uniknya untuk membukanya."; +"settings_sign_out_e2e_warn" = "Anda akan kehilangan kunci enkripsi ujung ke ujung Anda. Ini berarti Anda tidak akan lagi dapat membaca pesan lama di ruangan terenkripsi di perangkat ini."; +"room_participants_security_information_room_encrypted_for_dm" = "Pesan di pesan langsung ini dienkripsi secara ujung ke ujung.\n\nPesan Anda diamankan dengan kunci dan hanya Anda dan penerima punya kunci uniknya untuk membukanya."; "auth_softlogout_clear_data_sign_out_msg" = "Apakah Anda yakin ingin menghapus semua data yang saat ini tersimpan di perangkat ini? Masuk lagi untuk mengakses data dan pesan akun Anda."; "auth_softlogout_recover_encryption_keys" = "Masuk untuk memulihkan kunci enkripsi yang disimpan secara eksklusif di perangkat ini. Anda membutuhkannya untuk membaca semua pesan aman Anda di perangkat apa saja."; "version_check_modal_subtitle_deprecated" = "Kami telah berupaya meningkatkan %@ untuk pengalaman yang lebih cepat dan lebih halus. Sayangnya versi iOS Anda saat ini tidak kompatibel dengan beberapa perbaikan tersebut dan tidak akan didukung lagi.\nKami menyarankan Anda untuk meningkatkan sistem operasi Anda untuk menggunakan %@ secara maksimal."; @@ -1539,17 +1539,17 @@ // Success from passphrase "key_backup_setup_success_from_passphrase_info" = "Kunci Anda sedang dicadangkan.\n\nKunci Keamanan Anda adalah jaring pengaman — Anda dapat menggunakannya untuk memulihkan akses ke pesan terenkripsi jika Anda lupa frasa sandi.\n\nSimpan Kunci Keamanan Anda di suatu tempat yang sangat aman, seperti pengelola kata sandi (atau brankas)."; "key_backup_setup_passphrase_info" = "Kami akan menyimpan salinan terenkripsi dari kunci Anda di server kami. Lindungi cadangan Anda dengan frasa agar tetap aman.\n\nUntuk keamanan maksimum, ini harus berbeda dari kata sandi akun Matrix Anda."; -"key_backup_setup_intro_info" = "Pesan di ruang terenkripsi diamankan dengan enkripsi ujung-ke-ujung. Hanya Anda dan penerima yang memiliki kunci untuk membaca pesan ini.\n\nCadangkan kunci Anda dengan aman untuk menghindari kehilangannya."; +"key_backup_setup_intro_info" = "Pesan di ruang terenkripsi diamankan dengan enkripsi ujung ke ujung. Hanya Anda dan penerima yang memiliki kunci untuk membaca pesan ini.\n\nCadangkan kunci Anda dengan aman untuk menghindari kehilangannya."; "deactivate_account_informations_part5" = "Jika Anda ingin kami melupakan pesan Anda, silakan centang kotak di bawah ini\n\nVisibilitas pesan di Matrix mirip dengan email. Kami melupakan pesan Anda berarti bahwa pesan yang telah Anda kirim tidak akan dibagikan dengan pengguna baru atau tidak terdaftar, tetapi pengguna terdaftar yang sudah memiliki akses ke pesan ini akan tetap memiliki akses ke salinannya."; "deactivate_account_informations_part1" = "Ini akan membuat akun Anda tidak dapat digunakan secara permanen. Anda tidak akan dapat masuk, dan tidak seorang pun dapat mendaftarkan ulang ID pengguna yang sama. Ini akan menyebabkan akun Anda meninggalkan semua ruangan yang diikutinya, dan akan menghapus detail akun Anda dari server identitas Anda. "; -"e2e_need_log_in_again" = "Anda harus masuk kembali untuk membuat kunci enkripsi ujung-ke-ujung untuk sesi ini dan mengirimkan kunci publik ke homeserver Anda.\nIni hanya dilakukan sekali saja; maaf untuk ketidaknyamanannya."; +"e2e_need_log_in_again" = "Anda harus masuk kembali untuk membuat kunci enkripsi ujung ke ujung untuk sesi ini dan mengirimkan kunci publik ke homeserver Anda.\nIni hanya dilakukan sekali saja; maaf untuk ketidaknyamanan."; "call_no_stun_server_error_message_2" = "Atau, Anda dapat mencoba menggunakan server publik di %@, tetapi ini tidak akan dapat diandalkan, dan alamat IP Anda akan dibagikan dengan server tersebut. Anda juga dapat mengelola ini di Pengaturan"; "identity_server_settings_alert_disconnect_still_sharing_3pid" = "Anda masih membagikan data personal Anda di server identitas %@.\n\nKami mensarankan Anda untuk menghapus alamat email dan nomor telepon Anda dari server identitasnya sebelum memutuskan hubungannya."; "security_settings_crosssigning_info_trusted" = "Penandatanganan silang diaktifkan. Anda dapat mempercayai pengguna lain dan sesi lain Anda berbasis penandatanganan silang tetapi Anda tidak dapat menandatangani sesi ini karena tidak memiliki kunci privat penandatanganan silang. Keamanan lengkap sesi ini."; "settings_discovery_three_pids_management_information_part1" = "Kelola alamat email atau nomor telepon apa saja yang pengguna lain dapat menggunakan untuk menemukan Anda dan menggunakannya untuk mengundang Anda ke ruangan. Tambahkan atau hapus alamat email atau nomor telepon dari daftar ini di "; "room_preview_unlinked_email_warning" = "Undangan ini telah dikirim ke %@, yang tidak diasosiasikan dengan akun ini. Anda mungkin ingin masuk ke akun yang lain, atau tambahkan email ini ke akun Anda."; "unknown_devices_alert" = "Ruangan ini berisi sesi tidak dikenal yang belum diverifikasi.\nIni berarti tidak ada jaminan bahwa sesi tersebut adalah milik pengguna yang mereka klaim.\nKami menyarankan Anda memverifikasinya untuk setiap sesi sebelum melanjutkan, tetapi Anda dapat mengirim ulang pesan tanpa memverifikasi jika Anda ingin."; -"room_warning_about_encryption" = "Enkripsi ujung-ke-ujung masih dalam beta dan mungkin tidak dapat diandalkan.\n\nAnda seharusnya tidak mempercayainya dulu untuk mengamankan data.\n\nPerangkat masih belum dapat mendekripsi riwayat sebelum mereka bergabung ke ruangannya.\n\nPesan terenkripsi masih belum terlihat di klen yang belum mengimplementasikan enkripsi."; +"room_warning_about_encryption" = "Enkripsi ujung ke ujung masih dalam beta dan mungkin tidak dapat diandalkan.\n\nAnda seharusnya tidak mempercayainya dulu untuk mengamankan data.\n\nPerangkat masih belum dapat mendekripsi riwayat sebelum mereka bergabung ke ruangannya.\n\nPesan terenkripsi masih belum terlihat di klien yang belum mengimplementasikan enkripsi."; "auth_add_email_and_phone_warning" = "Pendaftaran dengan email dan nomor telepon sekaligus belum didukung sampai API-nya sudah ada. Hanya nomor telepon yang akan diperhitungkan. Anda dapat menambahkan email Anda di profil Anda di pengaturan."; "auth_reset_password_success_message" = "Kata sandi akun Matrix Anda telah diatur ulang.\n\nAnda telah dikeluarkan dari semua sesi dan tidak akan menerima lagi notifikasi push. Untuk mengaktifkan ulang notifikasi, masuk ulang di setiap perangkat."; "spaces_add_rooms_coming_soon_title" = "Penambahan ruangan akan segera datang"; @@ -1724,7 +1724,7 @@ "settings_enable_room_message_bubbles" = "Gelembung pesan"; "onboarding_splash_page_4_message" = "Element juga bagus untuk tempat kerja. Terpercayai oleh organisasi paling aman di dunia."; "onboarding_splash_page_4_title_no_pun" = "Perpesanan untuk tim Anda."; -"onboarding_splash_page_3_message" = "Terenkripsi secara ujung-ke-ujung dan tidak memerlukan nomor telepon. Tidak ada iklan atau penambangan data."; +"onboarding_splash_page_3_message" = "Terenkripsi secara ujung ke ujung dan tidak memerlukan nomor telepon. Tanpa iklan atau penambangan data."; "onboarding_splash_page_3_title" = "Perpesanan aman."; "onboarding_splash_page_2_message" = "Pilihlah di mana percakapan Anda disimpan, memberikan Anda kendali dan kebebasan. Terhubung via Matrix."; "onboarding_splash_page_2_title" = "Anda dalam kendali."; @@ -1861,16 +1861,16 @@ "login_mobile_device" = "Ponsel"; "login_error_forgot_password_is_not_supported" = "Lupa kata sandi saat ini belum didukung"; "register_error_title" = "Pendaftaran Gagal"; -"login_invalid_param" = "Parameter tidak valid"; +"login_invalid_param" = "Parameter tidak absah"; "login_leave_fallback" = "Batalkan"; "login_use_fallback" = "Gunakan halaman fallback"; "login_error_login_email_not_yet" = "Tautan email yang belum diklik"; "login_error_user_in_use" = "Nama pengguna ini sudah dipakai"; "login_error_limit_exceeded" = "Terlalu banyak permintaan yang dikirim"; -"login_error_not_json" = "Tidak mengandung JSON yang valid"; +"login_error_not_json" = "Tidak mengandung JSON yang absah"; "login_error_unknown_token" = "Token akses yang ditentukan tidak diketahui"; "login_error_bad_json" = "JSON cacat"; -"login_error_forbidden" = "Nama pengguna/kata sandi tidak valid"; +"login_error_forbidden" = "Nama pengguna/kata sandi tidak absah"; "login_error_registration_is_not_supported" = "Pendaftaran saat ini tidak didukung"; "login_error_do_not_support_login_flows" = "Saat ini kami tidak mendukung salah satu atau semua alur masuk yang ditentukan oleh homeserver ini"; "login_error_no_login_flow" = "Kami gagal untuk menerima informasi otentikasi dari homeserver ini"; @@ -1931,15 +1931,15 @@ "call_connecting" = "Menghubungkan…"; // gcm section -"notification_settings_notify_all_other" = "Beritahu untuk semua pesan/ruangan lainnya"; +"notification_settings_notify_all_other" = "Beri tahu untuk semua pesan/ruangan lainnya"; "notification_settings_by_default" = "Secara default..."; -"notification_settings_suppress_from_bots" = "Jangan beritahu saya tentang notifikasi dari bot"; -"notification_settings_receive_a_call" = "Beritahu saya ketika saya menerima panggilan"; -"notification_settings_people_join_leave_rooms" = "Beritahu saya ketika ada orang bergabung atau meninggalkan ruangan"; -"notification_settings_invite_to_a_new_room" = "Beritahu saya ketika saya diundang ke ruangan baru"; -"notification_settings_just_sent_to_me" = "Beritahu saya dengan suara tentang pesan yang baru saja dikirim ke saya"; -"notification_settings_contain_my_display_name" = "Beritahu saya dengan suara tentang pesan yang berisi nama tampilan saya"; -"notification_settings_contain_my_user_name" = "Beritahu saya dengan suara tentang pesan yang berisi nama pengguna saya"; +"notification_settings_suppress_from_bots" = "Jangan beri tahu saya tentang notifikasi dari bot"; +"notification_settings_receive_a_call" = "Beri tahu saya ketika saya menerima panggilan"; +"notification_settings_people_join_leave_rooms" = "Beri tahu saya ketika ada orang bergabung atau meninggalkan ruangan"; +"notification_settings_invite_to_a_new_room" = "Beri tahu saya ketika saya diundang ke ruangan baru"; +"notification_settings_just_sent_to_me" = "Beri tahu saya dengan suara tentang pesan yang baru saja dikirim ke saya"; +"notification_settings_contain_my_display_name" = "Beri tahu saya dengan suara tentang pesan yang berisi nama tampilan saya"; +"notification_settings_contain_my_user_name" = "Beri tahu saya dengan suara tentang pesan yang berisi nama pengguna saya"; "notification_settings_other_alerts" = "Pemberitahuan Lainnya"; "notification_settings_select_room" = "Pilih sebuah ruangan"; "notification_settings_sender_hint" = "@pengguna:domain.com"; @@ -1948,8 +1948,8 @@ "notification_settings_custom_sound" = "Suara kustom"; "notification_settings_highlight" = "Sorotan"; "notification_settings_word_to_match" = "kata untuk dicocokkan"; -"notification_settings_never_notify" = "Jangan diberitahu"; -"notification_settings_always_notify" = "Selalu diberitahu"; +"notification_settings_never_notify" = "Jangan beri tahu"; +"notification_settings_always_notify" = "Selalu beri tahu"; "notification_settings_per_word_info" = "Kata-kata tidak cocok dengan huruf besar-kecil, dan mungkin menyertakan karakter pengganti *. Jadi:\nfoo cocok dengan string foo yang dikelilingi oleh pembatas kata (misalnya tanda baca dan spasi atau awal/akhir baris).\nfoo* cocok dengan kata apa pun yang dimulai foo.\n*foo* cocok dengan kata apa pun yang menyertakan 3 huruf foo."; "notification_settings_per_word_notifications" = "Notifikasi per kata"; "notification_settings_global_info" = "Pengaturan notifikasi disimpan ke akun pengguna Anda dan dibagikan di antara semua client yang mendukungnya (termasuk pemberitahuan desktop).\n\nAturan diterapkan secara berurutan; aturan pertama yang cocok menentukan hasil untuk pesan.\nJadi: Notifikasi per kata lebih penting daripada notifikasi per ruangan yang lebih penting daripada notifikasi per pengirim.\nUntuk beberapa aturan dengan jenis yang sama, yang pertama dalam daftar yang cocok akan diprioritaskan."; @@ -2025,7 +2025,7 @@ // button names "notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Anda membuat sejarah pesan di masa mendatang dapat dilihat oleh semuanya, sejak mereka bergabung."; "notice_room_history_visible_to_members_from_joined_point_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruang, sejak mereka bergabung."; -"notice_encryption_enabled_unknown_algorithm_by_you" = "Anda mengaktifkan enkripsi ujung-ke-ujung (algoritma %@ tidak dikenal)."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Anda mengaktifkan enkripsi ujung ke ujung (algoritma %@ tidak dikenal)."; "notice_room_third_party_revoked_invite" = "%@ menghilangkan undangannya %@ untuk bergabung ke ruangan ini"; "notice_room_third_party_revoked_invite_by_you" = "Anda menghilangkan undangannya %@ untuk bergabung ke ruangan ini"; "account_email_validation_error" = "Tidak dapat memverifikasi alamat email. Silakan cek email Anda dan tekan tautannya yang ada. Setelah selesai, tekan lanjut"; @@ -2038,7 +2038,7 @@ "notice_room_history_visible_to_members_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruangan."; "notice_room_history_visible_to_anyone_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh siapa saja."; "notice_redaction_by_you" = "Anda menghapus sebuah peristiwa (id: %@)"; -"notice_encryption_enabled_ok_by_you" = "Anda mengaktifkan enkripsi ujung-ke-ujung."; +"notice_encryption_enabled_ok_by_you" = "Anda mengaktifkan enkripsi ujung ke ujung."; "notice_room_created_by_you_for_dm" = "Anda bergabung."; "notice_room_created_by_you" = "Anda membuat dan mengatur ruangan ini."; "notice_profile_change_redacted_by_you" = "Anda memperbarui profil Anda %@"; @@ -2135,7 +2135,7 @@ "error_common_message" = "Sebuah kesalahan terjadi. Coba lagi nanti."; "error" = "Gagal"; "unsent" = "Belum Terkirim"; -"offline" = "offline"; +"offline" = "luring"; // Others "user_id_title" = "ID Pangguna:"; @@ -2225,10 +2225,10 @@ // Room creation "room_creation_name_title" = "Nama ruangan:"; "account_error_push_not_allowed" = "Notifikasi tidak diizinkan"; -"account_error_msisdn_wrong_description" = "Ini sepertinya bukan nomor telepon yang valid"; -"account_error_msisdn_wrong_title" = "Nomor Telepon Tidak Valid"; -"account_error_email_wrong_description" = "Ini sepertinya bukan alamat email yang valid"; -"account_error_email_wrong_title" = "Alamat Email Tidak Valid"; +"account_error_msisdn_wrong_description" = "Ini sepertinya bukan nomor telepon yang Absah"; +"account_error_msisdn_wrong_title" = "Nomor Telepon Tidak Absah"; +"account_error_email_wrong_description" = "Ini sepertinya bukan alamat email yang absah"; +"account_error_email_wrong_title" = "Alamat Email Tidak Absah"; "account_error_matrix_session_is_not_opened" = "Sesi Matrix tidak dibuka"; "account_error_picture_change_failed" = "Penggantian gambar gagal"; "account_error_display_name_change_failed" = "Penggantian nama tampilan gagal"; @@ -2282,7 +2282,7 @@ // Devices "device_details_title" = "Informasi sesi\n"; "notification_settings_room_rule_title" = "Ruangan: '%@'"; -"settings_enter_validation_token_for" = "Masukkan token validasi untuk %@:"; +"settings_enter_validation_token_for" = "Masukkan token absah untuk %@:"; "settings_enable_push_notifications" = "Aktifkan notifikasi push"; "settings_enable_inapp_notifications" = "Aktifkan notifikasi dalam aplikasi"; @@ -2311,14 +2311,14 @@ "notice_redaction" = "%@ menghapus sebuah peristiwa (id: %@)"; "notice_feedback" = "Peristiwa umpan balik (id: %@): %@"; "notice_unsupported_attachment" = "Lampiran yang tidak didukung: %@"; -"notice_invalid_attachment" = "lampiran tidak valid"; +"notice_invalid_attachment" = "lampiran tidak absah"; "notice_file_attachment" = "lampiran file"; "notice_location_attachment" = "lampiran lokasi"; "notice_video_attachment" = "lampiran video"; "notice_audio_attachment" = "lampiran audio"; "notice_image_attachment" = "lampiran gambar"; -"notice_encryption_enabled_unknown_algorithm" = "%1$@ mengaktifkan enkripsi ujung-ke-ujung (algoritma %2$@ tidak dikenal)."; -"notice_encryption_enabled_ok" = "%@ mengaktifkan enkripsi ujung-ke-ujung."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ mengaktifkan enkripsi ujung ke ujung (algoritma %2$@ tidak dikenal)."; +"notice_encryption_enabled_ok" = "%@ mengaktifkan enkripsi ujung ke ujung."; "notice_encrypted_message" = "Pesan terenkripsi"; "notice_room_related_groups" = "Grup yang terkait dengan ruangan ini adalah: %@"; "notice_room_aliases_for_dm" = "Aliasnya adalah: %@"; @@ -2358,7 +2358,7 @@ "spaces_creation_invite_by_username_message" = "Anda juga dapat mengundang mereka nanti."; "spaces_creation_invite_by_username_title" = "Undang tim Anda"; "spaces_creation_invite_by_username" = "Undang dengan nama pengguna"; -"spaces_creation_add_rooms_message" = "Space ini hanya untuk Anda, tidak akan diberitahukan kepada siapa saja. Anda dapat menambahkan lagi nanti."; +"spaces_creation_add_rooms_message" = "Space ini hanya untuk Anda, tidak akan diberi tahu kepada siapa pun. Anda dapat menambahkan lagi nanti."; "spaces_creation_add_rooms_title" = "Apa yang Anda ingin tambahkan?"; "spaces_creation_sharing_type_me_and_teammates_detail" = "Space privat untuk Anda & tim Anda"; "spaces_creation_sharing_type_me_and_teammates_title" = "Saya dan tim saya"; @@ -2380,7 +2380,7 @@ "spaces_creation_private_space_title" = "Space privat Anda"; "spaces_creation_public_space_title" = "Space publik Anda"; "spaces_creation_address_already_exists" = "%@\nsudah ada"; -"spaces_creation_address_invalid_characters" = "%@\nmemiliki karakter yang tidak valid"; +"spaces_creation_address_invalid_characters" = "%@\nmemiliki karakter yang tidak absah"; "spaces_creation_address_default_message" = "Space Anda dapat ditampilkan di\n%@"; "spaces_creation_empty_room_name_error" = "Nama dibutuhkan"; "spaces_creation_address" = "Alamat"; @@ -2573,7 +2573,7 @@ "authentication_terms_policy_url_error" = "Tidak dapat menemukan kebijakan yang dipilih. Mohon coba lagi nanti."; "authentication_terms_message" = "Mohon baca ketentuan dan kebijakan %@"; "authentication_terms_title" = "Kebijakan privasi"; -"authentication_verify_msisdn_invalid_phone_number" = "Nomor telepon tidak valid"; +"authentication_verify_msisdn_invalid_phone_number" = "Nomor telepon tidak absah"; "authentication_verify_msisdn_waiting_button" = "Kirim ulang kode"; /* The placeholder will show the phone number that was entered. */ "authentication_verify_msisdn_waiting_message" = "Sebuah kode terkirim ke %@"; @@ -2674,7 +2674,7 @@ "room_invites_empty_view_title" = "Belum ada yang baru."; "all_chats_onboarding_try_it" = "Coba"; "all_chats_onboarding_title" = "Apa yang baru"; -"all_chats_onboarding_page_message3" = "Ketuk profil Anda untuk memberitahukan kami bagaimana menurut Anda."; +"all_chats_onboarding_page_message3" = "Ketuk profil Anda untuk memberi tahu kami bagaimana menurut Anda."; "all_chats_onboarding_page_title3" = "Berikan Masukan"; "all_chats_onboarding_page_message2" = "Akses Space Anda (di kiri bawah) dengan lebih cepat dan lebih mudah dari sebelumnya."; "all_chats_onboarding_page_title2" = "Akses Space"; @@ -2696,7 +2696,7 @@ "device_name_mobile" = "%@ Ponsel"; "device_name_web" = "%@ Web"; "device_name_desktop" = "%@ Desktop"; -"user_session_item_details" = "%@ · Aktivitas terakhir %@"; +"user_session_item_details" = "%1$@ · %2$@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2709,8 +2709,138 @@ "user_session_verified_short" = "Terverifikasi"; "user_session_unverified" = "Sesi belum diverifikasi"; "user_session_verified" = "Sesi terverifikasi"; -"user_sessions_overview_current_session_section_title" = "SESI SAAT INI"; +"user_sessions_overview_current_session_section_title" = "Sesi saat ini"; "user_sessions_overview_other_sessions_section_info" = "Untuk keamanan yang terbaik, verifikasi sesi Anda dan keluarkan dari sesi yang Anda tidak kenal atau tidak digunakan lagi."; -"user_sessions_overview_other_sessions_section_title" = "SESI LAINNYA"; +"user_sessions_overview_other_sessions_section_title" = "Sesi lainnya"; "settings_labs_enable_new_app_layout" = "Tata Letak Aplikasi Baru"; "room_first_message_placeholder" = "Kirim pesan pertama Anda…"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "Keaslian pesan terenkripsi ini tidak dapat dijamin pada perangkat ini."; +"user_session_overview_session_details_button_title" = "Detail sesi"; +"user_session_overview_session_title" = "Sesi"; +"user_session_overview_current_session_title" = "Sesi saat ini"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Versi"; +"user_session_details_application_name" = "Nama"; +"user_session_details_device_os" = "Sistem Operasi"; +"user_session_details_device_browser" = "Peramban"; +"user_session_details_device_model" = "Model"; +"user_session_details_device_ip_location" = "Lokasi IP"; +"user_session_details_device_ip_address" = "Alamat IP"; +"user_session_details_session_section_footer" = "Salin data apa pun dengan mengetuk dan menahan."; +"user_session_details_session_id" = "ID sesi"; +"user_session_details_session_name" = "Nama sesi"; +"user_session_details_device_section_header" = "Perangkat"; +"user_session_details_application_section_header" = "Aplikasi"; +"user_session_details_session_section_header" = "Sesi"; +"user_session_details_title" = "Detail sesi"; +"user_session_push_notifications_message" = "Ketika ini diaktifkan, sesi ini akan menerima notifikasi dorongan."; +"user_session_push_notifications" = "Notifikasi dorongan"; +"user_sessions_view_all_action" = "Tampilkan semua (%d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Pertimbangkan untuk mengeluarkan sesi lama (90 hari atau lebih) yang Anda tidak gunakan lagi."; +"user_sessions_overview_security_recommendations_inactive_title" = "Sesi yang tidak aktif"; +"user_sessions_overview_security_recommendations_unverified_info" = "Verifikasi atau keluarkan sesi yang belum diverifikasi."; +"user_sessions_overview_security_recommendations_unverified_title" = "Sesi yang belum diverifikasi"; +"user_sessions_overview_security_recommendations_section_info" = "Tingkatkan keamanan akun Anda dengan mengikuti saran berikut."; +"user_sessions_overview_security_recommendations_section_title" = "Saran keamanan"; +"all_chats_user_menu_accessibility_label" = "Menu pengguna"; +"settings_labs_enable_new_client_info_feature" = "Rekam nama, versi, dan URL klien untuk dapat mengenal sesi dengan lebih muda dalam pengelola sesi"; +"settings_labs_enable_new_session_manager" = "Pengelola sesi baru"; +"device_type_name_unknown" = "Tidak diketahui"; +"device_type_name_mobile" = "Ponsel"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Desktop"; +"user_inactive_session_item_with_date" = "Tidak aktif selama 90+ hari (%@)"; +"user_inactive_session_item" = "Tidak aktif selama 90+ hari"; +"user_other_session_unverified_sessions_header_subtitle" = "Verifikasi sesi Anda untuk perpesanan aman yang terbaik atau keluarkan sesi yang Anda tidak kenal atau gunakan lagi."; +"user_other_session_security_recommendation_title" = "Saran keamanan"; +"user_sessions_overview_link_device" = "Tautkan sebuah perangkat"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; + +// MARK: Sign out warning + +"sign_out" = "Keluar dari akun"; +"sign_out_confirmation_message" = "Apakah Anda yakin ingin keluar dari akun Anda?"; +"manage_session_rename" = "Ubah nama sesi"; +"authentication_qr_login_failure_retry" = "Coba lagi"; +"authentication_qr_login_failure_request_timed_out" = "Penautan tidak selesai dalam waktu yang diperlukan."; +"authentication_qr_login_failure_request_denied" = "Permintaan ditolak di perangkat yang lain."; +"authentication_qr_login_failure_invalid_qr" = "Kode QR tidak absah."; +"authentication_qr_login_display_subtitle" = "Pindai kode QR di bawah dengan perangkat Anda yang sudah keluar dari akun."; +"authentication_qr_login_confirm_alert" = "Pastikan Anda tahu asalnya dari kode ini. Dengan menautkan perangkat, Anda akan memberikan seseorang akses penuh ke akun Anda."; +"authentication_qr_login_loading_signed_in" = "Anda sekarang masuk di perangkat Anda yang lain."; +"authentication_qr_login_failure_title" = "Penautan gagal"; +"authentication_qr_login_loading_waiting_signin" = "Menunggu perangkat untuk masuk."; +"authentication_qr_login_loading_connecting_device" = "Menghubungkan ke perangkat"; +"authentication_qr_login_confirm_subtitle" = "Konfirmasi bahwa kode di bawah sesuai dengan perangkat Anda yang lain:"; +"authentication_qr_login_confirm_title" = "Koneksi aman dibuat"; +"authentication_qr_login_scan_subtitle" = "Tempatkan kode QR di dalam kotak di bawah"; +"authentication_qr_login_scan_title" = "Pindai kode QR"; +"authentication_qr_login_display_step2" = "Pilih ‘Masuk dengan kode QR’"; +"authentication_qr_login_display_step1" = "Buka Element di perangkat Anda yang lain"; +"authentication_qr_login_display_title" = "Tautkan sebuah perangkat"; +"authentication_qr_login_start_display_qr" = "Tampilkan kode QR di perangkat ini"; +"authentication_qr_login_start_need_alternative" = "Butuh metode yang lain?"; +"authentication_qr_login_start_step4" = "Pilih ‘Tampilkan kode QR di perangkat ini’"; +"authentication_qr_login_start_step3" = "Pilih ‘Tautkan sebuah perangkat’"; +"authentication_qr_login_start_step2" = "Pergi ke Pengaturan → Keamanan & Privasi"; +"authentication_qr_login_start_step1" = "Buka Element di perangkat Anda yang lain"; +"authentication_qr_login_start_subtitle" = "Gunakan kamera pada perangkat ini untuk memindai kode QR yang ditampilkan di perangkat Anda yang lain:"; +"authentication_qr_login_start_title" = "Pindai kode QR"; +"authentication_login_with_qr" = "Masuk dengan kode QR"; +"wysiwyg_composer_format_action_strikethrough" = "Terapkan format garis bawah"; +"wysiwyg_composer_format_action_underline" = "Terapkan format coret"; +"wysiwyg_composer_format_action_italic" = "Terapkan format miring"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Terapkan format tebal"; +"wysiwyg_composer_start_action_text_formatting" = "Format Teks"; +"wysiwyg_composer_start_action_polls" = "Pemungutan Suara"; +"wysiwyg_composer_start_action_camera" = "Kamera"; +"wysiwyg_composer_start_action_location" = "Lokasi"; +"wysiwyg_composer_start_action_attachments" = "Lampiran"; +"wysiwyg_composer_start_action_stickers" = "Stiker"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Pustaka Foto"; +"user_session_details_last_activity" = "Aktivitas terakhir"; +"user_session_item_details_last_activity" = "Aktivitas terakhir %@"; +"user_other_session_clear_filter" = "Hapus saringan"; +"user_other_session_no_unverified_sessions" = "Tidak ditemukan sesi yang belum diverifikasi."; +"user_other_session_no_verified_sessions" = "Tidak ditemukan sesi yang terverifikasi."; +"user_other_session_no_inactive_sessions" = "Tidak ditemukan sesi yang tidak aktif."; +"user_other_session_filter_menu_inactive" = "Tidak aktif"; +"user_other_session_filter_menu_unverified" = "Belum diverifikasi"; +"user_other_session_filter_menu_verified" = "Terverifikasi"; +"user_other_session_filter_menu_all" = "Semua sesi"; +"user_other_session_filter" = "Saring"; +"user_other_session_verified_sessions_header_subtitle" = "Untuk keamanan yang terbaik, keluarkan sesi yang Anda tidak kenal atau tidak digunakan lagi."; +"user_other_session_current_session_details" = "Sesi Anda saat ini"; +"user_other_session_verified_additional_info" = "Sesi ini siap untuk perpesanan aman."; +"user_other_session_unverified_additional_info" = "Verifikasi atau keluarkan sesi ini untuk keamanan dan keandalan yang terbaik."; +"user_session_verification_unknown_additional_info" = "Verifikasi sesi Anda saat ini untuk menampilkan status verifikasi sesi ini."; +"user_session_verification_unknown_short" = "Tidak diketahui"; +"user_session_verification_unknown" = "Status verifikasi tidak diketahui"; +"manage_session_name_info_link" = "Pelajari lebih lanjut"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Harap diketahui bahwa nama sesi juga terlihat ke orang-orang yang Anda berkomunikasi. %@"; +"manage_session_name_hint" = "Nama sesi khusus dapat membantu Anda mengenal perangkat Anda dengan lebih mudah."; +"settings_labs_enable_wysiwyg_composer" = "Coba editor teks kaya (mode teks biasa akan datang)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Siaran suara"; +"voice_broadcast_playback_loading_error" = "Tidak dapat memainkan siaran suara ini."; +"voice_broadcast_already_in_progress_message" = "Anda saat ini merekam sebuah siaran suara. Mohon akhiri siaran suara Anda saat ini untuk memulai yang baru."; +"voice_broadcast_blocked_by_someone_else_message" = "Ada orang lain yang saat ini merekam sebuah siaran suara. Tunggu siaran suaranya berakhir untuk memulai yang baru."; +"voice_broadcast_permission_denied_message" = "Anda tidak memiliki izin untuk memulai sebuah siaran suara di ruangan ini. Hubungi sebuah administrator ruangan untuk meningkatkan izin Anda."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Tidak dapat memulai sebuah siaran suara baru"; +"settings_labs_enable_voice_broadcast" = "Siaran suara (dalam pengembangan aktif)"; +"deselect_all" = "Batalkan Semua Pilihan"; +"user_other_session_menu_select_sessions" = "Pilih sesi"; +"user_other_session_selected_count" = "%@ dipilih"; diff --git a/Riot/Assets/is.lproj/InfoPlist.strings b/Riot/Assets/is.lproj/InfoPlist.strings index 688960701..e6227c08b 100644 --- a/Riot/Assets/is.lproj/InfoPlist.strings +++ b/Riot/Assets/is.lproj/InfoPlist.strings @@ -3,9 +3,9 @@ "NSLocationWhenInUseUsageDescription" = "Þegar þú deilir staðsetningunni þinni með öðru fólki, þarf Element aðgang að henni til að geta birt hana á landakorti."; "NSFaceIDUsageDescription" = "Face ID er notað til að fá aðgang að forritinu þínu."; "NSCalendarsUsageDescription" = "Skoðaðu áætlaða fundi þína í forritinu."; -"NSContactsUsageDescription" = "Element mun birta tengiliðina þína svo þú getir boðið þeim að spjalla."; +"NSContactsUsageDescription" = "Þeim verður deilt með auðkennisþjóninum þínum til að þú getir fundið tengiliðina þína á Matrix."; "NSMicrophoneUsageDescription" = "Element þarf að fá aðgang að hljóðnemanum þínum fyrir símtöl, upptöku á myndskeiðum og upptöku talskilaboða."; -"NSPhotoLibraryUsageDescription" = "Myndasafnið er notað til að senda myndir og myndskeið."; +"NSPhotoLibraryUsageDescription" = "Leyfðu aðgang að myndum til að geta sent myndir og myndskeið úr myndasafninu."; // Permissions usage explanations -"NSCameraUsageDescription" = "Myndavélin er notuð til að taka myndir og myndskeið og fyrir myndsímtöl."; +"NSCameraUsageDescription" = "Myndavélin er notuð fyrir myndsímtöl og til að taka myndir og myndskeið."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "Þegar þú deilir staðsetningu þinni til annarra, þarf Element aðgang til að geta birt hana á landakorti."; diff --git a/Riot/Assets/is.lproj/Vector.strings b/Riot/Assets/is.lproj/Vector.strings index ad9ba1194..aba02e2d6 100644 --- a/Riot/Assets/is.lproj/Vector.strings +++ b/Riot/Assets/is.lproj/Vector.strings @@ -63,7 +63,7 @@ "room_creation_appearance_name" = "Heiti"; "room_creation_privacy" = "Friðhelgi"; "room_creation_make_private" = "Gera einka"; -"room_recents_favourites_section" = "Eftirlæti"; +"room_recents_favourites_section" = "EFTIRLÆTI"; "room_recents_people_section" = "FÓLK"; "room_recents_conversations_section" = "SPJALLRÁSIR"; "room_recents_no_conversation" = "Engar spjallrásir"; @@ -243,8 +243,8 @@ "media_picker_library" = "Safn"; "media_picker_select" = "Veldu"; // Directory -"directory_title" = "Mappa"; -"directory_server_picker_title" = "Veldu möppu"; +"directory_title" = "Yfirlitsskrá"; +"directory_server_picker_title" = "Veldu yfirlitsskrá"; "directory_server_all_rooms" = "Allar spjallrásir á %@ vefþjóninum"; "directory_server_all_native_rooms" = "Allar innbyggðar Matrix-spjallrásir"; // Others @@ -851,7 +851,7 @@ "space_home_show_all_rooms" = "Sýna allar spjallrásir"; "spaces_coming_soon_title" = "Kemur bráðum"; "spaces_no_result_found_title" = "Engar niðurstöður fundust"; -"space_tag" = "bil"; +"space_tag" = "svæði"; "spaces_suggested_room" = "Tillögur"; "spaces_explore_rooms" = "Kanna spjallrásir"; "leave_space_only_action" = "Ekki yfirgefa neinar spjallrásir"; @@ -2091,10 +2091,10 @@ "threads_beta_title" = "Spjallþræðir"; "threads_notice_done" = "Náði því"; "onboarding_celebration_button" = "Hefjumst handa"; -"onboarding_celebration_message" = "Farðu hvenær sem er í stillingarnar til að breyta notandasniðinu þínu."; +"onboarding_celebration_message" = "Farðu hvenær sem er í stillingarnar til að breyta notandasniðinu þínu"; "onboarding_celebration_title" = "Lítur vel út!"; "onboarding_avatar_accessibility_label" = "Auðkennismynd"; -"onboarding_avatar_message" = "Þú getur breytt þessu hvenær sem er."; +"onboarding_avatar_message" = "Tími til að setja andlit á nafnið"; "onboarding_avatar_title" = "Bættu við auðkennismynd"; "onboarding_display_name_hint" = "Þú getur breytt þessu síðar"; "onboarding_display_name_placeholder" = "Birtingarnafn"; @@ -2177,3 +2177,194 @@ "authentication_login_username" = "Notandanafn / tölvupóstfang / símanúmer"; "authentication_login_title" = "Velkomin(n) aftur!"; "authentication_registration_username" = "Notandanafn"; +"threads_beta_information" = "Haltu samræðum skipulögðum með spjallþráðum.\n\nSpjallþræðir hjálpa til við að halda samræðum við efnið og gerir auðveldara að rekja þær. "; +"room_no_privileges_to_create_group_call" = "Þú þarft að vera stjórnandi eða umsjónarmaður til að hefja símtal."; +"room_accessibility_record_voice_message_hint" = "Tvípikkaðu og haltu niðri til að taka upp."; +"room_participants_start_new_chat_error_using_user_email_without_identity_server" = "Enginn auðkennisþjónn er stilltur þannig að þú getur ekki byrjað spjall við tengilið með því að nota tölvupóstfang."; +"find_your_contacts_title" = "Byrjum á því að gera lista yfir tengiliðina þína"; +"contacts_address_book_permission_denied_alert_message" = "Til að virkja tengiliði, skaltu fara í stillingar tækisins þíns."; +"rooms_empty_view_information" = "Spjallrásir eru frábærar fyrir hópspjall, einka eða opinbert. Ýttu á + til að finna fyrirliggjandi spjallrásir eða búa til nýjar."; +"people_empty_view_information" = "Spjallaðu á öruggan hátt við hvern sem er. Ýttu á + til að bæta við fólki."; +"room_creation_error_invite_user_by_email_without_identity_server" = "Enginn auðkennisþjónn er stilltur þannig að þú getur ekki byrjað spjall við tengilið með því að nota tölvupóstfang."; + +// Errors +"error_user_already_logged_in" = "Það lítur út fyrir að þú sért að reyna að tengjast öðrum heimaþjóni. Viltu skrá þig út?"; +"create_room_show_in_directory_footer" = "Þetta hjálpar fólki að finna og taka þátt."; +"room_access_settings_screen_upgrade_alert_message" = "Hver sem er í %@ mun geta fundið og tekið þátt í þessari spjallrás - ekki er þörf á að bjóða öllum handvirkt. Þú munt geta breytt þessu í stillingum spjallrásarinnar hvenær sem er."; +"room_access_settings_screen_restricted_message" = "Hver sem er í svæði getur fundið og tekið þátt. \nÞý verður beðin/n um að staðfesta hvaða svæði."; +"room_access_settings_screen_private_message" = "Aðeins fólk sem er boðið getur fundið og tekið þátt."; +"room_access_settings_screen_message" = "Veldu hverjir geta fundið %@ og tekið þátt."; +"auth_reset_password_error_is_required" = "Enginn auðkennisþjónn er stilltur: bættu við einum slíkum í stillingum fyrir netþjónninn til að geta endurstillt Matrix-lykilorðið þitt."; +"auth_forgot_password_error_no_configured_identity_server" = "Enginn auðkennisþjónn er stilltur: bættu við einum slíkum til að geta endurstillt Matrix-lykilorðið þitt."; +"auth_phone_is_required" = "Enginn auðkennisþjónn er stilltur, þannig að þú getur ekki bætt við símanúmeri til að geta í framtíðinni endurstillt Matrix-lykilorðið þitt."; +"auth_email_is_required" = "Enginn auðkennisþjónn er stilltur, þannig að þú getur ekki bætt við tölvupóstfangi til að geta í framtíðinni endurstillt Matrix-lykilorðið þitt."; +"auth_add_email_phone_message_2" = "Notaðu tölvupóstfang til að endurheimta aðganginn þinn. Notaðu síðar tölvupóstfang eða símanúmer til að vera geta verið finnanleg/ur fyrir fólk sem þekkir þig."; +"auth_add_phone_message_2" = "Notaðu símanúmer til að vera geta verið finnanleg/ur fyrir fólk sem þekkir þig."; +"auth_add_email_message_2" = "Notaðu tölvupóstfang til að endurheimta aðganginn þinn, og síðar til að vera geta verið finnanleg/ur fyrir fólk sem þekkir þig."; +"authentication_terms_policy_url_error" = "Tókst ekki að finna viðkomandi stefnu. Reyndu aftur síðar."; +"authentication_cancel_flow_confirmation_message" = "Ekki er enn búið að útbúa notandaaðganginn þinn. Á að hætta skráningarferlinu?"; +"authentication_server_selection_generic_error" = "Finn ekki heimaþjón á þessari slóð, athugaðu hvort slóðin sé rétt."; +"authentication_server_selection_register_message" = "Hvert er vistfang netþjónsins þíns? Þetta er staður sem geymir öll gögnin þín"; +"onboarding_display_name_message" = "Þetta verður birt þegar þú sendir skilaboð."; +"onboarding_congratulations_personalize_button" = "Persónugerðu forsíðuna"; +"notice_room_join_rule_public_by_you_for_dm" = "Þú gerðir þetta opinbert."; +"notice_room_join_rule_public_by_you" = "Þú gerðir spjallrásina opinbera."; +"notice_room_join_rule_public_for_dm" = "%@ gerði þetta opinbert."; +"notice_room_join_rule_public" = "%@ gerði spjallrásina opinbera."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ gerði skilaboð héðan í frá sýnileg fyrir alla meðlimi spjallrásarinnar síðan þeir skráðu sig."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ gerði ferilskrá spjallrásar héðan í frá sýnilega fyrir alla meðlimi spjallrásarinnar síðan þeir skráðu sig."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ gerði skilaboð héðan í frá sýnileg fyrir alla meðlimi spjallrásarinnar síðan þeim var boðið."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ gerði ferilskrá spjallrásar héðan í frá sýnilega fyrir alla meðlimi spjallrásarinnar síðan þeim var boðið."; +"notice_room_history_visible_to_members_for_dm" = "%@ gerði skilaboð héðan í frá sýnileg fyrir alla meðlimi spjallrásarinnar."; +"notice_room_join_rule_invite_for_dm" = "%@ gerði þetta einungis aðgengilegt gegn boði."; +// New +"notice_room_join_rule_invite" = "%@ gerði spjallrásina einungis aðgengilega gegn boði."; +// Old +"notice_room_join_rule" = "Reglan fyrir þátttöku er: %@"; +"location_sharing_live_lab_promotion_title" = "Deiling staðsetningar í rauntíma"; +"location_sharing_live_stop_sharing_progress" = "Stöðva deilingu staðsetninga"; +"location_sharing_live_stop_sharing_error" = "Mistókst að stöðva deilingu staðsetninga"; +"location_sharing_live_no_user_locations_error_title" = "Engar staðsetningar notenda tiltækar"; +"location_sharing_live_error" = "Villa í rauntímastaðsetningu"; +"live_location_sharing_ended" = "Staðsetningu í rauntíma lauk"; +"location_sharing_map_loading_error" = "Tókst ekki að hlaða inn landakorti\nHeimaþjónninn er ekki stilltur til að birta landakort"; +"location_sharing_invalid_power_level_title" = "Þú hefur ekki heimildir til að deila rauntímastaðsetningum"; +"room_invites_empty_view_information" = "Þetta er þar sem boðsgestirnir þínir birtast."; + +// Mark: - Room invites + +"room_invites_empty_view_title" = "Ekkert nýtt."; +"all_chats_onboarding_page_title1" = "Velkomin í nýja sýn!"; +"all_chats_nothing_found_placeholder_message" = "Reyndu að aðlaga leitina þína."; +"all_chats_edit_layout_alphabetical_order" = "Raða A-Ö"; +"all_chats_edit_layout_activity_order" = "Raða eftir virkni"; +"all_chats_edit_layout_sorting_options_title" = "Raða skilaboðum eftir"; +"all_chats_edit_layout_add_filters_title" = "Síaðu skilaboðin þín"; +"all_chats_edit_layout_add_section_title" = "Bæta við hlutanum á forsíðu"; +"all_chats_edit_layout" = "Kjörstillingar framsetningar"; +"all_chats_section_title" = "Spjallrásir"; + +// Mark: - All Chats + +"all_chats_title" = "Allar spjallrásir"; +"room_intro_cell_information_room_without_topic_sentence2_part2" = " svo fólk viti að um hvað málin snúist."; +"share_invite_link_space_text" = "Hæ, taktu þátt í þessu svæði á %@"; +"share_invite_link_room_text" = "Hæ, taktu þátt í þessari spjallrás á %@"; +"create_room_suggest_room" = "Stinga uppá við meðlimi svæðis"; +"room_details_promote_room_title" = "Hækka spjallrás"; +"room_first_message_placeholder" = "Sendu fyrstu skilaboðin þín…"; +"room_participants_security_information_room_encrypted_for_dm" = "Skilaboð hér eru enda-í-enda dulrituð.\n\nÖryggi skilaboðanna þinna er tryggt og einungis þú og viðtakendurnir hafa dulritunarlyklana til að opna skilaboðin."; +"room_participants_security_information_room_encrypted" = "Skilaboð á þessari spjallrás eru enda-í-enda dulrituð.\n\nÖryggi skilaboðanna þinna er tryggt og einungis þú og viðtakendurnir hafa dulritunarlyklana til að opna skilaboðin."; +"room_participants_invite_prompt_to_msg" = "Ertu viss um að þú viljir bjóða %@ á %@?"; +"password_validation_error_contain_symbol" = "Innihalda tákn."; +"password_validation_error_contain_number" = "Innihalda tölu."; +"password_validation_error_contain_uppercase_letter" = "Innihalda hástaf."; +"password_validation_error_contain_lowercase_letter" = "Innihalda lágstaf."; +/* The placeholder will show a number */ +"password_validation_error_max_length" = "Ekki vera lengra en %d stafir."; +/* The placeholder will show a number */ +"password_validation_error_min_length" = "Að minnsta kosti %d stafa langt."; +"password_validation_error_header" = "Uppgefið lykilorð uppfyllir ekki eftirfarandi skilyrði:"; + +// MARK: Password Validation +"password_validation_info_header" = "Lykilorðið þitt ætti að uppfylla eftirfarandi skilyrði:"; +/* The placeholder will show the homeserver's domain */ +"authentication_terms_message" = "Endilega lestu í gegnum stefnur og skilmála fyrir %@"; +"authentication_terms_title" = "persónuverndarstefna"; +/* The placeholder will show the phone number that was entered. */ +"authentication_verify_msisdn_waiting_message" = "Kóði var sendur til: %@"; +"authentication_verify_msisdn_waiting_title" = "Sannreyndu símanúmerið þitt"; +"authentication_verify_msisdn_otp_text_field_placeholder" = "Staðfestingarkóði"; +/* The placeholder will show the homeserver's domain */ +"authentication_verify_msisdn_input_message" = "%@ þarf að sannreyna notandaaðganginn þinn"; +"authentication_choose_password_not_verified_title" = "Tölvupóstfang ekki staðfest"; +"authentication_choose_password_signout_all_devices" = "Skrá út af öllum tækjum"; +"authentication_choose_password_input_message" = "Hafðu það að minnsta kosti 8 stafa langt"; +/* The placeholder will show the email address that was entered. */ +"authentication_forgot_password_waiting_message" = "Farðu eftir leiðbeiningunum sem sendar voru á %@"; +/* The placeholder will show the homeserver's domain */ +"authentication_forgot_password_input_message" = "%@ mun senda þér staðfestingartengil"; +"authentication_verify_email_waiting_hint" = "Fékkstu ekki tölvupóst?"; +/* The placeholder will show the email address that was entered. */ +"authentication_verify_email_waiting_message" = "Farðu eftir leiðbeiningunum sem sendar voru á %@"; +/* The placeholder will show the homeserver's domain */ +"authentication_verify_email_input_message" = "%@ þarf að sannreyna notandaaðganginn þinn"; +"authentication_server_selection_register_title" = "Veldu heimaþjóninn þinn"; +"authentication_server_selection_login_message" = "Hvert er vistfang netþjónsins þíns?"; +"authentication_server_selection_login_title" = "Tengjast við heimaþjón"; +"authentication_server_info_title_login" = "Þar sem samtölin þín eru"; +"authentication_server_info_title" = "Þar sem samtölin þín verða"; +"authentication_registration_password_footer" = "Verður að vera að minnsta kosti 8 stafir"; +/* The placeholder will show the full Matrix ID that has been entered. */ +"authentication_registration_username_footer_available" = "Aðrir geta fundið þig %@"; +"authentication_registration_username_footer" = "Þú getur ekki breytt þessu síðar"; +"onboarding_display_name_max_length" = "Birtingarnafnið þitt verður að vera styttra en 256 stafir"; +"onboarding_congratulations_home_button" = "Fara á forsíðuna"; +/* The placeholder string contains the user's matrix ID */ +"onboarding_congratulations_message" = "%@ aðgangur þinn hefur verið útbúinn"; +"onboarding_use_case_existing_server_message" = "Ætlarðu að ganga til liðs við fyrirliggjandi netþjón?"; +"onboarding_use_case_title" = "Við hverja muntu helst spjalla?"; +"onboarding_splash_page_4_message" = "Element er líka frábært fyrir vinnustaðinn. Heimsins öruggustu samtök treysta því."; +"onboarding_splash_page_4_title_no_pun" = "Skilaboð fyrir teymið þitt."; +"onboarding_splash_page_3_message" = "Enda-í-enda dulritað og ekkert símanúmer nauðsynlegt. Engar auglýsingar eða gagnasöfnun."; +"onboarding_splash_page_2_message" = "Veldu hvar á að geyma samtölin þín, sem gefur þér stjórnina og algert sjálfstæði. Tengt í gegnum Matrix."; +"onboarding_splash_page_1_message" = "Örugg og óháð samskipti sem gefa þér færi á að ræða málin í friði rétt eins og þetta sé maður á mann í heimahúsi."; +"invite_to" = "Bjóða í %@"; +"call_consulting_with_user" = "Ráðfæri við %@"; +"message_reply_to_sender_sent_their_live_location" = "Staðsetning í rauntíma."; +"notice_room_history_visible_to_members" = "%@ gerði ferilskrá spjallrásar héðan í frá sýnilega fyrir alla meðlimi spjallrásarinnar."; +"notice_room_history_visible_to_anyone" = "%@ gerði ferilskrá spjallrásar héðan í frá sýnilega fyrir hvern sem er."; +"device_name_mobile" = "%@ fyrir farsíma"; +"device_name_web" = "%@ á vefnum"; +"device_name_desktop" = "%@ fyrir einkatölvur"; +"user_session_item_details" = "%@ · Síðasta virkni %@"; +"location_sharing_live_loading" = "Hleð inn rauntímastaðsetningu..."; +"location_sharing_live_list_item_time_left" = "%@ fór"; +"location_sharing_map_credits_title" = "© Höfundarréttur"; +"location_sharing_post_failure_title" = "Við gátum ekki sent staðsetninguna þína"; +"space_invite_nav_title" = "Boð á svæði"; +"space_detail_nav_title" = "Nánar um svæði"; +"space_selector_empty_view_information" = "Svæði eru ný leið til að hópa fólk og spjallrásir. Útbúðu svæði til að komast í gang."; +"space_selector_empty_view_title" = "Engin svæði ennþá."; + +// Mark: - Space Selector + +"space_selector_title" = "Svæðin mín"; +"all_chats_onboarding_title" = "Hvað er nýtt"; +"all_chats_onboarding_page_title3" = "Gefðu umsögn"; +"all_chats_onboarding_page_title2" = "Aðgangur að svæðum"; +"all_chats_user_menu_settings" = "Notandastillingar"; +"all_chats_edit_layout_pin_spaces_title" = "Festu svæðin þín"; + +// MARK: Reactions + +"room_event_action_reaction_more" = "%@ til viðbótar"; +"leave_space_selection_no_rooms" = "Velja engar spjallrásir"; +"leave_space_selection_all_rooms" = "Velja allar spjallrásir"; +"leave_space_selection_title" = "VELJA SPJALLRÁSIR"; +"leave_space_and_more_rooms" = "Yfirgefa svæði og %@ spjallrásir"; +"leave_space_and_one_room" = "Yfirgefa svæði og 1 spjallrás"; +"spaces_creation_invite_by_username_message" = "Þú getur boðið þeim síðar."; +"spaces_creation_add_rooms_title" = "Hverju viltu bæta við?"; +"spaces_creation_new_rooms_message" = "Við búum til spjallrás fyrir hvern og einn þeirra."; +"spaces_invites_coming_soon_title" = "Boð á spjallrásir koma bráðum"; +"spaces_add_rooms_coming_soon_title" = "Að bæta við spjallrásum kemur bráðum"; +"spaces_create_subspace_title" = "Búa til undirsvæði"; +"spaces_add_subspace_title" = "Búa til svæði innan %@"; +"space_invite_not_enough_permission" = "Þú hefur ekki heimild til að bjóða fólk á þetta svæði"; +"room_invite_not_enough_permission" = "Þú hefur ekki heimild til að bjóða fólk í þessa spjallrás"; +"home_context_menu_mark_as_read" = "Merkja sem lesið"; +"create_room_promotion_header" = "KYNNING"; +"pin_protection_reset_alert_message" = "Til að endurstilla PIN-númerið, þarftu að skrá þig inn aftur og útbúa nýtt"; +"major_update_information" = "Við iðum í skinninu eftir að tilkynna að við höfum skipt um nafn! Forritið er að fullu uppfært og þú ert skráð/ur aftur inn á aðganginn þinn."; +"widget_sticker_picker_no_stickerpacks_alert" = "Í augnablikinu ertu ekki með neina límmerkjapakka virkjaða."; +"room_access_space_chooser_known_spaces_section" = "Svæði sem þú þekkir sem innihalda %@"; +"room_details_promote_room_suggest_title" = "Stinga uppá við meðlimi svæðis"; + +// User sessions management +"user_sessions_settings" = "Sýsla með setur"; +"settings_labs_enable_auto_report_decryption_errors" = "Tilkynna afkóðunarvillur sjálfvirkt"; +"settings_timeline" = "TÍMALÍNA"; + +// MARK: Authentication +"authentication_registration_title" = "Búðu til aðganginn þinn"; diff --git a/Riot/Assets/it.lproj/Vector.strings b/Riot/Assets/it.lproj/Vector.strings index ec31cebb3..0637f0fe9 100644 --- a/Riot/Assets/it.lproj/Vector.strings +++ b/Riot/Assets/it.lproj/Vector.strings @@ -2469,7 +2469,7 @@ "device_name_mobile" = "%@ Mobile"; "device_name_web" = "%@ Web"; "device_name_desktop" = "%@ Desktop"; -"user_session_item_details" = "%@ · Ultima attività %@"; +"user_session_item_details" = "%1$@ · %2$@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2482,8 +2482,138 @@ "user_session_verified_short" = "Verificata"; "user_session_unverified" = "Sessione non verificata"; "user_session_verified" = "Sessione verificata"; -"user_sessions_overview_current_session_section_title" = "SESSIONE ATTUALE"; +"user_sessions_overview_current_session_section_title" = "Sessione attuale"; "user_sessions_overview_other_sessions_section_info" = "Per una maggiore sicurezza, verifica le tue sessioni e disconnetti quelle che non riconosci o che non usi più."; -"user_sessions_overview_other_sessions_section_title" = "ALTRE SESSIONI"; +"user_sessions_overview_other_sessions_section_title" = "Altre sessioni"; "settings_labs_enable_new_app_layout" = "Nuova disposizione dell'applicazione"; "room_first_message_placeholder" = "Invia il tuo primo messaggio…"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "L'autenticità di questo messaggio cifrato non può essere garantita su questo dispositivo."; +"user_session_overview_session_details_button_title" = "Dettagli sessione"; +"user_session_overview_session_title" = "Sessione"; +"user_session_overview_current_session_title" = "Sessione attuale"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Versione"; +"user_session_details_application_name" = "Nome"; +"user_session_details_device_os" = "Sistema operativo"; +"user_session_details_device_browser" = "Browser"; +"user_session_details_device_model" = "Modello"; +"user_session_details_device_ip_location" = "Posizione IP"; +"user_session_details_device_ip_address" = "Indirizzo IP"; +"user_session_details_session_section_footer" = "Copia qualsiasi dato tenendolo premuto."; +"user_session_details_session_id" = "ID sessione"; +"user_session_details_session_name" = "Nome sessione"; +"user_session_details_device_section_header" = "Dispositivo"; +"user_session_details_application_section_header" = "Applicazione"; +"user_session_details_session_section_header" = "Sessione"; +"user_session_details_title" = "Dettagli sessione"; +"user_session_push_notifications_message" = "Quando attivo, questa sessione riceverà notifiche push."; +"user_session_push_notifications" = "Notifiche push"; +"user_sessions_view_all_action" = "Vedi tutte (%d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Considera di disconnettere le sessioni vecchie (90 giorni o più) che non usi più."; +"user_sessions_overview_security_recommendations_inactive_title" = "Sessioni inattive"; +"user_sessions_overview_security_recommendations_unverified_info" = "Verifica o disconnetti le sessioni non verificate."; +"user_sessions_overview_security_recommendations_unverified_title" = "Sessioni non verificate"; +"user_sessions_overview_security_recommendations_section_info" = "Migliora la sicurezza del tuo account seguendo questi consigli."; +"user_sessions_overview_security_recommendations_section_title" = "Consigli di sicurezza"; +"all_chats_user_menu_accessibility_label" = "Menu utente"; +"settings_labs_enable_new_client_info_feature" = "Registra il nome, la versione e l'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni"; +"settings_labs_enable_new_session_manager" = "Nuovo gestore di sessioni"; +"device_type_name_unknown" = "Sconosciuto"; +"device_type_name_mobile" = "Mobile"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Desktop"; +"user_inactive_session_item" = "Inattiva da 90+ giorni"; +"user_inactive_session_item_with_date" = "Inattiva da 90+ giorni (%@)"; +"user_other_session_unverified_sessions_header_subtitle" = "Verifica le tue sessioni per avere conversazioni più sicure o disconnetti quelle che non riconosci o che non usi più."; +"user_other_session_security_recommendation_title" = "Consiglio di sicurezza"; +"user_sessions_overview_link_device" = "Collega un dispositivo"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"sign_out_confirmation_message" = "Vuoi davvero disconnetterti?"; + +// MARK: Sign out warning + +"sign_out" = "Disconnetti"; +"manage_session_rename" = "Rinomina sessione"; +"authentication_qr_login_failure_retry" = "Riprova"; +"authentication_qr_login_failure_request_timed_out" = "Il collegamento non è stato completato nel tempo previsto."; +"authentication_qr_login_failure_request_denied" = "La richiesta è stata negata sull'altro dispositivo."; +"authentication_qr_login_failure_invalid_qr" = "Codice QR non valido."; +"authentication_qr_login_failure_title" = "Collegamento fallito"; +"authentication_qr_login_loading_signed_in" = "Ora hai fatto l'accesso sull'altro dispositivo."; +"authentication_qr_login_loading_waiting_signin" = "In attesa che il dispositivo acceda."; +"authentication_qr_login_loading_connecting_device" = "Connessione al dispositivo"; +"authentication_qr_login_confirm_alert" = "Assicurati di conoscere l'origine di questo codice. Collegando i dispositivi, fornirai a qualcuno l'accesso totale al tuo account."; +"authentication_qr_login_confirm_subtitle" = "Conferma che il codice sottostante corrisponda nell'altro dispositivo:"; +"authentication_qr_login_confirm_title" = "Connessione sicura stabilita"; +"authentication_qr_login_scan_subtitle" = "Posiziona il codice QR nel riquadro sotto"; +"authentication_qr_login_scan_title" = "Scansiona codice QR"; +"authentication_qr_login_display_step2" = "Seleziona ‘Accedi con codice QR’"; +"authentication_qr_login_display_step1" = "Apri Element sull'altro dispositivo"; +"authentication_qr_login_display_subtitle" = "Scansiona il codice QR sottostante con il dispositivo che è disconnesso."; +"authentication_qr_login_display_title" = "Collega un dispositivo"; +"authentication_qr_login_start_display_qr" = "Mostra codice QR in questo dispositivo"; +"authentication_qr_login_start_need_alternative" = "Serve un metodo alternativo?"; +"authentication_qr_login_start_step4" = "Seleziona ‘Mostra codice QR in questo dispositivo’"; +"authentication_qr_login_start_step3" = "Seleziona ‘Collega un dispositivo’"; +"authentication_qr_login_start_step2" = "Vai in Impostazioni -> Sicurezza & Privacy"; +"authentication_qr_login_start_step1" = "Apri Element sull'altro dispositivo"; +"authentication_qr_login_start_subtitle" = "Usa la fotocamera di questo dispositivo per scansionare il codice QR mostrato nell'altro dispositivo:"; +"authentication_qr_login_start_title" = "Scansiona codice QR"; +"authentication_login_with_qr" = "Accedi con codice QR"; +"wysiwyg_composer_format_action_strikethrough" = "Applica formato sottolineato"; +"wysiwyg_composer_format_action_underline" = "Applica formato sbarrato"; +"wysiwyg_composer_format_action_italic" = "Applica formato corsivo"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Applica formato grassetto"; +"wysiwyg_composer_start_action_text_formatting" = "Formattazione testo"; +"wysiwyg_composer_start_action_camera" = "Fotocamera"; +"wysiwyg_composer_start_action_location" = "Posizione"; +"wysiwyg_composer_start_action_polls" = "Sondaggi"; +"wysiwyg_composer_start_action_attachments" = "Allegati"; +"wysiwyg_composer_start_action_stickers" = "Adesivi"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Album di foto"; +"user_session_details_last_activity" = "Ultima attività"; +"user_session_item_details_last_activity" = "Ultima attività %@"; +"user_other_session_clear_filter" = "Annulla filtro"; +"user_other_session_no_unverified_sessions" = "Nessuna sessione non verificata trovata."; +"user_other_session_no_verified_sessions" = "Nessuna sessione verificata trovata."; +"user_other_session_no_inactive_sessions" = "Nessuna sessione inattiva trovata."; +"user_other_session_filter_menu_inactive" = "Inattive"; +"user_other_session_filter_menu_unverified" = "Non verificate"; +"user_other_session_filter_menu_verified" = "Verificate"; +"user_other_session_filter_menu_all" = "Tutte le sessioni"; +"user_other_session_filter" = "Filtra"; +"user_other_session_verified_sessions_header_subtitle" = "Per una maggiore sicurezza, disconnetti tutte le sessioni che non riconosci o che non usi più."; +"user_other_session_current_session_details" = "La sessione attuale"; +"user_other_session_verified_additional_info" = "Questa sessione è pronta per i messaggi sicuri."; +"user_other_session_unverified_additional_info" = "Verifica o disconnetti questa sessione per una migliore sicurezza e affidabilità."; +"user_session_verification_unknown_additional_info" = "Verifica l'attuale sessione per rivelare lo stato di verifica di questa sessione."; +"user_session_verification_unknown_short" = "Sconosciuto"; +"user_session_verification_unknown" = "Stato di verifica sconosciuto"; +"manage_session_name_info_link" = "Maggiori info"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Ricorda che i nomi di sessione sono anche visibili alle persone con cui comunichi. %@"; +"manage_session_name_hint" = "I nomi di sessione personalizzati possono aiutarti a riconoscere i tuoi dispositivi più facilmente."; +"settings_labs_enable_wysiwyg_composer" = "Prova l'editor in rich text (il testo semplice è in arrivo)"; +"settings_labs_enable_voice_broadcast" = "Trasmissione vocale (in sviluppo attivo)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Trasmissione vocale"; +"voice_broadcast_playback_loading_error" = "Impossibile avviare questa trasmissione vocale."; +"voice_broadcast_already_in_progress_message" = "Stai già registrando una trasmissione vocale. Termina quella in corso per iniziarne una nuova."; +"voice_broadcast_blocked_by_someone_else_message" = "Qualcun altro sta già registrando una trasmissione vocale. Aspetta che finisca prima di iniziarne una nuova."; +"voice_broadcast_permission_denied_message" = "Non hai l'autorizzazione necessaria per iniziare un broadcast vocale in questa stanza. Contatta un amministratore della stanza per aggiornare le tue autorizzazioni."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Impossibile iniziare una nuova trasmissione vocale"; +"deselect_all" = "Deseleziona tutti"; +"user_other_session_menu_select_sessions" = "Seleziona sessioni"; +"user_other_session_selected_count" = "%@ selezionate"; diff --git a/Riot/Assets/nl.lproj/Vector.strings b/Riot/Assets/nl.lproj/Vector.strings index 254398f9d..297ff79b0 100644 --- a/Riot/Assets/nl.lproj/Vector.strings +++ b/Riot/Assets/nl.lproj/Vector.strings @@ -2463,7 +2463,7 @@ "room_access_settings_screen_upgrade_alert_note" = "Houd er rekening mee dat bij het upgraden een nieuwe versie van de kamer wordt gemaakt. Alle huidige berichten blijven in deze gearchiveerde ruimte."; "room_access_settings_screen_upgrade_alert_message_no_param" = "Iedereen in een bovenliggende space kan deze ruimte vinden en er lid van worden. Het is niet nodig om iedereen handmatig uit te nodigen. U kunt dit op elk moment wijzigen in de kamerinstellingen."; "room_access_settings_screen_upgrade_alert_message" = "Iedereen in %@ kan deze ruimte vinden en er lid van worden - het is niet nodig om iedereen handmatig uit te nodigen. U kunt dit op elk moment wijzigen in de kamerinstellingen."; -"settings_presence_offline_mode_description" = "Indien ingeschakeld, verschijnt u altijd offline voor andere personen, zelfs wanneer u de applicatie gebruikt."; +"settings_presence_offline_mode_description" = "Indien ingeschakeld, verschijnt u altijd offline voor andere personen, zelfs wanneer u de toepassing gebruikt."; "settings_presence_offline_mode" = "Offline modus"; "settings_presence" = "Aanwezigheid"; "threads_discourage_information_2" = "\n\nWilt u toch threads inschakelen?"; @@ -2652,3 +2652,156 @@ // User sessions management "user_sessions_settings" = "Beheer sessies"; "invite_to" = "Uitnodigen %@"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "De authenticiteit van dit versleutelde bericht kan niet worden gegarandeerd op dit apparaat."; +"deselect_all" = "Deselecteer alles"; +"wysiwyg_composer_format_action_strikethrough" = "Onderstrepen formaat toepassen"; +"wysiwyg_composer_format_action_underline" = "Doorstrepen formaat toepassen"; +"wysiwyg_composer_format_action_italic" = "Cursief formaat toepassen"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Vet formaat toepassen"; +"wysiwyg_composer_start_action_voice_broadcast" = "Spraakuitzending"; +"wysiwyg_composer_start_action_text_formatting" = "Tekst opmaak"; +"wysiwyg_composer_start_action_camera" = "Camera"; +"wysiwyg_composer_start_action_location" = "Locatie"; +"wysiwyg_composer_start_action_polls" = "Peilingen"; +"wysiwyg_composer_start_action_attachments" = "Bijlagen"; +"wysiwyg_composer_start_action_stickers" = "Stikkers"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Fotobibliotheek"; +"user_session_overview_session_details_button_title" = "Sessie details"; +"user_session_overview_session_title" = "Sessie"; +"user_session_overview_current_session_title" = "Huidige sessie"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Versie"; +"user_session_details_application_name" = "Naam"; +"user_session_details_device_os" = "Besturingssysteem"; +"user_session_details_device_browser" = "Browser"; +"user_session_details_device_model" = "Model"; +"user_session_details_device_ip_location" = "IP locatie"; +"user_session_details_device_ip_address" = "IP adres"; +"user_session_details_last_activity" = "Laatste activiteit"; +"user_session_details_session_section_footer" = "Kopieer alle gegevens door erop te tikken en ingedrukt te houden."; +"user_session_details_session_id" = "Sessie ID"; +"user_session_details_session_name" = "Sessie naam"; +"user_session_details_device_section_header" = "Apparaat"; +"device_name_unknown" = "Onbekende toepassing"; +"settings_labs_enable_new_app_layout" = "Nieuwe toepassing-indeling"; +"settings_labs_enable_new_client_info_feature" = "Noteer de naam, versie en url van de toepassing om sessies gemakkelijker te herkennen in sessiebeheer"; +"user_session_details_application_section_header" = "Toepassing"; +"user_session_details_session_section_header" = "Sessie"; +"user_session_details_title" = "Toon details"; +"device_type_name_unknown" = "Onbekend"; +"device_type_name_mobile" = "Mobiel"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Desktop"; +"device_name_mobile" = "%@ Mobiel"; +"device_name_web" = "%@ Web"; +"device_name_desktop" = "%@ Desktop"; +"user_inactive_session_item_with_date" = "Meer dan 90 dagen inactief (%@)"; +"user_inactive_session_item" = "90+ dagen inactief"; +"user_session_item_details_last_activity" = "Laatste activiteit %@"; + +/* %1$@ will be the verification state and %2$@ will be user_session_item_details_verification_unknown or user_other_session_current_session_details */ +"user_session_item_details" = "%1$@ · %2$@"; +// First item is client name and second item is session display name +"user_session_name" = "%@: %@"; +"user_other_session_menu_select_sessions" = "Selecteer sessies"; +"user_other_session_selected_count" = "%@ geselecteerd"; +"user_other_session_clear_filter" = "Leeg filter"; +"user_other_session_no_unverified_sessions" = "Geen niet geverifieerde sessies gevonden."; +"user_other_session_no_verified_sessions" = "Geen geverifieerde sessies gevonden."; +"user_other_session_no_inactive_sessions" = "Geen inactieve sessies gevonden."; +"user_other_session_filter_menu_inactive" = "Inactief"; +"user_other_session_filter_menu_unverified" = "Niet geverifieerd"; +"user_other_session_filter_menu_verified" = "Geverifieerd"; +"user_other_session_filter_menu_all" = "Alle sessies"; +"user_other_session_filter" = "Filter"; +"user_other_session_verified_sessions_header_subtitle" = "Voor de beste beveiliging logt u uit bij elke sessie die u niet meer herkent of gebruikt."; +"user_other_session_current_session_details" = "Uw huidige sessie"; +"user_other_session_unverified_sessions_header_subtitle" = "Verifieer uw sessies voor verbeterde beveiligde berichtenuitwisseling of meld u af bij sessies die u niet meer herkent of gebruikt."; +"user_other_session_security_recommendation_title" = "Beveiligingsaanbeveling"; +"user_session_push_notifications_message" = "Indien ingeschakeld, ontvangt deze sessie pushmeldingen."; +"user_session_push_notifications" = "Pushmeldingen"; +"user_other_session_verified_additional_info" = "Deze sessie is klaar voor beveiligde berichtenuitwisseling."; +"user_other_session_unverified_additional_info" = "Verifieer of meld u af bij deze sessie voor de beste beveiliging en betrouwbaarheid."; +"user_session_verification_unknown_additional_info" = "Verifieer uw huidige sessie om de verificatiestatus van deze sessie weer te geven."; +"user_session_unverified_additional_info" = "Verifieer uw huidige sessie voor verbeterde beveiligde berichtenuitwisseling."; +"user_session_verified_additional_info" = "Uw huidige sessie is klaar voor beveiligde berichtenuitwisseling."; +"user_session_learn_more" = "Meer lezen"; +"user_session_view_details" = "Bekijk details"; +"user_session_verify_action" = "Sessie verifiëren"; +"user_session_verification_unknown_short" = "Onbekend"; +"user_session_unverified_short" = "Niet geverifieerd"; +"user_session_verified_short" = "Geverifieerd"; +"user_session_verification_unknown" = "Onbekende verificatiestatus"; +"user_session_unverified" = "Niet geverifieerde sessie"; +"user_session_verified" = "Geverifieerde sessie"; +"user_sessions_view_all_action" = "Alles bekijken (%d)"; +"user_sessions_overview_link_device" = "Een apparaat koppelen"; +"user_sessions_overview_current_session_section_title" = "Huidige sessie"; +"user_sessions_overview_other_sessions_section_info" = "Voor de beste beveiliging verifieert u uw sessies en meldt u zich af bij elke sessie die u niet meer herkent of gebruikt."; +"user_sessions_overview_other_sessions_section_title" = "Andere sessies"; +"user_sessions_overview_security_recommendations_inactive_info" = "Overweeg om u af te melden bij oude sessies (90 dagen of ouder) die u niet meer gebruikt."; +"user_sessions_overview_security_recommendations_inactive_title" = "Inactieve sessies"; +"user_sessions_overview_security_recommendations_unverified_info" = "Verifieer of meld u af bij niet geverifieerde sessies."; +"user_sessions_overview_security_recommendations_unverified_title" = "Niet geverifieerde sessies"; +"user_sessions_overview_security_recommendations_section_info" = "Verbeter uw accountbeveiliging door deze aanbevelingen op te volgen."; +"user_sessions_overview_security_recommendations_section_title" = "Beveiligingsaanbevelingen"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"all_chats_user_menu_accessibility_label" = "Gebruikersmenu"; +"voice_broadcast_playback_loading_error" = "Kan deze spraakuitzending niet afspelen."; +"voice_broadcast_already_in_progress_message" = "U neemt al een spraakuitzending op. Beëindig uw huidige spraakuitzending om een nieuwe te starten."; +"voice_broadcast_blocked_by_someone_else_message" = "Iemand anders neemt al een spraakuitzending op. Wacht tot hun spraakuitzending is afgelopen om een nieuwe te starten."; +"voice_broadcast_permission_denied_message" = "U heeft niet de vereiste rechten om een spraakuitzending in deze kamer te starten. Neem contact op met een kamer beheerder om uw machtigingen te upgraden."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Kan geen nieuwe spraakuitzending starten"; +"sign_out_confirmation_message" = "Weet u zeker dat u zich wilt afmelden?"; + +// MARK: Sign out warning + +"sign_out" = "Afmelden"; +"manage_session_rename" = "Sessie hernoemen"; +"manage_session_name_info_link" = "Lees meer"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Houd er rekening mee dat sessienamen ook zichtbaar zijn voor mensen met wie u communiceert. %@"; +"manage_session_name_hint" = "Met aangepaste sessienamen kunt u uw apparaten gemakkelijker herkennen."; +"settings_labs_enable_voice_broadcast" = "Voice-uitzending (in actieve ontwikkeling)"; +"settings_labs_enable_wysiwyg_composer" = "Probeer de rich-text-editor (platte tekst-modus komt binnenkort)"; +"settings_labs_enable_new_session_manager" = "Nieuwe sessiemanager"; +"room_first_message_placeholder" = "Stuur uw eerste bericht…"; +"authentication_qr_login_failure_retry" = "Probeer het nog eens"; +"authentication_qr_login_failure_request_timed_out" = "De koppeling is niet binnen de vereiste tijd voltooid."; +"authentication_qr_login_failure_request_denied" = "Het verzoek is geweigerd op het andere apparaat."; +"authentication_qr_login_failure_invalid_qr" = "QR-code is ongeldig."; +"authentication_qr_login_failure_title" = "Koppelen mislukt"; +"authentication_qr_login_loading_signed_in" = "U bent nu aangemeld op uw andere apparaat."; +"authentication_qr_login_loading_waiting_signin" = "Wachten tot het apparaat zich aanmeldt."; +"authentication_qr_login_loading_connecting_device" = "Verbinden met apparaat"; +"authentication_qr_login_confirm_alert" = "Zorg ervoor dat u de herkomst van deze code kent. Door apparaten te koppelen, geeft u iemand volledige toegang tot uw account."; +"authentication_qr_login_confirm_subtitle" = "Controleer of de onderstaande code overeenkomt met uw andere apparaat:"; +"authentication_qr_login_confirm_title" = "Beveiligde verbinding tot stand gebracht"; +"authentication_qr_login_scan_subtitle" = "Positioneer de QR-code in het vierkant hieronder"; +"authentication_qr_login_scan_title" = "Scan QR-code"; +"authentication_qr_login_display_step2" = "Selecteer 'Aanmelden met QR-code'"; +"authentication_qr_login_display_step1" = "Open Element op uw andere apparaat"; +"authentication_qr_login_display_subtitle" = "Scan de onderstaande QR-code met uw apparaat dat is uitgelogd."; +"authentication_qr_login_display_title" = "Een apparaat koppelen"; +"authentication_qr_login_start_display_qr" = "QR-code weergeven op dit apparaat"; +"authentication_qr_login_start_need_alternative" = "Een alternatieve methode nodig?"; +"authentication_qr_login_start_step4" = "Selecteer 'Toon QR-code op dit apparaat'"; +"authentication_qr_login_start_step3" = "Selecteer 'Een apparaat koppelen'"; +"authentication_qr_login_start_step2" = "Ga naar Instellingen -> Beveiliging en privacy"; +"authentication_qr_login_start_step1" = "Open Element op uw andere apparaat"; +"authentication_qr_login_start_subtitle" = "Gebruik de camera op dit apparaat om de QR-code te scannen die op uw andere apparaat wordt weergegeven:"; +"authentication_qr_login_start_title" = "Scan QR-code"; +"authentication_login_with_qr" = "Log in met QR-code"; diff --git a/Riot/Assets/pl.lproj/Vector.strings b/Riot/Assets/pl.lproj/Vector.strings index 3b82fe79e..8532c4b4d 100644 --- a/Riot/Assets/pl.lproj/Vector.strings +++ b/Riot/Assets/pl.lproj/Vector.strings @@ -751,7 +751,7 @@ "group_participants_invited_section" = "ZAPROSZONY"; "receipt_status_read" = "Odczytano: "; // Media picker -"media_picker_title" = "Selektor mediów"; +"media_picker_title" = "Biblioteka mediów"; // Image picker "image_picker_action_camera" = "Zrób zdjęcie"; "image_picker_action_library" = "Wybierz z biblioteki"; @@ -2569,7 +2569,7 @@ // Mark: - All Chats -"all_chats_title" = "Wszystkie rozmowy"; +"all_chats_title" = "Rozmowy"; "spaces_subspace_creation_visibility_message" = "Utworzona przestrzeń zostanie dodana do %@."; "spaces_subspace_creation_visibility_title" = "Jakiego rodzaju podprzestrzeń chcesz utworzyć?"; "spaces_explore_rooms_format" = "Przeglądaj %@"; diff --git a/Riot/Assets/pt_BR.lproj/Vector.strings b/Riot/Assets/pt_BR.lproj/Vector.strings index 362dd9620..3741d9841 100644 --- a/Riot/Assets/pt_BR.lproj/Vector.strings +++ b/Riot/Assets/pt_BR.lproj/Vector.strings @@ -1688,7 +1688,7 @@ "invite_user" = "Convidar Usuária(o) matrix"; "reset_to_default" = "Resettar para default"; "resend_message" = "Reenviar a mensagem"; -"select_all" = "Selecionar Todas"; +"select_all" = "Selecionar Todas(os)"; "cancel_upload" = "Cancelar Upload"; "cancel_download" = "Cancelar Download"; "show_details" = "Mostrar Detalhes"; @@ -2470,7 +2470,7 @@ "device_name_mobile" = "%@ Mobile"; "device_name_web" = "%@ Web"; "device_name_desktop" = "%@ Desktop"; -"user_session_item_details" = "%@ · Última atividade %@"; +"user_session_item_details" = "%1$@ · %2$@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2483,8 +2483,138 @@ "user_session_verified_short" = "Verificada"; "user_session_unverified" = "Sessão não-verificada"; "user_session_verified" = "Sessão verificada"; -"user_sessions_overview_current_session_section_title" = "SESSÃO ATUAL"; +"user_sessions_overview_current_session_section_title" = "Sessão atual"; "user_sessions_overview_other_sessions_section_info" = "Para melhor segurança, verifique suas sessões e faça signout de qualquer sessão que você não reconhece ou usa mais."; -"user_sessions_overview_other_sessions_section_title" = "OUTRAS SESSÕES"; +"user_sessions_overview_other_sessions_section_title" = "Outras sessões"; "settings_labs_enable_new_app_layout" = "Novo Layout de Aplicativo"; "room_first_message_placeholder" = "Envie sua primeira mensagem…"; +"user_session_push_notifications_message" = "Quando ativada, esta sessão vai receber notificações push."; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "A autenticidade desta mensagem encriptada não pode ser garantida neste dispositivo."; +"user_session_overview_session_details_button_title" = "Detalhes da sessão"; +"user_session_overview_session_title" = "Sessão"; +"user_session_overview_current_session_title" = "Sessão atual"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Versão"; +"user_session_details_application_name" = "Nome"; +"user_session_details_device_os" = "Sistema Operativo"; +"user_session_details_device_browser" = "Browser"; +"user_session_details_device_model" = "Modelo"; +"user_session_details_device_ip_location" = "Localização de IP"; +"user_session_details_device_ip_address" = "Endereço de IP"; +"user_session_details_session_section_footer" = "Copie qualquer dado ao tocar nele e segurá-lo."; +"user_session_details_session_id" = "ID da sessão"; +"user_session_details_title" = "Detalhes da sessão"; +"user_session_details_session_name" = "Nome da sessão"; +"user_session_details_device_section_header" = "Dispositivo"; +"user_session_details_application_section_header" = "Aplicativo"; +"user_session_details_session_section_header" = "Sessão"; +"user_session_push_notifications" = "Notificações push"; +"user_sessions_view_all_action" = "Ver todas (%d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Considere fazer signout de sessões antigas (90 dias ou mais antigo) que você não usa mais."; +"user_sessions_overview_security_recommendations_inactive_title" = "Sessões inativas"; +"user_sessions_overview_security_recommendations_unverified_info" = "Verifique ou faça signout de sessões não-verificadas."; +"user_sessions_overview_security_recommendations_unverified_title" = "Sessões não-verificadas"; +"user_sessions_overview_security_recommendations_section_info" = "Melhore a segurança de sua conta ao seguir esta recomendações."; +"user_sessions_overview_security_recommendations_section_title" = "Recomendações de segurança"; +"all_chats_user_menu_accessibility_label" = "Menu de usuária(o)"; +"settings_labs_enable_new_client_info_feature" = "Gravar o nome de cliente, versão, e url para reconhecer sessões mais facilmente em gerenciador de sessão"; +"settings_labs_enable_new_session_manager" = "Novo gerenciador de sessão"; +"device_type_name_unknown" = "Desconhecido"; +"device_type_name_mobile" = "Mobile"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Desktop"; +"user_inactive_session_item_with_date" = "Inativa por 90+ dias (%@)"; +"user_inactive_session_item" = "Inativa por 90+ dias"; +"user_other_session_unverified_sessions_header_subtitle" = "Verifique suas sessões para mensageria de segurança melhorada ou faça signout daquelas que você não reconhece ou usa mais."; +"user_other_session_security_recommendation_title" = "Recomendação de segurança"; +"user_sessions_overview_link_device" = "Linkar um dispositivo"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"sign_out_confirmation_message" = "Tem certeza que você quer fazer signout?"; + +// MARK: Sign out warning + +"sign_out" = "Fazer signout"; +"manage_session_rename" = "Renomear sessão"; +"authentication_qr_login_failure_retry" = "Tentar de novo"; +"authentication_qr_login_failure_request_timed_out" = "A linkagem não foi completada no tempo requerido."; +"authentication_qr_login_failure_request_denied" = "A requisição foi negada no outro dispositivo."; +"authentication_qr_login_failure_invalid_qr" = "QR code é inválido."; +"authentication_qr_login_failure_title" = "Linkagem falhou"; +"authentication_qr_login_loading_signed_in" = "Você está agora feito signin em seu outro dispositivo."; +"authentication_qr_login_loading_waiting_signin" = "Esperando por dispositivo para fazer signin."; +"authentication_qr_login_loading_connecting_device" = "Conectando a dispositivo"; +"authentication_qr_login_confirm_alert" = "Por favor assegure que você sabe a origem deste código. Ao linkar dispositivos, você vai prover alguém com acesso completo a sua conta."; +"authentication_qr_login_confirm_subtitle" = "Confirme que o código abaixo correspondem com seu outro dispositivo:"; +"authentication_qr_login_confirm_title" = "Conexão segura estabelecida"; +"authentication_qr_login_scan_subtitle" = "Posicione o QR code no quadrado abaixo"; +"authentication_qr_login_scan_title" = "Scannar QR code"; +"authentication_qr_login_display_step2" = "Selecione ‘Fazer signin com QR code’"; +"authentication_qr_login_display_step1" = "Abra Element em seu outro dispositivo"; +"authentication_qr_login_display_subtitle" = "Scanne o QR code abaixo com seu dispositivo que está feito signout."; +"authentication_qr_login_display_title" = "Linkar um dispositivo"; +"authentication_qr_login_start_display_qr" = "Mostrar QR code neste dispositivo"; +"authentication_qr_login_start_need_alternative" = "Precisa de um método alternativo?"; +"authentication_qr_login_start_step4" = "Selecione ‘Mostrar QR code neste dispositivo’"; +"authentication_qr_login_start_step3" = "Selecione ‘Linkar um dispositivo’"; +"authentication_qr_login_start_step2" = "Vá para Ajustes -> Segurança & Privacidade"; +"authentication_qr_login_start_step1" = "Abra Element em seu outro dispositivo"; +"authentication_qr_login_start_subtitle" = "Use a câmera neste dispositivo para scannar o QR code mostrado em seu outro dispositivo:"; +"authentication_qr_login_start_title" = "Scannar QR code"; +"authentication_login_with_qr" = "Fazer signin com QR code"; +"wysiwyg_composer_format_action_strikethrough" = "Aplicar formato sublinhar"; +"wysiwyg_composer_format_action_underline" = "Aplicar formato tachar"; +"wysiwyg_composer_format_action_italic" = "Aplicar formato itálico"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Aplicar formato negrito"; +"wysiwyg_composer_start_action_text_formatting" = "Formatação de Texto"; +"wysiwyg_composer_start_action_camera" = "Câmera"; +"wysiwyg_composer_start_action_location" = "Localização"; +"wysiwyg_composer_start_action_polls" = "Sondagens"; +"wysiwyg_composer_start_action_attachments" = "Anexos"; +"wysiwyg_composer_start_action_stickers" = "Stickers"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Biblioteca de Fotos"; +"user_session_details_last_activity" = "Última atividade"; +"user_session_item_details_last_activity" = "Última atividade %@"; +"user_other_session_clear_filter" = "Limpar filtro"; +"user_other_session_no_unverified_sessions" = "Nenhuma sessão não-verificada encontrada."; +"user_other_session_no_verified_sessions" = "Nenhuma sessão verificada encontrada."; +"user_other_session_no_inactive_sessions" = "Nenhuma sessão inativa encontrada."; +"user_other_session_filter_menu_inactive" = "Inativas"; +"user_other_session_filter_menu_unverified" = "Não-verificadas"; +"user_other_session_filter_menu_verified" = "Verificadas"; +"user_other_session_filter_menu_all" = "Todas as sessões"; +"user_other_session_filter" = "Filtrar"; +"user_other_session_verified_sessions_header_subtitle" = "Para melhor segurança, faça signout de qualquer sessão que você não reconhece ou usa mais."; +"user_other_session_current_session_details" = "Sua sessão atual"; +"user_other_session_verified_additional_info" = "Esta sessão está pronta para mensageria segura."; +"user_other_session_unverified_additional_info" = "Verifique ou faça signout desta sessão para melhor segurança e fiabilidade."; +"user_session_verification_unknown_additional_info" = "Verifique sua sessão atual para revelar o status de verificação desta sessão."; +"user_session_verification_unknown_short" = "Desconhecido"; +"user_session_verification_unknown" = "Status de verificação desconhecido"; +"manage_session_name_info_link" = "Saber mais"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Por favor esteja ciente que nomes de sessões também são visíveis a pessoas com quem você se comunica. %@"; +"manage_session_name_hint" = "Nomes de sessões personalizados podem ajudar você a reconhecer seus dispositivos mais facilmente."; +"settings_labs_enable_wysiwyg_composer" = "Experimente o editor de texto rico (modo de texto puro vindo em breve)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Broadcast de voz"; +"voice_broadcast_playback_loading_error" = "Incapaz de tocar este broadcast de voz."; +"voice_broadcast_already_in_progress_message" = "Você já está gravando um broadcast de voz. Por favor termine seu broadcast de voz atual para começar um novo."; +"voice_broadcast_blocked_by_someone_else_message" = "Alguma outra pessoa já está gravando um broadcast de voz. Espere que o broadcast de voz dela termine para começar um novo."; +"voice_broadcast_permission_denied_message" = "Você não tem as permissões requeridas para começar um broadcast de voz nesta sala. Contacte um(a) administrador(a) da sala para fazer upgrade de suas permissões."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Não dá para começar um novo broadcast de voz"; +"settings_labs_enable_voice_broadcast" = "Broadcast de voz (sob desenvolvimento ativo)"; +"deselect_all" = "Desselecionar Todas(os)"; +"user_other_session_menu_select_sessions" = "Selecionar sessões"; +"user_other_session_selected_count" = "%@ selecionadas"; diff --git a/Riot/Assets/ru.lproj/Vector.strings b/Riot/Assets/ru.lproj/Vector.strings index fd018bde0..7680e9765 100644 --- a/Riot/Assets/ru.lproj/Vector.strings +++ b/Riot/Assets/ru.lproj/Vector.strings @@ -603,7 +603,7 @@ "key_backup_recover_from_passphrase_passphrase_title" = "Ввод"; "key_backup_recover_from_passphrase_passphrase_placeholder" = "Введите секретную фразу"; "key_backup_recover_from_passphrase_recover_action" = "Разблокировать историю"; -"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Не знаете вашу секретную фразу для восстановления? Вы можете "; +"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Не помните свою мнемоническую фразу? Вы можете "; "key_backup_recover_from_passphrase_lost_passphrase_action_part2" = "использовать ключ безопасности"; "key_backup_recover_from_passphrase_lost_passphrase_action_part3" = "."; "key_backup_recover_from_recovery_key_info" = "Используйте ключ безопасности для разблокировки истории безопасных сообщений"; @@ -624,7 +624,7 @@ "key_backup_setup_success_from_recovery_key_recovery_key_title" = "Ключ безопасности"; "key_backup_setup_success_from_recovery_key_make_copy_action" = "Сделать копию"; "key_backup_setup_success_from_recovery_key_made_copy_action" = "Я сделал копию"; -"key_backup_recover_invalid_passphrase_title" = "Неверная секретная фраза для восстановления"; +"key_backup_recover_invalid_passphrase_title" = "Неверная мнемоническая фраза"; "key_backup_recover_invalid_recovery_key_title" = "Несоответствующий ключ безопасности"; "key_backup_setup_banner_title" = "Не теряйте зашифрованные сообщения"; "key_backup_setup_banner_subtitle" = "Начать использовать ключ восстановления"; @@ -641,7 +641,7 @@ "key_backup_setup_intro_setup_action_with_existing_backup" = "Использовать ключ восстановления"; "settings_key_backup_info" = "Зашифрованные сообщения защищены сквозным шифрованием. Только вы и получатель(и) имеют ключи для чтения этих сообщений."; "settings_key_backup_info_signout_warning" = "Сделайте резервную копию ключей перед выходом, чтобы не потерять их."; -"key_backup_setup_passphrase_title" = "Защитите резервную копию секретной фразой"; +"key_backup_setup_passphrase_title" = "Защитите резервную копию мнемонической фразой"; "key_backup_setup_passphrase_setup_recovery_key_info" = "Или защитите свою резервную копию с помощью ключа безопасности, сохранив ее в безопасном месте."; "key_backup_setup_passphrase_setup_recovery_key_action" = "(Расширенный) Настройка с ключом безопасности"; // Success from passphrase @@ -654,7 +654,7 @@ "sign_out_non_existing_key_backup_sign_out_confirmation_alert_title" = "Зашифрованные сообщения будут утеряны"; "sign_out_non_existing_key_backup_alert_discard_key_backup_action" = "Мне не нужны мои зашифрованные сообщения"; "sign_out_non_existing_key_backup_alert_title" = "Вы потеряете доступ к зашифрованным сообщениям если выйдете сейчас"; -"key_backup_recover_invalid_passphrase" = "Невозможно расшифровать резервную копию с помощью этой секретной фразы: убедитесь, что вы ввели верную секретную фразу для восстановления."; +"key_backup_recover_invalid_passphrase" = "Невозможно расшифровать резервную копию с помощью этой фразы: убедитесь, что вы ввели верную мнемоническую фразу."; "key_backup_recover_invalid_recovery_key" = "Невозможно расшифровать резервную копию с помощью этого ключа: убедитесь, что вы ввели верный ключ безопасности."; "e2e_key_backup_wrong_version_button_settings" = "Настройки"; "key_backup_setup_intro_manual_export_info" = "(Расширенный)"; @@ -986,7 +986,7 @@ "secure_key_backup_setup_intro_info" = "Защитите себя от потери доступа к зашифрованным сообщениям и данным, создав резервную копию ключей шифрования на своём сервере."; "secure_key_backup_setup_intro_use_security_key_title" = "Используйте ключ безопасности"; "secure_key_backup_setup_intro_use_security_key_info" = "Создайте ключ безопасности для хранения в надежном месте, например в менеджере паролей или сейфе."; -"secure_key_backup_setup_intro_use_security_passphrase_title" = "Использовать секретную фразу"; +"secure_key_backup_setup_intro_use_security_passphrase_title" = "Использовать мнемоническую фразу"; "secure_key_backup_setup_intro_use_security_passphrase_info" = "Введите секретную фразу, известную только вам, и создайте ключ для резервного копирования."; "secure_key_backup_setup_existing_backup_error_title" = "Резервная копия сообщений уже существует"; "secure_key_backup_setup_existing_backup_error_info" = "Разблокируйте его для повторного использования в защищенной резервной копии или удалите для создания новой резервной копии сообщений в защищенной резервной копии."; @@ -1024,7 +1024,7 @@ "device_verification_self_verify_wait_information" = "Подтвердите этот сеанс на одном из других ваших сеансов, предоставив ему доступ к зашифрованным сообщениям.\n\nИспользуйте последнюю версию %@ на других ваших устройствах:"; "device_verification_self_verify_wait_additional_information" = "Это работает с %@ и другими клиентами Matrix с поддержкой кросс-подписи."; "device_verification_self_verify_wait_recover_secrets_without_passphrase" = "Используйте ключ безопасности"; -"device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Используйте секретную фразу или ключ безопасности"; +"device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Используйте мнемоническую фразу или бумажный ключ"; "device_verification_self_verify_wait_recover_secrets_additional_information" = "Если вы не можете получить доступ к существующему сеансу"; "key_verification_verify_sas_title_emoji" = "Сравните смайлы"; "key_verification_verify_sas_title_number" = "Сравните числа"; @@ -1102,17 +1102,17 @@ "user_verification_session_details_verify_action_current_user" = "Интерактивная проверка"; "user_verification_session_details_verify_action_current_user_manually" = "Ручная проверка с помощью текста"; "user_verification_session_details_verify_action_other_user" = "Подтверждение вручную"; -"secrets_recovery_with_passphrase_title" = "Секретная фраза"; +"secrets_recovery_with_passphrase_title" = "Мнемоническая фраза"; "secrets_recovery_with_passphrase_information_default" = "Получите доступ к своей защищённой истории сообщений и вашей личности с кросс-подписью для проверки других сеансов, введя секретную фразу."; -"secrets_recovery_with_passphrase_information_verify_device" = "Используйте секретную фразу, чтобы проверить это устройство."; +"secrets_recovery_with_passphrase_information_verify_device" = "Используйте свою мнемоническую фразу, чтобы заверить эту сессию."; "secrets_recovery_with_passphrase_passphrase_title" = "Ввод"; -"secrets_recovery_with_passphrase_passphrase_placeholder" = "Введите секретную фразу"; +"secrets_recovery_with_passphrase_passphrase_placeholder" = "Введите мнемоническую фразу"; "secrets_recovery_with_passphrase_recover_action" = "Использовать секретную фразу"; -"secrets_recovery_with_passphrase_lost_passphrase_action_part1" = "Не знаете вашу секретную фразу? Вы можете "; -"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "использовать ключ безопасности"; +"secrets_recovery_with_passphrase_lost_passphrase_action_part1" = "Не помните свою мнемоническую фразу? Вы можете "; +"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "использовать бумажный ключ"; "secrets_recovery_with_passphrase_lost_passphrase_action_part3" = "."; "secrets_recovery_with_passphrase_invalid_passphrase_title" = "Невозможно получить доступ к секретному хранилищу"; -"secrets_recovery_with_passphrase_invalid_passphrase_message" = "Убедитесь, что вы ввели правильную секретную фразу."; +"secrets_recovery_with_passphrase_invalid_passphrase_message" = "Убедитесь, что вы ввели верную мнемоническую фразу."; "secrets_recovery_with_key_title" = "Ключ безопасности"; "secrets_recovery_with_key_information_default" = "Получите доступ к своей защищённой истории сообщений и вашей личности с кросс-подписью для проверки других сеансов, введя ключ безопасности."; "secrets_recovery_with_key_information_verify_device" = "Используйте ключ безопасности, чтобы проверить это устройство."; @@ -1128,11 +1128,11 @@ "secrets_setup_recovery_key_done_action" = "Готово"; "secrets_setup_recovery_key_storage_alert_title" = "Храните его в безопасности"; "secrets_setup_recovery_key_storage_alert_message" = "✓ Распечатайте и храните в безопасном месте\n✓ Сохраните его на USB-носителе или резервном носителе\n✓ Скопируйте его в свое личное облачное хранилище"; -"secrets_setup_recovery_passphrase_title" = "Задайте секретную фразу"; +"secrets_setup_recovery_passphrase_title" = "Задайте мнемоническую фразу"; "secrets_setup_recovery_passphrase_information" = "Введите секретную фразу, известную только вам, для защиты данных на вашем сервере."; "secrets_setup_recovery_passphrase_additional_information" = "Не используйте пароль своей учетной записи."; "secrets_setup_recovery_passphrase_validate_action" = "Готово"; -"secrets_setup_recovery_passphrase_confirm_information" = "Для подтверждения введите вашу секретную фразу ещё раз."; +"secrets_setup_recovery_passphrase_confirm_information" = "Введите мнемоническую фразу ещё раз, чтобы подтвердить её."; "secrets_setup_recovery_passphrase_confirm_passphrase_title" = "Подтвердить"; "secrets_setup_recovery_passphrase_confirm_passphrase_placeholder" = "Подтвердить секретную фразу"; "cross_signing_setup_banner_title" = "Настройка шифрования"; @@ -1238,8 +1238,8 @@ // MARK: - Home "home_empty_view_title" = "Добро пожаловать в %@,\n%@"; -"secrets_setup_recovery_passphrase_summary_information" = "Запомните свою секретную фразу. Её можно использовать для разблокировки ваших зашифрованных сообщений и данных."; -"secrets_setup_recovery_passphrase_summary_title" = "Сохраните вашу секретную фразу"; +"secrets_setup_recovery_passphrase_summary_information" = "Запомните свою мнемоническую фразу. Её можно использовать для разблокировки ваших зашифрованных сообщений и данных."; +"secrets_setup_recovery_passphrase_summary_title" = "Сохраните свою мнемоническую фразу"; "favourites_empty_view_information" = "Вы можете добавить в избранное несколькими способами - самый быстрый - просто нажать и удерживать. Нажмите на звёздочку, и они автоматически появятся здесь, и вы их навсегда сохраните."; // MARK: - Favourites @@ -1355,7 +1355,7 @@ "space_feature_unavailable_title" = "Пространств ещё нет"; "secrets_recovery_with_key_information_unlock_secure_backup_with_key" = "Введите свой ключ безопасности, чтобы продолжить."; -"secrets_recovery_with_key_information_unlock_secure_backup_with_phrase" = "Введите секретную фразу, чтобы продолжить."; +"secrets_recovery_with_key_information_unlock_secure_backup_with_phrase" = "Введите мнемоническую фразу, чтобы продолжить."; "key_verification_verify_qr_code_scan_code_other_device_action" = "Сканирование с помощью этого устройства"; // Success from secure backup @@ -2153,3 +2153,8 @@ "authentication_server_selection_login_title" = "Подключиться к домашнему серверу"; "authentication_cancel_flow_confirmation_message" = "Ваш аккаунт ещё не создан. Остановить процесс регистрации?"; "settings_timeline" = "Лента сообщений"; +"manage_session_name_info_link" = "Узнать больше"; +"user_other_session_verified_additional_info" = "Эта сессия готова к безопасному обмену сообщениями."; +"user_other_session_current_session_details" = "Текущая сессия"; +"user_other_session_filter_menu_all" = "Все сессии"; +"wysiwyg_composer_start_action_stickers" = "Наклейки"; diff --git a/Riot/Assets/sk.lproj/Vector.strings b/Riot/Assets/sk.lproj/Vector.strings index 5cecf24a5..ba65a69cb 100644 --- a/Riot/Assets/sk.lproj/Vector.strings +++ b/Riot/Assets/sk.lproj/Vector.strings @@ -1361,7 +1361,7 @@ "device_verification_verify_wait_partner" = "Čakanie na potvrdenie od partnera…"; "key_verification_manually_verify_device_additional_information" = "Ak sa nezhodujú, môže byť ohrozená bezpečnosť vašej komunikácie."; "key_verification_manually_verify_device_instruction" = "Potvrďte to porovnaním nasledujúcich údajov s nastaveniami používateľa v inej relácii:"; -"key_verification_verify_sas_additional_information" = "V záujme maximálnej bezpečnosti použite iný dôveryhodný komunikačný prostriedok alebo to urobte osobne."; +"key_verification_verify_sas_additional_information" = "V záujme maximálnej bezpečnosti, použite iný dôveryhodný komunikačný prostriedok alebo to urobte osobne."; "key_verification_verify_sas_cancel_action" = "Nezhodujú sa"; "key_verification_verify_sas_title_number" = "Porovnať čísla"; "device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Použiť bezpečnostnú frázu alebo kľúč"; @@ -2692,7 +2692,7 @@ "device_name_unknown" = "Neznámy klient"; "device_name_mobile" = "%@ Mobil"; "device_name_desktop" = "%@ Stolný počítač"; -"user_session_item_details" = "%@ · Posledná aktivita %@"; +"user_session_item_details" = "%1$@ · %2$@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2705,8 +2705,138 @@ "user_session_verified_short" = "Overené"; "user_session_unverified" = "Neoverená relácia"; "user_session_verified" = "Overená relácia"; -"user_sessions_overview_current_session_section_title" = "AKTUÁLNA RELÁCIA"; -"user_sessions_overview_other_sessions_section_info" = "V záujme čo najlepšieho zabezpečenia overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate."; -"user_sessions_overview_other_sessions_section_title" = "OSTATNÉ RELÁCIE"; +"user_sessions_overview_current_session_section_title" = "Aktuálna relácia"; +"user_sessions_overview_other_sessions_section_info" = "V záujme čo najlepšieho zabezpečenia, overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate."; +"user_sessions_overview_other_sessions_section_title" = "Iné relácie"; "settings_labs_enable_new_app_layout" = "Nové usporiadanie aplikácie"; "room_first_message_placeholder" = "Pošlite svoju prvú správu…"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "Vierohodnosť tejto zašifrovanej správy nie je možné zaručiť na tomto zariadení."; +"user_session_overview_session_details_button_title" = "Podrobnosti o relácii"; +"user_session_overview_session_title" = "Relácia"; +"user_session_overview_current_session_title" = "Aktuálna relácia"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Verzia"; +"user_session_details_application_name" = "Názov"; +"user_session_details_device_os" = "Operačný systém"; +"user_session_details_device_browser" = "Prehliadač"; +"user_session_details_device_model" = "Model"; +"user_session_details_device_ip_location" = "Poloha IP"; +"user_session_details_device_ip_address" = "IP adresa"; +"user_session_details_session_section_footer" = "Ťuknutím na ľubovoľný údaj a jeho podržaním ho skopírujte."; +"user_session_details_session_id" = "ID relácie"; +"user_session_details_session_name" = "Názov relácie"; +"user_session_details_device_section_header" = "Zariadenie"; +"user_session_details_application_section_header" = "Aplikácia"; +"user_session_details_session_section_header" = "Relácia"; +"user_session_details_title" = "Podrobnosti o relácii"; +"user_session_push_notifications_message" = "Ak je zapnuté, táto relácia bude dostávať oznámenia push."; +"user_session_push_notifications" = "Push oznámenia"; +"user_sessions_view_all_action" = "Zobraziť všetky (%d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Zvážte odhlásenie zo starých relácií (90 dní alebo viac), ktoré už nepoužívate."; +"user_sessions_overview_security_recommendations_inactive_title" = "Neaktívne relácie"; +"user_sessions_overview_security_recommendations_unverified_info" = "Overte alebo sa odhláste z neoverených relácií."; +"user_sessions_overview_security_recommendations_unverified_title" = "Neoverené relácie"; +"user_sessions_overview_security_recommendations_section_info" = "Zlepšite zabezpečenie svojho účtu dodržiavaním týchto odporúčaní."; +"user_sessions_overview_security_recommendations_section_title" = "Bezpečnostné odporúčania"; +"all_chats_user_menu_accessibility_label" = "Používateľské menu"; +"settings_labs_enable_new_client_info_feature" = "Zaznamenať názov klienta, verziu a url, aby bolo možné ľahšie rozpoznať relácie v správcovi relácií"; +"settings_labs_enable_new_session_manager" = "Nový správca relácií"; +"device_type_name_unknown" = "Neznámy"; +"device_type_name_mobile" = "Mobil"; +"device_type_name_web" = "Web"; +"device_type_name_desktop" = "Stolný počítač"; +"user_inactive_session_item_with_date" = "Neaktívna viac ako 90 dní (%@)"; +"user_inactive_session_item" = "Neaktívna viac ako 90 dní"; +"user_other_session_unverified_sessions_header_subtitle" = "Overte si relácie pre vylepšené bezpečné zasielanie správ alebo sa odhláste z tých, ktoré už nepoznáte alebo nepoužívate."; +"user_other_session_security_recommendation_title" = "Bezpečnostné odporúčania"; +"user_sessions_overview_link_device" = "Prepojiť zariadenie"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"sign_out_confirmation_message" = "Naozaj sa chcete odhlásiť?"; + +// MARK: Sign out warning + +"sign_out" = "Odhlásiť sa"; +"manage_session_rename" = "Premenovať reláciu"; +"authentication_qr_login_failure_retry" = "Skúste to znova"; +"authentication_qr_login_failure_request_timed_out" = "Prepojenie nebolo dokončené v požadovanom čase."; +"authentication_qr_login_failure_request_denied" = "Žiadosť bola na druhom zariadení zamietnutá."; +"authentication_qr_login_failure_invalid_qr" = "QR kód nie je platný."; +"authentication_qr_login_failure_title" = "Prepojenie zlyhalo"; +"authentication_qr_login_loading_signed_in" = "Teraz ste prihlásení na svojom druhom zariadení."; +"authentication_qr_login_loading_waiting_signin" = "Čaká sa na prihlásenie zariadenia."; +"authentication_qr_login_loading_connecting_device" = "Pripájanie k zariadeniu"; +"authentication_qr_login_confirm_alert" = "Uistite sa prosím, že poznáte pôvod tohto kódu. Prepojením zariadení poskytnete niekomu plný prístup k svojmu účtu."; +"authentication_qr_login_confirm_subtitle" = "Skontrolujte, či sa nižšie uvedený kód zhoduje s vaším druhým zariadením:"; +"authentication_qr_login_confirm_title" = "Zabezpečené pripojenie bolo vytvorené"; +"authentication_qr_login_scan_subtitle" = "Umiestnite QR kód do nižšie zobrazeného štvorca"; +"authentication_qr_login_scan_title" = "Skenovať QR kód"; +"authentication_qr_login_display_step2" = "Vyberte možnosť \"Prihlásiť sa pomocou QR kódu\""; +"authentication_qr_login_display_step1" = "Otvorte aplikáciu Element na vašom druhom zariadení"; +"authentication_qr_login_display_subtitle" = "Naskenujte nižšie uvedený QR kód pomocou zariadenia, ktoré je odhlásené."; +"authentication_qr_login_display_title" = "Prepojiť zariadenie"; +"authentication_qr_login_start_display_qr" = "Zobraziť QR kód na tomto zariadení"; +"authentication_qr_login_start_need_alternative" = "Potrebujete iný spôsob?"; +"authentication_qr_login_start_step4" = "Vyberte možnosť \"Zobraziť QR kód na tomto zariadení\""; +"authentication_qr_login_start_step3" = "Vyberte položku \"Prepojiť zariadenie\""; +"authentication_qr_login_start_step2" = "Prejdite do ponuky Nastavenia -> Zabezpečenie a súkromie"; +"authentication_qr_login_start_step1" = "Otvorte aplikáciu Element na vašom druhom zariadení"; +"authentication_qr_login_start_subtitle" = "Pomocou fotoaparátu na tomto zariadení naskenujte QR kód zobrazený na vašom druhom zariadení:"; +"authentication_qr_login_start_title" = "Skenovať QR kód"; +"authentication_login_with_qr" = "Prihlásiť sa pomocou QR kódu"; +"wysiwyg_composer_format_action_strikethrough" = "Použiť formát podčiarknutia"; +"wysiwyg_composer_format_action_underline" = "Použiť formát prečiarknutia"; +"wysiwyg_composer_format_action_italic" = "Použiť formát kurzívou"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Použiť tučný formát"; +"wysiwyg_composer_start_action_text_formatting" = "Formátovanie textu"; +"wysiwyg_composer_start_action_camera" = "Kamera"; +"wysiwyg_composer_start_action_location" = "Poloha"; +"wysiwyg_composer_start_action_polls" = "Ankety"; +"wysiwyg_composer_start_action_attachments" = "Prílohy"; +"wysiwyg_composer_start_action_stickers" = "Nálepky"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Knižnica fotografií"; +"user_session_details_last_activity" = "Posledná aktivita"; +"user_session_item_details_last_activity" = "Posledná aktivita %@"; +"user_other_session_clear_filter" = "Zrušiť filter"; +"user_other_session_no_unverified_sessions" = "Nenašli sa žiadne neoverené relácie."; +"user_other_session_no_verified_sessions" = "Nenašli sa žiadne overené relácie."; +"user_other_session_no_inactive_sessions" = "Nenašli sa žiadne neaktívne relácie."; +"user_other_session_filter_menu_inactive" = "Neaktívne"; +"user_other_session_filter_menu_unverified" = "Neoverené"; +"user_other_session_filter_menu_verified" = "Overené"; +"user_other_session_filter_menu_all" = "Všetky relácie"; +"user_other_session_filter" = "Filter"; +"user_other_session_verified_sessions_header_subtitle" = "V záujme čo najlepšieho zabezpečenia sa odhláste z každej relácie, ktorú už nepoznáte alebo nepoužívate."; +"user_other_session_current_session_details" = "Vaša súčasná relácia"; +"user_other_session_verified_additional_info" = "Táto relácia je pripravená na bezpečné zasielanie správ."; +"user_other_session_unverified_additional_info" = "V záujme čo najvyššej bezpečnosti a spoľahlivosti túto reláciu overte alebo sa z nej odhláste."; +"user_session_verification_unknown_additional_info" = "Overením aktuálnej relácie zistíte stav overenia tejto relácie."; +"user_session_verification_unknown_short" = "Neznámy"; +"user_session_verification_unknown" = "Neznámy stav overenia"; +"manage_session_name_info_link" = "Zistiť viac"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Uvedomte si, že názvy relácií sú viditeľné aj pre ľudí, s ktorými komunikujete. %@"; +"manage_session_name_hint" = "Vlastné názvy relácií vám pomôžu ľahšie rozpoznať vaše zariadenia."; +"settings_labs_enable_wysiwyg_composer" = "Vyskúšajte rozšírený textový editor (čistý textový režim sa objaví čoskoro)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Hlasové vysielanie"; +"voice_broadcast_already_in_progress_message" = "Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové."; +"voice_broadcast_blocked_by_someone_else_message" = "Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové."; +"voice_broadcast_permission_denied_message" = "Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Nie je možné spustiť nové hlasové vysielanie"; +"settings_labs_enable_voice_broadcast" = "Hlasové vysielanie (v štádiu aktívneho vývoja)"; +"voice_broadcast_playback_loading_error" = "Toto hlasové vysielanie nie je možné prehrať."; +"deselect_all" = "Zrušiť výber všetkých"; +"user_other_session_selected_count" = "%@ vybratých"; +"user_other_session_menu_select_sessions" = "Vyberte relácie"; diff --git a/Riot/Assets/sv.lproj/InfoPlist.strings b/Riot/Assets/sv.lproj/InfoPlist.strings index 246f254b8..35778cfde 100644 --- a/Riot/Assets/sv.lproj/InfoPlist.strings +++ b/Riot/Assets/sv.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ -"NSPhotoLibraryUsageDescription" = "Bildbiblioteket används för att skicka bilder och videor."; +"NSPhotoLibraryUsageDescription" = "Ge åtkomst till bilder för att ladda upp bilder och videor från ditt bibliotek."; "NSCalendarsUsageDescription" = "Se dina schemalagda möten i appen."; // Permissions usage explanations -"NSCameraUsageDescription" = "Kameran används för att ta bilder och videor, och ringa videosamtal."; +"NSCameraUsageDescription" = "Kameran används för att ringa videosamtal, eller att ta och ladda upp bilder och videor."; "NSMicrophoneUsageDescription" = "Element behöver åtkomst till din mikrofon för att kunna ringa och ta emot samtal samt spela in video och röstmeddelanden."; -"NSContactsUsageDescription" = "Element kommer att visa dina kontakter så du kan bjuda in dem att chatta."; +"NSContactsUsageDescription" = "De kommer att delas med din identitetsserver för att hjälpa dig att hitta dina kontakter på Matrix."; "NSFaceIDUsageDescription" = "Face ID används för att komma åt appen."; "NSLocationWhenInUseUsageDescription" = "När du delar din plats med folk så behöver Element åtkomst för att visa dem en karta."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "När du delar din plats med folk så behöver Element åtkomst för att visa dem en karta."; diff --git a/Riot/Assets/uk.lproj/Vector.strings b/Riot/Assets/uk.lproj/Vector.strings index 4d561fe3d..d1d1d7d7c 100644 --- a/Riot/Assets/uk.lproj/Vector.strings +++ b/Riot/Assets/uk.lproj/Vector.strings @@ -1228,7 +1228,7 @@ "user_verification_session_details_information_trusted_other_user_part1" = "Цей сеанс довірений для захищеного листування, бо "; "user_verification_session_details_information_trusted_other_user_part2" = " звіряє його:"; "user_verification_session_details_information_untrusted_other_user" = " входить у новому сеансі:"; -"user_verification_session_details_additional_information_untrusted_other_user" = "Надіслані цьому сеансу й цим сеансом повідомлення позначатимуться застереженнями, поки цей користувач йому не довірить. Або ви можете власноруч звірити сеанс."; +"user_verification_session_details_additional_information_untrusted_other_user" = "Поки цей користувач не довіряє цьому сеансу, повідомлення, що надсилаються до нього і від нього, позначаються попередженнями. Крім того, ви можете звірити його вручну."; "user_verification_session_details_additional_information_untrusted_current_user" = "Якщо ви не входили в цей сеанс, ваш обліковий запис може бути під загрозою."; "user_verification_session_details_verify_action_other_user" = "Звірити власноруч"; "key_verification_bootstrap_not_setup_message" = "Спершу налаштуйте перехресне підписування."; @@ -2694,7 +2694,7 @@ "device_name_mobile" = "%@ Мобільний"; "device_name_web" = "%@ Браузер"; "device_name_desktop" = "%@ Комп'ютер"; -"user_session_item_details" = "%@ · Остання активність %@"; +"user_session_item_details" = "%1$@ · %2$@"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2707,8 +2707,138 @@ "user_session_verified_short" = "Звірений"; "user_session_unverified" = "Не звірений сеанс"; "user_session_verified" = "Звірений сеанс"; -"user_sessions_overview_current_session_section_title" = "ПОТОЧНИЙ СЕАНС"; +"user_sessions_overview_current_session_section_title" = "Поточний сеанс"; "user_sessions_overview_other_sessions_section_info" = "Звірте свої сеанси та вийдіть з усіх сеансів, які ви більше не розпізнаєте або не використовуєте для кращої безпеки."; -"user_sessions_overview_other_sessions_section_title" = "ІНШІ СЕАНСИ"; +"user_sessions_overview_other_sessions_section_title" = "Інші сеанси"; "settings_labs_enable_new_app_layout" = "Новий вигляд застосунку"; "room_first_message_placeholder" = "Надішліть своє перше повідомлення…"; +"room_event_encryption_info_key_authenticity_not_guaranteed" = "Справжність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої."; +"user_session_overview_session_details_button_title" = "Подробиці сеансу"; +"user_session_overview_session_title" = "Сеанс"; +"user_session_overview_current_session_title" = "Поточний сеанс"; +"user_session_details_application_url" = "URL"; +"user_session_details_application_version" = "Версія"; +"user_session_details_application_name" = "Назва"; +"user_session_details_device_os" = "Операційна система"; +"user_session_details_device_browser" = "Браузер"; +"user_session_details_device_model" = "Модель"; +"user_session_details_device_ip_location" = "Локація IP"; +"user_session_details_device_ip_address" = "IP-адреса"; +"user_session_details_session_section_footer" = "Копіюйте будь-які дані, затиснувши їх."; +"user_session_details_session_id" = "ID сеансу"; +"user_session_details_session_name" = "Назва сеансу"; +"user_session_details_device_section_header" = "Пристрій"; +"user_session_details_application_section_header" = "Застосунок"; +"user_session_details_session_section_header" = "Сеанс"; +"user_session_details_title" = "Подробиці сеансу"; +"user_session_push_notifications_message" = "Після ввімкнення цей сеанс отримуватиме push-сповіщення."; +"user_session_push_notifications" = "Push-сповіщення"; +"user_sessions_view_all_action" = "Переглянути всі (%d)"; +"user_sessions_overview_security_recommendations_inactive_info" = "Розгляньте можливість виходу з давніх сеансів (90 днів або давніше), якими ви більше не користуєтесь."; +"user_sessions_overview_security_recommendations_inactive_title" = "Неактивні сеанси"; +"user_sessions_overview_security_recommendations_unverified_info" = "Звірте або вийдіть з не звірених сеансів."; +"user_sessions_overview_security_recommendations_unverified_title" = "Не звірені сеанси"; +"user_sessions_overview_security_recommendations_section_info" = "Посильте безпеку свого облікового запису, дотримуючись цих порад."; +"user_sessions_overview_security_recommendations_section_title" = "Поради з безпеки"; +"all_chats_user_menu_accessibility_label" = "Меню користувача"; +"settings_labs_enable_new_client_info_feature" = "Запишіть назву клієнта, версію та URL-адресу, щоб легше розпізнавати сеанси в менеджері сеансів"; +"settings_labs_enable_new_session_manager" = "Новий менеджер сеансів"; +"device_type_name_unknown" = "Невідомо"; +"device_type_name_mobile" = "Мобільний"; +"device_type_name_web" = "Браузер"; +"device_type_name_desktop" = "Комп'ютер"; +"user_inactive_session_item_with_date" = "Неактивний понад 90 днів (%@)"; +"user_inactive_session_item" = "Неактивний понад 90 днів"; +"user_other_session_unverified_sessions_header_subtitle" = "Перевірте свої сеанси для посилення безпеки обміну повідомленнями або вийдіть з тих, які ви більше не розпізнаєте або не використовуєте."; +"user_other_session_security_recommendation_title" = "Поради з безпеки"; +"user_sessions_overview_link_device" = "Пов'язати пристрій"; + +// MARK: User sessions management + +// Parameter is the application display name (e.g. "Element") +"user_sessions_default_session_display_name" = "%@ iOS"; +"sign_out_confirmation_message" = "Точно вийти?"; + +// MARK: Sign out warning + +"sign_out" = "Вийти"; +"manage_session_rename" = "Перейменувати сеанс"; +"authentication_qr_login_failure_retry" = "Повторити спробу"; +"authentication_qr_login_failure_request_timed_out" = "У встановлені терміни з'єднання не було встановлено."; +"authentication_qr_login_failure_request_denied" = "Запит відхилено на іншому пристрої."; +"authentication_qr_login_failure_invalid_qr" = "Хибний QR-код."; +"authentication_qr_login_failure_title" = "Не вдалося під'єднати"; +"authentication_qr_login_loading_signed_in" = "Ви ввійшли на іншому пристрої."; +"authentication_qr_login_loading_waiting_signin" = "Очікування входу пристрою."; +"authentication_qr_login_loading_connecting_device" = "Під'єднання до пристрою"; +"authentication_qr_login_confirm_alert" = "Переконайтеся, що ви знаєте походження цього коду. Пов'язавши пристрої, ви надасте будь-кому повний доступ до свого облікового запису."; +"authentication_qr_login_confirm_subtitle" = "Переконайтеся, що код внизу збігається з кодом вашого іншого пристрою:"; +"authentication_qr_login_confirm_title" = "Установлено захищене з'єднання"; +"authentication_qr_login_scan_subtitle" = "Розмістіть QR-код у квадраті знизу"; +"authentication_qr_login_scan_title" = "Сканувати QR-код"; +"authentication_qr_login_display_step2" = "Виберіть «Увійти використавши QR-код»"; +"authentication_qr_login_display_step1" = "Відкрийте Element на іншому пристрої"; +"authentication_qr_login_display_subtitle" = "Зіскануйте QR-код знизу своїм пристроєм, з якого ви вийшли."; +"authentication_qr_login_display_title" = "Пов'язати пристрій"; +"authentication_qr_login_start_display_qr" = "Показати QR-код на цьому пристрої"; +"authentication_qr_login_start_need_alternative" = "Потрібен альтернативний метод?"; +"authentication_qr_login_start_step4" = "Виберіть «Показати QR-код на цьому пристрої»"; +"authentication_qr_login_start_step3" = "Виберіть «Пов'язати пристрій»"; +"authentication_qr_login_start_step2" = "Перейдіть до Налаштування -> Безпека й приватність"; +"authentication_qr_login_start_step1" = "Відкрийте Element на іншому пристрої"; +"authentication_qr_login_start_subtitle" = "Використовуйте камеру на цьому пристрої, щоб зісканувати QR-код, показаний на іншому пристрої:"; +"authentication_qr_login_start_title" = "Сканувати QR-код"; +"authentication_login_with_qr" = "Увійти використавши QR-код"; +"wysiwyg_composer_format_action_strikethrough" = "Застосувати форматування підкресленим"; +"wysiwyg_composer_format_action_underline" = "Застосувати форматування перекресленим"; + +// Formatting Actions +"wysiwyg_composer_format_action_bold" = "Застосувати форматування жирним"; +"wysiwyg_composer_format_action_italic" = "Застосувати форматування курсивом"; +"wysiwyg_composer_start_action_text_formatting" = "Форматування тексту"; +"wysiwyg_composer_start_action_camera" = "Камера"; +"wysiwyg_composer_start_action_location" = "Місце перебування"; +"wysiwyg_composer_start_action_polls" = "Опитування"; +"wysiwyg_composer_start_action_attachments" = "Вкладення"; +"wysiwyg_composer_start_action_stickers" = "Наліпки"; + + +// Mark: - WYSIWYG Composer + +// Send Media Actions +"wysiwyg_composer_start_action_media_picker" = "Фотобібліотека"; +"user_session_details_last_activity" = "Остання активність"; +"user_session_item_details_last_activity" = "Остання активність %@"; +"user_other_session_clear_filter" = "Очистити фільтр"; +"user_other_session_no_unverified_sessions" = "Не звірених сеансів не знайдено."; +"user_other_session_no_verified_sessions" = "Звірених сеансів не знайдено."; +"user_other_session_no_inactive_sessions" = "Неактивних сеансів не знайдено."; +"user_other_session_filter_menu_inactive" = "Неактивний"; +"user_other_session_filter_menu_unverified" = "Не звірений"; +"user_other_session_filter_menu_verified" = "Звірений"; +"user_other_session_filter_menu_all" = "Усі сеанси"; +"user_other_session_filter" = "Фільтр"; +"user_other_session_verified_sessions_header_subtitle" = "Для кращої безпеки виходьте з будь-якого сеансу, який ви більше не розпізнаєте або не використовуєте."; +"user_other_session_current_session_details" = "Ваш поточний сеанс"; +"user_other_session_verified_additional_info" = "Цей сеанс готовий до безпечного обміну повідомленнями."; +"user_other_session_unverified_additional_info" = "Перевірте або вийдіть з цього сеансу для кращої безпеки та надійності."; +"user_session_verification_unknown_additional_info" = "Звірте свій поточний сеанс, щоб побачити стан перевірки цього сеансу."; +"user_session_verification_unknown_short" = "Невідомо"; +"user_session_verification_unknown" = "Невідомий стан перевірки"; +"manage_session_name_info_link" = "Докладніше"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Зауважте, що назви сеансів також видно людям, з якими ви спілкуєтесь. %@"; +"manage_session_name_hint" = "Власні назви сеансів допоможуть вам легше розпізнавати ваші пристрої."; +"settings_labs_enable_wysiwyg_composer" = "Спробуйте розширений текстовий редактор (незабаром з'явиться режим звичайного тексту)"; +"wysiwyg_composer_start_action_voice_broadcast" = "Голосові повідомлення"; +"voice_broadcast_playback_loading_error" = "Неможливо відтворити це голосове повідомлення."; +"voice_broadcast_already_in_progress_message" = "Ви вже записуєте голосове повідомлення. Завершіть поточну трансляцію, щоб розпочати нову."; +"voice_broadcast_blocked_by_someone_else_message" = "Хтось інший вже записує голосове повідомлення. Зачекайте, поки закінчиться трансляція, щоб розпочати нову."; +"voice_broadcast_permission_denied_message" = "Ви не маєте необхідних дозволів для початку трансляції голосового повідомлення в цій кімнаті. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи."; + +// Mark: - Voice broadcast +"voice_broadcast_unauthorized_title" = "Не вдалося розпочати трансляцію нового голосового повідомлення"; +"settings_labs_enable_voice_broadcast" = "Голосові повідомлення (в активній розробці)"; +"deselect_all" = "Скасувати вибір усіх"; +"user_other_session_menu_select_sessions" = "Вибрати сеанси"; +"user_other_session_selected_count" = "Вибрано %@"; diff --git a/Riot/Categories/MXRestClient+Async.swift b/Riot/Categories/MXRestClient+Async.swift index d72bb9ce1..214c5d4da 100644 --- a/Riot/Categories/MXRestClient+Async.swift +++ b/Riot/Categories/MXRestClient+Async.swift @@ -57,6 +57,11 @@ extension MXRestClient { return MXCredentials(loginResponse: loginResponse, andDefaultCredentials: credentials) } + /// An async version of generateLoginToken(completion:) + func generateLoginToken() async throws -> MXLoginToken { + try await getResponse(generateLoginToken) + } + // MARK: - Registration /// An async version of `getRegisterSession(completion:)`. @@ -155,6 +160,15 @@ extension MXRestClient { changePassword(from: oldPassword, to: newPassword, logoutDevices: logoutDevices, completion: completion) } } + + // MARK: - Versions + + /// An async version of `supportedMatrixVersions(completion:)`. + func supportedMatrixVersions() async throws -> MXMatrixVersions { + try await getResponse({ completion in + supportedMatrixVersions(completion: completion) + }) + } // MARK: - Private diff --git a/Riot/Categories/MXRoom+Riot.m b/Riot/Categories/MXRoom+Riot.m index 801221597..df47c1674 100644 --- a/Riot/Categories/MXRoom+Riot.m +++ b/Riot/Categories/MXRoom+Riot.m @@ -329,7 +329,7 @@ { if (self.mxSession.crypto) { - [self.mxSession.crypto trustLevelSummaryForUserIds:@[userId] onComplete:^(MXUsersTrustLevelSummary *usersTrustLevelSummary) { + [self.mxSession.crypto trustLevelSummaryForUserIds:@[userId] forceDownload:NO success:^(MXUsersTrustLevelSummary *usersTrustLevelSummary) { UserEncryptionTrustLevel userEncryptionTrustLevel; double trustedDevicesPercentage = usersTrustLevelSummary.trustedDevicesProgress.fractionCompleted; @@ -341,7 +341,7 @@ else if (trustedDevicesPercentage == 0.0) { // Verify if the user has the user has cross-signing enabled - if ([self.mxSession.crypto crossSigningKeysForUser:userId]) + if ([self.mxSession.crypto.crossSigning crossSigningKeysForUser:userId]) { userEncryptionTrustLevel = UserEncryptionTrustLevelNotVerified; } @@ -357,6 +357,9 @@ onComplete(userEncryptionTrustLevel); + } failure:^(NSError *error) { + MXLogErrorDetails(@"[MXRoom+Riot] Error fetching trust level summary", error); + onComplete(UserEncryptionTrustLevelUnknown); }]; } else diff --git a/Riot/Categories/String.swift b/Riot/Categories/String.swift index a6e48d0aa..337923493 100644 --- a/Riot/Categories/String.swift +++ b/Riot/Categories/String.swift @@ -63,6 +63,11 @@ extension String { func vc_reversed() -> String { return String(self.reversed()) } + + /// Returns nil if the string is empty or the string itself otherwise + func vc_nilIfEmpty() -> String? { + isEmpty ? nil : self + } } extension Optional where Wrapped == String { diff --git a/Riot/Categories/UIDevice.swift b/Riot/Categories/UIDevice.swift index c3405e8ed..9fabb0f82 100644 --- a/Riot/Categories/UIDevice.swift +++ b/Riot/Categories/UIDevice.swift @@ -35,7 +35,7 @@ import UIKit } var initialDisplayName: String { - isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice + VectorL10n.userSessionsDefaultSessionDisplayName(AppInfo.current.displayName) } } diff --git a/Riot/Categories/UITableViewCell.swift b/Riot/Categories/UITableViewCell.swift index 86c4b7ee0..6071a11ec 100644 --- a/Riot/Categories/UITableViewCell.swift +++ b/Riot/Categories/UITableViewCell.swift @@ -51,5 +51,16 @@ extension UITableViewCell { @objc func vc_setAccessoryDisclosureIndicatorWithCurrentTheme() { self.vc_setAccessoryDisclosureIndicator(withTheme: ThemeService.shared().theme) } + + @objc var vc_parentViewController: UIViewController? { + var parent: UIResponder? = self + while parent != nil { + parent = parent?.next + if let viewController = parent as? UIViewController { + return viewController + } + } + return nil + } } diff --git a/Riot/Categories/UITextView.swift b/Riot/Categories/UITextView.swift index 56b19047a..1c989cc68 100644 --- a/Riot/Categories/UITextView.swift +++ b/Riot/Categories/UITextView.swift @@ -22,7 +22,10 @@ extension UITextView { self.attributedText.enumerateAttribute( .attachment, in: NSRange(location: 0, length: self.attributedText.length), - options: []) { _, range, _ in + options: []) { value, range, _ in + guard value != nil else { + return + } self.layoutManager.invalidateDisplay(forCharacterRange: range) } } diff --git a/Riot/Categories/UIViewController+RiotSearch.m b/Riot/Categories/UIViewController+RiotSearch.m index 76d3af972..98556960f 100644 --- a/Riot/Categories/UIViewController+RiotSearch.m +++ b/Riot/Categories/UIViewController+RiotSearch.m @@ -89,8 +89,12 @@ self.navigationItem.leftBarButtonItem = nil; // Add the search bar - self.navigationItem.titleView = self.searchBar; - + UIView *searchBarContainer = [[UIView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, 44)]; + searchBarContainer.backgroundColor = [UIColor clearColor]; + searchBarContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth; + + self.navigationItem.titleView = searchBarContainer; + [searchBarContainer addSubview:self.searchBar]; self.extendedLayoutIncludesOpaqueBars = YES; // On iPad, there is no cancel button inside the UISearchBar @@ -177,8 +181,9 @@ // Initialise internal data at the first call searchInternals = [[UIViewControllerRiotSearchInternals alloc] init]; - UISearchBar *searchBar = [[UISearchBar alloc] init]; + UISearchBar *searchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, 44)]; searchBar.showsCancelButton = YES; + searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth; searchBar.delegate = (id)self; searchInternals.searchBar = searchBar; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index b222241ee..8105eacb3 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -36,6 +36,7 @@ internal class Asset: NSObject { internal static let authenticationEmailIcon = ImageAsset(name: "authentication_email_icon") internal static let authenticationMsisdnIcon = ImageAsset(name: "authentication_msisdn_icon") internal static let authenticationPasswordIcon = ImageAsset(name: "authentication_password_icon") + internal static let authenticationQrloginConfirmIcon = ImageAsset(name: "authentication_qrlogin_confirm_icon") internal static let authenticationRecaptchaIcon = ImageAsset(name: "authentication_recaptcha_icon") internal static let authenticationRevealPassword = ImageAsset(name: "authentication_reveal_password") internal static let authenticationServerSelectionIcon = ImageAsset(name: "authentication_server_selection_icon") @@ -83,6 +84,7 @@ internal class Asset: NSObject { internal static let coachMark = ImageAsset(name: "coach_mark") internal static let disclosureIcon = ImageAsset(name: "disclosure_icon") internal static let errorIcon = ImageAsset(name: "error_icon") + internal static let exclamationCircle = ImageAsset(name: "exclamation_circle") internal static let faceidIcon = ImageAsset(name: "faceid_icon") internal static let filterOff = ImageAsset(name: "filter_off") internal static let filterOn = ImageAsset(name: "filter_on") @@ -108,6 +110,20 @@ internal class Asset: NSObject { internal static let touchidIcon = ImageAsset(name: "touchid_icon") internal static let addGroupParticipant = ImageAsset(name: "add_group_participant") internal static let removeIconBlue = ImageAsset(name: "remove_icon_blue") + internal static let bold = ImageAsset(name: "Bold") + internal static let code = ImageAsset(name: "Code") + internal static let indentIncrease = ImageAsset(name: "Indent_increase") + internal static let italic = ImageAsset(name: "Italic") + internal static let link = ImageAsset(name: "Link") + internal static let numberedList = ImageAsset(name: "Numbered list") + internal static let quote = ImageAsset(name: "Quote") + internal static let strikethrough = ImageAsset(name: "Strikethrough") + internal static let underlined = ImageAsset(name: "Underlined") + internal static let bulletList = ImageAsset(name: "bullet_list") + internal static let indentDecrease = ImageAsset(name: "indent_decrease") + internal static let maximiseComposer = ImageAsset(name: "maximise_composer") + internal static let minimiseComposer = ImageAsset(name: "minimise_composer") + internal static let startComposeModule = ImageAsset(name: "start_compose_module") internal static let findYourContactsFacepile = ImageAsset(name: "find_your_contacts_facepile") internal static let captureAvatar = ImageAsset(name: "capture_avatar") internal static let deleteAvatar = ImageAsset(name: "delete_avatar") @@ -115,7 +131,16 @@ internal class Asset: NSObject { internal static let deviceTypeMobile = ImageAsset(name: "device_type_mobile") internal static let deviceTypeUnknown = ImageAsset(name: "device_type_unknown") internal static let deviceTypeWeb = ImageAsset(name: "device_type_web") + internal static let userOtherSessionsFilter = ImageAsset(name: "user_other_sessions_filter") + internal static let userOtherSessionsFilterSelected = ImageAsset(name: "user_other_sessions_filter_selected") + internal static let userOtherSessionsInactive = ImageAsset(name: "user_other_sessions_inactive") + internal static let userOtherSessionsUnverified = ImageAsset(name: "user_other_sessions_unverified") + internal static let userOtherSessionsVerified = ImageAsset(name: "user_other_sessions_verified") + internal static let userSessionListItemInactiveSession = ImageAsset(name: "user_session_list_item_inactive_session") + internal static let userSessionListItemNotSelected = ImageAsset(name: "user_session_list_item_not_selected") + internal static let userSessionListItemSelected = ImageAsset(name: "user_session_list_item_selected") internal static let userSessionUnverified = ImageAsset(name: "user_session_unverified") + internal static let userSessionVerificationUnknown = ImageAsset(name: "user_session_verification_unknown") internal static let userSessionVerified = ImageAsset(name: "user_session_verified") internal static let userSessionsInactive = ImageAsset(name: "user_sessions_inactive") internal static let userSessionsUnverified = ImageAsset(name: "user_sessions_unverified") @@ -184,6 +209,7 @@ internal class Asset: NSObject { internal static let personalNotesAvatar = ImageAsset(name: "personal_notes_avatar") internal static let actionCamera = ImageAsset(name: "action_camera") internal static let actionFile = ImageAsset(name: "action_file") + internal static let actionLive = ImageAsset(name: "action_live") internal static let actionLocation = ImageAsset(name: "action_location") internal static let actionMediaLibrary = ImageAsset(name: "action_media_library") internal static let actionPoll = ImageAsset(name: "action_poll") @@ -324,6 +350,12 @@ internal class Asset: NSObject { internal static let tabHome = ImageAsset(name: "tab_home") internal static let tabPeople = ImageAsset(name: "tab_people") internal static let tabRooms = ImageAsset(name: "tab_rooms") + internal static let voiceBroadcastLive = ImageAsset(name: "voice_broadcast_live") + internal static let voiceBroadcastPause = ImageAsset(name: "voice_broadcast_pause") + internal static let voiceBroadcastPlay = ImageAsset(name: "voice_broadcast_play") + internal static let voiceBroadcastRecord = ImageAsset(name: "voice_broadcast_record") + internal static let voiceBroadcastRecordPause = ImageAsset(name: "voice_broadcast_record_pause") + internal static let voiceBroadcastStop = ImageAsset(name: "voice_broadcast_stop") internal static let launchScreenLogo = ImageAsset(name: "launch_screen_logo") } @objcMembers diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index f8c0cc121..6c82fd266 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -739,6 +739,110 @@ public class VectorL10n: NSObject { public static var authenticationLoginUsername: String { return VectorL10n.tr("Vector", "authentication_login_username") } + /// Sign in with QR code + public static var authenticationLoginWithQr: String { + return VectorL10n.tr("Vector", "authentication_login_with_qr") + } + /// Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account. + public static var authenticationQrLoginConfirmAlert: String { + return VectorL10n.tr("Vector", "authentication_qr_login_confirm_alert") + } + /// Confirm that the code below matches with your other device: + public static var authenticationQrLoginConfirmSubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_confirm_subtitle") + } + /// Secure connection established + public static var authenticationQrLoginConfirmTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_confirm_title") + } + /// Open Element on your other device + public static var authenticationQrLoginDisplayStep1: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_step1") + } + /// Select ‘Sign in with QR code’ + public static var authenticationQrLoginDisplayStep2: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_step2") + } + /// Scan the QR code below with your device that’s signed out. + public static var authenticationQrLoginDisplaySubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_subtitle") + } + /// Link a device + public static var authenticationQrLoginDisplayTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_title") + } + /// QR code is invalid. + public static var authenticationQrLoginFailureInvalidQr: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_invalid_qr") + } + /// The request was denied on the other device. + public static var authenticationQrLoginFailureRequestDenied: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_request_denied") + } + /// The linking wasn’t completed in the required time. + public static var authenticationQrLoginFailureRequestTimedOut: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_request_timed_out") + } + /// Try again + public static var authenticationQrLoginFailureRetry: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_retry") + } + /// Linking failed + public static var authenticationQrLoginFailureTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_title") + } + /// Connecting to device + public static var authenticationQrLoginLoadingConnectingDevice: String { + return VectorL10n.tr("Vector", "authentication_qr_login_loading_connecting_device") + } + /// You are now signed in on your other device. + public static var authenticationQrLoginLoadingSignedIn: String { + return VectorL10n.tr("Vector", "authentication_qr_login_loading_signed_in") + } + /// Waiting for device to sign in. + public static var authenticationQrLoginLoadingWaitingSignin: String { + return VectorL10n.tr("Vector", "authentication_qr_login_loading_waiting_signin") + } + /// Position the QR code in the square below + public static var authenticationQrLoginScanSubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_scan_subtitle") + } + /// Scan QR code + public static var authenticationQrLoginScanTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_scan_title") + } + /// Show QR code on this device + public static var authenticationQrLoginStartDisplayQr: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_display_qr") + } + /// Need an alternative method? + public static var authenticationQrLoginStartNeedAlternative: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_need_alternative") + } + /// Open Element on your other device + public static var authenticationQrLoginStartStep1: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step1") + } + /// Go to Settings -> Security & Privacy + public static var authenticationQrLoginStartStep2: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step2") + } + /// Select ‘Link a device’ + public static var authenticationQrLoginStartStep3: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step3") + } + /// Select ‘Show QR code on this device’ + public static var authenticationQrLoginStartStep4: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step4") + } + /// Use the camera on this device to scan the QR code shown on your other device: + public static var authenticationQrLoginStartSubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_subtitle") + } + /// Scan QR code + public static var authenticationQrLoginStartTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_title") + } /// Are you a human? public static var authenticationRecaptchaTitle: String { return VectorL10n.tr("Vector", "authentication_recaptcha_title") @@ -1419,6 +1523,10 @@ public class VectorL10n: NSObject { public static var delete: String { return VectorL10n.tr("Vector", "delete") } + /// Deselect All + public static var deselectAll: String { + return VectorL10n.tr("Vector", "deselect_all") + } /// This operation requires additional authentication.\nTo continue, please enter your password. public static var deviceDetailsDeletePromptMessage: String { return VectorL10n.tr("Vector", "device_details_delete_prompt_message") @@ -1471,6 +1579,22 @@ public class VectorL10n: NSObject { public static func deviceNameWeb(_ p1: String) -> String { return VectorL10n.tr("Vector", "device_name_web", p1) } + /// Desktop + public static var deviceTypeNameDesktop: String { + return VectorL10n.tr("Vector", "device_type_name_desktop") + } + /// Mobile + public static var deviceTypeNameMobile: String { + return VectorL10n.tr("Vector", "device_type_name_mobile") + } + /// Unknown + public static var deviceTypeNameUnknown: String { + return VectorL10n.tr("Vector", "device_type_name_unknown") + } + /// Web + public static var deviceTypeNameWeb: String { + return VectorL10n.tr("Vector", "device_type_name_web") + } /// The other party cancelled the verification. public static var deviceVerificationCancelled: String { return VectorL10n.tr("Vector", "device_verification_cancelled") @@ -3499,10 +3623,26 @@ public class VectorL10n: NSObject { public static var manageSessionName: String { return VectorL10n.tr("Vector", "manage_session_name") } + /// Custom session names can help you recognize your devices more easily. + public static var manageSessionNameHint: String { + return VectorL10n.tr("Vector", "manage_session_name_hint") + } + /// Please be aware that session names are also visible to people you communicate with. %@ + public static func manageSessionNameInfo(_ p1: String) -> String { + return VectorL10n.tr("Vector", "manage_session_name_info", p1) + } + /// Learn more + public static var manageSessionNameInfoLink: String { + return VectorL10n.tr("Vector", "manage_session_name_info_link") + } /// Not trusted public static var manageSessionNotTrusted: String { return VectorL10n.tr("Vector", "manage_session_not_trusted") } + /// Rename session + public static var manageSessionRename: String { + return VectorL10n.tr("Vector", "manage_session_rename") + } /// Sign out of this session public static var manageSessionSignOut: String { return VectorL10n.tr("Vector", "manage_session_sign_out") @@ -7399,6 +7539,14 @@ public class VectorL10n: NSObject { public static var settingsLabsEnableThreads: String { return VectorL10n.tr("Vector", "settings_labs_enable_threads") } + /// Voice broadcast (under active development) + public static var settingsLabsEnableVoiceBroadcast: String { + return VectorL10n.tr("Vector", "settings_labs_enable_voice_broadcast") + } + /// Try out the rich text editor (plain text mode coming soon) + public static var settingsLabsEnableWysiwygComposer: String { + return VectorL10n.tr("Vector", "settings_labs_enable_wysiwyg_composer") + } /// Polls public static var settingsLabsEnabledPolls: String { return VectorL10n.tr("Vector", "settings_labs_enabled_polls") @@ -7756,6 +7904,14 @@ public class VectorL10n: NSObject { return VectorL10n.tr("Vector", "side_menu_reveal_action_accessibility_label") } /// Sign out + public static var signOut: String { + return VectorL10n.tr("Vector", "sign_out") + } + /// Are you sure you want to sign out? + public static var signOutConfirmationMessage: String { + return VectorL10n.tr("Vector", "sign_out_confirmation_message") + } + /// Sign out public static var signOutExistingKeyBackupAlertSignOutAction: String { return VectorL10n.tr("Vector", "sign_out_existing_key_backup_alert_sign_out_action") } @@ -8483,6 +8639,82 @@ public class VectorL10n: NSObject { public static var userIdTitle: String { return VectorL10n.tr("Vector", "user_id_title") } + /// Inactive for 90+ days + public static var userInactiveSessionItem: String { + return VectorL10n.tr("Vector", "user_inactive_session_item") + } + /// Inactive for 90+ days (%@) + public static func userInactiveSessionItemWithDate(_ p1: String) -> String { + return VectorL10n.tr("Vector", "user_inactive_session_item_with_date", p1) + } + /// Clear filter + public static var userOtherSessionClearFilter: String { + return VectorL10n.tr("Vector", "user_other_session_clear_filter") + } + /// Your current session + public static var userOtherSessionCurrentSessionDetails: String { + return VectorL10n.tr("Vector", "user_other_session_current_session_details") + } + /// Filter + public static var userOtherSessionFilter: String { + return VectorL10n.tr("Vector", "user_other_session_filter") + } + /// All sessions + public static var userOtherSessionFilterMenuAll: String { + return VectorL10n.tr("Vector", "user_other_session_filter_menu_all") + } + /// Inactive + public static var userOtherSessionFilterMenuInactive: String { + return VectorL10n.tr("Vector", "user_other_session_filter_menu_inactive") + } + /// Unverified + public static var userOtherSessionFilterMenuUnverified: String { + return VectorL10n.tr("Vector", "user_other_session_filter_menu_unverified") + } + /// Verified + public static var userOtherSessionFilterMenuVerified: String { + return VectorL10n.tr("Vector", "user_other_session_filter_menu_verified") + } + /// Select sessions + public static var userOtherSessionMenuSelectSessions: String { + return VectorL10n.tr("Vector", "user_other_session_menu_select_sessions") + } + /// No inactive sessions found. + public static var userOtherSessionNoInactiveSessions: String { + return VectorL10n.tr("Vector", "user_other_session_no_inactive_sessions") + } + /// No unverified sessions found. + public static var userOtherSessionNoUnverifiedSessions: String { + return VectorL10n.tr("Vector", "user_other_session_no_unverified_sessions") + } + /// No verified sessions found. + public static var userOtherSessionNoVerifiedSessions: String { + return VectorL10n.tr("Vector", "user_other_session_no_verified_sessions") + } + /// Security recommendation + public static var userOtherSessionSecurityRecommendationTitle: String { + return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title") + } + /// %@ selected + public static func userOtherSessionSelectedCount(_ p1: String) -> String { + return VectorL10n.tr("Vector", "user_other_session_selected_count", p1) + } + /// Verify or sign out from this session for best security and reliability. + public static var userOtherSessionUnverifiedAdditionalInfo: String { + return VectorL10n.tr("Vector", "user_other_session_unverified_additional_info") + } + /// Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore. + public static var userOtherSessionUnverifiedSessionsHeaderSubtitle: String { + return VectorL10n.tr("Vector", "user_other_session_unverified_sessions_header_subtitle") + } + /// This session is ready for secure messaging. + public static var userOtherSessionVerifiedAdditionalInfo: String { + return VectorL10n.tr("Vector", "user_other_session_verified_additional_info") + } + /// For best security, sign out from any session that you don’t recognize or use anymore. + public static var userOtherSessionVerifiedSessionsHeaderSubtitle: String { + return VectorL10n.tr("Vector", "user_other_session_verified_sessions_header_subtitle") + } /// Name public static var userSessionDetailsApplicationName: String { return VectorL10n.tr("Vector", "user_session_details_application_name") @@ -8523,6 +8755,10 @@ public class VectorL10n: NSObject { public static var userSessionDetailsDeviceSectionHeader: String { return VectorL10n.tr("Vector", "user_session_details_device_section_header") } + /// Last activity + public static var userSessionDetailsLastActivity: String { + return VectorL10n.tr("Vector", "user_session_details_last_activity") + } /// Session ID public static var userSessionDetailsSessionId: String { return VectorL10n.tr("Vector", "user_session_details_session_id") @@ -8543,10 +8779,14 @@ public class VectorL10n: NSObject { public static var userSessionDetailsTitle: String { return VectorL10n.tr("Vector", "user_session_details_title") } - /// %@ · Last activity %@ + /// %1$@ · %2$@ public static func userSessionItemDetails(_ p1: String, _ p2: String) -> String { return VectorL10n.tr("Vector", "user_session_item_details", p1, p2) } + /// Last activity %@ + public static func userSessionItemDetailsLastActivity(_ p1: String) -> String { + return VectorL10n.tr("Vector", "user_session_item_details_last_activity", p1) + } /// Learn more public static var userSessionLearnMore: String { return VectorL10n.tr("Vector", "user_session_learn_more") @@ -8587,6 +8827,18 @@ public class VectorL10n: NSObject { public static var userSessionUnverifiedShort: String { return VectorL10n.tr("Vector", "user_session_unverified_short") } + /// Unknown verification status + public static var userSessionVerificationUnknown: String { + return VectorL10n.tr("Vector", "user_session_verification_unknown") + } + /// Verify your current session to reveal this session's verification status. + public static var userSessionVerificationUnknownAdditionalInfo: String { + return VectorL10n.tr("Vector", "user_session_verification_unknown_additional_info") + } + /// Unknown + public static var userSessionVerificationUnknownShort: String { + return VectorL10n.tr("Vector", "user_session_verification_unknown_short") + } /// Verified session public static var userSessionVerified: String { return VectorL10n.tr("Vector", "user_session_verified") @@ -8607,10 +8859,18 @@ public class VectorL10n: NSObject { public static var userSessionViewDetails: String { return VectorL10n.tr("Vector", "user_session_view_details") } + /// %@ iOS + public static func userSessionsDefaultSessionDisplayName(_ p1: String) -> String { + return VectorL10n.tr("Vector", "user_sessions_default_session_display_name", p1) + } /// Current session public static var userSessionsOverviewCurrentSessionSectionTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_current_session_section_title") } + /// Link a device + public static var userSessionsOverviewLinkDevice: String { + return VectorL10n.tr("Vector", "user_sessions_overview_link_device") + } /// For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. public static var userSessionsOverviewOtherSessionsSectionInfo: String { return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info") @@ -8803,6 +9063,26 @@ public class VectorL10n: NSObject { public static var voice: String { return VectorL10n.tr("Vector", "voice") } + /// You are already recording a voice broadcast. Please end your current voice broadcast to start a new one. + public static var voiceBroadcastAlreadyInProgressMessage: String { + return VectorL10n.tr("Vector", "voice_broadcast_already_in_progress_message") + } + /// Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one. + public static var voiceBroadcastBlockedBySomeoneElseMessage: String { + return VectorL10n.tr("Vector", "voice_broadcast_blocked_by_someone_else_message") + } + /// You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions. + public static var voiceBroadcastPermissionDeniedMessage: String { + return VectorL10n.tr("Vector", "voice_broadcast_permission_denied_message") + } + /// Unable to play this voice broadcast. + public static var voiceBroadcastPlaybackLoadingError: String { + return VectorL10n.tr("Vector", "voice_broadcast_playback_loading_error") + } + /// Can't start a new voice broadcast + public static var voiceBroadcastUnauthorizedTitle: String { + return VectorL10n.tr("Vector", "voice_broadcast_unauthorized_title") + } /// Voice message public static var voiceMessageLockScreenPlaceholder: String { return VectorL10n.tr("Vector", "voice_message_lock_screen_placeholder") @@ -8915,6 +9195,54 @@ public class VectorL10n: NSObject { public static var widgetStickerPickerNoStickerpacksAlertAddNow: String { return VectorL10n.tr("Vector", "widget_sticker_picker_no_stickerpacks_alert_add_now") } + /// Apply bold format + public static var wysiwygComposerFormatActionBold: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_bold") + } + /// Apply italic format + public static var wysiwygComposerFormatActionItalic: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_italic") + } + /// Apply underline format + public static var wysiwygComposerFormatActionStrikethrough: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_strikethrough") + } + /// Apply strikethrough format + public static var wysiwygComposerFormatActionUnderline: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_underline") + } + /// Attachments + public static var wysiwygComposerStartActionAttachments: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_attachments") + } + /// Camera + public static var wysiwygComposerStartActionCamera: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_camera") + } + /// Location + public static var wysiwygComposerStartActionLocation: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_location") + } + /// Photo Library + public static var wysiwygComposerStartActionMediaPicker: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_media_picker") + } + /// Polls + public static var wysiwygComposerStartActionPolls: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_polls") + } + /// Stickers + public static var wysiwygComposerStartActionStickers: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_stickers") + } + /// Text Formatting + public static var wysiwygComposerStartActionTextFormatting: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_text_formatting") + } + /// Voice broadcast + public static var wysiwygComposerStartActionVoiceBroadcast: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_voice_broadcast") + } /// Yes public static var yes: String { return VectorL10n.tr("Vector", "yes") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index f273877eb..1f417c770 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -14,6 +14,10 @@ public extension VectorL10n { static var imagePickerActionFiles: String { return VectorL10n.tr("Untranslated", "image_picker_action_files") } + /// Voice broadcast detected (under active development) + static var voiceBroadcastInTimelineTitle: String { + return VectorL10n.tr("Untranslated", "voice_broadcast_in_timeline_title") + } } // swiftlint:enable function_parameter_count identifier_name line_length type_body_length diff --git a/Riot/Managers/Settings/RiotSettings.swift b/Riot/Managers/Settings/RiotSettings.swift index fe27fddd2..ed0ddd65d 100644 --- a/Riot/Managers/Settings/RiotSettings.swift +++ b/Riot/Managers/Settings/RiotSettings.swift @@ -186,6 +186,14 @@ final class RiotSettings: NSObject { @UserDefault(key: "enableClientInformationFeature", defaultValue: false, storage: defaults) var enableClientInformationFeature + /// Flag indicating if the wysiwyg composer feature is enabled + @UserDefault(key: "enableWysiwygComposer", defaultValue: false, storage: defaults) + var enableWysiwygComposer + + /// Flag indicating if the voice broadcast feature is enabled + @UserDefault(key: "enableVoiceBroadcast", defaultValue: false, storage: defaults) + var enableVoiceBroadcast + // MARK: Calls /// Indicate if `allowStunServerFallback` settings has been set once. @@ -464,5 +472,4 @@ final class RiotSettings: NSObject { // MARK: - RiotSettings notification constants extension RiotSettings { public static let didUpdateLiveLocationSharingActivation = Notification.Name("RiotSettingsDidUpdateLiveLocationSharingActivation") - public static let newAppLayoutBetaToggleDidChange = Notification.Name("RiotSettingsNewAppLayoutBetaToggleDidChange") } diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index 665bf5113..ad17da025 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -106,8 +106,6 @@ final class AppCoordinator: NSObject, AppCoordinatorType { } } - NotificationCenter.default.addObserver(self, selector: #selector(self.newAppLayoutToggleDidChange(notification:)), name: RiotSettings.newAppLayoutBetaToggleDidChange, object: nil) - // NOTE: When split view is shown there can be no Matrix sessions ready. Keep this behavior or use a loading screen before showing the split view. self.showSplitView() MXLog.debug("[AppCoordinator] Showed split view") @@ -163,12 +161,6 @@ final class AppCoordinator: NSObject, AppCoordinatorType { ThemePublisher.shared.republish(themeIdPublisher: themeIdPublisher) } - @objc private func newAppLayoutToggleDidChange(notification: Notification) { - if BWIBuildSettings.shared.enableSideMenu { - self.addSideMenu() - } - } - private func excludeAllItemsFromBackup() { let manager = FileManager.default diff --git a/Riot/Modules/Application/LegacyAppDelegate.h b/Riot/Modules/Application/LegacyAppDelegate.h index 77f63e7da..96b0adf70 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.h +++ b/Riot/Modules/Application/LegacyAppDelegate.h @@ -196,7 +196,9 @@ UINavigationControllerDelegate - (BOOL)presentIncomingKeyVerificationRequest:(id)incomingKeyVerificationRequest inSession:(MXSession*)session; -- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember session:(MXSession*)mxSession; +- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember + session:(MXSession*)mxSession + completion:(void (^)(void))completion; - (BOOL)presentCompleteSecurityForSession:(MXSession*)mxSession; diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 43730fd5e..f2a2d9322 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -129,6 +129,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni If any the currently displayed key verification dialog */ KeyVerificationCoordinatorBridgePresenter *keyVerificationCoordinatorBridgePresenter; + + /** + Completion block for the requester of key verification + */ + void (^keyVerificationCompletionBlock)(void); /** Currently displayed secure backup setup @@ -614,6 +619,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // Analytics: Force to send the pending actions [[DecryptionFailureTracker sharedInstance] dispatch]; [Analytics.shared forceUpload]; + + // Pause Voice Broadcast recording if needed + [VoiceBroadcastRecorderProvider.shared pauseRecording]; } - (void)applicationWillEnterForeground:(UIApplication *)application @@ -2401,9 +2409,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // 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) + if (mainSession.crypto.crossSigning && mainSession.crypto.crossSigning.state == MXCrossSigningStateCrossSigningExists && [mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) { - [mainSession.crypto setOutgoingKeyRequestsEnabled:NO onComplete:nil]; + [(MXLegacyCrypto *)mainSession.crypto setOutgoingKeyRequestsEnabled:NO onComplete:nil]; } break; case MXSessionStateRunning: @@ -2617,6 +2625,12 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni - (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]) @@ -2637,7 +2651,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni { // We should have 3 of them. If not, request them again as mitigation MXLogDebug(@"[AppDelegate] checkLocalPrivateKeysInSession: request keys because keysCount = %@", @(keysCount)); - [mxSession.crypto requestAllPrivateKeys]; + [crypto requestAllPrivateKeys]; } } @@ -3596,17 +3610,24 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni 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); - [mxSession.crypto pendingKeyRequests:^(MXUsersDevicesMap *> *pendingKeyRequests) { + [crypto pendingKeyRequests:^(MXUsersDevicesMap *> *pendingKeyRequests) { MXStrongifyAndReturnIfNil(self); MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: cross-signing state: %ld, pendingKeyRequests.count: %@. Already displayed: %@", - mxSession.crypto.crossSigning.state, + crypto.crossSigning.state, @(pendingKeyRequests.count), self->roomKeyRequestViewController ? @"YES" : @"NO"); - if (!mxSession.crypto.crossSigning || mxSession.crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped) + if (!crypto.crossSigning || crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped) { if (self->roomKeyRequestViewController) { @@ -3636,13 +3657,13 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // Give the client a chance to refresh the device list MXWeakify(self); - [mxSession.crypto downloadKeys:@[userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { + [crypto downloadKeys:@[userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { MXStrongifyAndReturnIfNil(self); MXDeviceInfo *deviceInfo = [usersDevicesInfoMap objectForDevice:deviceId forUser:userId]; if (deviceInfo) { - if (!mxSession.crypto.crossSigning || mxSession.crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped) + if (!crypto.crossSigning || crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped) { BOOL wasNewDevice = (deviceInfo.trustLevel.localVerificationStatus == MXDeviceUnknown); @@ -3650,7 +3671,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni { MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Open dialog for %@", deviceInfo); - self->roomKeyRequestViewController = [[RoomKeyRequestViewController alloc] initWithDeviceInfo:deviceInfo wasNewDevice:wasNewDevice andMatrixSession:mxSession onComplete:^{ + self->roomKeyRequestViewController = [[RoomKeyRequestViewController alloc] initWithDeviceInfo:deviceInfo wasNewDevice:wasNewDevice andMatrixSession:mxSession crypto:crypto onComplete:^{ self->roomKeyRequestViewController = nil; @@ -3664,7 +3685,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // If the device was new before, it's not any more. if (wasNewDevice) { - [mxSession.crypto setDeviceVerification:MXDeviceUnverified forDevice:deviceId ofUser:userId success:openDialog failure:nil]; + [crypto setDeviceVerification:MXDeviceUnverified forDevice:deviceId ofUser:userId success:openDialog failure:nil]; } else { @@ -3673,13 +3694,13 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } else if (deviceInfo.trustLevel.isVerified) { - [mxSession.crypto acceptAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ + [crypto acceptAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ [self checkPendingRoomKeyRequests]; }]; } else { - [mxSession.crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ + [crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ [self checkPendingRoomKeyRequests]; }]; } @@ -3687,7 +3708,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni else { MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: No details found for device %@:%@", userId, deviceId); - [mxSession.crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ + [crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{ [self checkPendingRoomKeyRequests]; }]; } @@ -3720,7 +3741,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni usingBlock:^(NSNotification *notif) { NSObject *object = notif.userInfo[MXKeyVerificationManagerNotificationTransactionKey]; - if ([object isKindOfClass:MXIncomingSASTransaction.class]) + if ([object conformsToProtocol:@protocol(MXSASTransaction)] && ((id)object).isIncoming) { [self checkPendingIncomingKeyVerificationsInSession:mxSession]; } @@ -3751,9 +3772,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni for (id transaction in transactions) { - if (transaction.isIncoming) + if ([transaction conformsToProtocol:@protocol(MXSASTransaction)] && transaction.isIncoming) { - MXIncomingSASTransaction *incomingTransaction = (MXIncomingSASTransaction*)transaction; + id incomingTransaction = (id)transaction; if (incomingTransaction.state == MXSASTransactionStateIncomingShowAccept) { [self presentIncomingKeyVerification:incomingTransaction inSession:mxSession]; @@ -3797,7 +3818,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni return presented; } -- (BOOL)presentIncomingKeyVerification:(MXIncomingSASTransaction*)transaction inSession:(MXSession*)mxSession +- (BOOL)presentIncomingKeyVerification:(id)transaction inSession:(MXSession*)mxSession { MXLogDebug(@"[AppDelegate][MXKeyVerification] presentIncomingKeyVerification: %@", transaction); @@ -3818,7 +3839,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni return presented; } -- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember session:(MXSession*)mxSession +- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember + session:(MXSession*)mxSession + completion:(void (^)(void))completion; { MXLogDebug(@"[AppDelegate][MXKeyVerification] presentUserVerificationForRoomMember: %@", roomMember); @@ -3831,6 +3854,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [keyVerificationCoordinatorBridgePresenter presentFrom:self.presentedViewController roomMember:roomMember animated:YES]; presented = YES; + + keyVerificationCompletionBlock = completion; } else { @@ -3862,11 +3887,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni - (void)keyVerificationCoordinatorBridgePresenterDelegateDidComplete:(KeyVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter otherUserId:(NSString * _Nonnull)otherUserId otherDeviceId:(NSString * _Nonnull)otherDeviceId { - MXCrypto *crypto = coordinatorBridgePresenter.session.crypto; - if (!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled) + 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"); - [crypto setOutgoingKeyRequestsEnabled:YES onComplete:nil]; + [(MXLegacyCrypto *)crypto setOutgoingKeyRequestsEnabled:YES onComplete:nil]; } [self dismissKeyVerificationCoordinatorBridgePresenter]; [BWIAnalytics.sharedTracker trackEvent:@"Session" action:@"Login"]; @@ -3884,20 +3909,25 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni }]; keyVerificationCoordinatorBridgePresenter = nil; + + if (keyVerificationCompletionBlock) { + keyVerificationCompletionBlock(); + } + keyVerificationCompletionBlock = nil; } #pragma mark - New request - (void)registerNewRequestNotificationForSession:(MXSession*)session { - MXKeyVerificationManager *keyverificationManager = session.crypto.keyVerificationManager; + id keyVerificationManager = session.crypto.keyVerificationManager; - if (!keyverificationManager) + if (!keyVerificationManager) { return; } - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyVerificationNewRequestNotification:) name:MXKeyVerificationManagerNewRequestNotification object:keyverificationManager]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyVerificationNewRequestNotification:) name:MXKeyVerificationManagerNewRequestNotification object:keyVerificationManager]; } - (void)keyVerificationNewRequestNotification:(NSNotification *)notification @@ -3922,28 +3952,26 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni id keyVerificationRequest = userInfo[MXKeyVerificationManagerNotificationRequestKey]; - if ([keyVerificationRequest isKindOfClass:MXKeyVerificationByDMRequest.class]) + if (keyVerificationRequest.transport == MXKeyVerificationTransportDirectMessage) { - MXKeyVerificationByDMRequest *keyVerificationByDMRequest = (MXKeyVerificationByDMRequest*)keyVerificationRequest; - - if (!keyVerificationByDMRequest.isFromMyUser && keyVerificationByDMRequest.state == MXKeyVerificationRequestStatePending) + if (!keyVerificationRequest.isFromMyUser && keyVerificationRequest.state == MXKeyVerificationRequestStatePending) { MXKAccount *currentAccount = [MXKAccountManager sharedManager].activeAccounts.firstObject; MXSession *session = currentAccount.mxSession; - MXRoom *room = [currentAccount.mxSession roomWithRoomId:keyVerificationByDMRequest.roomId]; + MXRoom *room = [currentAccount.mxSession roomWithRoomId:keyVerificationRequest.roomId]; if (!room) { MXLogDebug(@"[AppDelegate][KeyVerification] keyVerificationRequestDidChangeNotification: Unknown room"); return; } - NSString *sender = keyVerificationByDMRequest.otherUser; + NSString *sender = keyVerificationRequest.otherUser; [room state:^(MXRoomState *roomState) { NSString *senderName = [roomState.members memberName:sender]; - [self presentNewKeyVerificationRequestAlertForSession:session senderName:senderName senderId:sender request:keyVerificationByDMRequest]; + [self presentNewKeyVerificationRequestAlertForSession:session senderName:senderName senderId:sender request:keyVerificationRequest]; }]; } } @@ -3980,7 +4008,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // This happens when they or our user do not have cross-signing enabled MXLogDebug(@"[AppDelegate][KeyVerification] keyVerificationNewRequestNotification: Device verification from other user %@:%@", keyVerificationRequest.otherUser, keyVerificationRequest.otherDevice); - NSString *myUserId = ((MXKeyVerificationByToDeviceRequest*)keyVerificationRequest).to; + NSString *myUserId = keyVerificationRequest.myUserId; NSString *userId = keyVerificationRequest.otherUser; MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:myUserId]; if (account) @@ -4105,7 +4133,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni - (void)registerUserDidSignInOnNewDeviceNotificationForSession:(MXSession*)session { - MXCrossSigning *crossSigning = session.crypto.crossSigning; + id crossSigning = session.crypto.crossSigning; if (!crossSigning) { @@ -4196,7 +4224,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni - (void)registerDidChangeCrossSigningKeysNotificationForSession:(MXSession*)session { - MXCrossSigning *crossSigning = session.crypto.crossSigning; + id crossSigning = session.crypto.crossSigning; if (!crossSigning) { diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 19316a4a1..5aa6b3731 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -336,6 +336,9 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc password = loginPassword authenticationType = .password onSessionCreated(session: session, flow: .login) + case .loggedInWithQRCode(let session, let securityCompleted): + authenticationType = .other + onSessionCreated(session: session, flow: .login, securityCompleted: securityCompleted) case .fallback: showFallback(for: .login) } @@ -522,9 +525,15 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } /// Handles the creation of a new session following on from a successful authentication. - @MainActor private func onSessionCreated(session: MXSession, flow: AuthenticationFlow) { + @MainActor private func onSessionCreated(session: MXSession, flow: AuthenticationFlow, securityCompleted: Bool = false) { self.session = session + guard !securityCompleted else { + callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: authenticationType ?? .other)) + callback?(.didComplete) + return + } + if canPresentAdditionalScreens { showLoadingAnimation() } @@ -749,8 +758,8 @@ extension AuthenticationCoordinator: AuthenticationServiceDelegate { // MARK: - KeyVerificationCoordinatorDelegate extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { - if let crypto = session?.crypto, - !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { + 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) } @@ -801,5 +810,4 @@ extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate { func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) { dismissFallback() } - } diff --git a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift index ad5e61b24..a50194de0 100644 --- a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift @@ -219,8 +219,8 @@ extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate // MARK: - KeyVerificationCoordinatorDelegate extension LegacyAuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { - if let crypto = session?.crypto, - !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { + 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) } diff --git a/Riot/Modules/Authentication/SessionVerificationListener.swift b/Riot/Modules/Authentication/SessionVerificationListener.swift index 7d5d7f48b..214c76695 100644 --- a/Riot/Modules/Authentication/SessionVerificationListener.swift +++ b/Riot/Modules/Authentication/SessionVerificationListener.swift @@ -69,7 +69,7 @@ class SessionVerificationListener { } if session.state == .storeDataReady { - if let crypto = session.crypto, crypto.crossSigning != nil { + 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 @@ -78,7 +78,8 @@ class SessionVerificationListener { } else if session.state == .running { unregisterSessionStateChangeNotification() - if let crypto = session.crypto, let crossSigning = crypto.crossSigning { + if let crypto = session.crypto { + let crossSigning = crypto.crossSigning crossSigning.refreshState { [weak self] stateUpdated in guard let self = self else { return } @@ -100,7 +101,7 @@ class SessionVerificationListener { self.completion?(.authenticationIsComplete) } failure: { error in MXLog.error("[SessionVerificationListener] sessionStateDidChange: Bootstrap failed", context: error) - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } } else { @@ -110,12 +111,12 @@ class SessionVerificationListener { self.completion?(.authenticationIsComplete) } failure: { error in MXLog.error("[SessionVerificationListener] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } } } else { - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.completion?(.authenticationIsComplete) } case .crossSigningExists: @@ -124,12 +125,12 @@ class SessionVerificationListener { default: MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Nothing to do") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + (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.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + (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 71c52ba72..a8019c06c 100644 --- a/Riot/Modules/Call/CallViewController.m +++ b/Riot/Modules/Call/CallViewController.m @@ -373,7 +373,12 @@ CallAudioRouteMenuViewDelegate> // Acknowledge the existence of all devices [self startActivityIndicator]; - [self.mainSession.crypto setDevicesKnown:unknownDevices complete:^{ + if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) + { + MXLogFailure(@"[CallViewController] call: Only legacy crypto supports manual setting of known devices"); + return; + } + [(MXLegacyCrypto *)self.mainSession.crypto setDevicesKnown:unknownDevices complete:^{ [self stopActivityIndicator]; diff --git a/Riot/Modules/Camera/CameraAccessManager.swift b/Riot/Modules/Camera/CameraAccessManager.swift index 93cacaafc..628bbc227 100644 --- a/Riot/Modules/Camera/CameraAccessManager.swift +++ b/Riot/Modules/Camera/CameraAccessManager.swift @@ -48,6 +48,22 @@ final class CameraAccessManager { break } } + + /// Checks and requests the camera access if needed. Returns `true` if granted, otherwise `false`. + func requestCameraAccessIfNeeded() async -> Bool { + let authStatus = AVCaptureDevice.authorizationStatus(for: .video) + + switch authStatus { + case .authorized: + return true + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: .video) + case .denied, .restricted: + return false + @unknown default: + return false + } + } // MARK: - Private diff --git a/Riot/Modules/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/AvatarView.swift index abb6707f0..0febb3f51 100644 --- a/Riot/Modules/Common/Avatar/AvatarView.swift +++ b/Riot/Modules/Common/Avatar/AvatarView.swift @@ -117,19 +117,9 @@ class AvatarView: UIView, Themable { } } - let defaultAvatarImage: UIImage? - var defaultAvatarImageContentMode: UIView.ContentMode = .scaleAspectFill + let (defaultAvatarImage, defaultAvatarImageContentMode) = viewData.fallbackImageParameters() ?? (nil, .scaleAspectFill) + updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode) - switch viewData.fallbackImage { - case .matrixItem(let matrixItemId, let matrixItemDisplayName): - defaultAvatarImage = AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName) - case .image(let image, let contentMode): - defaultAvatarImage = image - defaultAvatarImageContentMode = contentMode ?? .scaleAspectFill - case .none: - defaultAvatarImage = nil - } - if let avatarUrl = viewData.avatarUrl { avatarImageView.setImageURI(avatarUrl, withType: nil, @@ -138,12 +128,9 @@ class AvatarView: UIView, Themable { with: MXThumbnailingMethodScale, previewImage: defaultAvatarImage, mediaManager: viewData.mediaManager) - avatarImageView.contentMode = .scaleAspectFill - avatarImageView.imageView?.contentMode = .scaleAspectFill + updateAvatarContentMode(contentMode: .scaleAspectFill) } else { - avatarImageView.image = defaultAvatarImage - avatarImageView.contentMode = defaultAvatarImageContentMode - avatarImageView.imageView?.contentMode = defaultAvatarImageContentMode + updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode) } } @@ -159,6 +146,16 @@ class AvatarView: UIView, Themable { gestureRecognizer.minimumPressDuration = 0 self.addGestureRecognizer(gestureRecognizer) } + + private func updateAvatarImageView(image: UIImage?, contentMode: UIView.ContentMode) { + avatarImageView?.image = image + updateAvatarContentMode(contentMode: contentMode) + } + + private func updateAvatarContentMode(contentMode: UIView.ContentMode) { + avatarImageView?.contentMode = contentMode + avatarImageView?.imageView?.contentMode = contentMode + } // MARK: - Actions diff --git a/Riot/Modules/Common/Avatar/AvatarViewData.swift b/Riot/Modules/Common/Avatar/AvatarViewData.swift index ef5cbb89c..88eb47a07 100644 --- a/Riot/Modules/Common/Avatar/AvatarViewData.swift +++ b/Riot/Modules/Common/Avatar/AvatarViewData.swift @@ -29,6 +29,21 @@ struct AvatarViewData: AvatarViewDataProtocol { /// Matrix media handler if exists var mediaManager: MXMediaManager? - /// Fallback image used when avatarUrl is nil - var fallbackImage: AvatarFallbackImage? + /// Fallback images used when avatarUrl is nil + var fallbackImages: [AvatarFallbackImage]? +} + +extension AvatarViewData { + init(matrixItemId: String, + displayName: String? = nil, + avatarUrl: String? = nil, + mediaManager: MXMediaManager? = nil, + fallbackImage: AvatarFallbackImage?) { + + self.matrixItemId = matrixItemId + self.displayName = displayName + self.avatarUrl = avatarUrl + self.mediaManager = mediaManager + self.fallbackImages = fallbackImage.map { [$0] } + } } diff --git a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift index 9b677e581..f3410783b 100644 --- a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift +++ b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift @@ -41,6 +41,24 @@ protocol AvatarViewDataProtocol: AvatarProtocol { /// Matrix media handler var mediaManager: MXMediaManager? { get } - /// Fallback image used when avatarUrl is nil - var fallbackImage: AvatarFallbackImage? { get } + /// Fallback images used when avatarUrl is nil + var fallbackImages: [AvatarFallbackImage]? { get } +} + +extension AvatarViewDataProtocol { + func fallbackImageParameters() -> (UIImage?, UIView.ContentMode)? { + fallbackImages? + .lazy + .map { fallbackImage in + switch fallbackImage { + case .matrixItem(let matrixItemId, let matrixItemDisplayName): + return (AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName), .scaleAspectFill) + case .image(let image, let contentMode): + return (image, contentMode ?? .scaleAspectFill) + } + } + .first { (image, contentMode) in + image != nil + } + } } diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index 740019d06..da2c5d6df 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -16,6 +16,7 @@ import Foundation import SwiftUI +import Combine /** UIHostingController that applies some app-level specific configuration @@ -24,8 +25,9 @@ import SwiftUI class VectorHostingController: UIHostingController { // MARK: Private - + private var theme: Theme + private var heightSubject = CurrentValueSubject(0) // MARK: Public @@ -40,19 +42,20 @@ class VectorHostingController: UIHostingController { var enableNavigationBarScrollEdgeAppearance = false /// When non-nil, the style will be applied to the status bar. var statusBarStyle: UIStatusBarStyle? - - private let forceZeroSafeAreaInsets: Bool + /// Whether or not to publish when the height of the view changes. + var publishHeightChanges: Bool = false + /// The publisher to subscribe to if `publishHeightChanges` is enabled. + var heightPublisher: AnyPublisher { + return heightSubject.eraseToAnyPublisher() + } override var preferredStatusBarStyle: UIStatusBarStyle { statusBarStyle ?? super.preferredStatusBarStyle } /// Initializer /// - Parameter rootView: Root view for the controller. - /// - Parameter forceZeroSafeAreaInsets: Whether to force-set the hosting view's safe area insets to zero. Useful when the view is used as part of a table view. - init(rootView: Content, - forceZeroSafeAreaInsets: Bool = false) where Content: View { + init(rootView: Content) where Content: View { self.theme = ThemeService.shared().theme - self.forceZeroSafeAreaInsets = forceZeroSafeAreaInsets super.init(rootView: AnyView(rootView.vectorContent())) } @@ -88,9 +91,13 @@ class VectorHostingController: UIHostingController { override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - if let navigationController = navigationController, navigationController.isNavigationBarHidden != isNavigationBarHidden { - navigationController.isNavigationBarHidden = isNavigationBarHidden - } + guard + let navigationController = navigationController, + navigationController.topViewController == self, + navigationController.isNavigationBarHidden != isNavigationBarHidden + else { return } + + navigationController.isNavigationBarHidden = isNavigationBarHidden } override func viewDidLayoutSubviews() { @@ -100,21 +107,9 @@ class VectorHostingController: UIHostingController { if #available(iOS 15.0, *) { self.view.invalidateIntrinsicContentSize() } - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - - guard forceZeroSafeAreaInsets else { - return - } - - let counterSafeAreaInsets = UIEdgeInsets(top: -view.safeAreaInsets.top, - left: -view.safeAreaInsets.left, - bottom: -view.safeAreaInsets.bottom, - right: -view.safeAreaInsets.right) - if additionalSafeAreaInsets != counterSafeAreaInsets, counterSafeAreaInsets != .zero { - additionalSafeAreaInsets = counterSafeAreaInsets + if publishHeightChanges { + let height = sizeThatFits(in: CGSize(width: self.view.frame.width, height: UIView.layoutFittingExpandedSize.height)).height + heightSubject.send(height) } } diff --git a/Riot/Modules/CrossSigning/CrossSigningService.swift b/Riot/Modules/CrossSigning/CrossSigningService.swift index ab40e6369..c4773581b 100644 --- a/Riot/Modules/CrossSigning/CrossSigningService.swift +++ b/Riot/Modules/CrossSigning/CrossSigningService.swift @@ -85,7 +85,7 @@ final class CrossSigningService: NSObject { @discardableResult func setupCrossSigningWithoutAuthentication(for session: MXSession, success: @escaping (() -> Void), failure: @escaping ((Error) -> Void)) -> MXHTTPOperation? { - guard let crossSigning = session.crypto.crossSigning else { + guard let crossSigning = session.crypto?.crossSigning else { failure(CrossSigningServiceError.unknown) return nil } diff --git a/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift b/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift index f545b2e44..2877de09d 100644 --- a/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift +++ b/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift @@ -72,7 +72,7 @@ final class CrossSigningSetupCoordinator: CrossSigningSetupCoordinatorType { } private func setupCrossSigning(with authenticationParameters: [String: Any]) { - guard let crossSigning = self.parameters.session.crypto.crossSigning else { + guard let crossSigning = self.parameters.session.crypto?.crossSigning else { return } diff --git a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift index fca7c46ed..fa8bcf173 100644 --- a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift +++ b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift @@ -74,7 +74,7 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } private var indicators = [UserIndicator]() - private var signOutAlertPresenter = SignOutAlertPresenter() + private var signOutFlowPresenter: SignOutFlowPresenter? // MARK: Public @@ -107,8 +107,6 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { // If start has been done once do not setup view controllers again if self.hasStartedOnce == false { - signOutAlertPresenter.delegate = self - let allChatsViewController = AllChatsViewController.instantiate() allChatsViewController.allChatsDelegate = self allChatsViewController.userIndicatorStore = UserIndicatorStore(presenter: indicatorPresenter) @@ -342,7 +340,7 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { createAvatarButtonItem(for: viewController) } - private func createAvatarButtonItem(for viewController: UIViewController) { + private var avatarMenu: UIMenu { var actions: [UIMenuElement] = [] actions.append(UIAction(title: VectorL10n.allChatsUserMenuSettings, image: UIImage(systemName: "gearshape")) { [weak self] action in @@ -368,32 +366,30 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } ])) - let menu = UIMenu(options: .displayInline, children: actions) - + return UIMenu(options: .displayInline, children: actions) + } + + private func createAvatarButtonItem(for viewController: UIViewController) { let view = UIView(frame: CGRect(x: 0, y: 0, width: 36, height: 36)) view.backgroundColor = .clear - let button: UIButton = UIButton(frame: view.bounds.inset(by: UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7))) + let avatarInsets: UIEdgeInsets = .init(top: 7, left: 7, bottom: 7, right: 7) + let button: UIButton = .init(frame: view.bounds.inset(by: avatarInsets)) button.setImage(Asset.Images.tabPeople.image, for: .normal) - button.menu = menu + button.menu = avatarMenu button.showsMenuAsPrimaryAction = true button.autoresizingMask = [.flexibleHeight, .flexibleWidth] button.accessibilityLabel = VectorL10n.allChatsUserMenuAccessibilityLabel view.addSubview(button) self.avatarMenuButton = button - let avatarView = UserAvatarView(frame: view.bounds.inset(by: UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7))) + let avatarView = UserAvatarView(frame: view.bounds.inset(by: avatarInsets)) avatarView.isUserInteractionEnabled = false avatarView.update(theme: ThemeService.shared().theme) avatarView.autoresizingMask = [.flexibleHeight, .flexibleWidth] view.addSubview(avatarView) self.avatarMenuView = avatarView - - if let avatar = userAvatarViewData(from: currentMatrixSession) { - avatarView.fill(with: avatar) - button.setImage(nil, for: .normal) - } - + updateAvatarButtonItem() viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: view) } @@ -588,87 +584,16 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { // MARK: Sign out process private func signOut() { - guard let keyBackup = currentMatrixSession?.crypto.backup else { - return - } - - signOutAlertPresenter.present(for: keyBackup.state, - areThereKeysToBackup: keyBackup.hasKeysToBackup, - from: self.allChatsViewController, - sourceView: avatarMenuButton, - animated: true) - } - - // MARK: - SecureBackupSetupCoordinatorBridgePresenter - - private var secureBackupSetupCoordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter? - private var crossSigningSetupCoordinatorBridgePresenter: CrossSigningSetupCoordinatorBridgePresenter? - - private func showSecureBackupSetupFromSignOutFlow() { - if canSetupSecureBackup { - setupSecureBackup2() - } else { - // Set up cross-signing first - setupCrossSigning(title: VectorL10n.secureKeyBackupSetupIntroTitle, - message: VectorL10n.securitySettingsUserPasswordDescription) { [weak self] result in - switch result { - case .success(let isCompleted): - if isCompleted { - self?.setupSecureBackup2() - } - case .failure: - break - } - } - } - } - - private var canSetupSecureBackup: Bool { - return currentMatrixSession?.vc_canSetupSecureBackup() ?? false - } - - private func setupSecureBackup2() { guard let session = currentMatrixSession else { + MXLog.warning("[AllChatsCoordinator] Unable to sign out due to missing current session.") return } - let secureBackupSetupCoordinatorBridgePresenter = SecureBackupSetupCoordinatorBridgePresenter(session: session, allowOverwrite: true) - secureBackupSetupCoordinatorBridgePresenter.delegate = self - secureBackupSetupCoordinatorBridgePresenter.present(from: allChatsViewController, animated: true) - self.secureBackupSetupCoordinatorBridgePresenter = secureBackupSetupCoordinatorBridgePresenter - } - - private func setupCrossSigning(title: String, message: String, completion: @escaping (Result) -> Void) { - guard let session = currentMatrixSession else { - return - } - - allChatsViewController.startActivityIndicator() - allChatsViewController.view.isUserInteractionEnabled = false + let flowPresenter = SignOutFlowPresenter(session: session, presentingViewController: toPresentable()) + flowPresenter.delegate = self - let dismissAnimation = { [weak self] in - guard let self = self else { return } - - self.allChatsViewController.stopActivityIndicator() - self.allChatsViewController.view.isUserInteractionEnabled = true - self.crossSigningSetupCoordinatorBridgePresenter?.dismiss(animated: true, completion: { - self.crossSigningSetupCoordinatorBridgePresenter = nil - }) - } - - let crossSigningSetupCoordinatorBridgePresenter = CrossSigningSetupCoordinatorBridgePresenter(session: session) - crossSigningSetupCoordinatorBridgePresenter.present(with: title, message: message, from: allChatsViewController, animated: true) { - dismissAnimation() - completion(.success(true)) - } cancel: { - dismissAnimation() - completion(.success(false)) - } failure: { error in - dismissAnimation() - completion(.failure(error)) - } - - self.crossSigningSetupCoordinatorBridgePresenter = crossSigningSetupCoordinatorBridgePresenter + flowPresenter.start(sourceView: avatarMenuButton) + self.signOutFlowPresenter = flowPresenter } // MARK: - Private methods @@ -728,42 +653,21 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { viewController.loadViewIfNeeded() return viewController } - } -// MARK: - SignOutAlertPresenterDelegate -extension AllChatsCoordinator: SignOutAlertPresenterDelegate { - - func signOutAlertPresenterDidTapSignOutAction(_ presenter: SignOutAlertPresenter) { - // Prevent user to perform user interaction in settings when sign out - // TODO: Prevent user interaction in all application (navigation controller and split view controller included) +extension AllChatsCoordinator: SignOutFlowPresenterDelegate { + func signOutFlowPresenterDidStartLoading(_ presenter: SignOutFlowPresenter) { allChatsViewController.view.isUserInteractionEnabled = false allChatsViewController.startActivityIndicator() - - AppDelegate.theDelegate().logout(withConfirmation: false) { [weak self] isLoggedOut in - self?.allChatsViewController.stopActivityIndicator() - self?.allChatsViewController.view.isUserInteractionEnabled = true - } } - func signOutAlertPresenterDidTapBackupAction(_ presenter: SignOutAlertPresenter) { - showSecureBackupSetupFromSignOutFlow() + func signOutFlowPresenterDidStopLoading(_ presenter: SignOutFlowPresenter) { + allChatsViewController.view.isUserInteractionEnabled = true + allChatsViewController.stopActivityIndicator() } -} - -// MARK: - SecureBackupSetupCoordinatorBridgePresenterDelegate -extension AllChatsCoordinator: SecureBackupSetupCoordinatorBridgePresenterDelegate { - func secureBackupSetupCoordinatorBridgePresenterDelegateDidCancel(_ coordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter) { - coordinatorBridgePresenter.dismiss(animated: true) { - self.secureBackupSetupCoordinatorBridgePresenter = nil - } - } - - func secureBackupSetupCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter) { - coordinatorBridgePresenter.dismiss(animated: true) { - self.secureBackupSetupCoordinatorBridgePresenter = nil - } + func signOutFlowPresenter(_ presenter: SignOutFlowPresenter, didFailWith error: Error) { + AppDelegate.theDelegate().showError(asAlert: error) } } diff --git a/Riot/Modules/Home/AllChats/AllChatsViewController.swift b/Riot/Modules/Home/AllChats/AllChatsViewController.swift index 88a973557..ec8b75652 100644 --- a/Riot/Modules/Home/AllChats/AllChatsViewController.swift +++ b/Riot/Modules/Home/AllChats/AllChatsViewController.swift @@ -439,7 +439,7 @@ class AllChatsViewController: HomeViewController { } override func shouldShowEmptyView() -> Bool { - let shouldShowEmptyView = super.shouldShowEmptyView() + let shouldShowEmptyView = super.shouldShowEmptyView() && !AllChatsLayoutSettingsManager.shared.hasAnActiveFilter if shouldShowEmptyView { self.navigationItem.searchController = nil diff --git a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift index d7d0a9a31..82404834e 100644 --- a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift +++ b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift @@ -22,7 +22,7 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType { // MARK: Private - private let session: MXSession + private let keyBackup: MXKeyBackup private let navigationRouter: NavigationRouterType private let keyBackupVersion: MXKeyBackupVersion @@ -34,8 +34,8 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType { // MARK: - Setup - init(session: MXSession, keyBackupVersion: MXKeyBackupVersion, navigationRouter: NavigationRouterType? = nil) { - self.session = session + init(keyBackup: MXKeyBackup, keyBackupVersion: MXKeyBackupVersion, navigationRouter: NavigationRouterType? = nil) { + self.keyBackup = keyBackup self.keyBackupVersion = keyBackupVersion if let navigationRouter = navigationRouter { @@ -52,7 +52,7 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType { let rootCoordinator: Coordinator & Presentable // Check if we have the private key locally - if self.session.crypto.backup.hasPrivateKeyInCryptoStore { + if keyBackup.hasPrivateKeyInCryptoStore { rootCoordinator = self.createRecoverFromPrivateKeyCoordinator() } else { rootCoordinator = self.createRecoverWithUserInteractionCoordinator() @@ -93,19 +93,19 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType { } private func createRecoverFromPrivateKeyCoordinator() -> KeyBackupRecoverFromPrivateKeyCoordinator { - let coordinator = KeyBackupRecoverFromPrivateKeyCoordinator(keyBackup: self.session.crypto.backup, keyBackupVersion: self.keyBackupVersion) + let coordinator = KeyBackupRecoverFromPrivateKeyCoordinator(keyBackup: keyBackup, keyBackupVersion: self.keyBackupVersion) coordinator.delegate = self return coordinator } private func createRecoverFromPassphraseCoordinator() -> KeyBackupRecoverFromPassphraseCoordinator { - let coordinator = KeyBackupRecoverFromPassphraseCoordinator(keyBackup: self.session.crypto.backup, keyBackupVersion: self.keyBackupVersion) + let coordinator = KeyBackupRecoverFromPassphraseCoordinator(keyBackup: keyBackup, keyBackupVersion: self.keyBackupVersion) coordinator.delegate = self return coordinator } private func createRecoverFromRecoveryKeyCoordinator() -> KeyBackupRecoverFromRecoveryKeyCoordinator { - let coordinator = KeyBackupRecoverFromRecoveryKeyCoordinator(keyBackup: self.session.crypto.backup, keyBackupVersion: self.keyBackupVersion) + let coordinator = KeyBackupRecoverFromRecoveryKeyCoordinator(keyBackup: keyBackup, keyBackupVersion: self.keyBackupVersion) coordinator.delegate = self return coordinator } diff --git a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift index a06e9befd..2d5be4578 100644 --- a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift +++ b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift @@ -49,7 +49,12 @@ final class KeyBackupRecoverCoordinatorBridgePresenter: NSObject { // MARK: - Public func present(from viewController: UIViewController, animated: Bool) { - let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(session: self.session, keyBackupVersion: keyBackupVersion) + guard let keyBackup = session.crypto?.backup else { + MXLog.failure("[KeyBackupRecoverCoordinatorBridgePresenter] Cannot setup backups without backup module") + return + } + + let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(keyBackup: keyBackup, keyBackupVersion: keyBackupVersion) keyBackupSetupCoordinator.delegate = self viewController.present(keyBackupSetupCoordinator.toPresentable(), animated: animated, completion: nil) keyBackupSetupCoordinator.start() @@ -58,12 +63,16 @@ final class KeyBackupRecoverCoordinatorBridgePresenter: NSObject { } func push(from navigationController: UINavigationController, animated: Bool) { + guard let keyBackup = session.crypto?.backup else { + MXLog.failure("[KeyBackupRecoverCoordinatorBridgePresenter] Cannot setup backups without backup module") + return + } MXLog.debug("[KeyBackupRecoverCoordinatorBridgePresenter] Push complete security from \(navigationController)") let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) - let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(session: self.session, keyBackupVersion: keyBackupVersion, navigationRouter: navigationRouter) + let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(keyBackup: keyBackup, keyBackupVersion: keyBackupVersion, navigationRouter: navigationRouter) keyBackupSetupCoordinator.delegate = self keyBackupSetupCoordinator.start() // Will trigger view controller push diff --git a/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift b/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift index 03171ebd2..aab964c2f 100644 --- a/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift +++ b/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift @@ -66,7 +66,7 @@ final class KeyBackupSetupCoordinator: KeyBackupSetupCoordinatorType { private func createSetupIntroViewController() -> KeyBackupSetupIntroViewController { - let backupState = self.session.crypto.backup?.state ?? MXKeyBackupStateUnknown + let backupState = self.session.crypto?.backup?.state ?? MXKeyBackupStateUnknown let isABackupAlreadyExists: Bool switch backupState { @@ -99,7 +99,12 @@ final class KeyBackupSetupCoordinator: KeyBackupSetupCoordinatorType { } private func showSetupPassphrase(animated: Bool) { - let keyBackupSetupPassphraseCoordinator = KeyBackupSetupPassphraseCoordinator(session: self.session) + guard let keyBackup = self.session.crypto?.backup else { + MXLog.failure("[KeyBackupSetupCoordinator] Cannot setup backups without backup module") + return + } + + let keyBackupSetupPassphraseCoordinator = KeyBackupSetupPassphraseCoordinator(keyBackup: keyBackup) keyBackupSetupPassphraseCoordinator.delegate = self keyBackupSetupPassphraseCoordinator.start() @@ -130,7 +135,7 @@ final class KeyBackupSetupCoordinator: KeyBackupSetupCoordinatorType { } private func createKeyBackupUsingSecureBackup(privateKey: Data, completion: @escaping (Result) -> Void) { - guard let keyBackup = session.crypto.backup, let recoveryService = session.crypto.recoveryService else { + guard let keyBackup = session.crypto?.backup, let recoveryService = session.crypto?.recoveryService else { return } diff --git a/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift b/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift index ea0d2b549..f9c0342be 100644 --- a/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift +++ b/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift @@ -23,7 +23,6 @@ final class KeyBackupSetupPassphraseCoordinator: KeyBackupSetupPassphraseCoordin // MARK: Private - private let session: MXSession private var keyBackupSetupPassphraseViewModel: KeyBackupSetupPassphraseViewModelType private let keyBackupSetupPassphraseViewController: KeyBackupSetupPassphraseViewController @@ -35,10 +34,8 @@ final class KeyBackupSetupPassphraseCoordinator: KeyBackupSetupPassphraseCoordin // MARK: - Setup - init(session: MXSession) { - self.session = session - - let keyBackupSetupPassphraseViewModel = KeyBackupSetupPassphraseViewModel(keyBackup: self.session.crypto.backup) + init(keyBackup: MXKeyBackup) { + let keyBackupSetupPassphraseViewModel = KeyBackupSetupPassphraseViewModel(keyBackup: keyBackup) let keyBackupSetupPassphraseViewController = KeyBackupSetupPassphraseViewController.instantiate(with: keyBackupSetupPassphraseViewModel) self.keyBackupSetupPassphraseViewModel = keyBackupSetupPassphraseViewModel self.keyBackupSetupPassphraseViewController = keyBackupSetupPassphraseViewController diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift index e6fa2f6c9..555c392c1 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift @@ -252,7 +252,7 @@ final class KeyVerificationCoordinator: KeyVerificationCoordinatorType { } } - private func showIncoming(otherUser: MXUser, transaction: MXIncomingSASTransaction) { + private func showIncoming(otherUser: MXUser, transaction: MXSASTransaction) { let coordinator = DeviceVerificationIncomingCoordinator(session: self.session, otherUser: otherUser, transaction: transaction) coordinator.delegate = self coordinator.start() @@ -336,12 +336,8 @@ extension KeyVerificationCoordinator: KeyVerificationDataLoadingCoordinatorDeleg // MARK: - DeviceVerificationStartCoordinatorDelegate extension KeyVerificationCoordinator: DeviceVerificationStartCoordinatorDelegate { - func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) { - self.showVerifyBySAS(transaction: transaction, animated: true) - } - - func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didTransactionCancelled transaction: MXSASTransaction) { - self.didCancel() + func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, otherDidAcceptRequest request: MXKeyVerificationRequest) { + self.showVerifyByScanning(keyVerificationRequest: request, animated: true) } func deviceVerificationStartCoordinatorDidCancel(_ coordinator: DeviceVerificationStartCoordinatorType) { @@ -441,7 +437,7 @@ extension KeyVerificationCoordinator: KeyVerificationSelfVerifyWaitCoordinatorDe self.showVerifyByScanning(keyVerificationRequest: keyVerificationRequest, animated: true) } - func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) { + func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) { self.showVerifyBySAS(transaction: incomingSASTransaction, animated: true) } diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift index f07ea75a2..6b81e87ae 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift @@ -74,7 +74,7 @@ final class KeyVerificationCoordinatorBridgePresenter: NSObject { self.present(coordinator: keyVerificationCoordinator, from: viewController, animated: animated) } - func present(from viewController: UIViewController, incomingTransaction: MXIncomingSASTransaction, animated: Bool) { + func present(from viewController: UIViewController, incomingTransaction: MXSASTransaction, animated: Bool) { MXLog.debug("[KeyVerificationCoordinatorBridgePresenter] Present incoming verification from \(viewController)") diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift index 1d7d4612f..57cd6e30e 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift @@ -28,5 +28,5 @@ enum KeyVerificationFlow { case verifyDevice(userId: String, deviceId: String) case completeSecurity(_ isNewSignIn: Bool) case incomingRequest(_ request: MXKeyVerificationRequest) - case incomingSASTransaction(_ transaction: MXIncomingSASTransaction) + case incomingSASTransaction(_ transaction: MXSASTransaction) } diff --git a/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift b/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift index b2874db8f..7db1624c9 100644 --- a/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift +++ b/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift @@ -19,7 +19,6 @@ import Foundation enum KeyVerificationDataLoadingViewModelError: Error { - case unknown case transactionCancelled case transactionCancelledByMe(reason: MXTransactionCancelCode) } @@ -137,9 +136,7 @@ final class KeyVerificationDataLoadingViewModel: KeyVerificationDataLoadingViewM return } - let finalError = error ?? KeyVerificationDataLoadingViewModelError.unknown - - sself.update(viewState: .error(finalError)) + sself.update(viewState: .error(error)) }) } else { diff --git a/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift b/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift index fe54a2467..284ea3276 100644 --- a/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift +++ b/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift @@ -102,7 +102,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca self.update(viewState: .loaded(viewData: viewData)) - self.registerTransactionDidStateChangeNotification() + self.registerDidStateChangeNotification() } private func canShowScanAction(from verificationMethods: [String]) -> Bool { @@ -112,7 +112,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca private func cancel() { self.cancelQRCodeTransaction() self.keyVerificationRequest.cancel(with: MXTransactionCancelCode.user(), success: nil, failure: nil) - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModelDidCancel(self) } @@ -148,7 +148,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModel(self, didScanOtherQRCodeData: scannedQRCodeData, withTransaction: qrCodeTransaction) } @@ -176,7 +176,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca // Check due to legacy implementation of key verification which could pass incorrect type of transaction if keyVerificationTransaction is MXIncomingSASTransaction { MXLog.debug("[KeyVerificationVerifyByScanningViewModel] SAS transaction should be outgoing") - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .error(KeyVerificationVerifyByScanningViewModelError.unknown)) } @@ -191,14 +191,27 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca // MARK: - MXKeyVerificationTransactionDidChange - private func registerTransactionDidStateChangeNotification() { + private func registerDidStateChangeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(requestDidStateChange(notification:)), name: .MXKeyVerificationRequestDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: .MXKeyVerificationTransactionDidChange, object: nil) } - private func unregisterTransactionDidStateChangeNotification() { + private func unregisterDidStateChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationRequestDidChange, object: nil) NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationTransactionDidChange, object: nil) } + @objc private func requestDidStateChange(notification: Notification) { + guard let request = notification.object as? MXKeyVerificationRequest else { + return + } + + if request.state == MXKeyVerificationRequestStateCancelled, let reason = request.reasonCancelCode { + self.unregisterDidStateChangeNotification() + self.update(viewState: .cancelled(cancelCode: reason, verificationKind: verificationKind)) + } + } + @objc private func transactionDidStateChange(notification: Notification) { guard let transaction = notification.object as? MXKeyVerificationTransaction else { return @@ -219,19 +232,19 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca private func sasTransactionDidStateChange(_ transaction: MXSASTransaction) { switch transaction.state { case MXSASTransactionStateShowSAS: - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModel(self, didStartSASVerificationWithTransaction: transaction) case MXSASTransactionStateCancelled: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelled(cancelCode: reason, verificationKind: verificationKind)) case MXSASTransactionStateCancelledByMe: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelledByMe(reason)) default: break @@ -242,22 +255,22 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca switch transaction.state { case .verified: // Should not happen - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModelDidCancel(self) case .qrScannedByOther: - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModel(self, qrCodeDidScannedByOtherWithTransaction: transaction) case .cancelled: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelled(cancelCode: reason, verificationKind: verificationKind)) case .cancelledByMe: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelledByMe(reason)) default: break diff --git a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift index 2240255d9..f55f74970 100644 --- a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift +++ b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift @@ -38,7 +38,7 @@ final class DeviceVerificationIncomingCoordinator: DeviceVerificationIncomingCoo // MARK: - Setup - init(session: MXSession, otherUser: MXUser, transaction: MXIncomingSASTransaction) { + init(session: MXSession, otherUser: MXUser, transaction: MXSASTransaction) { self.session = session let deviceVerificationIncomingViewModel = DeviceVerificationIncomingViewModel(session: self.session, otherUser: otherUser, transaction: transaction) diff --git a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift index 69a324221..2ea84bbee 100644 --- a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift +++ b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift @@ -25,7 +25,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM // MARK: Private private let session: MXSession - private let transaction: MXIncomingSASTransaction + private let transaction: MXSASTransaction // MARK: Public @@ -41,7 +41,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM // MARK: - Setup - init(session: MXSession, otherUser: MXUser, transaction: MXIncomingSASTransaction) { + init(session: MXSession, otherUser: MXUser, transaction: MXSASTransaction) { self.session = session self.transaction = transaction self.userId = otherUser.userId @@ -83,7 +83,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM // MARK: - MXKeyVerificationTransactionDidChange - private func registerTransactionDidStateChangeNotification(transaction: MXIncomingSASTransaction) { + private func registerTransactionDidStateChangeNotification(transaction: MXSASTransaction) { NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: NSNotification.Name.MXKeyVerificationTransactionDidChange, object: transaction) } @@ -92,7 +92,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM } @objc private func transactionDidStateChange(notification: Notification) { - guard let transaction = notification.object as? MXIncomingSASTransaction else { + guard let transaction = notification.object as? MXSASTransaction, transaction.isIncoming else { return } diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift index 407830a47..df2d8dbbc 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift @@ -69,7 +69,7 @@ extension KeyVerificationSelfVerifyWaitCoordinator: KeyVerificationSelfVerifyWai self.delegate?.keyVerificationSelfVerifyWaitCoordinator(self, didAcceptKeyVerificationRequest: keyVerificationRequest) } - func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) { + func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) { self.delegate?.keyVerificationSelfVerifyWaitCoordinator(self, didAcceptIncomingSASTransaction: incomingSASTransaction) } diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift index ba1b6c410..8724493ea 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift @@ -20,7 +20,7 @@ import Foundation protocol KeyVerificationSelfVerifyWaitCoordinatorDelegate: AnyObject { func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptKeyVerificationRequest keyVerificationRequest: MXKeyVerificationRequest) - func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) + func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) func keyVerificationSelfVerifyWaitCoordinatorDidCancel(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType) func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, wantsToRecoverSecretsWith secretsRecoveryMode: SecretsRecoveryMode) } diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift index 822436f6b..b064d4f84 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift @@ -92,21 +92,29 @@ final class KeyVerificationSelfVerifyWaitViewModel: KeyVerificationSelfVerifyWai // be sure that session has completed its first sync if session.state >= .running { - // Always send request instead of waiting for an incoming one as per recent EW changes - MXLog.debug("[KeyVerificationSelfVerifyWaitViewModel] loadData: Send a verification request to all devices instead of waiting") - - let keyVerificationService = KeyVerificationService() - self.verificationManager.requestVerificationByToDevice(withUserId: self.session.myUserId, deviceIds: nil, methods: keyVerificationService.supportedKeyVerificationMethods(), success: { [weak self] (keyVerificationRequest) in - guard let self = self else { - return - } + if let existingRequest = verificationManager.pendingRequests.first(where: { $0.isFromMyUser && !$0.isFromMyDevice && $0.state == MXKeyVerificationRequestStatePending }) { + MXLog.debug("[KeyVerificationSelfVerifyWaitViewModel] loadData: Accepting an existing self-verification request instead of starting a new one") - self.keyVerificationRequest = keyVerificationRequest + registerTransactionDidStateChangeNotification() + acceptKeyVerificationRequest(existingRequest) + } else { - }, failure: { [weak self] error in - self?.update(viewState: .error(error)) - }) - continueLoadData() + // Always send request instead of waiting for an incoming one as per recent EW changes + MXLog.debug("[KeyVerificationSelfVerifyWaitViewModel] loadData: Send a verification request to all devices instead of waiting") + + let keyVerificationService = KeyVerificationService() + self.verificationManager.requestVerificationByToDevice(withUserId: self.session.myUserId, deviceIds: nil, methods: keyVerificationService.supportedKeyVerificationMethods(), success: { [weak self] (keyVerificationRequest) in + guard let self = self else { + return + } + + self.keyVerificationRequest = keyVerificationRequest + + }, failure: { [weak self] error in + self?.update(viewState: .error(error)) + }) + continueLoadData() + } } else { // show loader self.update(viewState: .secretsRecoveryCheckingAvailability(VectorL10n.deviceVerificationSelfVerifyWaitRecoverSecretsCheckingAvailability)) @@ -181,7 +189,7 @@ final class KeyVerificationSelfVerifyWaitViewModel: KeyVerificationSelfVerifyWai @objc private func keyVerificationManagerNewRequestNotification(notification: Notification) { - guard let userInfo = notification.userInfo, let keyVerificationRequest = userInfo[MXKeyVerificationManagerNotificationRequestKey] as? MXKeyVerificationByToDeviceRequest else { + guard let userInfo = notification.userInfo, let keyVerificationRequest = userInfo[MXKeyVerificationManagerNotificationRequestKey] as? MXKeyVerificationRequest, keyVerificationRequest.transport == .toDevice else { return } @@ -242,14 +250,14 @@ final class KeyVerificationSelfVerifyWaitViewModel: KeyVerificationSelfVerifyWai } @objc private func transactionDidStateChange(notification: Notification) { - guard let sasTransaction = notification.object as? MXIncomingSASTransaction, - sasTransaction.otherUserId == self.session.myUserId else { + guard let sasTransaction = notification.object as? MXSASTransaction, + sasTransaction.isIncoming, sasTransaction.otherUserId == self.session.myUserId else { return } self.sasTransactionDidStateChange(sasTransaction) } - private func sasTransactionDidStateChange(_ transaction: MXIncomingSASTransaction) { + private func sasTransactionDidStateChange(_ transaction: MXSASTransaction) { switch transaction.state { case MXSASTransactionStateIncomingShowAccept: transaction.accept() diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift index 264a73b83..66027c9e8 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift @@ -24,7 +24,7 @@ protocol KeyVerificationSelfVerifyWaitViewModelViewDelegate: AnyObject { protocol KeyVerificationSelfVerifyWaitViewModelCoordinatorDelegate: AnyObject { func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptKeyVerificationRequest keyVerificationRequest: MXKeyVerificationRequest) - func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) + func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) func keyVerificationSelfVerifyWaitViewModelDidCancel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType) func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, wantsToRecoverSecretsWith secretsRecoveryMode: SecretsRecoveryMode) } diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift index f6ba2bec8..0c806cedd 100644 --- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift +++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift @@ -63,13 +63,9 @@ extension DeviceVerificationStartCoordinator: DeviceVerificationStartViewModelCo func deviceVerificationStartViewModelDidUseLegacyVerification(_ viewModel: DeviceVerificationStartViewModelType) { self.delegate?.deviceVerificationStartCoordinatorDidCancel(self) } - - func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) { - self.delegate?.deviceVerificationStartCoordinator(self, didCompleteWithOutgoingTransaction: transaction) - } - - func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didTransactionCancelled transaction: MXSASTransaction) { - self.delegate?.deviceVerificationStartCoordinator(self, didTransactionCancelled: transaction) + + func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, otherDidAcceptRequest request: MXKeyVerificationRequest) { + self.delegate?.deviceVerificationStartCoordinator(self, otherDidAcceptRequest: request) } func deviceVerificationStartViewModelDidCancel(_ viewModel: DeviceVerificationStartViewModelType) { diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift index 16a79760c..f26862e90 100644 --- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift +++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift @@ -19,8 +19,7 @@ import Foundation protocol DeviceVerificationStartCoordinatorDelegate: AnyObject { - func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) - func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didTransactionCancelled transaction: MXSASTransaction) + func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, otherDidAcceptRequest request: MXKeyVerificationRequest) func deviceVerificationStartCoordinatorDidCancel(_ coordinator: DeviceVerificationStartCoordinatorType) } diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift index 694cb3474..8a64cc881 100644 --- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift +++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift @@ -29,7 +29,7 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy private let otherUser: MXUser private let otherDevice: MXDeviceInfo - private var transaction: MXSASTransaction! + private var request: MXKeyVerificationRequest? // MARK: Public @@ -52,12 +52,12 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy case .beginVerifying: self.beginVerifying() case .verifyUsingLegacy: - self.cancelTransaction() + self.cancelRequest() self.update(viewState: .verifyUsingLegacy(self.session, self.otherDevice)) case .verifiedUsingLegacy: self.coordinatorDelegate?.deviceVerificationStartViewModelDidUseLegacyVerification(self) case .cancel: - self.cancelTransaction() + self.cancelRequest() self.coordinatorDelegate?.deviceVerificationStartViewModelDidCancel(self) } } @@ -67,30 +67,22 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy private func beginVerifying() { self.update(viewState: .loading) - self.verificationManager.beginKeyVerification(withUserId: self.otherUser.userId, andDeviceId: self.otherDevice.deviceId, method: MXKeyVerificationMethodSAS, success: { [weak self] (transaction) in - - guard let sself = self else { - return - } - guard let sasTransaction: MXOutgoingSASTransaction = transaction as? MXOutgoingSASTransaction else { + self.verificationManager.requestVerificationByToDevice(withUserId: otherUser.userId, deviceIds: [otherDevice.deviceId], methods: [MXKeyVerificationMethodSAS], success: { [weak self] request in + guard let self = self else { return } - sself.transaction = sasTransaction + self.request = request - sself.update(viewState: .loaded) - sself.registerTransactionDidStateChangeNotification(transaction: sasTransaction) + self.update(viewState: .loaded) + self.registerKeyVerificationRequestDidChangeNotification(for: request) }, failure: {[weak self] error in self?.update(viewState: .error(error)) }) } - private func cancelTransaction() { - guard let transaction = self.transaction else { - return - } - - transaction.cancel(with: MXTransactionCancelCode.user()) + private func cancelRequest() { + request?.cancel(with: MXTransactionCancelCode.user(), success: nil) } private func update(viewState: DeviceVerificationStartViewState) { @@ -98,37 +90,41 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy } - // MARK: - MXKeyVerificationTransactionDidChange + // MARK: - MXKeyVerificationRequestDidChange - private func registerTransactionDidStateChangeNotification(transaction: MXOutgoingSASTransaction) { - NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: NSNotification.Name.MXKeyVerificationTransactionDidChange, object: transaction) + private func registerKeyVerificationRequestDidChangeNotification(for request: MXKeyVerificationRequest) { + NotificationCenter.default.addObserver(self, selector: #selector(requestDidStateChange(notification:)), name: .MXKeyVerificationRequestDidChange, object: request) } - private func unregisterTransactionDidStateChangeNotification() { - NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationTransactionDidChange, object: nil) + private func unregisterKeyVerificationRequestDidChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationRequestDidChange, object: nil) } - - @objc private func transactionDidStateChange(notification: Notification) { - guard let transaction = notification.object as? MXOutgoingSASTransaction else { + + @objc private func requestDidStateChange(notification: Notification) { + guard let request = notification.object as? MXKeyVerificationRequest, request.requestId == self.request?.requestId else { return } - switch transaction.state { - case MXSASTransactionStateShowSAS: - self.unregisterTransactionDidStateChangeNotification() - self.coordinatorDelegate?.deviceVerificationStartViewModel(self, didCompleteWithOutgoingTransaction: transaction) - case MXSASTransactionStateCancelled: - guard let reason = transaction.reasonCancelCode else { + switch request.state { + case MXKeyVerificationRequestStateAccepted, MXKeyVerificationRequestStateReady: + self.unregisterKeyVerificationRequestDidChangeNotification() + self.coordinatorDelegate?.deviceVerificationStartViewModel(self, otherDidAcceptRequest: request) + + case MXKeyVerificationRequestStateCancelled: + guard let reason = request.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterKeyVerificationRequestDidChangeNotification() self.update(viewState: .cancelled(reason)) - case MXSASTransactionStateCancelledByMe: - guard let reason = transaction.reasonCancelCode else { + case MXKeyVerificationRequestStateCancelledByMe: + guard let reason = request.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterKeyVerificationRequestDidChangeNotification() self.update(viewState: .cancelledByMe(reason)) + case MXKeyVerificationRequestStateExpired: + self.unregisterKeyVerificationRequestDidChangeNotification() + self.update(viewState: .error(UserVerificationStartViewModelError.keyVerificationRequestExpired)) default: break } diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift index 015e80faf..c4f04b287 100644 --- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift +++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift @@ -25,8 +25,7 @@ protocol DeviceVerificationStartViewModelViewDelegate: AnyObject { protocol DeviceVerificationStartViewModelCoordinatorDelegate: AnyObject { func deviceVerificationStartViewModelDidUseLegacyVerification(_ viewModel: DeviceVerificationStartViewModelType) - func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) - func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didTransactionCancelled transaction: MXSASTransaction) + func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, otherDidAcceptRequest request: MXKeyVerificationRequest) func deviceVerificationStartViewModelDidCancel(_ viewModel: DeviceVerificationStartViewModelType) } diff --git a/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift b/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift index 104da3b32..a46b30555 100644 --- a/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift +++ b/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift @@ -18,10 +18,6 @@ import Foundation -enum UserVerificationSessionsStatusViewModelError: Error { - case unknown -} - final class UserVerificationSessionsStatusViewModel: UserVerificationSessionsStatusViewModelType { // MARK: - Properties @@ -103,7 +99,7 @@ final class UserVerificationSessionsStatusViewModel: UserVerificationSessionsSta } private func getDevicesFromCache(for userId: String) -> [MXDeviceInfo] { - guard let deviceInfoMap = self.session.crypto.devices(forUser: self.userId) else { + guard let deviceInfoMap = self.session.crypto?.devices(forUser: self.userId) else { return [] } return Array(deviceInfoMap.values) @@ -128,9 +124,7 @@ final class UserVerificationSessionsStatusViewModel: UserVerificationSessionsSta completion(.success(sessionsViewData)) }, failure: { error in - - let finalError = error ?? UserVerificationSessionsStatusViewModelError.unknown - completion(.failure(finalError)) + completion(.failure(error)) }) return httpOperation diff --git a/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift b/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift index 45e8e378f..604253c26 100644 --- a/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift +++ b/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift @@ -189,6 +189,7 @@ extension UserVerificationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { dismissPresenter(coordinator: coordinator) + delegate?.userVerificationCoordinatorDidComplete(self) } func keyVerificationCoordinatorDidCancel(_ coordinator: KeyVerificationCoordinatorType) { diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h index 38b29a8ae..7c28a022a 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h @@ -321,6 +321,15 @@ typedef BOOL (^MXKAccountOnCertificateChange)(MXKAccount *mxAccount, NSData *cer */ - (void)load3PIDs:(void (^)(void))success failure:(void (^)(NSError *error))failure; +/** + Loads the pusher instance linked to this account. + This method must be called to refresh self.pushNotificationServiceIsActive + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)loadCurrentPusher:(nullable void (^)(void))success failure:(nullable void (^)(NSError *error))failure; + /** Load the current device information for this account. This method must be called to refresh self.device. diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index e52e721bd..7be37ea05 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -86,6 +86,8 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Observe NSCurrentLocaleDidChangeNotification to refresh MXRoomSummaries on time formatting change. id NSCurrentLocaleDidChangeNotificationObserver; + + MXPusher *currentPusher; } /// Will be true if the session is not in a pauseable state or we requested for the session to pause but not finished yet. Will be reverted to false again after `resume` called. @@ -148,6 +150,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; userPresence = MXPresenceUnknown; // Refresh device information [self loadDeviceInformation:nil failure:nil]; + [self loadCurrentPusher:nil failure:nil]; [self registerAccountDataDidChangeIdentityServerNotification]; [self registerIdentityServiceDidChangeAccessTokenNotification]; @@ -182,6 +185,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; userPresence = MXPresenceUnknown; // Refresh device information [self loadDeviceInformation:nil failure:nil]; + [self loadCurrentPusher:nil failure:nil]; } return self; @@ -310,6 +314,12 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; - (BOOL)pushNotificationServiceIsActive { + if (currentPusher && currentPusher.enabled) + { + MXLogDebug(@"[MXKAccount][Push] pushNotificationServiceIsActive: currentPusher.enabled %@", currentPusher.enabled); + return currentPusher.enabled.boolValue; + } + BOOL pushNotificationServiceIsActive = ([[MXKAccountManager sharedManager] isAPNSAvailable] && self.hasPusherForPushNotifications && mxSession); MXLogDebug(@"[MXKAccount][Push] pushNotificationServiceIsActive: %@", @(pushNotificationServiceIsActive)); @@ -324,7 +334,44 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; if (enable) { - if ([[MXKAccountManager sharedManager] isAPNSAvailable]) + if (currentPusher && currentPusher.enabled && !currentPusher.enabled.boolValue) + { + [self.mxSession.matrixRestClient setPusherWithPushkey:currentPusher.pushkey + kind:currentPusher.kind + appId:currentPusher.appId + appDisplayName:currentPusher.appDisplayName + deviceDisplayName:currentPusher.deviceDisplayName + profileTag:currentPusher.profileTag + lang:currentPusher.lang + data:currentPusher.data.JSONDictionary + append:NO + enabled:enable + success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: remotely enabled Push: Success"); + [self loadCurrentPusher:^{ + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogWarning(@"[MXKAccount][Push] enablePushNotifications: load current pusher failed with error: %@", error); + if (failure) + { + failure(error); + } + }]; + } failure:^(NSError *error) { + + MXLogWarning(@"[MXKAccount][Push] enablePushNotifications: remotely enable push failed with error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else if ([[MXKAccountManager sharedManager] isAPNSAvailable]) { MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push for %@ account", self.mxCredentials.userId); @@ -361,7 +408,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; } } } - else if (self.hasPusherForPushNotifications) + else if (self.hasPusherForPushNotifications || currentPusher) { MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable APNS for %@ account", self.mxCredentials.userId); @@ -633,6 +680,65 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; }]; } +- (void)loadCurrentPusher:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (!self.mxSession.myDeviceId) + { + MXLogWarning(@"[MXKAccount] loadPusher: device ID not found"); + if (failure) + { + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + return; + } + + [self.mxSession supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + if (!matrixVersions.supportsRemotelyTogglingPushNotifications) + { + MXLogDebug(@"[MXKAccount] loadPusher: remotely toggling push notifications not supported"); + + if (success) + { + success(); + } + + return; + } + + [self.mxSession.matrixRestClient pushers:^(NSArray *pushers) { + MXPusher *ownPusher; + for (MXPusher *pusher in pushers) + { + if ([pusher.deviceId isEqualToString:self.mxSession.myDeviceId]) + { + ownPusher = pusher; + } + } + + self->currentPusher = ownPusher; + + if (success) + { + success(); + } + } failure:^(NSError *error) { + MXLogWarning(@"[MXKAccount] loadPusher: get pushers failed due to error %@", error); + + if (failure) + { + failure(error); + } + }]; + } failure:^(NSError *error) { + MXLogWarning(@"[MXKAccount] loadPusher: supportedMatrixVersions failed due to error %@", error); + + if (failure) + { + failure(error); + } + }]; +} + - (void)loadDeviceInformation:(void (^)(void))success failure:(void (^)(NSError *error))failure { if (self.mxCredentials.deviceId) @@ -780,7 +886,9 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; [MXKContactManager.sharedManager validateSyncLocalContactsStateForSession:self.mxSession]; // Refresh pusher state - [self refreshAPNSPusher]; + [self loadCurrentPusher:^{ + [self refreshAPNSPusher]; + } failure:nil]; [self refreshPushKitPusher]; // Launch server sync @@ -851,7 +959,10 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; { // Force a reload of device keys at the next session start. // This will fix potential UISIs other peoples receive for our messages. - [mxSession.crypto resetDeviceKeys]; + if ([mxSession.crypto isKindOfClass:[MXLegacyCrypto class]]) + { + [(MXLegacyCrypto *)mxSession.crypto resetDeviceKeys]; + } // Clean other stores [mxSession.scanManager deleteAllAntivirusScans]; @@ -1113,6 +1224,12 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; - (void)refreshAPNSPusher { MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher"); + + if (currentPusher) + { + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher aborted as a pusher has been found"); + return; + } // Check the conditions required to run the pusher if (self.pushNotificationServiceIsActive) @@ -1172,12 +1289,35 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; self->_hasPusherForPushNotifications = enabled; [[MXKAccountManager sharedManager] saveAccounts]; - if (success) + if (enabled) { - success(); + [self loadCurrentPusher:^{ + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; + } failure:^(NSError *error) { + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; + }]; + } + else + { + self->currentPusher = nil; + + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; } - - [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; } failure:^(NSError *error) { @@ -1422,7 +1562,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; MXRestClient *restCli = self.mxRestClient; - [restCli setPusherWithPushkey:b64Token kind:kind appId:appId appDisplayName:appDisplayName deviceDisplayName:[[UIDevice currentDevice] name] profileTag:profileTag lang:deviceLang data:pushData append:append success:success failure:failure]; + [restCli setPusherWithPushkey:b64Token kind:kind appId:appId appDisplayName:appDisplayName deviceDisplayName:[[UIDevice currentDevice] name] profileTag:profileTag lang:deviceLang data:pushData append:append enabled:enabled success:success failure:failure]; } #pragma mark - InApp notifications @@ -1613,8 +1753,18 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; return; } + if (![mxSession.crypto.crossSigning isKindOfClass:[MXLegacyCrossSigning class]]) { + MXLogFailure(@"Device dehydratation is currently only supported by legacy cross signing, add support to all implementations"); + if (failure) + { + failure(nil); + } + return; + } + MXLegacyCrossSigning *crossSigning = (MXLegacyCrossSigning *)mxSession.crypto.crossSigning;; + MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: starting device dehydration"); - [[MXKAccountManager sharedManager].dehydrationService dehydrateDeviceWithMatrixRestClient:mxRestClient crypto:mxSession.crypto dehydrationKey:keyData success:^(NSString *deviceId) { + [[MXKAccountManager sharedManager].dehydrationService dehydrateDeviceWithMatrixRestClient:mxRestClient crossSigning:crossSigning dehydrationKey:keyData success:^(NSString *deviceId) { MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device successfully dehydrated"); if (success) diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m index 6d231262c..520209860 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m @@ -65,9 +65,12 @@ _event = event; _displayFix = MXKRoomBubbleComponentDisplayFixNone; - if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML]) + + NSString *format = event.content[@"format"]; + if ([format isKindOfClass:[NSString class]] && [format isEqualToString:kMXRoomMessageFormatHTML]) { - if ([((NSString*)event.content[@"formatted_body"]) containsString:@" UIImage { + func generateCode(from data: Data, + with size: CGSize, + onColor: UIColor = .black, + offColor: UIColor = .white) throws -> UIImage { let writer = ZXMultiFormatWriter() let endodedString = String(data: data, encoding: .isoLatin1) let scale = UIScreen.main.scale @@ -33,8 +37,10 @@ final class QRCodeGenerator { height: Int32(size.height * scale), hints: ZXEncodeHints() ) - - guard let cgImage = ZXImage(matrix: bitMatrix).cgimage else { + + guard let cgImage = ZXImage(matrix: bitMatrix, + on: onColor.cgColor, + offColor: offColor.cgColor).cgimage else { throw Error.cannotCreateImage } diff --git a/Riot/Modules/Rendezvous/MockRendezvousTransport.swift b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift new file mode 100644 index 000000000..bdea728f6 --- /dev/null +++ b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift @@ -0,0 +1,61 @@ +// +// Copyright 2022 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 + +class MockRendezvousTransport: RendezvousTransportProtocol { + var rendezvousURL: URL? + + private var currentPayload: Data? + + func create(body: T) async -> Result<(), RendezvousTransportError> { + guard let url = URL(string: "rendezvous.mock/1234") else { + fatalError() + } + + rendezvousURL = url + + guard let encodedBody = try? JSONEncoder().encode(body) else { + fatalError() + } + + currentPayload = encodedBody + + return .success(()) + } + + func get() async -> Result { + guard let data = currentPayload else { + fatalError() + } + + return .success(data) + } + + func send(body: T) async -> Result<(), RendezvousTransportError> { + guard let encodedBody = try? JSONEncoder().encode(body) else { + fatalError() + } + + currentPayload = encodedBody + + return .success(()) + } + + func tearDown() async -> Result<(), RendezvousTransportError> { + return .success(()) + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift new file mode 100644 index 000000000..49c3486bb --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -0,0 +1,274 @@ +// +// Copyright 2022 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 CryptoKit +import Combine +import MatrixSDK + +enum RendezvousServiceError: Error { + case invalidInterlocutorKey + case decodingError + case internalError + case channelNotReady + case transportError(RendezvousTransportError) +} + +/// Algorithm name as per MSC3903 +enum RendezvousChannelAlgorithm: String { + case ECDH_V1 = "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256" +} + +/// Allows communication through a secure channel. Based on MSC3886 and MSC3903 +@MainActor +class RendezvousService { + private let transport: RendezvousTransportProtocol + + private var privateKey: Curve25519.KeyAgreement.PrivateKey! + private var interlocutorPublicKey: Curve25519.KeyAgreement.PublicKey? + private var symmetricKey: SymmetricKey? + + init(transport: RendezvousTransportProtocol) { + self.transport = transport + } + + /// Creates a new rendezvous endpoint and publishes the creator's public key + func createRendezvous() async -> Result { + privateKey = Curve25519.KeyAgreement.PrivateKey() + + let publicKeyString = privateKey.publicKey.rawRepresentation.base64EncodedString() + let details = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue) + + switch await transport.create(body: details) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + guard let rendezvousURL = transport.rendezvousURL else { + return .failure(.transportError(.rendezvousURLInvalid)) + } + + let fullDetails = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + transport: RendezvousTransportDetails(type: "org.matrix.msc3886.http.v1", + uri: rendezvousURL.absoluteString), + key: publicKeyString) + return .success(fullDetails) + } + } + + /// After creation we need to wait for the pair to publish its public key as well + /// At the end of this a symmetric key will be available for encryption + func waitForInterlocutor() async -> Result { + switch await transport.get() { + case .failure(let error): + return .failure(.transportError(error)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousDetails.self, from: data) else { + return .failure(.decodingError) + } + + guard let key = response.key, + let interlocutorPublicKeyData = Data(base64Encoded: key), + let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + return .failure(.invalidInterlocutorKey) + } + + self.interlocutorPublicKey = interlocutorPublicKey + + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + return .failure(.internalError) + } + + self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: privateKey.publicKey, + recipientPublicKey: interlocutorPublicKey) + + let validationCode = generateValidationCodeFrom(symmetricKey: generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: privateKey.publicKey, + recipientPublicKey: interlocutorPublicKey, + byteCount: 5)) + + return .success(validationCode) + } + } + + /// Joins an existing rendezvous and publishes the joiner's public key + /// At the end of this a symmetric key will be available for encryption + func joinRendezvous(withPublicKey publicKey: String) async -> Result { + guard let interlocutorPublicKeyData = Data(base64Encoded: publicKey), + let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + MXLog.debug("[RendezvousService] Invalid interlocutor data") + return .failure(.invalidInterlocutorKey) + } + + privateKey = Curve25519.KeyAgreement.PrivateKey() + + let publicKeyString = privateKey.publicKey.rawRepresentation.base64EncodedString() + let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + key: publicKeyString) + + guard case .success = await transport.send(body: payload) else { + return .failure(.internalError) + } + + self.interlocutorPublicKey = interlocutorPublicKey + + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + MXLog.debug("[RendezvousService] Couldn't create shared secret") + return .failure(.internalError) + } + + symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: interlocutorPublicKey, + recipientPublicKey: privateKey.publicKey) + + let validationCode = generateValidationCodeFrom(symmetricKey: generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: interlocutorPublicKey, + recipientPublicKey: privateKey.publicKey, + byteCount: 5)) + + return .success(validationCode) + } + + /// Send arbitrary data over the secure channel + /// This will use the previously generated symmetric key to AES encrypt the payload + /// - Parameter data: the data to be encrypted and sent + /// - Returns: nothing if succeeded or a RendezvousServiceError failure + func send(data: Data) async -> Result<(), RendezvousServiceError> { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + // Generate a custom random 256 bit nonce/iv as per MSC3903. The default one is 96 bit. + guard let nonce = try? AES.GCM.Nonce(data: generateRandomData(ofLength: 32)), + let sealedBox = try? AES.GCM.seal(data, using: symmetricKey, nonce: nonce) else { + return .failure(.internalError) + } + + // The resulting cipher text needs to contain both the message and the authentication tag + // in order to play nicely with other platforms + var ciphertext = sealedBox.ciphertext + ciphertext.append(contentsOf: sealedBox.tag) + + let body = RendezvousMessage(iv: Data(nonce).base64EncodedString(), + ciphertext: ciphertext.base64EncodedString()) + + switch await transport.send(body: body) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + return .success(()) + } + } + + + /// Waits for and returns newly available rendezvous channel data + /// - Returns: The unencrypted data or a RendezvousServiceError + func receive() async -> Result { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + switch await transport.get() { + case.failure(let transportError): + return .failure(.transportError(transportError)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousMessage.self, from: data) else { + return .failure(.decodingError) + } + + MXLog.debug("Received rendezvous response: \(response)") + + guard let ciphertextData = Data(base64Encoded: response.ciphertext), + let nonceData = Data(base64Encoded: response.iv), + let nonce = try? AES.GCM.Nonce(data: nonceData) else { + return .failure(.decodingError) + } + + // Split the ciphertext into the message and authentication tag data + let messageData = ciphertextData.dropLast(16) // The last 16 bytes are the tag + let tagData = ciphertextData.dropFirst(messageData.count) + + guard let sealedBox = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: messageData, tag: tagData), + let messageData = try? AES.GCM.open(sealedBox, using: symmetricKey) else { + return .failure(.decodingError) + } + + return .success(messageData) + } + } + + func tearDown() async -> Result<(), RendezvousServiceError> { + switch await transport.tearDown() { + case .failure(let error): + return .failure(.transportError(error)) + case .success: + privateKey = nil + interlocutorPublicKey = nil + symmetricKey = nil + + return .success(()) + } + } + + // MARK: - Private + + private func generateValidationCodeFrom(symmetricKey: SymmetricKey) -> String { + let bytes = symmetricKey.withUnsafeBytes { + return Data(Array($0)) + }.map { UInt($0) } + + let first = (bytes[0] << 5 | bytes[1] >> 3) + 1000 + let secondPart1 = UInt(bytes[1] & 0x7) << 10 + let secondPart2 = bytes[2] << 2 | bytes[3] >> 6 + let second = (secondPart1 | secondPart2) + 1000 + let third = ((bytes[3] & 0x3f) << 7 | bytes[4] >> 1) + 1000 + + return "\(first)-\(second)-\(third)" + } + + private func generateSymmetricKeyFrom(sharedSecret: SharedSecret, + initiatorPublicKey: Curve25519.KeyAgreement.PublicKey, + recipientPublicKey: Curve25519.KeyAgreement.PublicKey, + byteCount: Int = SHA256Digest.byteCount) -> SymmetricKey { + guard let sharedInfoData = [RendezvousChannelAlgorithm.ECDH_V1.rawValue, + initiatorPublicKey.rawRepresentation.base64EncodedString(), + recipientPublicKey.rawRepresentation.base64EncodedString()] + .joined(separator: "|") + .data(using: .utf8) else { + fatalError("[RendezvousService] Failed creating symmetric key shared data") + } + + // MSC3903 asks for a 8 zero byte salt when deriving the keys + let salt = Data(repeating: 0, count: 8) + return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, + salt: salt, + sharedInfo: sharedInfoData, + outputByteCount: byteCount) + } + + private func generateRandomData(ofLength length: Int) -> Data { + var data = Data(count: length) + _ = data.withUnsafeMutableBytes { pointer -> Int32 in + if let baseAddress = pointer.baseAddress { + return SecRandomCopyBytes(kSecRandomDefault, length, baseAddress) + } + + return 0 + } + + return data + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift new file mode 100644 index 000000000..9c4c299ab --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -0,0 +1,191 @@ +// +// Copyright 2022 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 MatrixSDK + +class RendezvousTransport: RendezvousTransportProtocol { + private let baseURL: URL + + private var currentEtag: String? + + private(set) var rendezvousURL: URL? { + didSet { + self.currentEtag = nil + } + } + + init(baseURL: URL, rendezvousURL: URL? = nil) { + self.baseURL = baseURL + self.rendezvousURL = rendezvousURL + } + + func get() async -> Result { + // Keep trying until resource changed + while true { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + MXLog.debug("[RendezvousTransport] polling \(url) after etag: \(String(describing: currentEtag))") + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + if let etag = currentEtag { + request.addValue(etag, forHTTPHeaderField: "If-None-Match") + } + + // Newer swift concurrency api unavailable due to iOS 14 support + let result: Result = await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, error in + guard error == nil, + let data = data, + let httpURLResponse = response as? HTTPURLResponse else { + continuation.resume(returning: .failure(.networkError)) + return + } + + // Return empty data from here if unchanged so that the external while can continue + if httpURLResponse.statusCode == 404 { + MXLog.warning("[RendezvousTransport] Rendezvous no longer available") + continuation.resume(returning: .failure(.rendezvousCancelled)) + } else if httpURLResponse.statusCode == 304 { + MXLog.debug("[RendezvousTransport] Rendezvous unchanged") + continuation.resume(returning: .success(nil)) + } else if httpURLResponse.statusCode == 200 { + // The resouce changed, update the etag + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag + } + + MXLog.debug("[RendezvousTransport] Received update") + + continuation.resume(returning: .success(data)) + } + } + .resume() + } + + switch result { + case .failure(let error): + return .failure(error) + case .success(let data): + guard let data = data else { + // Avoid making too many requests. Sleep for one second before the next attempt + try? await Task.sleep(nanoseconds: 1_000_000_000) + + continue + } + + return .success(data) + } + } + } + + func create(body: T) async -> Result<(), RendezvousTransportError> { + switch await send(body: body, url: baseURL, usingMethod: "POST") { + case .failure(let error): + return .failure(error) + case .success(let response): + guard let rendezvousIdentifier = response.allHeaderFields["Location"] as? String else { + return .failure(.networkError) + } + + rendezvousURL = baseURL.appendingPathComponent(rendezvousIdentifier) + + return .success(()) + } + } + + func send(body: T) async -> Result<(), RendezvousTransportError> { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + switch await send(body: body, url: url, usingMethod: "PUT") { + case .failure(let error): + return .failure(error) + case .success: + return .success(()) + } + } + + func tearDown() async -> Result<(), RendezvousTransportError> { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + return await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard error == nil, response as? HTTPURLResponse != nil else { + MXLog.warning("[RendezvousTransport] Failed tearing down rendezvous with error: \(String(describing: error))") + continuation.resume(returning: .failure(.networkError)) + return + } + + MXLog.debug("[RendezvousTransport] Tore down rendezvous at URL: \(url)") + + self?.rendezvousURL = nil + + continuation.resume(returning: .success(())) + } + .resume() + } + } + + // MARK: - Private + + private func send(body: T, url: URL, usingMethod method: String) async -> Result { + guard let bodyData = try? JSONEncoder().encode(body) else { + return .failure(.encodingError) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + request.httpBody = bodyData + + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + if let etag = currentEtag { + request.addValue(etag, forHTTPHeaderField: "If-Match") + } + + return await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, error in + guard error == nil, let httpURLResponse = response as? HTTPURLResponse else { + MXLog.warning("[RendezvousTransport] Failed sending data with error: \(String(describing: error))") + continuation.resume(returning: .failure(.networkError)) + return + } + + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag + } + + MXLog.debug("[RendezvousTransport] Sent data: \(body)") + + continuation.resume(returning: .success(httpURLResponse)) + } + .resume() + } + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift new file mode 100644 index 000000000..82a8be0d0 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -0,0 +1,46 @@ +// +// Copyright 2022 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 RendezvousTransportError: Error { + case rendezvousURLInvalid + case encodingError + case networkError + case rendezvousCancelled +} + +/// HTTP based MSC3886 channel implementation +@MainActor +protocol RendezvousTransportProtocol { + /// The current rendezvous endpoint. + /// Automatically assigned after a successful creation + var rendezvousURL: URL? { get } + + /// Creates a new rendezvous point containing the body + /// - Parameter body: arbitrary data to publish on the rendevous + /// - Returns:a transport error in case of failure + func create(body: T) async -> Result<(), RendezvousTransportError> + + /// Waits for and returns newly availalbe rendezvous data + func get() async -> Result + + /// Publishes new rendezvous data + func send(body: T) async -> Result<(), RendezvousTransportError> + + /// Deletes the resource at the current rendezvous endpoint + func tearDown() async -> Result<(), RendezvousTransportError> +} diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.h b/Riot/Modules/Room/CellData/RoomBubbleCellData.h index c72ad7903..94f7346aa 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.h +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.h @@ -36,7 +36,10 @@ typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag) RoomBubbleCellDataTagRoomCreationIntro, RoomBubbleCellDataTagPoll, RoomBubbleCellDataTagLocation, - RoomBubbleCellDataTagLiveLocation + RoomBubbleCellDataTagLiveLocation, + RoomBubbleCellDataTagVoiceBroadcastRecord, + RoomBubbleCellDataTagVoiceBroadcastPlayback, + RoomBubbleCellDataTagVoiceBroadcastNoDisplay }; /** diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index 35f306f11..adcd6692e 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -183,16 +183,62 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO; } } + else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) + { + VoiceBroadcastInfo *voiceBroadcastInfo = [VoiceBroadcastInfo modelFromJSON: event.content]; + if ([VoiceBroadcastInfo isStartedFor:voiceBroadcastInfo.state]) + { + // This state event corresponds to the beginning of a voice broadcast + // Check whether this is a local live broadcast to display it with the recorder view or not + // Note: Because of race condition, the voiceBroadcastService may be running without id here (the sync response may be received before + // the success of the event sending), in that case, we will display a recorder view by default to let the user be able to stop a potential record. + if ([event.sender isEqualToString: self.mxSession.myUserId] && + [voiceBroadcastInfo.deviceId isEqualToString:self.mxSession.myDeviceId] && + self.mxSession.voiceBroadcastService != nil && + ([event.eventId isEqualToString: self.mxSession.voiceBroadcastService.voiceBroadcastInfoEventId] || + self.mxSession.voiceBroadcastService.voiceBroadcastInfoEventId == nil)) + { + self.tag = RoomBubbleCellDataTagVoiceBroadcastRecord; + } + else + { + self.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback; + } + } + else + { + self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay; + + if ([VoiceBroadcastInfo isStoppedFor:voiceBroadcastInfo.state]) + { + // This state event corresponds to the end of a voice broadcast + // Force the tag of the potential cellData which corresponds to the started event to switch the display from recorder to listener + id bubbleData = [roomDataSource cellDataOfEventWithEventId:voiceBroadcastInfo.eventId]; + bubbleData.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback; + } + } + self.collapsable = NO; + self.collapsed = NO; + + break; + } break; } case MXEventTypeRoomMessage: { - if (event.location) { + if (event.location) + { self.tag = RoomBubbleCellDataTagLocation; self.collapsable = NO; self.collapsed = NO; } + else if (event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType]) + { + self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay; + self.collapsable = NO; + self.collapsed = NO; + } break; } @@ -271,42 +317,46 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat - (BOOL)hasNoDisplay { - if (self.tag == RoomBubbleCellDataTagKeyVerificationNoDisplay) + BOOL hasNoDisplay = YES; + + switch (self.tag) { - return YES; + case RoomBubbleCellDataTagKeyVerificationNoDisplay: + hasNoDisplay = YES; + break; + case RoomBubbleCellDataTagRoomCreationIntro: + hasNoDisplay = NO; + break; + case RoomBubbleCellDataTagPoll: + if (!self.events.lastObject.isEditEvent) + { + hasNoDisplay = NO; + } + + break; + case RoomBubbleCellDataTagLocation: + hasNoDisplay = NO; + break; + case RoomBubbleCellDataTagLiveLocation: + // Show the cell only if the summary exists + if (self.beaconInfoSummary) + { + hasNoDisplay = NO; + } + + break; + case RoomBubbleCellDataTagVoiceBroadcastRecord: + case RoomBubbleCellDataTagVoiceBroadcastPlayback: + hasNoDisplay = NO; + break; + case RoomBubbleCellDataTagVoiceBroadcastNoDisplay: + break; + default: + hasNoDisplay = [super hasNoDisplay]; + break; } - if (self.tag == RoomBubbleCellDataTagRoomCreationIntro) - { - return NO; - } - - if (self.tag == RoomBubbleCellDataTagPoll) - { - if (self.events.lastObject.isEditEvent) { - return YES; - } - - return NO; - } - - if (self.tag == RoomBubbleCellDataTagLocation) - { - return NO; - } - - if (self.tag == RoomBubbleCellDataTagLiveLocation) - { - // If the summary does not exist don't show the cell - if (!self.beaconInfoSummary) - { - return YES; - } - - return NO; - } - - return [super hasNoDisplay]; + return hasNoDisplay; } - (BOOL)hasThreadRoot @@ -1050,6 +1100,11 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat case RoomBubbleCellDataTagLiveLocation: shouldAddEvent = NO; break; + case RoomBubbleCellDataTagVoiceBroadcastRecord: + case RoomBubbleCellDataTagVoiceBroadcastPlayback: + case RoomBubbleCellDataTagVoiceBroadcastNoDisplay: + shouldAddEvent = NO; + break; default: break; } @@ -1118,6 +1173,8 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat { shouldAddEvent = NO; } + } else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) { + shouldAddEvent = NO; } break; } diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index 149528b14..eb7e769b8 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -781,13 +781,13 @@ const CGFloat kTypingCellHeight = 24; { id notificationObject = notification.object; - if ([notificationObject isKindOfClass:MXKeyVerificationByDMRequest.class]) + if ([notificationObject conformsToProtocol:@protocol(MXKeyVerificationRequest)]) { - MXKeyVerificationByDMRequest *keyVerificationByDMRequest = (MXKeyVerificationByDMRequest*)notificationObject; + id keyVerificationRequest = (id)notificationObject; - if ([keyVerificationByDMRequest.roomId isEqualToString:self.roomId]) + if (keyVerificationRequest.transport == MXKeyVerificationTransportDirectMessage && [keyVerificationRequest.roomId isEqualToString:self.roomId]) { - RoomBubbleCellData *roomBubbleCellData = [self roomBubbleCellDataForEventId:keyVerificationByDMRequest.eventId]; + RoomBubbleCellData *roomBubbleCellData = [self roomBubbleCellDataForEventId:keyVerificationRequest.requestId]; roomBubbleCellData.isKeyVerificationOperationPending = NO; roomBubbleCellData.keyVerification = nil; @@ -922,6 +922,7 @@ const CGFloat kTypingCellHeight = 24; } __block MXHTTPOperation *operation = [self.mxSession.crypto.keyVerificationManager keyVerificationFromKeyVerificationEvent:event + roomId:self.roomId success:^(MXKeyVerification * _Nonnull keyVerification) { BOOL shouldRefreshCells = bubbleCellData.isKeyVerificationOperationPending || bubbleCellData.keyVerification == nil; diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.swift b/Riot/Modules/Room/DataSources/RoomDataSource.swift index f57896b62..281a7a046 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.swift +++ b/Riot/Modules/Room/DataSources/RoomDataSource.swift @@ -21,7 +21,7 @@ extension RoomDataSource { private enum Constants { static let emoteMessageSlashCommandPrefix = String(format: "%@ ", kMXKSlashCmdEmote) } - + // MARK: - NSAttributedString Sending /// Send a text message to the room. /// While sending, a fake event will be echoed in the messages list. @@ -33,7 +33,7 @@ extension RoomDataSource { func sendAttributedTextMessage(_ attributedText: NSAttributedString, completion: @escaping (MXResponse) -> Void) { var localEcho: MXEvent? - + let isEmote = isAttributedTextMessageAnEmote(attributedText) let sanitized = sanitizedAttributedMessageText(attributedText) let rawText: String @@ -43,7 +43,7 @@ extension RoomDataSource { } else { rawText = sanitized.string } - + if isEmote { room.sendEmote(rawText, formattedText: html, @@ -57,13 +57,38 @@ extension RoomDataSource { localEcho: &localEcho, completion: completion) } - + if localEcho != nil { self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) self.processQueuedEvents(nil) } } - + + // MARK: - NSAttributedString Sending + /// Send a text message to the room. + /// While sending, a fake event will be echoed in the messages list. + /// Once complete, this local echo will be replaced by the event saved by the homeserver. + /// + /// - Parameters: + /// - rawText: the raw text to send + /// - html: the formatted html to send + /// - completion: http operation completion block + func sendFormattedTextMessage(_ rawText: String, + html: String, + completion: @escaping (MXResponse) -> Void) { + var localEcho: MXEvent? + room.sendTextMessage(rawText, + formattedText: html, + threadId: self.threadId, + localEcho: &localEcho, + completion: completion) + + if localEcho != nil { + self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) + self.processQueuedEvents(nil) + } + } + /// Send a reply to an event with text message to the room. /// /// While sending, a fake event will be echoed in the messages list. @@ -76,8 +101,6 @@ extension RoomDataSource { func sendReply(to eventToReply: MXEvent, withAttributedTextMessage attributedText: NSAttributedString, completion: @escaping (MXResponse) -> Void) { - var localEcho: MXEvent? - let sanitized = sanitizedAttributedMessageText(attributedText) let rawText: String let html: String? = htmlMessageFromSanitizedAttributedText(sanitized) @@ -86,23 +109,29 @@ extension RoomDataSource { } else { rawText = sanitized.string } - - let stringLocalizer: MXSendReplyEventStringLocalizerProtocol = MXKSendReplyEventStringLocalizer() - - room.sendReply(to: eventToReply, - textMessage: rawText, - formattedTextMessage: html, - stringLocalizer: stringLocalizer, - threadId: self.threadId, - localEcho: &localEcho, - completion: completion) - - if localEcho != nil { - self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) - self.processQueuedEvents(nil) - } + + handleFormattedSendReply(to: eventToReply, rawText: rawText, html: html, completion: completion) } - + + /// Send a reply to an event with a html formatted text message to the room. + /// + /// While sending, a fake event will be echoed in the messages list. + /// Once complete, this local echo will be replaced by the event saved by the homeserver. + /// + /// - Parameters: + /// - eventToReply: the event to reply + /// - rawText: the raw text to send + /// - htmlText: the html text to send + /// - completion: http operation completion block + func sendReply(to eventToReply: MXEvent, + rawText: String, + htmlText: String, + completion: @escaping (MXResponse) -> Void) { + + handleFormattedSendReply(to: eventToReply, rawText: rawText, html: htmlText, completion: completion) + } + + /// Replace a text in an event. /// /// - Parameters: @@ -122,29 +151,24 @@ extension RoomDataSource { } else { rawText = sanitized.string } - - let eventBody = event.content[kMXMessageBodyKey] as? String - let eventFormattedBody = event.content["formatted_body"] as? String - - if rawText != eventBody && (eventFormattedBody == nil || html != eventFormattedBody) { - self.mxSession.aggregations.replaceTextMessageEvent( - event, - withTextMessage: rawText, - formattedText: html, - localEcho: { localEcho in - // Apply the local echo to the timeline - self.updateEvent(withReplace: localEcho) - - // Integrate the replace local event into the timeline like when sending a message - // This also allows to manage read receipt on this replace event - self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) - self.processQueuedEvents(nil) - }, - success: success, - failure: failure) - } else { - failure(nil) - } + + handleReplaceFormattedMessage(for: event, rawText: rawText, html: html, success: success, failure: failure) + } + + /// Replace a formatted html text in an event + /// + /// - Parameters: + /// - event: The event to replace + /// - rawText: The new rawText + /// - html: The new html text + /// - success: A block object called when the operation succeeds. It returns the event id of the event generated on the homeserver + /// - failure: A block object called when the operation fails + func replaceFormattedTextMessage( for event: MXEvent, + rawText: String, + html: String, + success: @escaping ((String?) -> Void), + failure: @escaping ((Error?) -> Void)) { + handleReplaceFormattedMessage(for: event, rawText: rawText, html: html, success: success, failure: failure) } /// Retrieve editable attributed text message from an event. @@ -197,8 +221,10 @@ extension RoomDataSource { return editableTextMessage } - + @objc func editableHtmlTextMessage(for event: MXEvent) -> String { + event.content["formatted_body"] as? String ?? event.content["body"] as? String ?? "" + } } // MARK: - Private Helpers @@ -232,4 +258,54 @@ private extension RoomDataSource { func isAttributedTextMessageAnEmote(_ attributedText: NSAttributedString) -> Bool { return attributedText.string.starts(with: Constants.emoteMessageSlashCommandPrefix) } + + func handleReplaceFormattedMessage(for event: MXEvent, + rawText: String, + html: String?, + success: @escaping ((String?) -> Void), + failure: @escaping ((Error?) -> Void)) { + let eventBody = event.content[kMXMessageBodyKey] as? String + let eventFormattedBody = event.content["formatted_body"] as? String + if rawText != eventBody && (eventFormattedBody == nil || html != eventFormattedBody) { + self.mxSession.aggregations.replaceTextMessageEvent( + event, + withTextMessage: rawText, + formattedText: html, + localEcho: { localEcho in + // Apply the local echo to the timeline + self.updateEvent(withReplace: localEcho) + + // Integrate the replace local event into the timeline like when sending a message + // This also allows to manage read receipt on this replace event + self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) + self.processQueuedEvents(nil) + }, + success: success, + failure: failure) + } else { + failure(nil) + } + } + + func handleFormattedSendReply(to eventToReply: MXEvent, + rawText: String, + html: String?, + completion: @escaping (MXResponse) -> Void) { + var localEcho: MXEvent? + + let stringLocalizer: MXSendReplyEventStringLocalizerProtocol = MXKSendReplyEventStringLocalizer() + + room.sendReply(to: eventToReply, + textMessage: rawText, + formattedTextMessage: html, + stringLocalizer: stringLocalizer, + threadId: self.threadId, + localEcho: &localEcho, + completion: completion) + + if localEcho != nil { + self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) + self.processQueuedEvents(nil) + } + } } diff --git a/Riot/Modules/Room/MXKRoomViewController.h b/Riot/Modules/Room/MXKRoomViewController.h index 0ff875fc3..dd3bfd205 100644 --- a/Riot/Modules/Room/MXKRoomViewController.h +++ b/Riot/Modules/Room/MXKRoomViewController.h @@ -73,11 +73,6 @@ typedef NS_ENUM(NSUInteger, MXKRoomViewControllerJoinRoomResult) { */ MXKAttachment *currentSharedAttachment; - /** - The potential text input placeholder is saved when it is replaced temporarily - */ - NSString *savedInputToolbarPlaceholder; - /** Tell whether the input toolbar required to run an animation indicator. */ diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index 9eb3ad3b2..b0f547bc4 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -1116,7 +1116,7 @@ MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView to class %@", roomInputToolbarViewClass); - id inputToolbarView = [roomInputToolbarViewClass roomInputToolbarView]; + id inputToolbarView = [roomInputToolbarViewClass instantiateRoomInputToolbarView]; self->inputToolbarView = inputToolbarView; self->inputToolbarView.delegate = self; @@ -3359,32 +3359,34 @@ - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion { - _roomInputToolbarContainerHeightConstraint.constant = height; - - // Update layout with animation - [UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn - animations:^{ - // We will scroll to bottom if the bottom of the table is currently visible - BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; - - CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant; - - self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst; - - // Force to render the view - [self.view layoutIfNeeded]; - - if (shouldScrollToBottom) - { - [self scrollBubblesTableViewToBottomAnimated:NO]; - } - } - completion:^(BOOL finished){ - if (completion) - { - completion(finished); - } - }]; + // This dispatch fixes a simultaneous accesses crash if this gets called twice quickly in succession + dispatch_async(dispatch_get_main_queue(), ^{ + // Update layout with animation + [UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn + animations:^{ + // We will scroll to bottom if the bottom of the table is currently visible + BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + + self->_roomInputToolbarContainerHeightConstraint.constant = height; + CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant; + + self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst; + + // Force to render the view + [self.view layoutIfNeeded]; + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:NO]; + } + } + completion:^(BOOL finished){ + if (completion) + { + completion(finished); + } + }]; + }); } - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage diff --git a/Riot/Modules/Room/MXKRoomViewController.xib b/Riot/Modules/Room/MXKRoomViewController.xib index 1ad5d0650..c1a016ff5 100644 --- a/Riot/Modules/Room/MXKRoomViewController.xib +++ b/Riot/Modules/Room/MXKRoomViewController.xib @@ -1,10 +1,9 @@ - - - - + + - + + @@ -59,6 +58,7 @@ + diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m index b4403120f..bd557bc67 100644 --- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m +++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m @@ -487,7 +487,9 @@ - (void)startUserVerification { - [[AppDelegate theDelegate] presentUserVerificationForRoomMember:self.mxRoomMember session:self.mainSession]; + [[AppDelegate theDelegate] presentUserVerificationForRoomMember:self.mxRoomMember session:self.mainSession completion:^{ + [self refreshUserEncryptionTrustLevel]; + }]; } - (void)presentUserVerification @@ -1496,6 +1498,7 @@ - (void)keyVerificationCoordinatorBridgePresenterDelegateDidComplete:(KeyVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter otherUserId:(NSString * _Nonnull)otherUserId otherDeviceId:(NSString * _Nonnull)otherDeviceId { + [self refreshUserEncryptionTrustLevel]; [self dismissKeyVerificationCoordinatorBridgePresenter]; } diff --git a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift index 744bfa2b6..caab08a86 100644 --- a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift +++ b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift @@ -25,7 +25,7 @@ class RoomNotificationSettingsAvatarView: UIView { func configure(viewData: AvatarViewDataProtocol) { avatarView.fill(with: viewData) - switch viewData.fallbackImage { + switch viewData.fallbackImages?.first { case .matrixItem(_, let matrixItemDisplayName): nameLabel.text = matrixItemDisplayName default: diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index 1eeca7f0c..35caf9084 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -92,6 +92,8 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { self.roomViewController.parentSpaceId = parameters.parentSpaceId TimelinePollProvider.shared.session = parameters.session + VoiceBroadcastPlaybackProvider.shared.session = parameters.session + VoiceBroadcastRecorderProvider.shared.session = parameters.session super.init() } diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index a01f6c567..7da32359f 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -33,6 +33,7 @@ @class RoomDisplayConfiguration; @class ThreadsCoordinatorBridgePresenter; @class LiveLocationSharingBannerView; +@class VoiceBroadcastService; NS_ASSUME_NONNULL_BEGIN @@ -107,6 +108,9 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; // The customized room data source for Vector @property (nonatomic, nullable) RoomDataSource *customizedRoomDataSource; +// The voice broadcast service +@property (nonatomic, nullable) VoiceBroadcastService *voiceBroadcastService; + /** Retrieve the live data source in cases where the timeline is not live. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 7e1a92cdc..701251920 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, UserSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate, RoomInputToolbarViewDelegate, ComposerCreateActionListBridgePresenterDelegate> { // The preview header @@ -198,6 +198,7 @@ static CGSize kThreadListBarButtonItemImageSize; @property (nonatomic, strong) RoomContextualMenuPresenter *roomContextualMenuPresenter; @property (nonatomic, strong) MXKErrorAlertPresentation *errorPresenter; @property (nonatomic, strong) NSAttributedString *textMessageBeforeEditing; +@property (nonatomic, strong) NSString *htmlTextBeforeEditing; @property (nonatomic, strong) EditHistoryCoordinatorBridgePresenter *editHistoryPresenter; @property (nonatomic, strong) MXKDocumentPickerPresenter *documentPickerPresenter; @property (nonatomic, strong) EmojiPickerCoordinatorBridgePresenter *emojiPickerCoordinatorBridgePresenter; @@ -212,6 +213,7 @@ static CGSize kThreadListBarButtonItemImageSize; @property (nonatomic, strong) ThreadsCoordinatorBridgePresenter *threadsBridgePresenter; @property (nonatomic, strong) ThreadsBetaCoordinatorBridgePresenter *threadsBetaBridgePresenter; @property (nonatomic, strong) SlidingModalPresenter *threadsNoticeModalPresenter; +@property (nonatomic, strong) ComposerCreateActionListBridgePresenter *composerCreateActionListBridgePresenter; @property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded; @property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden; @property (nonatomic, getter=isMissedDiscussionsBadgeHidden) BOOL missedDiscussionsBadgeHidden; @@ -605,6 +607,7 @@ static CGSize kThreadListBarButtonItemImageSize; isAppeared = NO; [VoiceMessageMediaServiceProvider.sharedProvider pauseAllServices]; + [VoiceBroadcastRecorderProvider.shared pauseRecording]; if([BWIBuildSettings.shared showMentionsInRoom]) { [Mentions setCountMentionsToZeroWithRoomID:self.roomDataSource.roomId]; @@ -685,9 +688,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) - RoomInputToolbarView *inputToolbar = (RoomInputToolbarView *)self.inputToolbarView; - - inputToolbar.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage; + self.inputToolbarView.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage; } } @@ -1165,10 +1166,23 @@ static CGSize kThreadListBarButtonItemImageSize; [self notifyDelegateOnLeaveRoomIfNecessary]; } + ++ (Class) mainToolbarClass +{ + if (RiotSettings.shared.enableWysiwygComposer) + { + return WysiwygInputToolbarView.class; + } + else + { + return RoomInputToolbarView.class; + } +} + // Set the input toolbar according to the current display - (void)updateRoomInputToolbarViewClassIfNeeded { - Class roomInputToolbarViewClass = RoomInputToolbarView.class; + Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; BOOL shouldDismissContextualMenu = NO; @@ -1211,10 +1225,10 @@ static CGSize kThreadListBarButtonItemImageSize; { [super setRoomInputToolbarViewClass:roomInputToolbarViewClass]; - // The voice message toolbar cannot be set on DisabledInputToolbarView and on new direct chat. - if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class] && !self.isNewDirectChat) - { - [(RoomInputToolbarView *)self.inputToolbarView setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView]; + + if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) { + id inputToolbar = (id)self.inputToolbarView; + [inputToolbar setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView]; } [self updateInputToolBarViewHeight]; @@ -1227,9 +1241,9 @@ static CGSize kThreadListBarButtonItemImageSize; { CGFloat height = 0; - if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) - { - height = ((RoomInputToolbarView*)self.inputToolbarView).mainToolbarHeightConstraint.constant; + if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) { + id inputToolbar = (id)self.inputToolbarView; + height = inputToolbar.toolbarHeight; } else if ([self.inputToolbarView isKindOfClass:DisabledRoomInputToolbarView.class]) { @@ -1775,15 +1789,20 @@ static CGSize kThreadListBarButtonItemImageSize; || self.customizedRoomDataSource.jitsiWidget; } +- (BOOL)canSendStateEventWithType:(MXEventTypeString)eventTypeString +{ + MXRoomPowerLevels *powerLevels = [self.roomDataSource.roomState powerLevels]; + NSInteger requiredPower = [powerLevels minimumPowerLevelForSendingEventAsStateEvent:eventTypeString]; + NSInteger myPower = [powerLevels powerLevelOfUserWithUserID:self.roomDataSource.mxSession.myUserId]; + return myPower >= requiredPower; +} + /** Returns a flag for the current user whether it's privileged to add/remove Jitsi widgets to this room. */ - (BOOL)canEditJitsiWidget { - MXRoomPowerLevels *powerLevels = [self.roomDataSource.roomState powerLevels]; - NSInteger requiredPower = [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kWidgetModularEventTypeString]; - NSInteger myPower = [powerLevels powerLevelOfUserWithUserID:self.roomDataSource.mxSession.myUserId]; - return myPower >= requiredPower; + return [self canSendStateEventWithType:kWidgetModularEventTypeString]; } - (void)registerURLPreviewNotifications @@ -2000,9 +2019,9 @@ static CGSize kThreadListBarButtonItemImageSize; [self updateInputToolBarVisibility]; // Check whether the input toolbar is ready before updating it. - if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) + if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol]) { - RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView; + id roomInputToolbarView = (id) self.inputToolbarView; // Update encryption decoration if needed [self updateEncryptionDecorationForRoomInputToolbar:roomInputToolbarView]; @@ -2049,9 +2068,9 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)setInputToolBarSendMode:(RoomInputToolbarViewSendMode)sendMode forEventWithId:(NSString *)eventId { - if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:[RoomInputToolbarView class]]) + if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol]) { - RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView; + MXKRoomInputToolbarView *roomInputToolbarView = (MXKRoomInputToolbarView *) self.inputToolbarView; if (eventId) { MXEvent *event = [self.roomDataSource eventWithEventId:eventId]; @@ -2122,9 +2141,9 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)updateInputToolbarEncryptionDecoration { - if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) + if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol]) { - RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView; + id roomInputToolbarView = (id)self.inputToolbarView; [self updateEncryptionDecorationForRoomInputToolbar:roomInputToolbarView]; } } @@ -2140,7 +2159,7 @@ static CGSize kThreadListBarButtonItemImageSize; roomTitleView.badgeImageView.image = self.roomEncryptionBadgeImage; } -- (void)updateEncryptionDecorationForRoomInputToolbar:(RoomInputToolbarView*)roomInputToolbarView +- (void)updateEncryptionDecorationForRoomInputToolbar:(id)roomInputToolbarView { roomInputToolbarView.isEncryptionEnabled = self.isEncryptionEnabled; } @@ -2185,11 +2204,9 @@ static CGSize kThreadListBarButtonItemImageSize; UIView *sourceView; - RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView]; - - if (roomInputToolbarView) + if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { - sourceView = roomInputToolbarView.attachMediaButton; + sourceView = ((RoomInputToolbarView*)self.inputToolbarView).attachMediaButton; } else { @@ -2261,6 +2278,7 @@ static CGSize kThreadListBarButtonItemImageSize; } - (void)setupActions { + if (![self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { return; } @@ -2298,6 +2316,16 @@ static CGSize kThreadListBarButtonItemImageSize; [self roomInputToolbarViewDidTapFileUpload]; }]]; } + if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat) + { + [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionLive.image andAction:^{ + MXStrongifyAndReturnIfNil(self); + if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { + ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; + } + [self roomInputToolbarViewDidTapVoiceBroadcast]; + }]]; + } if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionPoll.image andAction:^{ @@ -2415,6 +2443,39 @@ static CGSize kThreadListBarButtonItemImageSize; self.documentPickerPresenter = documentPickerPresenter; } +- (void)roomInputToolbarViewDidTapVoiceBroadcast +{ + // Check first the room permission + if (![self canSendStateEventWithType:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) + { + [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastPermissionDeniedMessage]]; + return; + } + + MXSession* session = self.roomDataSource.mxSession; + // Check whether the user is not already broadcasting here or in another room + if (session.voiceBroadcastService) + { + [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastAlreadyInProgressMessage]]; + return; + } + + // Request the voice broadcast service to start recording - No service is returned if someone else is already broadcasting in the room + [session getOrCreateVoiceBroadcastServiceFor:self.roomDataSource.room completion:^(VoiceBroadcastService *voiceBroadcastService) { + if (voiceBroadcastService) { + [voiceBroadcastService startVoiceBroadcastWithSuccess:^(NSString * _Nullable success) { + + } failure:^(NSError * _Nonnull error) { + + }]; + } + else + { + [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastBlockedBySomeoneElseMessage]]; + } + }]; +} + /** Send a video asset via the room input toolbar prompting the user for the conversion preset to use if the `showMediaCompressionPrompt` setting has been enabled. @@ -2423,8 +2484,7 @@ static CGSize kThreadListBarButtonItemImageSize; */ - (void)sendVideoAsset:(AVAsset *)videoAsset isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset { - RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView]; - if (!roomInputToolbarView) + if (![self inputToolbarConformsToToolbarViewProtocol]) { return; } @@ -2445,15 +2505,27 @@ static CGSize kThreadListBarButtonItemImageSize; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { - if (readyToSend) + if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { - [[self inputToolbarViewAsRoomInputToolbarView] sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; + [self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; }]; - compressionPrompt.popoverPresentationController.sourceView = roomInputToolbarView.attachMediaButton; - compressionPrompt.popoverPresentationController.sourceRect = roomInputToolbarView.attachMediaButton.bounds; + + UIView *sourceView; + + if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) + { + sourceView = ((RoomInputToolbarView*)self.inputToolbarView).attachMediaButton; + } + else + { + sourceView = self.inputToolbarView; + } + + compressionPrompt.popoverPresentationController.sourceView = sourceView; + compressionPrompt.popoverPresentationController.sourceRect = sourceView.bounds; [self presentViewController:compressionPrompt animated:YES completion:nil]; } @@ -2464,9 +2536,9 @@ static CGSize kThreadListBarButtonItemImageSize; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { - if (readyToSend) + if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { - [[self inputToolbarViewAsRoomInputToolbarView] sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; + [self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; @@ -3189,6 +3261,55 @@ static CGSize kThreadListBarButtonItemImageSize; } } } + else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcastPlayback) + { + if (bubbleData.isIncoming) + { + if (bubbleData.isPaginationFirstBubble) + { + cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceBroadcastWithPaginationTitle; + } + else if (bubbleData.shouldHideSenderInformation) + { + cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceBroadcastWithoutSenderInfo; + } + else + { + cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceBroadcast; + } + } + else + { + if (bubbleData.isPaginationFirstBubble) + { + cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithPaginationTitle; + } + else if (bubbleData.shouldHideSenderInformation) + { + cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithoutSenderInfo; + } + else + { + cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcast; + } + } + } + else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcastRecord) + { + if (bubbleData.isPaginationFirstBubble) + { + cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle; + } + else if (bubbleData.shouldHideSenderInformation) + { + cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo; + } + else + { + cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder; + } + } + else if (roomBubbleCellData.getFirstBubbleComponentWithDisplay.event.isEmote) { if (bubbleData.isIncoming) @@ -4509,6 +4630,9 @@ static CGSize kThreadListBarButtonItemImageSize; // Do nothing for dummy links shouldDoAction = NO; break; + case RoomMessageURLTypeHttp: + shouldDoAction = YES; + break; default: { MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; @@ -4534,16 +4658,20 @@ static CGSize kThreadListBarButtonItemImageSize; break; case UITextItemInteractionPresentActions: { - // Retrieve the tapped event - MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; - - if (tappedEvent) - { - // Long press on link, present room contextual menu. - [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:NO cell:cell animated:YES]; + if (roomMessageURLType == RoomMessageURLTypeHttp) { + shouldDoAction = YES; + } else { + // Retrieve the tapped event + MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; + + if (tappedEvent) + { + // Long press on link, present room contextual menu. + [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:NO cell:cell animated:YES]; + } + + shouldDoAction = NO; } - - shouldDoAction = NO; } break; case UITextItemInteractionPreview: @@ -4608,12 +4736,16 @@ static CGSize kThreadListBarButtonItemImageSize; { MXEvent *event = [self.roomDataSource eventWithEventId:eventId]; - RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView]; - - if (roomInputToolbarView) + if ([self inputToolbarConformsToHtmlToolbarViewProtocol]) { - self.textMessageBeforeEditing = roomInputToolbarView.attributedTextMessage; - roomInputToolbarView.attributedTextMessage = [self.customizedRoomDataSource editableAttributedTextMessageFor:event]; + MXKRoomInputToolbarView *htmlInputToolBarView = (MXKRoomInputToolbarView *) self.inputToolbarView; + self.htmlTextBeforeEditing = htmlInputToolBarView.htmlContent; + htmlInputToolBarView.htmlContent = [self.customizedRoomDataSource editableHtmlTextMessageFor:event]; + } + else if ([self inputToolbarConformsToToolbarViewProtocol]) + { + self.textMessageBeforeEditing = self.inputToolbarView.attributedTextMessage; + self.inputToolbarView.attributedTextMessage = [self.customizedRoomDataSource editableAttributedTextMessageFor:event]; } [self selectEventWithId:eventId inputToolBarSendMode:RoomInputToolbarViewSendModeEdit showTimestamp:YES]; @@ -4621,26 +4753,30 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)restoreTextMessageBeforeEditing { - RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView]; - if (self.textMessageBeforeEditing) + + if (self.htmlTextBeforeEditing && [self inputToolbarConformsToHtmlToolbarViewProtocol]) { - roomInputToolbarView.attributedTextMessage = self.textMessageBeforeEditing; + MXKRoomInputToolbarView *htmlInputToolBarView = (MXKRoomInputToolbarView *) self.inputToolbarView; + htmlInputToolBarView.htmlContent = self.htmlTextBeforeEditing; + } + else if (self.textMessageBeforeEditing && [self inputToolbarConformsToToolbarViewProtocol]) + { + self.inputToolbarView.attributedTextMessage = self.textMessageBeforeEditing; } self.textMessageBeforeEditing = nil; + self.htmlTextBeforeEditing = nil; } -- (RoomInputToolbarView*)inputToolbarViewAsRoomInputToolbarView +- (BOOL)inputToolbarConformsToHtmlToolbarViewProtocol { - RoomInputToolbarView *roomInputToolbarView; - - if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:[RoomInputToolbarView class]]) - { - roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView; - } - - return roomInputToolbarView; + return [self.inputToolbarView conformsToProtocol:@protocol(HtmlRoomInputToolbarViewProtocol)]; +} + +- (BOOL)inputToolbarConformsToToolbarViewProtocol +{ + return [self.inputToolbarView conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]; } - (void)showDifferentURLsAlertFor:(NSURL *)url visibleURLString:(NSString *)visibleURLString @@ -4933,32 +5069,17 @@ static CGSize kThreadListBarButtonItemImageSize; { if (self.roomInputToolbarContainerHeightConstraint.constant != height) { - // Hide temporarily the placeholder to prevent its distorsion during height animation - if (!savedInputToolbarPlaceholder) - { - savedInputToolbarPlaceholder = toolbarView.placeholder.length ? toolbarView.placeholder : @""; - } - toolbarView.placeholder = nil; - [super roomInputToolbarView:toolbarView heightDidChanged:height completion:^(BOOL finished) { if (completion) { completion (finished); } - - // Consider here the saved placeholder only if no new placeholder has been defined during the height animation. - if (!toolbarView.placeholder) - { - // Restore the placeholder if any - toolbarView.placeholder = self->savedInputToolbarPlaceholder.length ? self->savedInputToolbarPlaceholder : nil; - } - self->savedInputToolbarPlaceholder = nil; }]; } } -- (void)roomInputToolbarViewDidTapCancel:(RoomInputToolbarView*)toolbarView +- (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView*)toolbarView { [self cancelEventSelection]; } @@ -4977,6 +5098,57 @@ static CGSize kThreadListBarButtonItemImageSize; } } +- (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText +{ + // Create before sending the message in case of a discussion (direct chat) + MXWeakify(self); + [self createDiscussionIfNeeded:^(BOOL readyToSend) { + MXStrongifyAndReturnIfNil(self); + + if (readyToSend) { + [self sendFormattedTextMessage:rawText htmlMsg:formattedTextMessage]; + } + // Errors are handled at the request level. This should be improved in case of code rewriting. + }]; +} + +- (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView +{ + NSMutableArray *actionItems = [NSMutableArray new]; + if (RiotSettings.shared.roomScreenAllowMediaLibraryAction) + { + [actionItems addObject:@(ComposerCreateActionPhotoLibrary)]; + } + if (RiotSettings.shared.roomScreenAllowStickerAction && !self.isNewDirectChat) + { + [actionItems addObject:@(ComposerCreateActionStickers)]; + } + if (RiotSettings.shared.roomScreenAllowFilesAction) + { + [actionItems addObject:@(ComposerCreateActionAttachments)]; + } + if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat) + { + [actionItems addObject:@(ComposerCreateActionVoiceBroadcast)]; + } + if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat) + { + [actionItems addObject:@(ComposerCreateActionPolls)]; + } + if (BuildSettings.locationSharingEnabled && !self.isNewDirectChat) + { + [actionItems addObject:@(ComposerCreateActionLocation)]; + } + if (RiotSettings.shared.roomScreenAllowCameraAction) + { + [actionItems addObject:@(ComposerCreateActionCamera)]; + } + + self.composerCreateActionListBridgePresenter = [[ComposerCreateActionListBridgePresenter alloc] initWithActions:actionItems]; + self.composerCreateActionListBridgePresenter.delegate = self; + [self.composerCreateActionListBridgePresenter presentFrom:self animated:YES]; +} + - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage { // Create before sending the message in case of a discussion (direct chat) @@ -5323,7 +5495,7 @@ static CGSize kThreadListBarButtonItemImageSize; else { // Enable back the text input - [self setRoomInputToolbarViewClass:RoomInputToolbarView.class]; + [self setRoomInputToolbarViewClass:[RoomViewController mainToolbarClass]]; [self updateInputToolBarViewHeight]; // And the extra area @@ -6122,7 +6294,13 @@ static CGSize kThreadListBarButtonItemImageSize; // Acknowledge the existence of all devices [self startActivityIndicator]; - [self.mainSession.crypto setDevicesKnown:self->unknownDevices complete:^{ + + 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]; @@ -7590,9 +7768,9 @@ static CGSize kThreadListBarButtonItemImageSize; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { - if (readyToSend) + if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { - [[self inputToolbarViewAsRoomInputToolbarView] sendSelectedImage:imageData + [self.inputToolbarView sendSelectedImage:imageData withMimeType:MXKUTI.jpeg.mimeType andCompressionMode:MediaCompressionHelper.defaultCompressionMode isPhotoLibraryAsset:NO]; @@ -7625,9 +7803,9 @@ static CGSize kThreadListBarButtonItemImageSize; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { - if (readyToSend) + if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { - [[self inputToolbarViewAsRoomInputToolbarView] sendSelectedImage:imageData + [self.inputToolbarView sendSelectedImage:imageData withMimeType:uti.mimeType andCompressionMode:MediaCompressionHelper.defaultCompressionMode isPhotoLibraryAsset:YES]; @@ -7654,9 +7832,9 @@ static CGSize kThreadListBarButtonItemImageSize; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { - if (readyToSend) + if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { - [[self inputToolbarViewAsRoomInputToolbarView] sendSelectedAssets:assets withCompressionMode:MediaCompressionHelper.defaultCompressionMode]; + [self.inputToolbarView sendSelectedAssets:assets withCompressionMode:MediaCompressionHelper.defaultCompressionMode]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; @@ -7920,4 +8098,42 @@ static CGSize kThreadListBarButtonItemImageSize; } } +#pragma mark - ComposerCreateActionListBridgePresenter + +- (void)composerCreateActionListBridgePresenterDelegateDidComplete:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter action:(enum ComposerCreateAction)action +{ + + [coordinatorBridgePresenter dismissWithAnimated:true completion:^{ + switch (action) { + case ComposerCreateActionPhotoLibrary: + [self showMediaPickerAnimated:YES]; + break; + case ComposerCreateActionStickers: + [self roomInputToolbarViewPresentStickerPicker]; + break; + case ComposerCreateActionAttachments: + [self roomInputToolbarViewDidTapFileUpload]; + break; + case ComposerCreateActionVoiceBroadcast: + [self roomInputToolbarViewDidTapVoiceBroadcast]; + break; + case ComposerCreateActionPolls: + [self.delegate roomViewControllerDidRequestPollCreationFormPresentation:self]; + break; + case ComposerCreateActionLocation: + [self.delegate roomViewControllerDidRequestLocationSharingFormPresentation:self]; + break; + case ComposerCreateActionCamera: + [self showCameraControllerAnimated:YES]; + break; + } + self.composerCreateActionListBridgePresenter = nil; + }]; +} + +- (void)composerCreateActionListBridgePresenterDidDismissInteractively:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter +{ + self.composerCreateActionListBridgePresenter = nil; +} + @end diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 3fc72f6ab..7bbc6812c 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -52,6 +52,55 @@ extension RoomViewController { } + /// Send the formatted text message and its raw counterpat to the room + /// + /// - Parameter rawTextMsg: the raw text message + /// - Parameter htmlMsg: the html text message + @objc func sendFormattedTextMessage(_ rawTextMsg: String, htmlMsg: String) { + let eventModified = self.roomDataSource.event(withEventId: customizedRoomDataSource?.selectedEventId) + self.setupRoomDataSource { roomDataSource in + guard let roomDataSource = roomDataSource as? RoomDataSource else { return } + if self.wysiwygInputToolbar?.sendMode == .reply, let eventModified = eventModified { + roomDataSource.sendReply(to: eventModified, rawText: rawTextMsg, htmlText: htmlMsg) { response in + switch response { + case .success: + break + case .failure: + MXLog.error("[RoomViewController] sendFormattedTextMessage failed while updating event", context: [ + "event_id": eventModified.eventId + ]) + } + } + } else if self.wysiwygInputToolbar?.sendMode == .edit, let eventModified = eventModified { + roomDataSource.replaceFormattedTextMessage( + for: eventModified, + rawText: rawTextMsg, + html: htmlMsg, + success: { _ in + // + }, + failure: { _ in + MXLog.error("[RoomViewController] sendFormattedTextMessage failed while updating event", context: [ + "event_id": eventModified.eventId + ]) + }) + } else { + roomDataSource.sendFormattedTextMessage(rawTextMsg, html: htmlMsg) { response in + switch response { + case .success: + break + case .failure: + MXLog.error("[RoomViewController] sendFormattedTextMessage failed") + } + } + } + + if self.customizedRoomDataSource?.selectedEventId != nil { + self.cancelEventSelection() + } + } + } + /// Send given attributed text message to the room /// /// - Parameter attributedTextMsg: the attributed text message @@ -107,4 +156,8 @@ private extension RoomViewController { var inputToolbar: RoomInputToolbarView? { return self.inputToolbarView as? RoomInputToolbarView } + + var wysiwygInputToolbar: WysiwygInputToolbarView? { + return self.inputToolbarView as? WysiwygInputToolbarView + } } diff --git a/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m b/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m index 13eac3bcc..d5d9f08ed 100644 --- a/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m +++ b/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m @@ -237,6 +237,7 @@ static BOOL _disableLongPressGestureOnEvent; [tapGesture setDelegate:self]; [self.messageTextView addGestureRecognizer:tapGesture]; self.messageTextView.userInteractionEnabled = YES; + self.messageTextView.clipsToBounds = NO; // Recognise and make tappable phone numbers, address, etc. self.messageTextView.dataDetectorTypes = UIDataDetectorTypeAll; @@ -805,7 +806,7 @@ static BOOL _disableLongPressGestureOnEvent; mimetype = bubbleData.attachment.contentInfo[@"mimetype"]; } - if ([mimetype isEqualToString:@"image/gif"]) + if ([mimetype isKindOfClass:[NSString class]] && [mimetype isEqualToString:@"image/gif"]) { if (_isAutoAnimatedGif) { diff --git a/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h b/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h index 0edd51535..f8f02070c 100644 --- a/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h +++ b/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h @@ -169,6 +169,21 @@ typedef NS_ENUM(NSUInteger, RoomTimelineCellIdentifier) { RoomTimelineCellIdentifierOutgoingLocationWithoutSenderInfo, RoomTimelineCellIdentifierOutgoingLocationWithPaginationTitle, + // - Voice broadcast + // -- Incoming + RoomTimelineCellIdentifierIncomingVoiceBroadcast, + RoomTimelineCellIdentifierIncomingVoiceBroadcastWithoutSenderInfo, + RoomTimelineCellIdentifierIncomingVoiceBroadcastWithPaginationTitle, + // -- Outgoing + RoomTimelineCellIdentifierOutgoingVoiceBroadcast, + RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithoutSenderInfo, + RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithPaginationTitle, + + // - Voice broadcast recorder + RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder, + RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo, + RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle, + // - Others RoomTimelineCellIdentifierEmpty, RoomTimelineCellIdentifierSelectedSticker, diff --git a/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift b/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift index 5aa5f10e5..f33762144 100644 --- a/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift +++ b/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift @@ -16,6 +16,7 @@ import UIKit import MatrixSDK +import SwiftUI @objc protocol SizableBaseRoomCellType: BaseRoomCellProtocol { static func sizingViewHeightHashValue(from bubbleCellData: MXKRoomBubbleCellData) -> Int @@ -35,6 +36,7 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType { private static let reactionsViewModelBuilder = RoomReactionsViewModelBuilder() private static let urlPreviewViewSizer = URLPreviewViewSizer() + private var contentVC: UIViewController? private class var sizingView: SizableBaseRoomCell { let sizingView: SizableBaseRoomCell @@ -115,6 +117,10 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType { sizingView.setNeedsLayout() sizingView.layoutIfNeeded() + if let contentVC = sizingView.contentVC as? UIHostingController { + contentVC.view.invalidateIntrinsicContentSize() + } + let fittingSize = CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) var height = sizingView.systemLayoutSizeFitting(fittingSize).height @@ -168,4 +174,24 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType { return height } + + func addContentViewController(_ controller: UIViewController, on contentView: UIView) { + controller.view.invalidateIntrinsicContentSize() + + let parent = vc_parentViewController + parent?.addChild(controller) + contentView.vc_addSubViewMatchingParent(controller.view) + controller.didMove(toParent: parent) + + contentVC = controller + } + + override func prepareForReuse() { + contentVC?.removeFromParent() + contentVC?.view.removeFromSuperview() + contentVC?.didMove(toParent: nil) + contentVC = nil + + super.prepareForReuse() + } } diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m b/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m index eb44e25ca..f632f7f10 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m @@ -132,6 +132,24 @@ [tableView registerClass:FileWithoutThumbnailOutoingWithPaginationTitleBubbleCell.class forCellReuseIdentifier:FileWithoutThumbnailOutoingWithPaginationTitleBubbleCell.defaultReuseIdentifier]; } +- (void)registerVoiceBroadcastCellsForTableView:(UITableView*)tableView +{ + // Incoming + [tableView registerClass:VoiceBroadcastIncomingBubbleCell.class forCellReuseIdentifier:VoiceBroadcastIncomingBubbleCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastIncomingWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceBroadcastIncomingWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastIncomingWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceBroadcastIncomingWithPaginationTitleBubbleCell.defaultReuseIdentifier]; + // Outgoing + [tableView registerClass:VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.defaultReuseIdentifier]; +} + +- (void)registerVoiceBroadcastRecorderCellsForTableView:(UITableView*)tableView +{ + // Outgoing + [tableView registerClass:VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.defaultReuseIdentifier]; +} + #pragma mark - Mapping - (NSDictionary*)incomingTextMessageCellsMapping @@ -293,6 +311,7 @@ }; } + - (NSDictionary*)bwiContentScannerCellsMapping { return @{ @@ -300,5 +319,29 @@ @(ContentScannerIdentifierThumbnail) : ContentScannerThumbnailCell.class }; } + +- (NSDictionary*)voiceBroadcastCellsMapping +{ + return @{ + // Incoming + @(RoomTimelineCellIdentifierIncomingVoiceBroadcast) : VoiceBroadcastIncomingBubbleCell.class, + @(RoomTimelineCellIdentifierIncomingVoiceBroadcastWithoutSenderInfo) : VoiceBroadcastIncomingWithoutSenderInfoBubbleCell.class, + @(RoomTimelineCellIdentifierIncomingVoiceBroadcastWithPaginationTitle) : VoiceBroadcastIncomingWithPaginationTitleBubbleCell.class, + // Outgoing + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcast) : VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithoutSenderInfo) : VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithPaginationTitle) : VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.class, + }; +} + +- (NSDictionary*)voiceBroadcastRecorderCellsMapping +{ + return @{ + // Outgoing + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder) : VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo) : VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle) : VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.class, + }; +} @end diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift index b69abdcd4..993b606c5 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift @@ -36,10 +36,10 @@ class PollBaseBubbleCell: PollPlainCell { self.setupBubbleBackgroundView() } - override func addPollView(_ pollView: UIView, on contentView: UIView) { - super.addPollView(pollView, on: contentView) + override func addContentViewController(_ controller: UIViewController, on contentView: UIView) { + super.addContentViewController(controller, on: contentView) - self.addBubbleBackgroundViewIfNeeded(for: pollView) + self.addBubbleBackgroundViewIfNeeded(for: controller.view) } // MARK: - Private diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingBubbleCell.swift new file mode 100644 index 000000000..f46acbae1 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingBubbleCell.swift @@ -0,0 +1,39 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastIncomingBubbleCell: VoiceBroadcastBubbleCell, BubbleIncomingRoomCellProtocol { + + override func setupViews() { + super.setupViews() + + // TODO: VB update margins attributes + let leftMargin: CGFloat = BubbleRoomCellLayoutConstants.incomingBubbleBackgroundMargins.left + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left + let rightMargin: CGFloat = BubbleRoomCellLayoutConstants.incomingBubbleBackgroundMargins.right + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right + + roomCellContentView?.innerContentViewLeadingConstraint.constant = leftMargin + roomCellContentView?.innerContentViewTrailingConstraint.constant = rightMargin + + self.setupBubbleDecorations() + } + + override func update(theme: Theme) { + super.update(theme: theme) + + self.bubbleBackgroundColor = theme.roomCellIncomingBubbleBackgroundColor + } +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingWithPaginationTitleBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingWithPaginationTitleBubbleCell.swift new file mode 100644 index 000000000..6bbb10d9a --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingWithPaginationTitleBubbleCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastIncomingWithPaginationTitleBubbleCell: VoiceBroadcastIncomingBubbleCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showPaginationTitle = true + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingWithoutSenderInfoBubbleCell.swift new file mode 100644 index 000000000..4f123da7d --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Incoming/VoiceBroadcastIncomingWithoutSenderInfoBubbleCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastIncomingWithoutSenderInfoBubbleCell: VoiceBroadcastIncomingBubbleCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showSenderInfo = false + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Outgoing/VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Outgoing/VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.swift new file mode 100644 index 000000000..72f69e4d7 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Outgoing/VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastOutgoingWithPaginationTitleBubbleCell: VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showPaginationTitle = true + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Outgoing/VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Outgoing/VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.swift new file mode 100644 index 000000000..b149647b6 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Outgoing/VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell.swift @@ -0,0 +1,41 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastOutgoingWithoutSenderInfoBubbleCell: VoiceBroadcastBubbleCell, BubbleOutgoingRoomCellProtocol { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showSenderInfo = false + + // TODO: VB update margins attributes + let leftMargin: CGFloat = BubbleRoomCellLayoutConstants.outgoingBubbleBackgroundMargins.left + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left + let rightMargin: CGFloat = BubbleRoomCellLayoutConstants.outgoingBubbleBackgroundMargins.right + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right + + roomCellContentView?.innerContentViewTrailingConstraint.constant = rightMargin + roomCellContentView?.innerContentViewLeadingConstraint.constant = leftMargin + + self.setupBubbleDecorations() + } + + override func update(theme: Theme) { + super.update(theme: theme) + + self.bubbleBackgroundColor = theme.roomCellOutgoingBubbleBackgroundColor + } +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.swift new file mode 100644 index 000000000..c30badc8e --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell: VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showPaginationTitle = true + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.swift new file mode 100644 index 000000000..4d56aee96 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.swift @@ -0,0 +1,41 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell: VoiceBroadcastRecorderBubbleCell, BubbleOutgoingRoomCellProtocol { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showSenderInfo = false + + // TODO: VB update margins attributes + let leftMargin: CGFloat = BubbleRoomCellLayoutConstants.outgoingBubbleBackgroundMargins.left + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left + let rightMargin: CGFloat = BubbleRoomCellLayoutConstants.outgoingBubbleBackgroundMargins.right + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right + + roomCellContentView?.innerContentViewTrailingConstraint.constant = rightMargin + roomCellContentView?.innerContentViewLeadingConstraint.constant = leftMargin + + self.setupBubbleDecorations() + } + + override func update(theme: Theme) { + super.update(theme: theme) + + self.bubbleBackgroundColor = theme.roomCellOutgoingBubbleBackgroundColor + } +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderBubbleCell.swift new file mode 100644 index 000000000..5b7a92a2f --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderBubbleCell.swift @@ -0,0 +1,113 @@ +// +// Copyright 2022 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 UIKit + +class VoiceBroadcastRecorderBubbleCell: VoiceBroadcastRecorderPlainCell { + + // MARK: - Properties + + var bubbleBackgroundColor: UIColor? + + // MARK: - Overrides + + override func render(_ cellData: MXKCellData!) { + super.render(cellData) + + self.update(theme: ThemeService.shared().theme) + } + + override func setupViews() { + super.setupViews() + + self.setupBubbleBackgroundView() + } + + override func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) { + super.addVoiceBroadcastView(voiceBroadcastView, on: contentView) + + self.addBubbleBackgroundViewIfNeeded(for: voiceBroadcastView) + } + + // MARK: - Private + + private func addBubbleBackgroundViewIfNeeded(for voiceBroadcastView: UIView) { + + guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else { + return + } + + self.addBubbleBackgroundView( messageBubbleBackgroundView, to: voiceBroadcastView) + messageBubbleBackgroundView.backgroundColor = self.bubbleBackgroundColor + } + + private func addBubbleBackgroundView(_ bubbleBackgroundView: RoomMessageBubbleBackgroundView, + to voiceBroadcastView: UIView) { + + // TODO: VB update margins attributes + let topMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.top + let leftMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left + let rightMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right + let bottomMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.bottom + + let topAnchor = voiceBroadcastView.topAnchor + let leadingAnchor = voiceBroadcastView.leadingAnchor + let trailingAnchor = voiceBroadcastView.trailingAnchor + let bottomAnchor = voiceBroadcastView.bottomAnchor + + NSLayoutConstraint.activate([ + bubbleBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: -topMargin), + bubbleBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -leftMargin), + bubbleBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightMargin), + bubbleBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomMargin) + ]) + } + + private func setupBubbleBackgroundView() { + let bubbleBackgroundView = RoomMessageBubbleBackgroundView() + self.roomCellContentView?.insertSubview(bubbleBackgroundView, at: 0) + } + + // The extension property MXKRoomBubbleTableViewCell.messageBubbleBackgroundView is not working there even by doing recursion + private func getBubbleBackgroundView() -> RoomMessageBubbleBackgroundView? { + guard let contentView = self.roomCellContentView else { + return nil + } + + let foundView = contentView.subviews.first { view in + return view is RoomMessageBubbleBackgroundView + } + return foundView as? RoomMessageBubbleBackgroundView + } +} + +// MARK: - TimestampDisplayable +extension VoiceBroadcastRecorderBubbleCell: TimestampDisplayable { + + func addTimestampView(_ timestampView: UIView) { + guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else { + return + } + messageBubbleBackgroundView.addTimestampView(timestampView) + } + + func removeTimestampView() { + guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else { + return + } + messageBubbleBackgroundView.removeTimestampView() + } +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift new file mode 100644 index 000000000..67db62e88 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift @@ -0,0 +1,113 @@ +// +// Copyright 2022 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 UIKit + +class VoiceBroadcastBubbleCell: VoiceBroadcastPlainCell { + + // MARK: - Properties + + var bubbleBackgroundColor: UIColor? + + // MARK: - Overrides + + override func render(_ cellData: MXKCellData!) { + super.render(cellData) + + self.update(theme: ThemeService.shared().theme) + } + + override func setupViews() { + super.setupViews() + + self.setupBubbleBackgroundView() + } + + override func addContentViewController(_ controller: UIViewController, on contentView: UIView) { + super.addContentViewController(controller, on: contentView) + + self.addBubbleBackgroundViewIfNeeded(for: controller.view) + } + + // MARK: - Private + + private func addBubbleBackgroundViewIfNeeded(for voiceBroadcastView: UIView) { + + guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else { + return + } + + self.addBubbleBackgroundView( messageBubbleBackgroundView, to: voiceBroadcastView) + messageBubbleBackgroundView.backgroundColor = self.bubbleBackgroundColor + } + + private func addBubbleBackgroundView(_ bubbleBackgroundView: RoomMessageBubbleBackgroundView, + to voiceBroadcastView: UIView) { + + // TODO: VB update margins attributes + let topMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.top + let leftMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left + let rightMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right + let bottomMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.bottom + + let topAnchor = voiceBroadcastView.topAnchor + let leadingAnchor = voiceBroadcastView.leadingAnchor + let trailingAnchor = voiceBroadcastView.trailingAnchor + let bottomAnchor = voiceBroadcastView.bottomAnchor + + NSLayoutConstraint.activate([ + bubbleBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: -topMargin), + bubbleBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -leftMargin), + bubbleBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightMargin), + bubbleBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomMargin) + ]) + } + + private func setupBubbleBackgroundView() { + let bubbleBackgroundView = RoomMessageBubbleBackgroundView() + self.roomCellContentView?.insertSubview(bubbleBackgroundView, at: 0) + } + + // The extension property MXKRoomBubbleTableViewCell.messageBubbleBackgroundView is not working there even by doing recursion + private func getBubbleBackgroundView() -> RoomMessageBubbleBackgroundView? { + guard let contentView = self.roomCellContentView else { + return nil + } + + let foundView = contentView.subviews.first { view in + return view is RoomMessageBubbleBackgroundView + } + return foundView as? RoomMessageBubbleBackgroundView + } +} + +// MARK: - RoomCellTimestampDisplayable +extension VoiceBroadcastBubbleCell: TimestampDisplayable { + + func addTimestampView(_ timestampView: UIView) { + guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else { + return + } + messageBubbleBackgroundView.addTimestampView(timestampView) + } + + func removeTimestampView() { + guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else { + return + } + messageBubbleBackgroundView.removeTimestampView() + } +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift index 345f0de95..70cf4370f 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift @@ -17,8 +17,7 @@ import Foundation class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCellReadMarkerDisplayable { - - private var pollView: UIView? + private var event: MXEvent? override func render(_ cellData: MXKCellData!) { @@ -28,12 +27,12 @@ class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCell let bubbleData = cellData as? RoomBubbleCellData, let event = bubbleData.events.last, event.eventType == __MXEventType.pollStart, - let view = TimelinePollProvider.shared.buildTimelinePollViewForEvent(event) else { + let controller = TimelinePollProvider.shared.buildTimelinePollVCForEvent(event) else { return } self.event = event - self.addPollView(view, on: contentView) + self.addContentViewController(controller, on: contentView) } override func setupViews() { @@ -52,13 +51,6 @@ class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCell delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event]) } - - func addPollView(_ pollView: UIView, on contentView: UIView) { - - self.pollView?.removeFromSuperview() - contentView.vc_addSubViewMatchingParent(pollView) - self.pollView = pollView - } } extension PollPlainCell: RoomCellThreadSummaryDisplayable {} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift new file mode 100644 index 000000000..a65254be5 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastRecorderPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCellReadMarkerDisplayable { + + private var voiceBroadcastView: UIView? + private var event: MXEvent? + + override func render(_ cellData: MXKCellData!) { + super.render(cellData) + + guard let contentView = roomCellContentView?.innerContentView, + let bubbleData = cellData as? RoomBubbleCellData, + let event = bubbleData.events.last, + let voiceBroadcastContent = VoiceBroadcastInfo(fromJSON: event.content), + voiceBroadcastContent.state == VoiceBroadcastInfo.State.started.rawValue, + let view = VoiceBroadcastRecorderProvider.shared.buildVoiceBroadcastRecorderViewForEvent(event, senderDisplayName: bubbleData.senderDisplayName) else { + return + } + + self.event = event + self.addVoiceBroadcastView(view, on: contentView) + } + + override func setupViews() { + super.setupViews() + + roomCellContentView?.backgroundColor = .clear + roomCellContentView?.showSenderInfo = true + roomCellContentView?.showPaginationTitle = false + } + + // The normal flow for tapping on cell content views doesn't work for bubbles without attributed strings + override func onContentViewTap(_ sender: UITapGestureRecognizer) { + guard let event = self.event else { + return + } + + delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event]) + } + + func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) { + + self.voiceBroadcastView?.removeFromSuperview() + contentView.vc_addSubViewMatchingParent(voiceBroadcastView) + self.voiceBroadcastView = voiceBroadcastView + } +} + +extension VoiceBroadcastRecorderPlainCell: RoomCellThreadSummaryDisplayable {} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithPaginationTitlePlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithPaginationTitlePlainCell.swift new file mode 100644 index 000000000..4247f306c --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithPaginationTitlePlainCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastRecorderWithPaginationTitlePlainCell: VoiceBroadcastRecorderPlainCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showPaginationTitle = true + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithoutSenderInfoPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithoutSenderInfoPlainCell.swift new file mode 100644 index 000000000..172b10aee --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithoutSenderInfoPlainCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastRecorderWithoutSenderInfoPlainCell: VoiceBroadcastRecorderPlainCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showSenderInfo = false + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift new file mode 100644 index 000000000..14c602c4c --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift @@ -0,0 +1,57 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCellReadMarkerDisplayable { + + private var event: MXEvent? + + override func render(_ cellData: MXKCellData!) { + super.render(cellData) + + guard let contentView = roomCellContentView?.innerContentView, + let bubbleData = cellData as? RoomBubbleCellData, + let event = bubbleData.events.last, + let voiceBroadcastContent = VoiceBroadcastInfo(fromJSON: event.content), + voiceBroadcastContent.state == VoiceBroadcastInfo.State.started.rawValue, + let controller = VoiceBroadcastPlaybackProvider.shared.buildVoiceBroadcastPlaybackVCForEvent(event, senderDisplayName: bubbleData.senderDisplayName) else { + return + } + + self.event = event + self.addContentViewController(controller, on: contentView) + } + + override func setupViews() { + super.setupViews() + + roomCellContentView?.backgroundColor = .clear + roomCellContentView?.showSenderInfo = true + roomCellContentView?.showPaginationTitle = false + } + + // The normal flow for tapping on cell content views doesn't work for bubbles without attributed strings + override func onContentViewTap(_ sender: UITapGestureRecognizer) { + guard let event = self.event else { + return + } + + delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event]) + } +} + +extension VoiceBroadcastPlainCell: RoomCellThreadSummaryDisplayable {} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastWithPaginationTitlePlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastWithPaginationTitlePlainCell.swift new file mode 100644 index 000000000..fa3c3bc50 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastWithPaginationTitlePlainCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastWithPaginationTitlePlainCell: VoiceBroadcastPlainCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showPaginationTitle = true + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastWithoutSenderInfoPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastWithoutSenderInfoPlainCell.swift new file mode 100644 index 000000000..6f3ec9110 --- /dev/null +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastWithoutSenderInfoPlainCell.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastWithoutSenderInfoPlainCell: VoiceBroadcastPlainCell { + + override func setupViews() { + super.setupViews() + + roomCellContentView?.showSenderInfo = false + } + +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h index 73f0d8f8b..b1e85a621 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h @@ -32,6 +32,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)registerLocationCellsForTableView:(UITableView*)tableView; +- (void)registerVoiceBroadcastCellsForTableView:(UITableView*)tableView; + #pragma mark - Mapping - (NSDictionary*)incomingTextMessageCellsMapping; @@ -54,6 +56,10 @@ NS_ASSUME_NONNULL_BEGIN - (NSDictionary*)locationCellsMapping; +- (NSDictionary*)voiceBroadcastCellsMapping; + +- (NSDictionary*)voiceBroadcastRecorderCellsMapping; + @end NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m index 6a64422e5..73d3adc0b 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m @@ -112,7 +112,11 @@ [self registerLocationCellsForTableView:tableView]; [self registerFileWithoutThumbnailCellsForTableView:tableView]; + + [self registerVoiceBroadcastCellsForTableView:tableView]; + [self registerVoiceBroadcastRecorderCellsForTableView:tableView]; + [tableView registerClass:RoomEmptyBubbleCell.class forCellReuseIdentifier:RoomEmptyBubbleCell.defaultReuseIdentifier]; [tableView registerClass:RoomSelectedStickerBubbleCell.class forCellReuseIdentifier:RoomSelectedStickerBubbleCell.defaultReuseIdentifier]; @@ -270,6 +274,20 @@ [tableView registerClass:FileWithoutThumbnailWithPaginationTitlePlainCell.class forCellReuseIdentifier:FileWithoutThumbnailWithPaginationTitlePlainCell.defaultReuseIdentifier]; } +- (void)registerVoiceBroadcastCellsForTableView:(UITableView*)tableView +{ + [tableView registerClass:VoiceBroadcastPlainCell.class forCellReuseIdentifier:VoiceBroadcastPlainCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastWithoutSenderInfoPlainCell.class forCellReuseIdentifier:VoiceBroadcastWithoutSenderInfoPlainCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastWithPaginationTitlePlainCell.class forCellReuseIdentifier:VoiceBroadcastWithPaginationTitlePlainCell.defaultReuseIdentifier]; +} + +- (void)registerVoiceBroadcastRecorderCellsForTableView:(UITableView*)tableView +{ + [tableView registerClass:VoiceBroadcastRecorderPlainCell.class forCellReuseIdentifier:VoiceBroadcastRecorderPlainCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastRecorderWithoutSenderInfoPlainCell.class forCellReuseIdentifier:VoiceBroadcastRecorderWithoutSenderInfoPlainCell.defaultReuseIdentifier]; + [tableView registerClass:VoiceBroadcastRecorderWithPaginationTitlePlainCell.class forCellReuseIdentifier:VoiceBroadcastRecorderWithPaginationTitlePlainCell.defaultReuseIdentifier]; +} + #pragma mark Cell class association - (NSDictionary*)buildCellClasses @@ -327,6 +345,12 @@ NSDictionary *locationCellsMapping = [self locationCellsMapping]; [cellClasses addEntriesFromDictionary:locationCellsMapping]; + + NSDictionary *voiceBroadcastCellsMapping = [self voiceBroadcastCellsMapping]; + [cellClasses addEntriesFromDictionary:voiceBroadcastCellsMapping]; + + NSDictionary *voiceBroadcastRecorderCellsMapping = [self voiceBroadcastRecorderCellsMapping]; + [cellClasses addEntriesFromDictionary:voiceBroadcastRecorderCellsMapping]; NSDictionary *othersCells = @{ @(RoomTimelineCellIdentifierEmpty) : RoomEmptyBubbleCell.class, @@ -523,6 +547,7 @@ @(RoomTimelineCellIdentifierOutgoingVoiceMessageWithoutSenderInfo) : VoiceMessageWithoutSenderInfoPlainCell.class, @(RoomTimelineCellIdentifierOutgoingVoiceMessageWithPaginationTitle) : VoiceMessageWithPaginationTitlePlainCell.class }; + } - (NSDictionary*)pollCellsMapping @@ -560,5 +585,29 @@ @(ContentScannerIdentifierThumbnail) : ContentScannerThumbnailCell.class }; } + +- (NSDictionary*)voiceBroadcastCellsMapping +{ + return @{ + // Incoming + @(RoomTimelineCellIdentifierIncomingVoiceBroadcast) : VoiceBroadcastPlainCell.class, + @(RoomTimelineCellIdentifierIncomingVoiceBroadcastWithoutSenderInfo) : VoiceBroadcastWithoutSenderInfoPlainCell.class, + @(RoomTimelineCellIdentifierIncomingVoiceBroadcastWithPaginationTitle) : VoiceBroadcastWithPaginationTitlePlainCell.class, + // Outoing + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcast) : VoiceBroadcastPlainCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithoutSenderInfo) : VoiceBroadcastWithoutSenderInfoPlainCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithPaginationTitle) : VoiceBroadcastWithPaginationTitlePlainCell.class + }; +} + +- (NSDictionary*)voiceBroadcastRecorderCellsMapping +{ + return @{ + // Outoing + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder) : VoiceBroadcastRecorderPlainCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo) : VoiceBroadcastRecorderWithoutSenderInfoPlainCell.class, + @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle) : VoiceBroadcastRecorderWithPaginationTitlePlainCell.class + }; +} @end diff --git a/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift b/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift index 1af0a99aa..7f8df4e18 100644 --- a/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift +++ b/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift @@ -26,7 +26,7 @@ struct RoomAvatarViewData: AvatarViewDataProtocol { return roomId } - var fallbackImage: AvatarFallbackImage? { - return .matrixItem(matrixItemId, displayName) + var fallbackImages: [AvatarFallbackImage]? { + [.matrixItem(matrixItemId, displayName)] } } diff --git a/Riot/Modules/Room/Views/InputToolbar/DisabledRoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/DisabledRoomInputToolbarView.m index c3337e887..32859feeb 100644 --- a/Riot/Modules/Room/Views/InputToolbar/DisabledRoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/DisabledRoomInputToolbarView.m @@ -27,7 +27,7 @@ bundle:[NSBundle bundleForClass:[DisabledRoomInputToolbarView class]]]; } -+ (instancetype)roomInputToolbarView ++ (MXKRoomInputToolbarView *)instantiateRoomInputToolbarView { if ([[self class] nib]) { diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift index 389e057ea..47d981c86 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift @@ -139,7 +139,7 @@ class RoomInputToolbarTextView: UITextView { } private func updateUI() { - var height = sizeThatFits(CGSize(width: bounds.size.width, height: CGFloat.greatestFiniteMagnitude)).height + var height = contentSize.height height = minHeight > 0 ? max(height, minHeight) : height height = maxHeight > 0 ? min(height, maxHeight) : height diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index 1f889bb9d..4bdea353b 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -33,6 +33,17 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) }; +@protocol RoomInputToolbarViewProtocol + +@property (nonatomic, strong) NSString *eventSenderDisplayName; +@property (nonatomic, assign) RoomInputToolbarViewSendMode sendMode; +@property (nonatomic, assign) BOOL isEncryptionEnabled; +- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView; +- (CGFloat)toolbarHeight; + + +@end + @protocol RoomInputToolbarViewDelegate /** @@ -40,7 +51,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) @param toolbarView the room input toolbar view */ -- (void)roomInputToolbarViewDidTapCancel:(RoomInputToolbarView*)toolbarView; +- (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView*)toolbarView; /** Inform the delegate that the text message has changed. @@ -70,7 +81,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) `RoomInputToolbarView` instance is a view used to handle all kinds of available inputs for a room (message composer, attachments selection...). */ -@interface RoomInputToolbarView : MXKRoomInputToolbarView +@interface RoomInputToolbarView : MXKRoomInputToolbarView /** The delegate notified when inputs are ready. diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index f3b93ad44..9abfde421 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -19,7 +19,6 @@ #import "ThemeService.h" #import "GeneratedInterface-Swift.h" -#import "GBDeviceInfo_iOS.h" static const CGFloat kContextBarHeight = 24; static const CGFloat kActionMenuAttachButtonSpringVelocity = 7; @@ -59,7 +58,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; @implementation RoomInputToolbarView @dynamic delegate; -+ (instancetype)roomInputToolbarView ++ (MXKRoomInputToolbarView *)instantiateRoomInputToolbarView { UINib *nib = [UINib nibWithNibName:NSStringFromClass([RoomInputToolbarView class]) bundle:nil]; return [nib instantiateWithOwner:nil options:nil].firstObject; @@ -85,25 +84,6 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; self.textView.inputAccessoryView = inputAccessoryViewForKeyboard; } -- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView -{ - if (voiceMessageToolbarView) { - _voiceMessageToolbarView = voiceMessageToolbarView; - self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:self.voiceMessageToolbarView]; - - [NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor], - [self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor], - [self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor], - [self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]]; - } - else - { - [self.voiceMessageToolbarView removeFromSuperview]; - _voiceMessageToolbarView = nil; - } -} - #pragma mark - Override MXKView -(void)customizeViewRendering @@ -300,69 +280,6 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; } } -- (void)updatePlaceholder -{ - // Consider the default placeholder - - NSString *placeholder; - - // Check the device screen size before using large placeholder - BOOL shouldDisplayLargePlaceholder = [GBDeviceInfo deviceInfo].family == GBDeviceFamilyiPad || [GBDeviceInfo deviceInfo].displayInfo.display >= GBDeviceDisplay5p8Inch; - - if (!shouldDisplayLargePlaceholder) - { - switch (_sendMode) - { - case RoomInputToolbarViewSendModeReply: - placeholder = [VectorL10n roomMessageReplyToShortPlaceholder]; - break; - - case RoomInputToolbarViewSendModeCreateDM: - placeholder = [VectorL10n roomFirstMessagePlaceholder]; - break; - - default: - placeholder = [VectorL10n roomMessageShortPlaceholder]; - break; - } - } - else - { - if (_isEncryptionEnabled) - { - switch (_sendMode) - { - case RoomInputToolbarViewSendModeReply: - placeholder = [VectorL10n encryptedRoomMessageReplyToPlaceholder]; - break; - - default: - placeholder = [VectorL10n encryptedRoomMessagePlaceholder]; - break; - } - } - else - { - switch (_sendMode) - { - case RoomInputToolbarViewSendModeReply: - placeholder = [VectorL10n roomMessageReplyToPlaceholder]; - break; - - case RoomInputToolbarViewSendModeCreateDM: - placeholder = [VectorL10n roomFirstMessagePlaceholder]; - break; - - default: - placeholder = [VectorL10n roomMessagePlaceholder]; - break; - } - } - } - - self.placeholder = placeholder; -} - - (void)setPlaceholder:(NSString *)inPlaceholder { [super setPlaceholder:inPlaceholder]; @@ -543,4 +460,28 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; }]; } +#pragma mark - RoomInputToolbarViewProtocol + +- (CGFloat)toolbarHeight { + return self.mainToolbarHeightConstraint.constant; +} + +- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView +{ + if (voiceMessageToolbarView) { + _voiceMessageToolbarView = voiceMessageToolbarView; + self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:self.voiceMessageToolbarView]; + + [NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor], + [self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor], + [self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor], + [self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]]; + } + else + { + [self.voiceMessageToolbarView removeFromSuperview]; + _voiceMessageToolbarView = nil; + } +} @end diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift index 6a9de2f30..045fcc9a4 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift @@ -16,6 +16,7 @@ import Foundation import UIKit +import GBDeviceInfo extension RoomInputToolbarView { open override func sendCurrentMessage() { @@ -28,15 +29,66 @@ extension RoomInputToolbarView { self.becomeFirstResponder() temp.removeFromSuperview() } - + // Send message if any. if let messageToSend = self.attributedTextMessage, messageToSend.length > 0 { self.delegate.roomInputToolbarView(self, sendAttributedTextMessage: messageToSend) } - + // Reset message, disable view animation during the update to prevent placeholder distorsion. UIView.setAnimationsEnabled(false) self.attributedTextMessage = nil UIView.setAnimationsEnabled(true) } } + +@objc extension RoomInputToolbarView { + func updatePlaceholder() { + updatePlaceholderText() + } +} + +extension RoomInputToolbarViewProtocol where Self: MXKRoomInputToolbarView { + func updatePlaceholderText() { + // Consider the default placeholder + + let placeholder: String + + // Check the device screen size before using large placeholder + let shouldDisplayLargePlaceholder = GBDeviceInfo.deviceInfo().family == .familyiPad || GBDeviceInfo.deviceInfo().displayInfo.display.rawValue >= GBDeviceDisplay.display5p8Inch.rawValue + + if !shouldDisplayLargePlaceholder { + switch sendMode { + case .reply: + placeholder = VectorL10n.roomMessageReplyToShortPlaceholder + case .createDM: + placeholder = VectorL10n.roomFirstMessagePlaceholder + + default: + placeholder = VectorL10n.roomMessageShortPlaceholder + } + } else { + if isEncryptionEnabled { + switch sendMode { + case .reply: + placeholder = VectorL10n.encryptedRoomMessageReplyToPlaceholder + + default: + placeholder = VectorL10n.encryptedRoomMessagePlaceholder + } + } else { + switch sendMode { + case .reply: + placeholder = VectorL10n.roomMessageReplyToPlaceholder + + case .createDM: + placeholder = VectorL10n.roomFirstMessagePlaceholder + default: + placeholder = VectorL10n.roomMessagePlaceholder + } + } + } + + self.placeholder = placeholder + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib index ff1cee7be..ca3b0f5a6 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib @@ -1,9 +1,9 @@ - + - + @@ -27,7 +27,7 @@ - - + diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h index be19d7b71..e9db3a583 100644 --- a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h +++ b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h @@ -39,10 +39,15 @@ @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 onComplete:(void (^)(void))onComplete; +- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo + wasNewDevice:(BOOL)wasNewDevice + andMatrixSession:(MXSession*)session + crypto:(MXLegacyCrypto *)crypto + onComplete:(void (^)(void))onComplete; /** Show the dialog in a modal way. diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m index 91f62a8d6..6f638bd78 100644 --- a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m +++ b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m @@ -26,16 +26,24 @@ BOOL wasNewDevice; } + +@property (nonatomic, strong) MXLegacyCrypto *crypto; + @end @implementation RoomKeyRequestViewController -- (instancetype)initWithDeviceInfo:(MXDeviceInfo *)deviceInfo wasNewDevice:(BOOL)theWasNewDevice andMatrixSession:(MXSession *)session onComplete:(void (^)(void))onCompleteBlock +- (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; @@ -90,7 +98,7 @@ self->_alertController = nil; // Accept the received requests from this device - [self.mxSession.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ + [self.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ self->onComplete(); }]; @@ -108,7 +116,7 @@ self->_alertController = nil; // Ignore all pending requests from this device - [self.mxSession.crypto ignoreAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ + [self.crypto ignoreAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ self->onComplete(); }]; @@ -160,14 +168,14 @@ keyVerificationCoordinatorBridgePresenter = nil; // Check device new status - [self.mxSession.crypto downloadKeys:@[self.device.userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { + [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.mxSession.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ + [self.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{ self->onComplete(); }]; diff --git a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift index 099102014..27659b9be 100644 --- a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift +++ b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift @@ -27,19 +27,7 @@ struct DirectoryRoomTableViewCellVM { // TODO: Use AvatarView subclass in the cell view func setAvatar(in avatarImageView: MXKImageView) { - - let defaultAvatarImage: UIImage? - var defaultAvatarImageContentMode: UIView.ContentMode = .scaleAspectFill - - switch self.avatarViewData.fallbackImage { - case .matrixItem(let matrixItemId, let matrixItemDisplayName): - defaultAvatarImage = AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName) - case .image(let image, let contentMode): - defaultAvatarImage = image - defaultAvatarImageContentMode = contentMode ?? .scaleAspectFill - case .none: - defaultAvatarImage = nil - } + let (defaultAvatarImage, defaultAvatarImageContentMode) = avatarViewData.fallbackImageParameters() ?? (nil, .scaleAspectFill) if let avatarUrl = self.avatarViewData.avatarUrl { avatarImageView.enableInMemoryCache = true diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift index 05b691f3a..2e8e7604c 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift @@ -63,7 +63,7 @@ final class SecretsResetViewModel: SecretsResetViewModelType { } private func resetSecrets(with authParameters: [String: Any]) { - guard let crossSigning = self.session.crypto.crossSigning else { + guard let crossSigning = self.session.crypto?.crossSigning else { return } MXLog.debug("[SecretsResetViewModel] resetSecrets") diff --git a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift index 179e4f9fb..1060d79ea 100644 --- a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift +++ b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift @@ -150,11 +150,11 @@ final class SecureBackupSetupCoordinator: SecureBackupSetupCoordinatorType { } private func showKeyBackupRestore() { - guard let keyBackupVersion = self.keyBackup?.keyBackupVersion else { + guard let backup = keyBackup, let keyBackupVersion = backup.keyBackupVersion else { return } - let coordinator = KeyBackupRecoverCoordinator(session: self.session, keyBackupVersion: keyBackupVersion, navigationRouter: self.navigationRouter) + let coordinator = KeyBackupRecoverCoordinator(keyBackup: backup, keyBackupVersion: keyBackupVersion, navigationRouter: self.navigationRouter) self.add(childCoordinator: coordinator) coordinator.delegate = self diff --git a/Riot/Modules/Settings/Discovery/ThreePidDetails/SettingsDiscoveryThreePidDetailsViewController.swift b/Riot/Modules/Settings/Discovery/ThreePidDetails/SettingsDiscoveryThreePidDetailsViewController.swift index 9aec34fe7..784453a72 100644 --- a/Riot/Modules/Settings/Discovery/ThreePidDetails/SettingsDiscoveryThreePidDetailsViewController.swift +++ b/Riot/Modules/Settings/Discovery/ThreePidDetails/SettingsDiscoveryThreePidDetailsViewController.swift @@ -59,6 +59,7 @@ final class SettingsDiscoveryThreePidDetailsViewController: UIViewController { // Do any additional setup after loading the view. + vc_setLargeTitleDisplayMode(.never) self.setupViews() self.keyboardAvoider = KeyboardAvoider(scrollViewContainerView: self.view, scrollView: self.scrollView) self.activityPresenter = ActivityIndicatorPresenter() diff --git a/Riot/Modules/Settings/IdentityServer/SettingsIdentityServerViewController.swift b/Riot/Modules/Settings/IdentityServer/SettingsIdentityServerViewController.swift index 33e88b16a..1f9713101 100644 --- a/Riot/Modules/Settings/IdentityServer/SettingsIdentityServerViewController.swift +++ b/Riot/Modules/Settings/IdentityServer/SettingsIdentityServerViewController.swift @@ -72,6 +72,7 @@ final class SettingsIdentityServerViewController: UIViewController { // Do any additional setup after loading the view. self.title = VectorL10n.identityServerSettingsTitle + vc_setLargeTitleDisplayMode(.never) self.setupViews() self.keyboardAvoider = KeyboardAvoider(scrollViewContainerView: self.view, scrollView: self.scrollView) diff --git a/Riot/Modules/Settings/Language/LanguagePickerViewController.m b/Riot/Modules/Settings/Language/LanguagePickerViewController.m index 7c4c69055..1a7901efc 100644 --- a/Riot/Modules/Settings/Language/LanguagePickerViewController.m +++ b/Riot/Modules/Settings/Language/LanguagePickerViewController.m @@ -48,6 +48,8 @@ { [super viewDidLoad]; + [self vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; + // Hide line separators of empty cells self.tableView.tableFooterView = [[UIView alloc] init]; diff --git a/Riot/Modules/Settings/PhoneCountry/CountryPickerViewController.m b/Riot/Modules/Settings/PhoneCountry/CountryPickerViewController.m index bdbc14ee1..be4e9110c 100644 --- a/Riot/Modules/Settings/PhoneCountry/CountryPickerViewController.m +++ b/Riot/Modules/Settings/PhoneCountry/CountryPickerViewController.m @@ -50,6 +50,7 @@ // Hide line separators of empty cells self.tableView.tableFooterView = [[UIView alloc] init]; + [self vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; // Add a top view which will be displayed in case of vertical bounce. CGFloat height = self.tableView.frame.size.height; diff --git a/Riot/Modules/Settings/Security/SecurityViewController.m b/Riot/Modules/Settings/Security/SecurityViewController.m index 555fb2bc7..5959447e9 100644 --- a/Riot/Modules/Settings/Security/SecurityViewController.m +++ b/Riot/Modules/Settings/Security/SecurityViewController.m @@ -162,6 +162,7 @@ TableViewSectionsDelegate> // Do any additional setup after loading the view, typically from a nib. self.navigationItem.title = [VectorL10n securitySettingsTitle]; + [self vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; [self vc_removeBackTitle]; [self.tableView registerClass:MXKTableViewCellWithLabelAndSwitch.class forCellReuseIdentifier:[MXKTableViewCellWithLabelAndSwitch defaultReuseIdentifier]]; @@ -335,8 +336,7 @@ TableViewSectionsDelegate> } // Crypto sessions section - - if (RiotSettings.shared.settingsSecurityScreenShowSessions) + if (RiotSettings.shared.settingsSecurityScreenShowSessions && !RiotSettings.shared.enableNewSessionManager) { Section *sessionsSection = [Section sectionWithTag:SECTION_CRYPTO_SESSIONS]; @@ -643,7 +643,7 @@ TableViewSectionsDelegate> - (void)loadCrossSigning { - MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning; + id crossSigning = self.mainSession.crypto.crossSigning; [crossSigning refreshStateWithSuccess:^(BOOL stateUpdated) { if (stateUpdated) @@ -659,7 +659,7 @@ TableViewSectionsDelegate> { NSInteger numberOfRowsInCrossSigningSection; - MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning; + id crossSigning = self.mainSession.crypto.crossSigning; switch (crossSigning.state) { case MXCrossSigningStateNotBootstrapped: // Action: Bootstrap @@ -677,7 +677,7 @@ TableViewSectionsDelegate> - (NSAttributedString*)crossSigningInformation { - MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning; + id crossSigning = self.mainSession.crypto.crossSigning; NSString *crossSigningInformation; switch (crossSigning.state) @@ -724,7 +724,7 @@ TableViewSectionsDelegate> buttonCell.mxkButton.accessibilityIdentifier = nil; // And customise it - MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning; + id crossSigning = self.mainSession.crypto.crossSigning; switch (crossSigning.state) { case MXCrossSigningStateNotBootstrapped: // Action: Bootstrap diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 2fc1a5d74..17efa0768 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -224,7 +224,9 @@ typedef NS_ENUM(NSUInteger, LABS_ENABLE) LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS, LABS_ENABLE_LIVE_LOCATION_SHARING, LABS_ENABLE_NEW_SESSION_MANAGER, - LABS_ENABLE_NEW_CLIENT_INFO_FEATURE + LABS_ENABLE_NEW_CLIENT_INFO_FEATURE, + LABS_ENABLE_WYSIWYG_COMPOSER, + LABS_ENABLE_VOICE_BROADCAST }; typedef NS_ENUM(NSUInteger, SECURITY) @@ -249,8 +251,7 @@ typedef void (^blockSettingsViewController_onReadyToDestroy)(void); @interface SettingsViewController () @property (nonatomic, strong) NotificationSettingsCoordinatorBridgePresenter *notificationSettingsBridgePresenter; -@property (nonatomic, strong) SignOutAlertPresenter *signOutAlertPresenter; +@property (nonatomic, strong) SignOutFlowPresenter *signOutFlowPresenter; @property (nonatomic, weak) UIButton *signOutButton; @property (nonatomic, strong) SingleImagePickerPresenter *imagePickerPresenter; @@ -337,12 +338,8 @@ ChangePasswordCoordinatorBridgePresenterDelegate> @property (nonatomic, strong) SettingsDiscoveryTableViewSection *settingsDiscoveryTableViewSection; @property (nonatomic, strong) SettingsDiscoveryThreePidDetailsCoordinatorBridgePresenter *discoveryThreePidDetailsPresenter; -@property (nonatomic, strong) SecureBackupSetupCoordinatorBridgePresenter *secureBackupSetupCoordinatorBridgePresenter; - @property (nonatomic, strong) TableViewSections *tableViewSections; -@property (nonatomic, strong) CrossSigningSetupCoordinatorBridgePresenter *crossSigningSetupCoordinatorBridgePresenter; - @property (nonatomic, strong) ReauthenticationCoordinatorBridgePresenter *reauthenticationCoordinatorBridgePresenter; @property (nonatomic, strong) UserInteractiveAuthenticationService *userInteractiveAuthenticationService; @@ -750,6 +747,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> } [sectionLabs addRowWithTag:LABS_ENABLE_NEW_SESSION_MANAGER]; [sectionLabs addRowWithTag:LABS_ENABLE_NEW_CLIENT_INFO_FEATURE]; + if (@available(iOS 15.0, *)) + { + [sectionLabs addRowWithTag:LABS_ENABLE_WYSIWYG_COMPOSER]; + } + [sectionLabs addRowWithTag:LABS_ENABLE_VOICE_BROADCAST]; sectionLabs.headerTitle = [VectorL10n settingsLabs]; if (sectionLabs.hasAnyRows) { @@ -856,9 +858,6 @@ ChangePasswordCoordinatorBridgePresenterDelegate> }]; [self userInterfaceThemeDidChange]; - self.signOutAlertPresenter = [SignOutAlertPresenter new]; - self.signOutAlertPresenter.delegate = self; - _tableViewSections = [TableViewSections new]; _tableViewSections.delegate = self; [self updateSections]; @@ -925,8 +924,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> [super destroy]; } - - _secureBackupSetupCoordinatorBridgePresenter = nil; + identityServerSettingsCoordinatorBridgePresenter = nil; } @@ -1490,6 +1488,8 @@ ChangePasswordCoordinatorBridgePresenterDelegate> // Update notification access [self refreshSystemNotificationSettings]; + + [[MXKAccountManager sharedManager].activeAccounts.firstObject loadCurrentPusher:nil failure:nil]; } - (void)refreshSystemNotificationSettings @@ -1600,13 +1600,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> NSString *sdkVersionInfo = [NSString stringWithFormat:@"Matrix SDK %@", MatrixSDKVersion]; - NSString *olmVersionInfo = [NSString stringWithFormat:@"OLM %@", [OLMKit versionString]]; - [footerText appendFormat:@"%@\n", loggedUserInfo]; [footerText appendFormat:@"%@\n", homeserverInfo]; [footerText appendFormat:@"%@\n", appVersionInfo]; [footerText appendFormat:@"%@\n", sdkVersionInfo]; - [footerText appendFormat:@"%@", olmVersionInfo]; + [footerText appendFormat:@"%@", self.mainSession.crypto.version]; return [footerText copy]; } @@ -2874,6 +2872,31 @@ ChangePasswordCoordinatorBridgePresenterDelegate> [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableNewClientInfoFeature:) forControlEvents:UIControlEventTouchUpInside]; + cell = labelAndSwitchCell; + } + else if (row == LABS_ENABLE_WYSIWYG_COMPOSER) + { + MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; + + labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnableWysiwygComposer]; + labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableWysiwygComposer; + labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor; + + [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableWysiwygComposerFeature:) forControlEvents:UIControlEventTouchUpInside]; + + cell = labelAndSwitchCell; + } + + else if (row == LABS_ENABLE_VOICE_BROADCAST) + { + MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; + + labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnableVoiceBroadcast]; + labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableVoiceBroadcast; + labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor; + + [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableVoiceBroadcastFeature:) forControlEvents:UIControlEventTouchUpInside]; + cell = labelAndSwitchCell; } } @@ -3221,6 +3244,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BWIBuildSettings.shared.applicationCopyrightUrlString]; webViewViewController.title = [BWIL10n settingsCopyright]; + [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; [self pushViewController:webViewViewController]; } @@ -3229,6 +3253,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BuildSettings.applicationTermsConditionsUrlString]; webViewViewController.title = [VectorL10n settingsTermConditions]; + [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; [self pushViewController:webViewViewController]; } @@ -3247,6 +3272,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BWIBuildSettings.shared.applicationPrivacyPolicyUrlString]; webViewViewController.title = [VectorL10n settingsPrivacyPolicy]; + [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; [self pushViewController:webViewViewController]; } @@ -3257,6 +3283,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithLocalHTMLFile:htmlFile]; webViewViewController.title = [VectorL10n settingsThirdPartyNotices]; + [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; [self pushViewController:webViewViewController]; } @@ -3368,13 +3395,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> { self.signOutButton = (UIButton*)sender; - MXKeyBackup *keyBackup = self.mainSession.crypto.backup; + SignOutFlowPresenter *flowPresenter = [[SignOutFlowPresenter alloc] initWithSession:self.mainSession presentingViewController:self]; + flowPresenter.delegate = self; - [self.signOutAlertPresenter presentFor:keyBackup.state - areThereKeysToBackup:keyBackup.hasKeysToBackup - from:self - sourceView:self.signOutButton - animated:YES]; + [flowPresenter startWithSourceView:self.signOutButton]; + self.signOutFlowPresenter = flowPresenter; } - (void)onRemove3PID:(NSIndexPath*)indexPath @@ -3729,6 +3754,17 @@ ChangePasswordCoordinatorBridgePresenterDelegate> BOOL isEnabled = sender.isOn; RiotSettings.shared.enableClientInformationFeature = isEnabled; MXSDKOptions.sharedInstance.enableNewClientInformationFeature = isEnabled; + [self.mainSession updateClientInformation]; +} + +- (void)toggleEnableWysiwygComposerFeature:(UISwitch *)sender +{ + RiotSettings.shared.enableWysiwygComposer = sender.isOn; +} + +- (void)toggleEnableVoiceBroadcastFeature:(UISwitch *)sender +{ + RiotSettings.shared.enableVoiceBroadcast = sender.isOn; } - (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender @@ -4622,123 +4658,25 @@ ChangePasswordCoordinatorBridgePresenterDelegate> self.notificationSettingsBridgePresenter = nil; } +#pragma mark - SignOutFlowPresenterDelegate -#pragma mark - SecureBackupSetupCoordinatorBridgePresenter - -- (void)showSecureBackupSetupFromSignOutFlow +- (void)signOutFlowPresenterDidStartLoading:(SignOutFlowPresenter *)presenter { - if (self.canSetupSecureBackup) - { - [self setupSecureBackup2]; - } - else - { - // Set up cross-signing first - [self setupCrossSigningWithTitle:[BWIL10n secureKeyBackupSetupIntroTitle] - message:[VectorL10n securitySettingsUserPasswordDescription] - success:^{ - [self setupSecureBackup2]; - } failure:^(NSError *error) { - }]; - } -} - -- (void)setupSecureBackup2 -{ - SecureBackupSetupCoordinatorBridgePresenter *secureBackupSetupCoordinatorBridgePresenter = [[SecureBackupSetupCoordinatorBridgePresenter alloc] initWithSession:self.mainSession allowOverwrite:YES]; - secureBackupSetupCoordinatorBridgePresenter.delegate = self; - - [secureBackupSetupCoordinatorBridgePresenter presentFrom:self animated:YES]; - - self.secureBackupSetupCoordinatorBridgePresenter = secureBackupSetupCoordinatorBridgePresenter; -} - -- (BOOL)canSetupSecureBackup -{ - return [self.mainSession vc_canSetupSecureBackup]; -} - -#pragma mark - SecureBackupSetupCoordinatorBridgePresenterDelegate - -- (void)secureBackupSetupCoordinatorBridgePresenterDelegateDidComplete:(SecureBackupSetupCoordinatorBridgePresenter *)coordinatorBridgePresenter -{ - [self.secureBackupSetupCoordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; - self.secureBackupSetupCoordinatorBridgePresenter = nil; -} - -- (void)secureBackupSetupCoordinatorBridgePresenterDelegateDidCancel:(SecureBackupSetupCoordinatorBridgePresenter *)coordinatorBridgePresenter -{ - [self.secureBackupSetupCoordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; - self.secureBackupSetupCoordinatorBridgePresenter = nil; -} - -#pragma mark - SignOutAlertPresenterDelegate - -- (void)signOutAlertPresenterDidTapBackupAction:(SignOutAlertPresenter * _Nonnull)presenter -{ - [self showSecureBackupSetupFromSignOutFlow]; -} - -- (void)signOutAlertPresenterDidTapSignOutAction:(SignOutAlertPresenter * _Nonnull)presenter -{ - // Prevent user to perform user interaction in settings when sign out - // TODO: Prevent user interaction in all application (navigation controller and split view controller included) + [self startActivityIndicator]; self.view.userInteractionEnabled = NO; self.signOutButton.enabled = NO; - - [self startActivityIndicator]; - - MXWeakify(self); - - [[AppDelegate theDelegate] logoutWithConfirmation:NO completion:^(BOOL isLoggedOut) { - MXStrongifyAndReturnIfNil(self); - - [self stopActivityIndicator]; - - self.view.userInteractionEnabled = YES; - self.signOutButton.enabled = YES; - }]; } -- (void)setupCrossSigningWithTitle:(NSString*)title - message:(NSString*)message - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure - +- (void)signOutFlowPresenterDidStopLoading:(SignOutFlowPresenter *)presenter { - [self startActivityIndicator]; - self.view.userInteractionEnabled = NO; - - MXWeakify(self); - - void (^animationCompletion)(void) = ^void () { - MXStrongifyAndReturnIfNil(self); - - [self stopActivityIndicator]; - self.view.userInteractionEnabled = YES; - [self.crossSigningSetupCoordinatorBridgePresenter dismissWithAnimated:YES completion:^{}]; - self.crossSigningSetupCoordinatorBridgePresenter = nil; - }; - - CrossSigningSetupCoordinatorBridgePresenter *crossSigningSetupCoordinatorBridgePresenter = [[CrossSigningSetupCoordinatorBridgePresenter alloc] initWithSession:self.mainSession]; - - [crossSigningSetupCoordinatorBridgePresenter presentWith:title - message:message - from:self - animated:YES - success:^{ - animationCompletion(); - success(); - } cancel:^{ - animationCompletion(); - failure(nil); - } failure:^(NSError * _Nonnull error) { - animationCompletion(); - [[AppDelegate theDelegate] showErrorAsAlert:error]; - failure(error); - }]; - - self.crossSigningSetupCoordinatorBridgePresenter = crossSigningSetupCoordinatorBridgePresenter; + [self stopActivityIndicator]; + self.view.userInteractionEnabled = YES; + self.signOutButton.enabled = YES; +} + +- (void)signOutFlowPresenter:(SignOutFlowPresenter *)presenter didFailWith:(NSError *)error +{ + [[AppDelegate theDelegate] showErrorAsAlert:error]; } #pragma mark - SingleImagePickerPresenterDelegate diff --git a/Riot/Modules/Settings/SignOut/SignOutFlowPresenter.swift b/Riot/Modules/Settings/SignOut/SignOutFlowPresenter.swift new file mode 100644 index 000000000..e65f6fd5d --- /dev/null +++ b/Riot/Modules/Settings/SignOut/SignOutFlowPresenter.swift @@ -0,0 +1,162 @@ +// +// Copyright 2022 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 SignOutFlowPresenterDelegate { + /// The presenter is starting an operation that might take while and the UI should indicate this. + func signOutFlowPresenterDidStartLoading(_ presenter: SignOutFlowPresenter) + /// The presenter has finished an operation and the UI should indicate this if necessary. + func signOutFlowPresenterDidStopLoading(_ presenter: SignOutFlowPresenter) + /// The presenter encountered an error and has stopped. + func signOutFlowPresenter(_ presenter: SignOutFlowPresenter, didFailWith error: Error) +} + +/// This class provides a reusable component to present the sign out flow +/// for the current session, including the initial prompt, and any follow-up +/// key-backup setup that is necessary for the user. +@objcMembers class SignOutFlowPresenter: NSObject { + private let session: MXSession + private let presentingViewController: UIViewController + + private var signOutAlertPresenter = SignOutAlertPresenter() + + weak var delegate: SignOutFlowPresenterDelegate? + + init(session: MXSession, presentingViewController: UIViewController) { + self.session = session + self.presentingViewController = presentingViewController + + super.init() + + signOutAlertPresenter.delegate = self + } + + /// Starts the flow without a specific source view. On iPad any popups + /// will show from the presenting view controller itself. + func start() { + start(sourceView: presentingViewController.view) + } + + /// Starts the flow, presenting any popups on iPad from the specified view. + func start(sourceView: UIView?) { + guard let keyBackup = session.crypto?.backup else { return } + + signOutAlertPresenter.present(for: keyBackup.state, + areThereKeysToBackup: keyBackup.hasKeysToBackup, + from: presentingViewController, + sourceView: sourceView ?? presentingViewController.view, + animated: true) + } + + // MARK: - SecureBackupSetupCoordinatorBridgePresenter + + private var secureBackupSetupCoordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter? + private var crossSigningSetupCoordinatorBridgePresenter: CrossSigningSetupCoordinatorBridgePresenter? + + private func showSecureBackupSetupFromSignOutFlow() { + if canSetupSecureBackup { + setupSecureBackup() + } else { + // Set up cross-signing first + setupCrossSigning(title: VectorL10n.secureKeyBackupSetupIntroTitle, + message: VectorL10n.securitySettingsUserPasswordDescription) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let isCompleted): + if isCompleted { + self.setupSecureBackup() + } + case .failure(let error): + self.delegate?.signOutFlowPresenter(self, didFailWith: error) + } + } + } + } + + private var canSetupSecureBackup: Bool { + return session.vc_canSetupSecureBackup() + } + + private func setupSecureBackup() { + let secureBackupSetupCoordinatorBridgePresenter = SecureBackupSetupCoordinatorBridgePresenter(session: session, allowOverwrite: true) + secureBackupSetupCoordinatorBridgePresenter.delegate = self + secureBackupSetupCoordinatorBridgePresenter.present(from: presentingViewController, animated: true) + self.secureBackupSetupCoordinatorBridgePresenter = secureBackupSetupCoordinatorBridgePresenter + } + + private func setupCrossSigning(title: String, message: String, completion: @escaping (Result) -> Void) { + delegate?.signOutFlowPresenterDidStartLoading(self) + + let dismissAnimation = { [weak self] in + guard let self = self else { return } + + self.delegate?.signOutFlowPresenterDidStopLoading(self) + self.crossSigningSetupCoordinatorBridgePresenter?.dismiss(animated: true, completion: { + self.crossSigningSetupCoordinatorBridgePresenter = nil + }) + } + + let crossSigningSetupCoordinatorBridgePresenter = CrossSigningSetupCoordinatorBridgePresenter(session: session) + crossSigningSetupCoordinatorBridgePresenter.present(with: title, message: message, from: presentingViewController, animated: true) { + dismissAnimation() + completion(.success(true)) + } cancel: { + dismissAnimation() + completion(.success(false)) + } failure: { error in + dismissAnimation() + completion(.failure(error)) + } + + self.crossSigningSetupCoordinatorBridgePresenter = crossSigningSetupCoordinatorBridgePresenter + } +} + +// MARK: - SignOutAlertPresenterDelegate +extension SignOutFlowPresenter: SignOutAlertPresenterDelegate { + + func signOutAlertPresenterDidTapSignOutAction(_ presenter: SignOutAlertPresenter) { + // Allow presenting screen to black user interaction when signing out + // TODO: Prevent user interaction in all application (navigation controller and split view controller included) + delegate?.signOutFlowPresenterDidStartLoading(self) + + AppDelegate.theDelegate().logout(withConfirmation: false) { [weak self] isLoggedOut in + guard let self = self else { return } + self.delegate?.signOutFlowPresenterDidStopLoading(self) + } + } + + func signOutAlertPresenterDidTapBackupAction(_ presenter: SignOutAlertPresenter) { + showSecureBackupSetupFromSignOutFlow() + } + +} + +// MARK: - SecureBackupSetupCoordinatorBridgePresenterDelegate +extension SignOutFlowPresenter: SecureBackupSetupCoordinatorBridgePresenterDelegate { + func secureBackupSetupCoordinatorBridgePresenterDelegateDidCancel(_ coordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.secureBackupSetupCoordinatorBridgePresenter = nil + } + } + + func secureBackupSetupCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.secureBackupSetupCoordinatorBridgePresenter = nil + } + } +} diff --git a/Riot/Modules/TabBar/MasterTabBarController.m b/Riot/Modules/TabBar/MasterTabBarController.m index f19aaedb0..ba166aa27 100644 --- a/Riot/Modules/TabBar/MasterTabBarController.m +++ b/Riot/Modules/TabBar/MasterTabBarController.m @@ -55,7 +55,6 @@ @property(nonatomic,getter=isHidden) BOOL hidden; @property (nonatomic, readwrite) OnboardingCoordinatorBridgePresenter *onboardingCoordinatorBridgePresenter; -@property (nonatomic) AllChatsOnboardingCoordinatorBridgePresenter *allChatsOnboardingCoordinatorBridgePresenter; // Tell whether the onboarding screen is preparing. @property (nonatomic, readwrite) BOOL isOnboardingCoordinatorPreparing; @@ -161,8 +160,6 @@ }]; [self userInterfaceThemeDidChange]; } - - self.tabBar.hidden = BuildSettings.newAppLayoutEnabled; } - (void)viewDidAppear:(BOOL)animated @@ -228,11 +225,6 @@ [[AppDelegate theDelegate] checkIntegrity]; } [[AppDelegate theDelegate] checkAppVersion]; - - if (BuildSettings.newAppLayoutEnabled && !RiotSettings.shared.allChatsOnboardingHasBeenDisplayed) - { - [self showAllChatsOnboardingScreen]; - } } if (BWIBuildSettings.shared.bwiClearMediaCacheOnRoomExit) { @@ -466,24 +458,6 @@ [self refreshTabBarBadges]; } -- (void)showAllChatsOnboardingScreen -{ - self.allChatsOnboardingCoordinatorBridgePresenter = [AllChatsOnboardingCoordinatorBridgePresenter new]; - MXWeakify(self); - self.allChatsOnboardingCoordinatorBridgePresenter.completion = ^{ - RiotSettings.shared.allChatsOnboardingHasBeenDisplayed = YES; - - MXStrongifyAndReturnIfNil(self); - - MXWeakify(self); - [self.allChatsOnboardingCoordinatorBridgePresenter dismissWithAnimated:YES completion:^{ - MXStrongifyAndReturnIfNil(self); - self.allChatsOnboardingCoordinatorBridgePresenter = nil; - }]; - }; - [self.allChatsOnboardingCoordinatorBridgePresenter presentFrom:self animated:YES]; -} - // TODO: Manage the onboarding coordinator at the AppCoordinator level - (void)presentOnboardingFlow { @@ -654,26 +628,20 @@ { if (roomParentId) { NSString *parentName = [mxSession roomSummaryWithRoomId:roomParentId].displayname; - if (!BuildSettings.newAppLayoutEnabled) - { - NSMutableArray *breadcrumbs = [[NSMutableArray alloc] initWithObjects:parentName, nil]; + NSMutableArray *breadcrumbs = [[NSMutableArray alloc] initWithObjects:parentName, nil]; - MXSpace *firstRootAncestor = roomParentId ? [mxSession.spaceService firstRootAncestorForRoomWithId:roomParentId] : nil; - NSString *rootName = nil; - if (firstRootAncestor) - { - rootName = [mxSession roomSummaryWithRoomId:firstRootAncestor.spaceId].displayname; - [breadcrumbs insertObject:rootName atIndex:0]; - } - titleView.breadcrumbView.breadcrumbs = breadcrumbs; + MXSpace *firstRootAncestor = roomParentId ? [mxSession.spaceService firstRootAncestorForRoomWithId:roomParentId] : nil; + NSString *rootName = nil; + if (firstRootAncestor) + { + rootName = [mxSession roomSummaryWithRoomId:firstRootAncestor.spaceId].displayname; + [breadcrumbs insertObject:rootName atIndex:0]; } + titleView.breadcrumbView.breadcrumbs = breadcrumbs; } else { - if (!BuildSettings.newAppLayoutEnabled) - { - titleView.breadcrumbView.breadcrumbs = @[]; - } + titleView.breadcrumbView.breadcrumbs = @[]; } recentsDataSource.currentSpace = [mxSession.spaceService getSpaceWithId:roomParentId]; @@ -682,8 +650,6 @@ - (void)updateSideMenuNotifcationIcon { - if (BuildSettings.newAppLayoutEnabled) { return; } - BOOL displayNotification = NO; for (MXRoomSummary *summary in recentsDataSource.mxSession.spaceService.rootSpaceSummaries) { @@ -714,11 +680,8 @@ -(void)setupTitleView { - if (!BuildSettings.newAppLayoutEnabled) - { - titleView = [MainTitleView new]; - self.navigationItem.titleView = titleView; - } + titleView = [MainTitleView new]; + self.navigationItem.titleView = titleView; } -(void)setTitleLabelText:(NSString *)text diff --git a/Riot/Modules/TabBar/TabBarCoordinator.swift b/Riot/Modules/TabBar/TabBarCoordinator.swift index 1e6a04995..f11f817fa 100644 --- a/Riot/Modules/TabBar/TabBarCoordinator.swift +++ b/Riot/Modules/TabBar/TabBarCoordinator.swift @@ -71,7 +71,6 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } private var indicators = [UserIndicator]() - private var signOutAlertPresenter = SignOutAlertPresenter() // MARK: Public @@ -105,8 +104,6 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { // If start has been done once do not setup view controllers again if self.hasStartedOnce == false { - signOutAlertPresenter.delegate = self - let masterTabBarController = self.createMasterTabBarController() masterTabBarController.masterTabBarDelegate = self self.masterTabBarController = masterTabBarController @@ -124,8 +121,6 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { self.registerUserSessionsServiceNotifications() self.registerSessionChange() - NotificationCenter.default.addObserver(self, selector: #selector(self.newAppLayoutToggleDidChange(notification:)), name: RiotSettings.newAppLayoutBetaToggleDidChange, object: nil) - self.updateMasterTabBarController(with: spaceId, forceReload: true) } else { self.updateMasterTabBarController(with: spaceId) @@ -258,15 +253,6 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { // MARK: - Private methods - @objc private func newAppLayoutToggleDidChange(notification: Notification) { - self.masterTabBarController = nil - start() -// updateMasterTabBarController(with: self.currentSpaceId, forceReload: true) -// createLeftButtonItem(for: self.masterTabBarController) -// createRightButtonItem(for: self.masterTabBarController) -// popToHome(animated: true, completion: nil) - } - private func createMasterTabBarController() -> MasterTabBarController { let tabBarController = MasterTabBarController() @@ -424,6 +410,16 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } } + if RiotSettings.shared.homeScreenShowPeopleTab { + let peopleViewController = self.createPeopleViewController() + viewControllers.append(peopleViewController) + } + + if RiotSettings.shared.homeScreenShowRoomsTab { + let roomsViewController = self.createRoomsViewController() + viewControllers.append(roomsViewController) + } + tabBarController.updateViewControllers(viewControllers) if let existingVersionCheckCoordinator = self.versionCheckCoordinator { @@ -734,8 +730,6 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { if let session = notification.object as? MXSession { showCoachMessageIfNeeded(with: session) } - - updateAvatarButtonItem() } // MARK: Navigation bar items management @@ -768,175 +762,17 @@ final class TabBarCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } private func createRightButtonItem(for viewController: UIViewController) { - guard !BuildSettings.newAppLayoutEnabled else { - return - } - let searchBarButtonItem: MXKBarButtonItem = MXKBarButtonItem(image: Asset.Images.searchIcon.image, style: .plain) { [weak self] in self?.showUnifiedSearch() } searchBarButtonItem.accessibilityLabel = VectorL10n.searchDefaultPlaceholder viewController.navigationItem.rightBarButtonItem = searchBarButtonItem } - - private func createAvatarButtonItem(for viewController: UIViewController) { - var actions: [UIMenuElement] = [] - - actions.append(UIAction(title: VectorL10n.settings, image: UIImage(systemName: "gearshape")) { [weak self] action in - self?.showSettings() - }) - - var subMenuActions: [UIAction] = [] - if BWIBuildSettings.shared.sideMenuShowInviteFriends { - subMenuActions.append(UIAction(title: VectorL10n.inviteTo(AppInfo.current.displayName), image: UIImage(systemName: "envelope")) { [weak self] action in - self?.showInviteFriends(from: nil) - }) - } - - subMenuActions.append(UIAction(title: VectorL10n.sideMenuActionFeedback, image: UIImage(systemName: "questionmark.circle")) { [weak self] action in - self?.showBugReport() - }) - - actions.append(UIMenu(title: "", options: .displayInline, children: subMenuActions)) - actions.append(UIMenu(title: "", options: .displayInline, children: [ - UIAction(title: VectorL10n.settingsSignOut, image: UIImage(systemName: "rectangle.portrait.and.arrow.right.fill"), attributes: .destructive) { [weak self] action in - self?.signOut() - } - ])) - - let menu = UIMenu(options: .displayInline, children: actions) - - let view = UIView(frame: CGRect(x: 0, y: 0, width: 36, height: 36)) - view.backgroundColor = .clear - - let button: UIButton = UIButton(frame: view.bounds.inset(by: UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7))) - button.setImage(Asset.Images.tabPeople.image, for: .normal) - button.menu = menu - button.showsMenuAsPrimaryAction = true - button.autoresizingMask = [.flexibleHeight, .flexibleWidth] - view.addSubview(button) - self.rightMenuButton = button - - let avatarView = UserAvatarView(frame: view.bounds.inset(by: UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7))) - avatarView.isUserInteractionEnabled = false - avatarView.update(theme: ThemeService.shared().theme) - avatarView.autoresizingMask = [.flexibleHeight, .flexibleWidth] - view.addSubview(avatarView) - self.rightMenuAvatarView = avatarView - - if let avatar = userAvatarViewData(from: currentMatrixSession) { - avatarView.fill(with: avatar) - button.setImage(nil, for: .normal) - } - - viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: view) - } - - private func updateAvatarButtonItem() { - guard let avatarView = rightMenuAvatarView, let button = rightMenuButton, let avatar = userAvatarViewData(from: currentMatrixSession) else { - return - } - - button.setImage(nil, for: .normal) - avatarView.fill(with: avatar) - } - - // MARK: Sign out process - - private func signOut() { - guard let keyBackup = currentMatrixSession?.crypto.backup else { - return - } - - signOutAlertPresenter.present(for: keyBackup.state, - areThereKeysToBackup: keyBackup.hasKeysToBackup, - from: self.masterTabBarController, - sourceView: nil, - animated: true) - } - - // MARK: - SecureBackupSetupCoordinatorBridgePresenter - - private var secureBackupSetupCoordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter? - private var crossSigningSetupCoordinatorBridgePresenter: CrossSigningSetupCoordinatorBridgePresenter? - - private func showSecureBackupSetupFromSignOutFlow() { - if canSetupSecureBackup { - setupSecureBackup2() - } else { - // Set up cross-signing first - setupCrossSigning(title: VectorL10n.secureKeyBackupSetupIntroTitle, - message: VectorL10n.securitySettingsUserPasswordDescription) { [weak self] result in - switch result { - case .success(let isCompleted): - if isCompleted { - self?.setupSecureBackup2() - } - case .failure: - break - } - } - } - } - - private var canSetupSecureBackup: Bool { - return currentMatrixSession?.vc_canSetupSecureBackup() ?? false - } - - private func setupSecureBackup2() { - guard let session = currentMatrixSession else { - return - } - - let secureBackupSetupCoordinatorBridgePresenter = SecureBackupSetupCoordinatorBridgePresenter(session: session, allowOverwrite: true) - secureBackupSetupCoordinatorBridgePresenter.delegate = self - secureBackupSetupCoordinatorBridgePresenter.present(from: masterTabBarController, animated: true) - self.secureBackupSetupCoordinatorBridgePresenter = secureBackupSetupCoordinatorBridgePresenter - } - - private func setupCrossSigning(title: String, message: String, completion: @escaping (Result) -> Void) { - guard let session = currentMatrixSession else { - return - } - - masterTabBarController.homeViewController.startActivityIndicator() - masterTabBarController.view.isUserInteractionEnabled = false - - let dismissAnimation = { [weak self] in - guard let self = self else { return } - - self.masterTabBarController.homeViewController.stopActivityIndicator() - self.masterTabBarController.view.isUserInteractionEnabled = true - self.crossSigningSetupCoordinatorBridgePresenter?.dismiss(animated: true, completion: { - self.crossSigningSetupCoordinatorBridgePresenter = nil - }) - } - - let crossSigningSetupCoordinatorBridgePresenter = CrossSigningSetupCoordinatorBridgePresenter(session: session) - crossSigningSetupCoordinatorBridgePresenter.present(with: title, message: message, from: masterTabBarController, animated: true) { - dismissAnimation() - completion(.success(true)) - } cancel: { - dismissAnimation() - completion(.success(false)) - } failure: { error in - dismissAnimation() - completion(.failure(error)) - } - - self.crossSigningSetupCoordinatorBridgePresenter = crossSigningSetupCoordinatorBridgePresenter - } - // MARK: Coach Message private var windowOverlay: WindowOverlayPresenter? func showCoachMessageIfNeeded(with session: MXSession) { - guard !BuildSettings.newAppLayoutEnabled else { - // Showing coach message makes no sense with the new App Layout - return - } - if !RiotSettings.shared.slideMenuRoomsCoachMessageHasBeenDisplayed { let isAuthenticated = MXKAccountManager.shared().activeAccounts.first != nil || MXKAccountManager.shared().accounts.first?.isSoftLogout == false @@ -1076,37 +912,3 @@ extension TabBarCoordinator: UIGestureRecognizerDelegate { } } } - -extension TabBarCoordinator: SignOutAlertPresenterDelegate { - - func signOutAlertPresenterDidTapSignOutAction(_ presenter: SignOutAlertPresenter) { - // Prevent user to perform user interaction in settings when sign out - // TODO: Prevent user interaction in all application (navigation controller and split view controller included) - masterNavigationController.view.isUserInteractionEnabled = false - masterTabBarController.homeViewController.startActivityIndicator() - - AppDelegate.theDelegate().logout(withConfirmation: false) { [weak self] isLoggedOut in - self?.masterTabBarController.homeViewController.stopActivityIndicator() - self?.masterNavigationController.view.isUserInteractionEnabled = true - } - } - - func signOutAlertPresenterDidTapBackupAction(_ presenter: SignOutAlertPresenter) { - showSecureBackupSetupFromSignOutFlow() - } - -} - -extension TabBarCoordinator: SecureBackupSetupCoordinatorBridgePresenterDelegate { - func secureBackupSetupCoordinatorBridgePresenterDelegateDidCancel(_ coordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter) { - coordinatorBridgePresenter.dismiss(animated: true) { - self.secureBackupSetupCoordinatorBridgePresenter = nil - } - } - - func secureBackupSetupCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: SecureBackupSetupCoordinatorBridgePresenter) { - coordinatorBridgePresenter.dismiss(animated: true) { - self.secureBackupSetupCoordinatorBridgePresenter = nil - } - } -} diff --git a/Riot/Modules/User/Avatar/UserAvatarViewData.swift b/Riot/Modules/User/Avatar/UserAvatarViewData.swift index 2f9dea0f0..2dad83e98 100644 --- a/Riot/Modules/User/Avatar/UserAvatarViewData.swift +++ b/Riot/Modules/User/Avatar/UserAvatarViewData.swift @@ -26,7 +26,7 @@ struct UserAvatarViewData: AvatarViewDataProtocol { return userId } - var fallbackImage: AvatarFallbackImage? { - return .matrixItem(matrixItemId, displayName) + var fallbackImages: [AvatarFallbackImage]? { + [.matrixItem(matrixItemId, displayName), .image(Asset.Images.tabPeople.image, .scaleAspectFill)] } } diff --git a/Riot/Modules/UserDevices/UsersDevicesViewController.m b/Riot/Modules/UserDevices/UsersDevicesViewController.m index 6e2145c4c..3b5b8c9a8 100644 --- a/Riot/Modules/UserDevices/UsersDevicesViewController.m +++ b/Riot/Modules/UserDevices/UsersDevicesViewController.m @@ -274,7 +274,12 @@ { // Acknowledge the existence of all devices before leaving this screen [self startActivityIndicator]; - [mxSession.crypto setDevicesKnown:usersDevices complete:^{ + if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]]) + { + MXLogFailure(@"[UsersDevicesViewController] onDone: Only legacy crypto supports manual setting of known devices"); + return; + } + [(MXLegacyCrypto *)mxSession.crypto setDevicesKnown:usersDevices complete:^{ [self stopActivityIndicator]; [self dismissViewControllerAnimated:YES completion:nil]; diff --git a/Riot/Modules/UserInteractiveAuthentication/AuthenticatedEndpointRequest.swift b/Riot/Modules/UserInteractiveAuthentication/AuthenticatedEndpointRequest.swift index dfa8c8ee5..f8827427a 100644 --- a/Riot/Modules/UserInteractiveAuthentication/AuthenticatedEndpointRequest.swift +++ b/Riot/Modules/UserInteractiveAuthentication/AuthenticatedEndpointRequest.swift @@ -29,3 +29,14 @@ class AuthenticatedEndpointRequest: NSObject { super.init() } } + +// MARK: - Helper methods + +extension AuthenticatedEndpointRequest { + /// Create an authenticated request on `_matrix/client/r0/devices/{deviceID}`. + /// - Parameter deviceID: The device ID that is to be deleted. + static func deleteDevice(_ deviceID: String) -> AuthenticatedEndpointRequest { + let path = String(format: "%@/devices/%@", kMXAPIPrefixPathR0, MXTools.encodeURIComponent(deviceID)) + return AuthenticatedEndpointRequest(path: path, httpMethod: "DELETE") + } +} diff --git a/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift b/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift new file mode 100644 index 000000000..b6ac2af21 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift @@ -0,0 +1,35 @@ +// +// Copyright 2022 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 MatrixSDK + +extension MXSession { + + /// Convenient getter to retrieve VoiceBroadcastService associated to the session + @objc var voiceBroadcastService: VoiceBroadcastService? { + return VoiceBroadcastServiceProvider.shared.currentVoiceBroadcastService + } + + /// Initialize VoiceBroadcastService + @objc public func getOrCreateVoiceBroadcastService(for room: MXRoom, completion: @escaping (VoiceBroadcastService?) -> Void) { + VoiceBroadcastServiceProvider.shared.getOrCreateVoiceBroadcastService(for: room, completion: completion) + } + + @objc public func tearDownVoiceBroadcastService() { + VoiceBroadcastServiceProvider.shared.tearDownVoiceBroadcastService() + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift new file mode 100644 index 000000000..1de022904 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift @@ -0,0 +1,210 @@ +// +// Copyright 2022 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 + +/// VoiceBroadcastAggregator errors +public enum VoiceBroadcastAggregatorError: Error { + case invalidVoiceBroadcastStartEvent +} + +public protocol VoiceBroadcastAggregatorDelegate: AnyObject { + func voiceBroadcastAggregatorDidStartLoading(_ aggregator: VoiceBroadcastAggregator) + func voiceBroadcastAggregatorDidEndLoading(_ aggregator: VoiceBroadcastAggregator) + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didFailWithError: Error) + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveChunk: VoiceBroadcastChunk) + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State) + func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) +} + +/** + Responsible for building voice broadcast models out of the original voice broadcast start event and listen to replies. + It will listen for voice broadcast chunk events on the live timline and update the built models accordingly. + I will also listen for `mxRoomDidFlushData` and reload all data to avoid gappy sync problems +*/ + +public class VoiceBroadcastAggregator { + + private let session: MXSession + private let room: MXRoom + private let voiceBroadcastStartEventId: String + private let voiceBroadcastBuilder: VoiceBroadcastBuilder + + private var voiceBroadcastInfoStartEventContent: VoiceBroadcastInfo! + private var voiceBroadcastSenderId: String! + + private var referenceEventsListener: Any? + + private var events: [MXEvent] = [] + + public private(set) var voiceBroadcast: VoiceBroadcast! { + didSet { + delegate?.voiceBroadcastAggregatorDidUpdateData(self) + } + } + + public private(set) var isStarted: Bool = false + public private(set) var voiceBroadcastState: VoiceBroadcastInfo.State + public var delegate: VoiceBroadcastAggregatorDelegate? + + deinit { + if let referenceEventsListener = referenceEventsListener { + room.removeListener(referenceEventsListener) + } + } + + public init(session: MXSession, room: MXRoom, voiceBroadcastStartEventId: String, voiceBroadcastState: VoiceBroadcastInfo.State) throws { + self.session = session + self.room = room + self.voiceBroadcastStartEventId = voiceBroadcastStartEventId + self.voiceBroadcastState = voiceBroadcastState + self.voiceBroadcastBuilder = VoiceBroadcastBuilder() + + NotificationCenter.default.addObserver(self, selector: #selector(handleRoomDataFlush), name: NSNotification.Name.mxRoomDidFlushData, object: self.room) + + try buildVoiceBroadcastStartContent() + } + + private func buildVoiceBroadcastStartContent() throws { + guard let event = session.store.event(withEventId: voiceBroadcastStartEventId, inRoom: room.roomId), + let eventContent = VoiceBroadcastInfo(fromJSON: event.content), + let senderId = event.stateKey + else { + throw VoiceBroadcastAggregatorError.invalidVoiceBroadcastStartEvent + } + + voiceBroadcastInfoStartEventContent = eventContent + voiceBroadcastSenderId = senderId + + voiceBroadcast = voiceBroadcastBuilder.build(mediaManager: session.mediaManager, + voiceBroadcastStartEventId: voiceBroadcastStartEventId, + voiceBroadcastInvoiceBroadcastStartEventContent: eventContent, + events: events, + currentUserIdentifier: session.myUserId) + } + + @objc private func handleRoomDataFlush(sender: Notification) { + guard let room = sender.object as? MXRoom, room == self.room else { + return + } + + // TODO: What is the impact on room data flush on voice broadcast audio streaming? + MXLog.warning("[VoiceBroadcastAggregator] handleRoomDataFlush is not supported yet") + } + + private func updateState() { + self.room.state { roomState in + guard let event = roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last, + event.stateKey == self.voiceBroadcastSenderId, + let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: event.content), + (event.eventId == self.voiceBroadcastStartEventId || voiceBroadcastInfo.eventId == self.voiceBroadcastStartEventId), + let state = VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) else { + return + } + + self.delegate?.voiceBroadcastAggregator(self, didReceiveState: state) + } + } + + func start() { + if isStarted { + return + } + isStarted = true + + delegate?.voiceBroadcastAggregatorDidStartLoading(self) + + session.aggregations.referenceEvents(forEvent: voiceBroadcastStartEventId, inRoom: room.roomId, from: nil, limit: -1) { [weak self] response in + guard let self = self else { + return + } + + self.events.removeAll() + + let filteredChunk = response.chunk.filter { event in + event.sender == self.voiceBroadcastSenderId && + event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil + } + + self.events.append(contentsOf: filteredChunk) + + let eventTypes = [VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType, kMXEventTypeStringRoomMessage] + self.referenceEventsListener = self.room.listen(toEventsOfTypes: eventTypes) { [weak self] event, direction, state in + + guard let self = self else { + return + } + + if event.eventType == .roomMessage { + guard event.sender == self.voiceBroadcastSenderId, + let relatedEventId = event.relatesTo?.eventId, + relatedEventId == self.voiceBroadcastStartEventId, + event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil else { + return + } + + if let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) { + self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk) + } + + if !self.events.contains(where: { newEvent in + newEvent.eventId == event.eventId + }) { + self.events.append(event) + MXLog.debug("[VoiceBroadcastAggregator] Got a new chunk for broadcast \(relatedEventId). Total: \(self.events.count)") + + self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager, + voiceBroadcastStartEventId: self.voiceBroadcastStartEventId, + voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent, + events: self.events, + currentUserIdentifier: self.session.myUserId) + } + } else { + self.updateState() + } + } as Any + + + self.events.forEach { event in + guard let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) else { + return + } + self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk) + } + + self.updateState() + + self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager, + voiceBroadcastStartEventId: self.voiceBroadcastStartEventId, + voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent, + events: self.events, + currentUserIdentifier: self.session.myUserId) + + MXLog.debug("[VoiceBroadcastAggregator] Start aggregation with \(self.voiceBroadcast.chunks.count) chunks for broadcast \(self.voiceBroadcastStartEventId)") + + self.delegate?.voiceBroadcastAggregatorDidEndLoading(self) + + } failure: { [weak self] error in + guard let self = self else { + return + } + + MXLog.error("[VoiceBroadcastAggregator] start failed", context: error) + self.isStarted = false + self.delegate?.voiceBroadcastAggregator(self, didFailWithError: error) + } + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift new file mode 100644 index 000000000..e27f5258a --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift @@ -0,0 +1,46 @@ +// +// Copyright 2022 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 + +struct VoiceBroadcastBuilder { + + func build(mediaManager: MXMediaManager, + voiceBroadcastStartEventId: String, + voiceBroadcastInvoiceBroadcastStartEventContent: VoiceBroadcastInfo, + events: [MXEvent], + currentUserIdentifier: String, + hasBeenEdited: Bool = false) -> VoiceBroadcast { + + var voiceBroadcast = VoiceBroadcast() + + voiceBroadcast.chunks = Set(events.compactMap { event in + buildChunk(event: event, mediaManager: mediaManager, voiceBroadcastStartEventId: voiceBroadcastStartEventId) + }) + + return voiceBroadcast + } + + func buildChunk(event: MXEvent, mediaManager: MXMediaManager, voiceBroadcastStartEventId: String) -> VoiceBroadcastChunk? { + guard let attachment = MXKAttachment(event: event, andMediaManager: mediaManager), + let chunkInfo = event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] as? [String: UInt], + let sequence = chunkInfo[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence] else { + return nil + } + + return VoiceBroadcastChunk(voiceBroadcastInfoEventId: voiceBroadcastStartEventId, sequence: sequence, attachment: attachment) + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift new file mode 100644 index 000000000..1d974d791 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift @@ -0,0 +1,50 @@ +// +// Copyright 2022 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 + +public class VoiceBroadcastChunk: NSObject { + public private(set) var voiceBroadcastInfoEventId: String + public private(set) var sequence: UInt + public private(set) var attachment: MXKAttachment + + public init(voiceBroadcastInfoEventId: String, + sequence: UInt, + attachment: MXKAttachment) { + self.voiceBroadcastInfoEventId = voiceBroadcastInfoEventId + self.sequence = sequence + self.attachment = attachment + } + + public static func == (lhs: VoiceBroadcastChunk, rhs: VoiceBroadcastChunk) -> Bool { + return lhs.voiceBroadcastInfoEventId == rhs.voiceBroadcastInfoEventId && lhs.sequence == rhs.sequence + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let object = object as? VoiceBroadcastChunk else { + return false + } + + return self.voiceBroadcastInfoEventId == object.voiceBroadcastInfoEventId && self.sequence == object.sequence + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(self.sequence) + hasher.combine(self.voiceBroadcastInfoEventId) + return hasher.finalize() + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h new file mode 100644 index 000000000..36b963e47 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h @@ -0,0 +1,47 @@ +// +// Copyright 2022 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 + +#import "MXJSONModel.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface VoiceBroadcastInfo : MXJSONModel + +/// The device id from which the broadcast has been started +@property (nonatomic) NSString *deviceId; + +/// The voice broadcast state (started - paused - resumed - stopped). +@property (nonatomic) NSString *state; + +/// The length of the voice chunks in seconds. Only required on the started state event. +@property (nonatomic) NSInteger chunkLength; + +/// The event id of the started voice broadcast info state event. +@property (nonatomic, strong, nullable) NSString* eventId; + +/// The event used to build the MXBeaconInfo. +@property (nonatomic, readonly, nullable) MXEvent *originalEvent; + +- (instancetype)initWithDeviceId:(NSString *)deviceId + state:(NSString *)state + chunkLength:(NSInteger)chunkLength + eventId:(NSString *)eventId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m new file mode 100644 index 000000000..51a50876c --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m @@ -0,0 +1,92 @@ +// +// Copyright 2022 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 "VoiceBroadcastInfo.h" +#import "GeneratedInterface-Swift.h" + +@implementation VoiceBroadcastInfo + +- (instancetype)initWithDeviceId:(NSString *)deviceId + state:(NSString *)state + chunkLength:(NSInteger)chunkLength + eventId:(NSString *)eventId +{ + if (self = [super init]) + { + _deviceId = deviceId; + _state = state; + _chunkLength = chunkLength; + _eventId = eventId; + } + + return self; +} + ++ (id)modelFromJSON:(NSDictionary *)JSONDictionary +{ + // Return nil for redacted state event + if (!JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyState]) + { + return nil; + } + + NSString *state; + MXJSONModelSetString(state, JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyState]); + + NSString *deviceId; + MXJSONModelSetString(deviceId, JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyDeviceId]); + + NSInteger chunkLength = BuildSettings.voiceBroadcastChunkLength; + if (JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkLength]) + { + MXJSONModelSetInteger(chunkLength, JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkLength]); + } + + NSString *eventId; + if (JSONDictionary[kMXEventRelationRelatesToKey]) { + MXEventContentRelatesTo *relatesTo; + + MXJSONModelSetMXJSONModel(relatesTo, MXEventContentRelatesTo, JSONDictionary[kMXEventRelationRelatesToKey]); + + if (relatesTo && [relatesTo.relationType isEqualToString:MXEventRelationTypeReference]) + { + eventId = relatesTo.eventId; + } + } + + return [[VoiceBroadcastInfo alloc] initWithDeviceId:deviceId state:state chunkLength:chunkLength eventId:eventId]; +} + +- (NSDictionary *)JSONDictionary +{ + NSMutableDictionary *JSONDictionary = [NSMutableDictionary dictionary]; + + JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyDeviceId] = self.deviceId; + + JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyState] = self.state; + + if (_eventId) { + MXEventContentRelatesTo *relatesTo = [[MXEventContentRelatesTo alloc] initWithRelationType:MXEventRelationTypeReference eventId:_eventId]; + + JSONDictionary[kMXEventRelationRelatesToKey] = relatesTo.JSONDictionary; + } else { + JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkLength] = @(self.chunkLength); + } + + return JSONDictionary; +} + +@end diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift new file mode 100644 index 000000000..3515a5b59 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift @@ -0,0 +1,38 @@ +// +// Copyright 2022 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 + +extension VoiceBroadcastInfo { + // MARK: - Constants + + public enum State: String { + case started + case paused + case resumed + case stopped + } + + // MARK: - Public + + @objc static func isStarted(for name: String) -> Bool { + return name == State.started.rawValue + } + + @objc static func isStopped(for name: String) -> Bool { + return name == State.stopped.rawValue + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift new file mode 100644 index 000000000..138af9e32 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 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 + +public enum VoiceBroadcastKind { + case player + case recorder +} + +public struct VoiceBroadcast { + var chunks: Set = [] + var kind: VoiceBroadcastKind = .player +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift new file mode 100644 index 000000000..81cbc51af --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift @@ -0,0 +1,291 @@ +// +// Copyright 2022 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 + +/// VoiceBroadcastService handles voice broadcast. +/// Note: Cannot use a protocol because of Objective-C compatibility +@objcMembers +public class VoiceBroadcastService: NSObject { + + // MARK: - Properties + + public private(set) var voiceBroadcastInfoEventId: String? + public let room: MXRoom + public private(set) var state: VoiceBroadcastInfo.State + + // MARK: - Setup + + public init(room: MXRoom, state: VoiceBroadcastInfo.State) { + self.room = room + self.state = state + } + + // MARK: - Constants + + // MARK: - Public + + // MARK: Voice broadcast info + + /// Start a voice broadcast. + /// - Parameters: + /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. + /// - Returns: a `MXHTTPOperation` instance. + func startVoiceBroadcast(completion: @escaping (MXResponse) -> Void) -> MXHTTPOperation? { + return sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.started) { [weak self] response in + guard let self = self else { return } + + switch response { + case .success((let eventIdResponse)): + self.voiceBroadcastInfoEventId = eventIdResponse + completion(.success(eventIdResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Pause a voice broadcast. + /// - Parameters: + /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. + /// - Returns: a `MXHTTPOperation` instance. + func pauseVoiceBroadcast(completion: @escaping (MXResponse) -> Void) -> MXHTTPOperation? { + return sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.paused, completion: completion) + } + + /// resume a voice broadcast. + /// - Parameters: + /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. + /// - Returns: a `MXHTTPOperation` instance. + func resumeVoiceBroadcast(completion: @escaping (MXResponse) -> Void) -> MXHTTPOperation? { + return sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.resumed, completion: completion) + } + + /// stop a voice broadcast info. + /// - Parameters: + /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. + /// - Returns: a `MXHTTPOperation` instance. + func stopVoiceBroadcast(completion: @escaping (MXResponse) -> Void) -> MXHTTPOperation? { + return sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.stopped, completion: completion) + } + + func getState() -> String { + return self.state.rawValue + } + + // MARK: Voice broadcast chunk + + /// Send a bunch of a voice broadcast. + /// + /// While sending, a fake event will be echoed in the messages list. + /// Once complete, this local echo will be replaced by the event saved by the homeserver. + /// + /// - Parameters: + /// - audioFileLocalURL: the local filesystem path of the audio file to send. + /// - mimeType: (optional) the mime type of the file. Defaults to `audio/ogg` + /// - duration: the length of the voice message in milliseconds + /// - samples: an array of floating point values normalized to [0, 1], boxed within NSNumbers + /// - sequence: value of the chunk sequence. + /// - success: A block object called when the operation succeeds. It returns the event id of the event generated on the homeserver + /// - failure: A block object called when the operation fails. + func sendChunkOfVoiceBroadcast(audioFileLocalURL: URL, + mimeType: String?, + duration: UInt, + samples: [Float]?, + sequence: UInt, + success: @escaping ((String?) -> Void), + failure: @escaping ((Error?) -> Void)) { + guard let voiceBroadcastInfoEventId = self.voiceBroadcastInfoEventId else { + return failure(VoiceBroadcastServiceError.notStarted) + } + + self.room.sendChunkOfVoiceBroadcast(localURL: audioFileLocalURL, + voiceBroadcastInfoEventId: voiceBroadcastInfoEventId, + mimeType: mimeType, + duration: duration, + samples: samples, + sequence: sequence, + success: success, + failure: failure) + } + + // MARK: - Private + + private func sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State, completion: @escaping (MXResponse) -> Void) -> MXHTTPOperation? { + guard let userId = self.room.mxSession.myUserId else { + completion(.failure(VoiceBroadcastServiceError.missingUserId)) + return nil + } + + let stateKey = userId + + let voiceBroadcastInfo = VoiceBroadcastInfo() + + voiceBroadcastInfo.deviceId = self.room.mxSession.myDeviceId + + voiceBroadcastInfo.state = state.rawValue + + if state != VoiceBroadcastInfo.State.started { + guard let voiceBroadcastInfoEventId = self.voiceBroadcastInfoEventId else { + completion(.failure(VoiceBroadcastServiceError.notStarted)) + return nil + } + + voiceBroadcastInfo.eventId = voiceBroadcastInfoEventId + } else { + voiceBroadcastInfo.chunkLength = BuildSettings.voiceBroadcastChunkLength + } + + guard let stateEventContent = voiceBroadcastInfo.jsonDictionary() as? [String: Any] else { + completion(.failure(VoiceBroadcastServiceError.unknown)) + return nil + } + + return self.room.sendStateEvent(.custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType), + content: stateEventContent, stateKey: stateKey) { [weak self] response in + guard let self = self else { return } + + switch response { + case .success(let object): + self.state = state + completion(.success(object)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} + +// MARK: - Objective-C interface +extension VoiceBroadcastService { + + /// Start a voice broadcast. + /// - Parameters: + /// - success: A closure called when the operation is complete. + /// - failure: A closure called when the operation fails. + /// - Returns: a `MXHTTPOperation` instance. + @discardableResult + @objc public func startVoiceBroadcast(success: @escaping (String?) -> Void, failure: @escaping (Error) -> Void) -> MXHTTPOperation? { + return self.startVoiceBroadcast { response in + switch response { + case .success(let object): + success(object) + case .failure(let error): + failure(error) + } + } + } + + /// Pause a voice broadcast. + /// - Parameters: + /// - success: A closure called when the operation is complete. + /// - failure: A closure called when the operation fails. + /// - Returns: a `MXHTTPOperation` instance. + @discardableResult + @objc public func pauseVoiceBroadcast(success: @escaping (String?) -> Void, failure: @escaping (Error) -> Void) -> MXHTTPOperation? { + return self.pauseVoiceBroadcast { response in + switch response { + case .success(let object): + success(object) + case .failure(let error): + failure(error) + } + } + } + + /// Resume a voice broadcast. + /// - Parameters: + /// - success: A closure called when the operation is complete. + /// - failure: A closure called when the operation fails. + /// - Returns: a `MXHTTPOperation` instance. + @discardableResult + @objc public func resumeVoiceBroadcast(success: @escaping (String?) -> Void, failure: @escaping (Error) -> Void) -> MXHTTPOperation? { + return self.resumeVoiceBroadcast { response in + switch response { + case .success(let object): + success(object) + case .failure(let error): + failure(error) + } + } + } + + /// Stop a voice broadcast. + /// - Parameters: + /// - success: A closure called when the operation is complete. + /// - failure: A closure called when the operation fails. + /// - Returns: a `MXHTTPOperation` instance. + @discardableResult + @objc public func stopVoiceBroadcast(success: @escaping (String?) -> Void, failure: @escaping (Error) -> Void) -> MXHTTPOperation? { + return self.stopVoiceBroadcast { response in + switch response { + case .success(let object): + success(object) + case .failure(let error): + failure(error) + } + } + } +} + +// MARK: - Internal room additions +extension MXRoom { + + /// Send a voice broadcast to the room. + /// - Parameters: + /// - localURL: the local filesystem path of the file to send. + /// - voiceBroadcastInfoEventId: The id of the voice broadcast info event. + /// - mimeType: (optional) the mime type of the file. Defaults to `audio/ogg`. + /// - duration: the length of the voice message in milliseconds + /// - samples: an array of floating point values normalized to [0, 1] + /// - threadId: the id of the thread to send the message. nil by default. + /// - sequence: value of the chunk sequence. + /// - success: A closure called when the operation is complete. + /// - failure: A closure called when the operation fails. + /// - Returns: a `MXHTTPOperation` instance. + @nonobjc @discardableResult func sendChunkOfVoiceBroadcast(localURL: URL, + voiceBroadcastInfoEventId: String, + mimeType: String?, + duration: UInt, + samples: [Float]?, + threadId: String? = nil, + sequence: UInt, + success: @escaping ((String?) -> Void), + failure: @escaping ((Error?) -> Void)) -> MXHTTPOperation? { + let boxedSamples = samples?.compactMap { NSNumber(value: $0) } + + + guard let relatesTo = MXEventContentRelatesTo(relationType: MXEventRelationTypeReference, + eventId: voiceBroadcastInfoEventId).jsonDictionary() as? [String: Any] else { + failure(VoiceBroadcastServiceError.unknown) + return nil + } + + let sequenceValue = [VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence: sequence] + + return __sendVoiceMessage(localURL, + additionalContentParams: [kMXEventRelationRelatesToKey: relatesTo, + VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType: sequenceValue], + mimeType: mimeType, + duration: duration, + samples: boxedSamples, + threadId: threadId, + localEcho: nil, + success: success, + failure: failure, + keepActualFilename: false) + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastServiceError.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastServiceError.swift new file mode 100644 index 000000000..55d0820fa --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastServiceError.swift @@ -0,0 +1,38 @@ +// +// Copyright 2022 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 + +/// VoiceBroadcastService error +public enum VoiceBroadcastServiceError: Int, Error { + case missingUserId + case roomNotFound + case notStarted + case unknown +} + +// MARK: - VoiceBroadcastService errors +extension VoiceBroadcastServiceError: CustomNSError { + public static let errorDomain = "io.element.voice_broadcast_info" + + public var errorCode: Int { + return Int(rawValue) + } + + public var errorUserInfo: [String: Any] { + return [:] + } +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift new file mode 100644 index 000000000..425cc03f4 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift @@ -0,0 +1,29 @@ +// +// Copyright 2022 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 + +/// Voice Broadcast settings. +@objcMembers +final class VoiceBroadcastSettings: NSObject { + static let voiceBroadcastInfoContentKeyType = "io.element.voice_broadcast_info" + + static let voiceBroadcastContentKeyDeviceId = "device_id" + static let voiceBroadcastContentKeyState = "state" + static let voiceBroadcastContentKeyChunkLength = "chunk_length" + static let voiceBroadcastContentKeyChunkType = "io.element.voice_broadcast_chunk" + static let voiceBroadcastContentKeyChunkSequence = "sequence" +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift new file mode 100644 index 000000000..e39c838b7 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift @@ -0,0 +1,120 @@ +// +// Copyright 2022 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 + +/// VoiceBroadcastServiceProvider to setup VoiceBroadcastService or retrieve the existing VoiceBroadcastService. +class VoiceBroadcastServiceProvider { + + // MARK: - Constants + + static let shared = VoiceBroadcastServiceProvider() + + // MARK: - Properties + + /// VoiceBroadcastService in the current session + public var currentVoiceBroadcastService: VoiceBroadcastService? + + // MARK: - Setup + + private init() {} + + // MARK: - Public + + public func getOrCreateVoiceBroadcastService(for room: MXRoom, completion: @escaping (VoiceBroadcastService?) -> Void) { + guard let voiceBroadcastService = self.currentVoiceBroadcastService else { + self.setupVoiceBroadcastService(for: room) { voiceBroadcastService in + completion(voiceBroadcastService) + } + return + } + + if voiceBroadcastService.room.roomId == room.roomId { + completion(voiceBroadcastService) + } + + completion(nil) + } + + public func tearDownVoiceBroadcastService() { + + self.currentVoiceBroadcastService = nil + + MXLog.debug("Stop monitoring voice broadcast recording") + } + + // MARK: - Private + + // MARK: VoiceBroadcastService setup + + /// Get latest voice broadcast info in a room + /// - Parameters: + /// - room: The room. + /// - completion: Completion block that will return the lastest voice broadcast info state event of the room. + private func getLastVoiceBroadcastInfo(for room: MXRoom, completion: @escaping (MXEvent?) -> Void) { + room.state { roomState in + completion(roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last ?? nil) + } + } + + private func createVoiceBroadcastService(for room: MXRoom, state: VoiceBroadcastInfo.State) { + + let voiceBroadcastService = VoiceBroadcastService(room: room, state: VoiceBroadcastInfo.State.stopped) + + self.currentVoiceBroadcastService = voiceBroadcastService + + MXLog.debug("Start monitoring voice broadcast recording") + } + + + /// Setup the voice broadcast service if no service is running locally. + /// + /// A voice broadcast service is created in the following cases : + /// - A voice broadcast info state event doesn't exist in the room. + /// - The last voice broadcast info state event doesn't contain a valid content. + /// - The state of the last voice broadcast info state event is stopped. + /// - The state of the last voice broadcast info state event started by the end user is not stopped. + /// This may be due the following situations the application crashed or the voice broadcast has been started from another session. + /// + /// - Parameters: + /// - room: The room. + /// - completion: Completion block that will return the voice broadcast service. + private func setupVoiceBroadcastService(for room: MXRoom, completion: @escaping (VoiceBroadcastService?) -> Void) { + self.getLastVoiceBroadcastInfo(for: room) { event in + guard let voiceBroadcastInfoEvent = event else { + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State.stopped) + completion(self.currentVoiceBroadcastService) + return + } + + guard let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: voiceBroadcastInfoEvent.content) else { + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State.stopped) + completion(self.currentVoiceBroadcastService) + return + } + + if voiceBroadcastInfo.state == VoiceBroadcastInfo.State.stopped.rawValue { + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State.stopped) + completion(self.currentVoiceBroadcastService) + } else if voiceBroadcastInfoEvent.stateKey == room.mxSession.myUserId { + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) ?? VoiceBroadcastInfo.State.stopped) + completion(self.currentVoiceBroadcastService) + } else { + completion(nil) + } + } + } +} diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index 7d86236cc..ef7892ecf 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -53,6 +53,7 @@ #import "MXRoom+sendRoomPowerLevels.h" #import "UniversalLink.h" +#import "VoiceBroadcastInfo.h" // MatrixKit common imports, shared with all targets #import "MatrixKit-Bridging-Header.h" diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 4313d0e30..630804015 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -185,95 +185,99 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; } BOOL isEventSenderMyUser = [event.sender isEqualToString:mxSession.myUserId]; - // Build strings for widget events - if (event.eventType == MXEventTypeCustom - && ([event.type isEqualToString:kWidgetMatrixEventTypeString] - || [event.type isEqualToString:kWidgetModularEventTypeString])) - { - NSString *displayText; - - Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:mxSession]; - if (widget) + if (event.eventType == MXEventTypeCustom) { + + // Build strings for widget events + if ([event.type isEqualToString:kWidgetMatrixEventTypeString] + || [event.type isEqualToString:kWidgetModularEventTypeString]) { - // Prepare the display name of the sender - NSString *senderDisplayName = roomState ? [self senderDisplayNameForEvent:event withRoomState:roomState] : event.sender; - - if (widget.isActive) + NSString *displayText; + + Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:mxSession]; + if (widget) { - if ([widget.type isEqualToString:kWidgetTypeJitsiV1] - || [widget.type isEqualToString:kWidgetTypeJitsiV2]) + // Prepare the display name of the sender + NSString *senderDisplayName = roomState ? [self senderDisplayNameForEvent:event withRoomState:roomState] : event.sender; + + if (widget.isActive) { - // This is an alive jitsi widget - if (isEventSenderMyUser) + if ([widget.type isEqualToString:kWidgetTypeJitsiV1] + || [widget.type isEqualToString:kWidgetTypeJitsiV2]) { - displayText = [VectorL10n eventFormatterJitsiWidgetAddedByYou]; + // This is an alive jitsi widget + if (isEventSenderMyUser) + { + displayText = [VectorL10n eventFormatterJitsiWidgetAddedByYou]; + } + else + { + displayText = [VectorL10n eventFormatterJitsiWidgetAdded:senderDisplayName]; + } } else { - displayText = [VectorL10n eventFormatterJitsiWidgetAdded:senderDisplayName]; + if (isEventSenderMyUser) + { + displayText = [VectorL10n eventFormatterWidgetAddedByYou:(widget.name ? widget.name : widget.type)]; + } + else + { + displayText = [VectorL10n eventFormatterWidgetAdded:(widget.name ? widget.name : widget.type) :senderDisplayName]; + } } } else { - if (isEventSenderMyUser) + // This is a closed widget + // Check if it corresponds to a jitsi widget by looking at other state events for + // this jitsi widget (widget id = event.stateKey). + // Get all widgets state events in the room + NSMutableArray *widgetStateEvents = [NSMutableArray arrayWithArray:[roomState stateEventsWithType:kWidgetMatrixEventTypeString]]; + [widgetStateEvents addObjectsFromArray:[roomState stateEventsWithType:kWidgetModularEventTypeString]]; + + for (MXEvent *widgetStateEvent in widgetStateEvents) { - displayText = [VectorL10n eventFormatterWidgetAddedByYou:(widget.name ? widget.name : widget.type)]; - } - else - { - displayText = [VectorL10n eventFormatterWidgetAdded:(widget.name ? widget.name : widget.type) :senderDisplayName]; - } - } - } - else - { - // This is a closed widget - // Check if it corresponds to a jitsi widget by looking at other state events for - // this jitsi widget (widget id = event.stateKey). - // Get all widgets state events in the room - NSMutableArray *widgetStateEvents = [NSMutableArray arrayWithArray:[roomState stateEventsWithType:kWidgetMatrixEventTypeString]]; - [widgetStateEvents addObjectsFromArray:[roomState stateEventsWithType:kWidgetModularEventTypeString]]; - - for (MXEvent *widgetStateEvent in widgetStateEvents) - { - if ([widgetStateEvent.stateKey isEqualToString:widget.widgetId]) - { - Widget *activeWidget = [[Widget alloc] initWithWidgetEvent:widgetStateEvent inMatrixSession:mxSession]; - if (activeWidget.isActive) + if ([widgetStateEvent.stateKey isEqualToString:widget.widgetId]) { - if ([activeWidget.type isEqualToString:kWidgetTypeJitsiV1] - || [activeWidget.type isEqualToString:kWidgetTypeJitsiV2]) + Widget *activeWidget = [[Widget alloc] initWithWidgetEvent:widgetStateEvent inMatrixSession:mxSession]; + if (activeWidget.isActive) { - // This was a jitsi widget - return nil; - } - else - { - if (isEventSenderMyUser) + if ([activeWidget.type isEqualToString:kWidgetTypeJitsiV1] + || [activeWidget.type isEqualToString:kWidgetTypeJitsiV2]) { - displayText = [VectorL10n eventFormatterWidgetRemovedByYou:(activeWidget.name ? activeWidget.name : activeWidget.type)]; + // This was a jitsi widget + return nil; } else { - displayText = [VectorL10n eventFormatterWidgetRemoved:(activeWidget.name ? activeWidget.name : activeWidget.type) :senderDisplayName]; + if (isEventSenderMyUser) + { + displayText = [VectorL10n eventFormatterWidgetRemovedByYou:(activeWidget.name ? activeWidget.name : activeWidget.type)]; + } + else + { + displayText = [VectorL10n eventFormatterWidgetRemoved:(activeWidget.name ? activeWidget.name : activeWidget.type) :senderDisplayName]; + } } + break; } - break; } } } } - } - - if (displayText) - { - if (error) + + if (displayText) { - *error = MXKEventFormatterErrorNone; - } + if (error) + { + *error = MXKEventFormatterErrorNone; + } - // Build the attributed string with the right font and color for the events - return [self renderString:displayText forEvent:event]; + // Build the attributed string with the right font and color for the events + return [self renderString:displayText forEvent:event]; + } + } else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) { + MXLogDebug(@"VB incoming build string") } } else if (event.eventType == MXEventTypeCustom) { if (BWIBuildSettings.shared.bwiUserLabelsTimelineEventVisible && [event.type isEqualToString:BWIBuildSettings.shared.bwiUserLabelEventTypeString]) { diff --git a/Riot/target.yml b/Riot/target.yml index 7445c4174..228883f55 100644 --- a/Riot/target.yml +++ b/Riot/target.yml @@ -43,6 +43,7 @@ targets: - package: OrderedCollections - package: SwiftOGG - package: Lottie + - package: WysiwygComposer - package: DeviceKit configFiles: diff --git a/RiotShareExtension/Shared/ShareManager.m b/RiotShareExtension/Shared/ShareManager.m index 2233b352d..22d0063be 100644 --- a/RiotShareExtension/Shared/ShareManager.m +++ b/RiotShareExtension/Shared/ShareManager.m @@ -102,7 +102,10 @@ static MXSession *fakeSession; [session setStore:self.fileStore success:^{ MXStrongifyAndReturnIfNil(session); - session.crypto.warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now + 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) { diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift index 7952e7db3..3a611f45a 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift @@ -24,6 +24,8 @@ struct AuthenticationHomeserverViewData: Equatable { let showLoginForm: Bool /// Whether or not to display the username and password text fields during registration. let showRegistrationForm: Bool + /// Whether or not to display the QR login button during login. + let showQRLogin: Bool /// The supported SSO login options. let ssoIdentityProviders: [SSOIdentityProvider] } @@ -36,6 +38,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "matrix.org", showLoginForm: true, showRegistrationForm: true, + showQRLogin: false, ssoIdentityProviders: [ SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), @@ -50,6 +53,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "example.com", showLoginForm: true, showRegistrationForm: true, + showQRLogin: false, ssoIdentityProviders: []) } @@ -58,6 +62,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "company.com", showLoginForm: false, showRegistrationForm: false, + showQRLogin: false, ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) } @@ -66,6 +71,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "company.com", showLoginForm: false, showRegistrationForm: false, + showQRLogin: false, ssoIdentityProviders: []) } } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift index 33d20f17b..0a353e67b 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift @@ -34,6 +34,8 @@ protocol AuthenticationRestClient: AnyObject { func login(parameters: LoginParameters) async throws -> MXCredentials func login(parameters: [String: Any]) async throws -> MXCredentials + func generateLoginToken() async throws -> MXLoginToken + // MARK: Registration var registerFallbackURL: URL { get } @@ -48,6 +50,10 @@ protocol AuthenticationRestClient: AnyObject { func forgetPassword(for email: String, clientSecret: String, sendAttempt: UInt) async throws -> String func resetPassword(parameters: CheckResetPasswordParameters) async throws func resetPassword(parameters: [String: Any]) async throws + + // MARK: Versions + + func supportedMatrixVersions() async throws -> MXMatrixVersions } extension MXRestClient: AuthenticationRestClient { } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 8af5502b4..c6071ed05 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -260,9 +260,13 @@ class AuthenticationService: NSObject { let loginFlow = try await getLoginFlowResult(client: client) + let supportsQRLogin = try await QRLoginService(client: client, + mode: .notAuthenticated).isServiceAvailable() + let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress, addressFromUser: homeserverAddress, - preferredLoginMode: loginFlow.loginMode) + preferredLoginMode: loginFlow.loginMode, + supportsQRLogin: supportsQRLogin) return (client, homeserver) } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index e2a48e315..38f1939f4 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -52,6 +52,9 @@ struct AuthenticationState { /// The preferred login mode for the server var preferredLoginMode: LoginMode = .unknown + + /// Flag indicating whether the homeserver supports logging in via a QR code. + var supportsQRLogin = false /// The response returned when querying the homeserver for registration flows. var registrationFlow: RegistrationResult? @@ -67,6 +70,7 @@ struct AuthenticationState { AuthenticationHomeserverViewData(address: displayableAddress, showLoginForm: preferredLoginMode.supportsPasswordFlow, showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback, + showQRLogin: supportsQRLogin, ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? []) } diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index e273d0d16..eadd28e68 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -31,6 +31,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible { case continueWithSSO(SSOIdentityProvider) /// Continue using the fallback page case fallback + /// Continue with QR login + case qrLogin /// A string representation of the result, ignoring any associated values that could leak PII. var description: String { @@ -47,6 +49,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible { return "continueWithSSO: \(provider)" case .fallback: return "fallback" + case .qrLogin: + return "qrLogin" } } } @@ -99,6 +103,8 @@ enum AuthenticationLoginViewAction { case fallback /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) + /// Continue using QR login + case qrLogin } enum AuthenticationLoginErrorType: Hashable { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index 6c5274d62..f1180c1d1 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -50,6 +50,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica Task { await callback?(.fallback) } case .continueWithSSO(let provider): Task { await callback?(.continueWithSSO(provider)) } + case .qrLogin: + Task { await callback?(.qrLogin) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 596e1cad7..2c6a7e3f9 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -30,6 +30,8 @@ enum AuthenticationLoginCoordinatorResult: CustomStringConvertible { case continueWithSSO(SSOIdentityProvider) /// Login was successful with the associated session created. case success(session: MXSession, password: String) + /// Login was successful with the associated session created. + case loggedInWithQRCode(session: MXSession, securityCompleted: Bool) /// Login requested a fallback case fallback @@ -40,6 +42,8 @@ enum AuthenticationLoginCoordinatorResult: CustomStringConvertible { return "continueWithSSO: \(provider)" case .success: return "success" + case .loggedInWithQRCode: + return "loggedInWithQRCode" case .fallback: return "fallback" } @@ -126,6 +130,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { self.callback?(.continueWithSSO(identityProvider)) case .fallback: self.callback?(.fallback) + case .qrLogin: + self.showQRLoginScreen() } } } @@ -282,6 +288,33 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { navigationRouter.present(modalRouter, animated: true) } + + /// Shows the QR login screen. + @MainActor private func showQRLoginScreen() { + MXLog.debug("[AuthenticationLoginCoordinator] showQRLoginScreen") + + let service = QRLoginService(client: parameters.authenticationService.client, + mode: .notAuthenticated) + let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: service) + let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] callback in + guard let self = self, let coordinator = coordinator else { return } + switch callback { + case .done(let session, let securityCompleted): + self.callback?(.loggedInWithQRCode(session: session, securityCompleted: securityCompleted)) + } + + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } /// Updates the view model to reflect any changes made to the homeserver. @MainActor private func updateViewModel() { diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index 3ff67aaf2..03798ce49 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -50,6 +50,10 @@ struct AuthenticationLoginScreen: View { if viewModel.viewState.homeserver.showLoginForm { loginForm } + + if viewModel.viewState.homeserver.showQRLogin { + qrLoginButton + } if viewModel.viewState.homeserver.showLoginForm, viewModel.viewState.showSSOButtons { Text(VectorL10n.or) @@ -129,6 +133,16 @@ struct AuthenticationLoginScreen: View { .accessibilityIdentifier("nextButton") } } + + /// A QR login button that can be used for login. + var qrLoginButton: some View { + Button(action: qrLogin) { + Text(VectorL10n.authenticationLoginWithQr) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.vertical) + .accessibilityIdentifier("qrLoginButton") + } /// A list of SSO buttons that can be used for login. var ssoButtons: some View { @@ -174,6 +188,11 @@ struct AuthenticationLoginScreen: View { func fallback() { viewModel.send(viewAction: .fallback) } + + /// Sends the `qrLogin` view action. + func qrLogin() { + viewModel.send(viewAction: .qrLogin) + } } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift new file mode 100644 index 000000000..b6bae6757 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift @@ -0,0 +1,100 @@ +// +// Copyright 2022 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 + +struct QRLoginCode: Codable { + let rendezvous: RendezvousDetails + let intent: String +} + +struct RendezvousDetails: Codable { + let algorithm: String + var transport: RendezvousTransportDetails? + var key: String? +} + +struct RendezvousTransportDetails: Codable { + let type: String + let uri: String +} + +struct RendezvousMessage: Codable { + let iv: String + let ciphertext: String +} + +struct QRLoginRendezvousPayload: Codable { + let type: `Type` + + var intent: Intent? + var outcome: Outcome? + + // swiftformat:disable:next redundantBackticks + var protocols: [`Protocol`]? + + // swiftformat:disable:next redundantBackticks + var `protocol`: `Protocol`? + + var homeserver: String? + var user: String? + var loginToken: String? + var deviceId: String? + var deviceKey: String? + + var verifyingDeviceId: String? + var verifyingDeviceKey: String? + + var masterKey: String? + + enum CodingKeys: String, CodingKey { + case type + case intent + case outcome + case homeserver + case user + case protocols + case `protocol` + case loginToken = "login_token" + case deviceId = "device_id" + case deviceKey = "device_key" + case verifyingDeviceId = "verifying_device_id" + case verifyingDeviceKey = "verifying_device_key" + case masterKey = "master_key" + } + + enum `Type`: String, Codable { + case loginStart = "m.login.start" + case loginProgress = "m.login.progress" + case loginFinish = "m.login.finish" + } + + enum Intent: String, Codable { + case loginStart = "login.start" + case loginReciprocate = "login.reciprocate" + } + + enum Outcome: String, Codable { + case success + case declined + case verified + } + + // swiftformat:disable:next redundantBackticks + enum `Protocol`: String, Codable { + case loginToken = "org.matrix.msc3906.login_token" + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift new file mode 100644 index 000000000..30059334e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift @@ -0,0 +1,409 @@ +// +// Copyright 2022 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 AVFoundation +import Combine +import Foundation +import MatrixSDK +import SwiftUI +import ZXingObjC + +// MARK: - QRLoginService + +class QRLoginService: NSObject, QRLoginServiceProtocol { + private let client: AuthenticationRestClient + private let sessionCreator: SessionCreatorProtocol + private var isCameraReady = false + private lazy var zxCapture = ZXCapture() + + private let cameraAccessManager = CameraAccessManager() + + private var rendezvousService: RendezvousService? + + init(client: AuthenticationRestClient, + mode: QRLoginServiceMode, + state: QRLoginServiceState = .initial) { + self.client = client + sessionCreator = SessionCreator() + self.mode = mode + self.state = state + super.init() + } + + // MARK: QRLoginServiceProtocol + + let mode: QRLoginServiceMode + + var state: QRLoginServiceState { + didSet { + if state != oldValue { + callbacks.send(.didUpdateState) + } + } + } + + let callbacks = PassthroughSubject() + + func isServiceAvailable() async throws -> Bool { + switch mode { + case .authenticated: + guard BuildSettings.qrLoginEnabledFromAuthenticated else { + return false + } + case .notAuthenticated: + guard BuildSettings.qrLoginEnabledFromNotAuthenticated else { + return false + } + } + return try await client.supportedMatrixVersions().supportsQRLogin + } + + func canDisplayQR() -> Bool { + BuildSettings.qrLoginEnableDisplayingQRs + } + + func generateQRCode() async throws -> QRLoginCode { + fatalError("Not implemented") + } + + func scannerView() -> AnyView { + let frame = UIScreen.main.bounds + let view = UIView(frame: frame) + zxCapture.layer.frame = frame + view.layer.addSublayer(zxCapture.layer) + return AnyView(ViewWrapper(view: view)) + } + + func startScanning() { + Task { @MainActor in + if cameraAccessManager.isCameraAvailable { + let granted = await cameraAccessManager.requestCameraAccessIfNeeded() + if granted { + state = .scanningQR + zxCapture.delegate = self + zxCapture.camera = zxCapture.back() + zxCapture.start() + } else { + state = .failed(error: .noCameraAccess) + } + } else { + state = .failed(error: .noCameraAvailable) + } + } + } + + func stopScanning(destroy: Bool) { + if (zxCapture.delegate != nil) { + // Setting the zxCapture to nil without checking makes it start + // scanning and implicitly requesting camera access + zxCapture.delegate = nil + } + + guard zxCapture.running else { + return + } + + if destroy { + zxCapture.hard_stop() + } else { + zxCapture.stop() + } + } + + @MainActor + func processScannedQR(_ data: Data) { + guard let code = try? JSONDecoder().decode(QRLoginCode.self, from: data) else { + state = .failed(error: .invalidQR) + return + } + + Task { + await processQRLoginCode(code) + } + } + + func confirmCode() { + switch state { + case .waitingForConfirmation: + // TODO: implement + break + default: + return + } + } + + func restart() { + state = .initial + + Task { + await declineRendezvous() + } + } + + func reset() { + stopScanning(destroy: false) + state = .initial + + Task { + await declineRendezvous() + } + } + + deinit { + stopScanning(destroy: true) + } + + // MARK: Private + + @MainActor + private func processQRLoginCode(_ code: QRLoginCode) async { + MXLog.debug("[QRLoginService] processQRLoginCode: \(code)") + state = .connectingToDevice + + guard code.intent == QRLoginRendezvousPayload.Intent.loginReciprocate.rawValue, + let uri = code.rendezvous.transport?.uri, + let rendezvousURL = URL(string: uri), + let key = code.rendezvous.key else { + MXLog.error("[QRLoginService] QR code invalid") + state = .failed(error: .invalidQR) + return + } + + let transport = RendezvousTransport(baseURL: BuildSettings.rendezvousServerBaseURL, + rendezvousURL: rendezvousURL) + let rendezvousService = RendezvousService(transport: transport) + self.rendezvousService = rendezvousService + + MXLog.debug("[QRLoginService] Joining the rendezvous at \(rendezvousURL)") + guard case .success(let validationCode) = await rendezvousService.joinRendezvous(withPublicKey: key) else { + MXLog.error("[QRLoginService] Failed joining rendezvous") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + state = .waitingForConfirmation(validationCode) + + MXLog.debug("[QRLoginService] Waiting for available protocols") + guard case let .success(data) = await rendezvousService.receive(), + let responsePayload = try? JSONDecoder().decode(QRLoginRendezvousPayload.self, from: data) else { + MXLog.error("[QRLoginService] Failed receiving available protocols") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Received available protocols \(responsePayload)") + guard let protocols = responsePayload.protocols, + protocols.contains(.loginToken) else { + MXLog.error("[QRLoginService] Unexpected protocols, cannot continue") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Request login with `login_token`") + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginProgress, protocol: .loginToken)), + case .success = await rendezvousService.send(data: requestData) else { + MXLog.error("[QRLoginService] Failed sending continue with `login_token` request") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Waiting for the login token") + guard case let .success(data) = await rendezvousService.receive(), + let responsePayload = try? JSONDecoder().decode(QRLoginRendezvousPayload.self, from: data), + let login_token = responsePayload.loginToken, + let homeserver = responsePayload.homeserver, + let homeserverURL = URL(string: homeserver) else { + MXLog.error("[QRLoginService] Invalid login details") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + MXLog.debug("[QRLoginService] Received login token \(responsePayload)") + + state = .waitingForRemoteSignIn + + // Use a custom rest client linked to the existing device's homeserver + let authenticationRestClient = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) + + MXLog.debug("[QRLoginService] Logging in with the login token") + guard let credentials = try? await authenticationRestClient.login(parameters: LoginTokenParameters(token: login_token)) else { + MXLog.error("[QRLoginService] Failed logging in with the login token") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Got acess token") + + 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) + } + } + + guard case .success = cryptoResult else { + MXLog.error("[QRLoginService] Failed enabling crypto") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Session created, sending device details") + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginProgress, + outcome: .success, + deviceId: session.myDeviceId, + deviceKey: session.crypto.deviceEd25519Key)), + case .success = await rendezvousService.send(data: requestData) else { + MXLog.error("[QRLoginService] Failed sending session details") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Wait for cross-signing details") + guard case let .success(data) = await rendezvousService.receive(), + let responsePayload = try? JSONDecoder().decode(QRLoginRendezvousPayload.self, from: data), + responsePayload.outcome == .verified, + let verifiyingDeviceId = responsePayload.verifyingDeviceId, + let verifyingDeviceKey = responsePayload.verifyingDeviceKey else { + MXLog.error("[QRLoginService] Received invalid cross-signing details") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Received cross-signing details \(responsePayload)") + + if let masterKeyFromVerifyingDevice = responsePayload.masterKey, + let localMasterKey = session.crypto.crossSigning.crossSigningKeys(forUser: session.myUserId)?.masterKeys?.keys { + guard masterKeyFromVerifyingDevice == localMasterKey else { + MXLog.error("[QRLoginService] Received invalid master key from verifying device") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Marking the received master key as trusted") + let mskVerificationResult = await withCheckedContinuation { (continuation: CheckedContinuation) in + session.crypto.setUserVerification(true, forUser: session.myUserId) { + MXLog.debug("[QRLoginService] Successfully marked the received master key as trusted") + continuation.resume(returning: true) + } failure: { error in + continuation.resume(returning: false) + } + } + + guard mskVerificationResult == true else { + MXLog.error("[QRLoginService] Failed marking the master key as trusted") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + } + + guard let verifyingDeviceInfo = session.crypto.device(withDeviceId: verifiyingDeviceId, ofUser: session.myUserId), + verifyingDeviceInfo.fingerprint == verifyingDeviceKey else { + MXLog.error("[QRLoginService] Received invalid verifying device info") + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Locally marking the existing device as verified \(verifyingDeviceInfo)") + await withCheckedContinuation { (continuation: CheckedContinuation) in + session.crypto.setDeviceVerification(.verified, forDevice: verifiyingDeviceId, ofUser: session.myUserId) { + MXLog.debug("[QRLoginService] Marked the existing device as verified") + continuation.resume(returning: ()) + } failure: { _ in + MXLog.error("[QRLoginService] Failed marking the existing device as verified") + continuation.resume(returning: ()) + } + } + + MXLog.debug("[QRLoginService] Login flow finished, returning session") + state = .completed(session: session, securityCompleted: true) + } + + private func declineRendezvous() async { + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginFinish, outcome: .declined)) else { + return + } + + _ = await rendezvousService?.send(data: requestData) + + await teardownRendezvous() + } + + @MainActor + private func teardownRendezvous(state: QRLoginServiceState? = nil) async { + // Stop listening for changes, try deleting the resource + _ = await rendezvousService?.tearDown() + + // Try setting the new state, if necessary + if let state = state { + switch self.state { + case .completed: + return + case .initial: + return + default: + self.state = state + } + } + } +} + +// MARK: - ZXCaptureDelegate + +extension QRLoginService: ZXCaptureDelegate { + func captureCameraIsReady(_ capture: ZXCapture!) { + isCameraReady = true + } + + func captureResult(_ capture: ZXCapture!, result: ZXResult!) { + guard isCameraReady, + let result = result, + result.barcodeFormat == kBarcodeFormatQRCode else { + return + } + + stopScanning(destroy: false) + + if let bytes = result.resultMetadata.object(forKey: kResultMetadataTypeByteSegments.rawValue) as? NSArray, + let byteArray = bytes.firstObject as? ZXByteArray { + let data = Data(bytes: UnsafeRawPointer(byteArray.array), count: Int(byteArray.length)) + + callbacks.send(.didScanQR(data)) + } + } +} + +// MARK: - ViewWrapper + +private struct ViewWrapper: UIViewRepresentable { + var view: UIView + + func makeUIView(context: Context) -> some UIView { + view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift new file mode 100644 index 000000000..3f511214e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift @@ -0,0 +1,87 @@ +// +// Copyright 2022 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 + +class MockQRLoginService: QRLoginServiceProtocol { + private let mockCanDisplayQR: Bool + + init(withState state: QRLoginServiceState = .initial, + mode: QRLoginServiceMode = .notAuthenticated, + canDisplayQR: Bool = true) { + self.state = state + self.mode = mode + mockCanDisplayQR = canDisplayQR + } + + // MARK: - QRLoginServiceProtocol + + let mode: QRLoginServiceMode + + var state: QRLoginServiceState { + didSet { + if state != oldValue { + callbacks.send(.didUpdateState) + } + } + } + + let callbacks = PassthroughSubject() + + func isServiceAvailable() async throws -> Bool { + true + } + + func canDisplayQR() -> Bool { + mockCanDisplayQR + } + + func generateQRCode() async throws -> QRLoginCode { + let details = RendezvousDetails(algorithm: "m.rendezvous.v1.curve25519-aes-sha256", + transport: .init(type: "http.v1", + uri: "https://matrix.org"), + key: "some.public.key") + return QRLoginCode(rendezvous: details, + intent: "login.start") + } + + func scannerView() -> AnyView { + AnyView(Color.red) + } + + func startScanning() { } + + func stopScanning(destroy: Bool) { } + + func processScannedQR(_ data: Data) { + state = .connectingToDevice + state = .waitingForConfirmation("28E-1B9-D0F-896") + } + + func confirmCode() { + state = .waitingForRemoteSignIn + } + + func restart() { + state = .initial + } + + func reset() { + state = .initial + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift new file mode 100644 index 000000000..823a4983c --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift @@ -0,0 +1,100 @@ +// +// Copyright 2022 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 + +// MARK: - QRLoginServiceMode + +enum QRLoginServiceMode { + case authenticated + case notAuthenticated +} + +// MARK: - QRLoginServiceError + +enum QRLoginServiceError: Error, Equatable { + case noCameraAccess + case noCameraAvailable + case invalidQR + case requestDenied + case requestTimedOut + case rendezvousFailed +} + +// MARK: - QRLoginServiceState + +enum QRLoginServiceState: Equatable { + case initial + case scanningQR + case connectingToDevice + case waitingForConfirmation(_ code: String) + case waitingForRemoteSignIn + case failed(error: QRLoginServiceError) + // This is really an MXSession but that would break RiotSwiftUI + case completed(session: Any, securityCompleted: Bool) + + static func == (lhs: QRLoginServiceState, rhs: QRLoginServiceState) -> Bool { + switch (lhs, rhs) { + case (.initial, .initial): + return true + case (.scanningQR, .scanningQR): + return true + case (.connectingToDevice, .connectingToDevice): + return true + case (let .waitingForConfirmation(code1), let .waitingForConfirmation(code2)): + return code1 == code2 + case (.waitingForRemoteSignIn, .waitingForRemoteSignIn): + return true + case (let .failed(error1), let .failed(error2)): + return error1 == error2 + case (.completed, .completed): + return true + default: + return false + } + } +} + +// MARK: - QRLoginServiceCallback + +enum QRLoginServiceCallback { + case didScanQR(Data) + case didUpdateState +} + +// MARK: - QRLoginServiceProtocol + +protocol QRLoginServiceProtocol { + var mode: QRLoginServiceMode { get } + var state: QRLoginServiceState { get } + var callbacks: PassthroughSubject { get } + func isServiceAvailable() async throws -> Bool + func canDisplayQR() -> Bool + func generateQRCode() async throws -> QRLoginCode + + // MARK: QR Scanner + + func scannerView() -> AnyView + func startScanning() + func stopScanning(destroy: Bool) + func processScannedQR(_ data: Data) + + func confirmCode() + func restart() + func reset() +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift new file mode 100644 index 000000000..8c4f581ac --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift @@ -0,0 +1,63 @@ +// +// Copyright 2022 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 + +struct LabelledDivider: View { + @Environment(\.theme) private var theme + + let label: String + let font: Font? // theme.fonts.subheadline by default + let labelColor: Color? // theme.colors.primaryContent by default + let lineColor: Color? // theme.colors.quinaryContent by default + + init(label: String, + font: Font? = nil, + labelColor: Color? = nil, + lineColor: Color? = nil) { + self.label = label + self.font = font + self.labelColor = labelColor + self.lineColor = lineColor + } + + var body: some View { + HStack { + line + Text(label) + .foregroundColor(labelColor ?? theme.colors.primaryContent) + .font(font ?? theme.fonts.subheadline) + .fixedSize() + line + } + } + + var line: some View { + VStack { Divider().background(lineColor ?? theme.colors.quinaryContent) } + } +} + +// MARK: - Previews + +struct LabelledDivider_Previews: PreviewProvider { + static var previews: some View { + LabelledDivider(label: "Label") + .theme(.light).preferredColorScheme(.light) + LabelledDivider(label: "Label") + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift new file mode 100644 index 000000000..91e9b4590 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift @@ -0,0 +1,37 @@ +// +// 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginConfirmViewModelResult { + case confirm + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginConfirmViewState: BindableState { + var confirmationCode: String? +} + +enum AuthenticationQRLoginConfirmViewAction { + case confirm + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift new file mode 100644 index 000000000..96e500ef2 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift @@ -0,0 +1,56 @@ +// +// 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 + +typealias AuthenticationQRLoginConfirmViewModelType = StateStoreViewModel + +class AuthenticationQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelType, AuthenticationQRLoginConfirmViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginConfirmViewState()) + + switch qrLoginService.state { + case .waitingForConfirmation(let code): + state.confirmationCode = code + default: + break + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginConfirmViewAction) { + switch viewAction { + case .confirm: + callback?(.confirm) + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift new file mode 100644 index 000000000..9e46d9661 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol AuthenticationQRLoginConfirmViewModelProtocol { + var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginConfirmViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift new file mode 100644 index 000000000..7b09d9454 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift @@ -0,0 +1,102 @@ +// +// 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginConfirmCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginConfirmCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginConfirmCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginConfirmCoordinatorParameters + private let onboardingQRLoginConfirmHostingController: VectorHostingController + private var onboardingQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginConfirmCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginConfirmCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginConfirmScreen(context: viewModel.context) + onboardingQRLoginConfirmViewModel = viewModel + + onboardingQRLoginConfirmHostingController = VectorHostingController(rootView: view) + onboardingQRLoginConfirmHostingController.vc_removeBackTitle() + onboardingQRLoginConfirmHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginConfirmHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] did start.") + onboardingQRLoginConfirmViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] AuthenticationQRLoginConfirmViewModel did complete with result: \(result).") + + switch result { + case .confirm: + self.parameters.qrLoginService.confirmCode() + case .cancel: + self.parameters.qrLoginService.reset() + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginConfirmHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift new file mode 100644 index 000000000..d97929f7b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift @@ -0,0 +1,50 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginConfirmScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case `default` + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginConfirmScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginConfirmScreenState] { + // Each of the presence statuses + [.default] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896"))) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginConfirmScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift new file mode 100644 index 000000000..738596d79 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift @@ -0,0 +1,37 @@ +// +// 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginConfirmUITests: MockScreenTestCase { + func testDefault() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginConfirmScreenState.default.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + XCTAssertTrue(app.staticTexts["confirmationCodeLabel"].exists) + XCTAssertTrue(app.staticTexts["alertText"].exists) + +// let confirmButton = app.buttons["confirmButton"] +// XCTAssertTrue(confirmButton.exists) +// XCTAssertTrue(confirmButton.isEnabled) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift new file mode 100644 index 000000000..ebddb2774 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift @@ -0,0 +1,53 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginConfirmViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginConfirmViewModelProtocol! + var context: AuthenticationQRLoginConfirmViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896"))) + context = viewModel.context + } + + func testConfirm() { + var result: AuthenticationQRLoginConfirmViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .confirm) + + XCTAssertEqual(result, .confirm) + } + + func testCancel() { + var result: AuthenticationQRLoginConfirmViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift new file mode 100644 index 000000000..b0ddb0906 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift @@ -0,0 +1,135 @@ +// +// 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 + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginConfirmScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginConfirmViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + codeView + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 16) { + Image(Asset.Images.authenticationQrloginConfirmIcon.name) + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginConfirmTitle) + .font(theme.fonts.title3SB) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginConfirmSubtitle) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + @ViewBuilder + var codeView: some View { + if let code = context.viewState.confirmationCode { + Text(code) + .multilineTextAlignment(.center) + .font(theme.fonts.title1) + .foregroundColor(theme.colors.primaryContent) + .padding(.top, 80) + .accessibilityIdentifier("confirmationCodeLabel") + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 16) { + Text(VectorL10n.authenticationQrLoginConfirmAlert) + .padding(10) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.alert) + .shapedBorder(color: theme.colors.alert, borderWidth: 1, shape: RoundedRectangle(cornerRadius: 8)) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 12) + .accessibilityIdentifier("alertText") + +// Button(action: confirm) { +// Text(VectorL10n.confirm) +// } +// .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) +// .accessibilityIdentifier("confirmButton") + + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `confirm` view action. + func confirm() { + context.send(viewAction: .confirm) + } + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginConfirm_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginConfirmScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift new file mode 100644 index 000000000..8c2bf963f --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift @@ -0,0 +1,36 @@ +// +// 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 UIKit + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginDisplayViewModelResult { + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginDisplayViewState: BindableState { + var qrImage: UIImage? +} + +enum AuthenticationQRLoginDisplayViewAction { + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift new file mode 100644 index 000000000..bfad16c61 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift @@ -0,0 +1,64 @@ +// +// 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 + +typealias AuthenticationQRLoginDisplayViewModelType = StateStoreViewModel + +class AuthenticationQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelType, AuthenticationQRLoginDisplayViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginDisplayViewState()) + + Task { @MainActor in + let generator = QRCodeGenerator() + let qrData = try await qrLoginService.generateQRCode() + guard let jsonString = qrData.jsonString, + let data = jsonString.data(using: .isoLatin1) else { + return + } + + do { + state.qrImage = try generator.generateCode(from: data, + with: CGSize(width: 240, height: 240), + offColor: .clear) + } catch { + // MXLog.error("[AuthenticationQRLoginDisplayViewModel] failed to generate QR", context: error) + } + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginDisplayViewAction) { + switch viewAction { + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift new file mode 100644 index 000000000..eada8791b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol AuthenticationQRLoginDisplayViewModelProtocol { + var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginDisplayViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift new file mode 100644 index 000000000..3e45357bf --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift @@ -0,0 +1,103 @@ +// +// 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginDisplayCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginDisplayCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginDisplayCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginDisplayCoordinatorParameters + private let onboardingQRLoginDisplayHostingController: VectorHostingController + private var onboardingQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginDisplayCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginDisplayCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginDisplayScreen(context: viewModel.context) + onboardingQRLoginDisplayViewModel = viewModel + + onboardingQRLoginDisplayHostingController = VectorHostingController(rootView: view) + onboardingQRLoginDisplayHostingController.vc_removeBackTitle() + onboardingQRLoginDisplayHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginDisplayHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] did start.") + onboardingQRLoginDisplayViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] AuthenticationQRLoginDisplayViewModel did complete with result: \(result).") + + switch result { + case .cancel: + self.navigationRouter.popModule(animated: true) + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginDisplayHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + private func showScanQRScreen() { } + + private func showDisplayQRScreen() { } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift new file mode 100644 index 000000000..f5802fca1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift @@ -0,0 +1,50 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginDisplayScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case `default` + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginDisplayScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginDisplayScreenState] { + // Each of the presence statuses + [.default] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService()) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginDisplayScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift new file mode 100644 index 000000000..2d3e5ca5b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift @@ -0,0 +1,32 @@ +// +// 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginDisplayUITests: MockScreenTestCase { + func testDefault() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginDisplayScreenState.default.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + XCTAssertTrue(app.images["qrImageView"].exists) + + let displayQRButton = app.buttons["cancelButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift new file mode 100644 index 000000000..fb43aabb0 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift @@ -0,0 +1,41 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginDisplayViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginDisplayViewModelProtocol! + var context: AuthenticationQRLoginDisplayViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService()) + context = viewModel.context + } + + func testCancel() { + var result: AuthenticationQRLoginDisplayViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift new file mode 100644 index 000000000..a81b4c4ac --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift @@ -0,0 +1,148 @@ +// +// 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 + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginDisplayScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginDisplayViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + stepsView + qrView + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 24) { + Text(VectorL10n.authenticationQrLoginDisplayTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginDisplaySubtitle) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 8) { + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// The buttons used to select a use case for the app. + var stepsView: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(steps) { step in + HStack { + Text(String(step.id)) + .font(theme.fonts.caption2SB) + .foregroundColor(theme.colors.accent) + .padding(6) + .shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle()) + .offset(x: 1, y: 0) + Text(step.description) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + Spacer() + } + } + } + } + + @ViewBuilder + var qrView: some View { + if let qrImage = context.viewState.qrImage { + VStack { + Image(uiImage: qrImage) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + .scaledToFit() + .accessibilityIdentifier("qrImageView") + } + .aspectRatio(1, contentMode: .fit) + .shapedBorder(color: theme.colors.quinaryContent, + borderWidth: 1, + shape: RoundedRectangle(cornerRadius: 8)) + .padding(1) + .padding(.top, 16) + } + } + + private let steps = [ + QRLoginDisplayStep(id: 1, description: VectorL10n.authenticationQrLoginDisplayStep1), + QRLoginDisplayStep(id: 2, description: VectorL10n.authenticationQrLoginDisplayStep2) + ] + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginDisplay_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginDisplayScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} + +private struct QRLoginDisplayStep: Identifiable { + let id: Int + let description: String +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift new file mode 100644 index 000000000..5395facdd --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift @@ -0,0 +1,38 @@ +// +// 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginFailureViewModelResult { + case retry + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginFailureViewState: BindableState { + var retryButtonVisible: Bool + var failureText: String? +} + +enum AuthenticationQRLoginFailureViewAction { + case retry + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift new file mode 100644 index 000000000..0e363e549 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift @@ -0,0 +1,82 @@ +// +// 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 + +typealias AuthenticationQRLoginFailureViewModelType = StateStoreViewModel + +class AuthenticationQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelType, AuthenticationQRLoginFailureViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginFailureViewState(retryButtonVisible: false)) + + updateFailureText(for: qrLoginService.state) + qrLoginService.callbacks.sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didUpdateState: + self.updateFailureText(for: qrLoginService.state) + default: + break + } + } + .store(in: &cancellables) + } + + private func updateFailureText(for state: QRLoginServiceState) { + switch state { + case .failed(let error): + switch error { + case .invalidQR: + self.state.failureText = VectorL10n.authenticationQrLoginFailureInvalidQr + self.state.retryButtonVisible = true + case .requestDenied: + self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestDenied + self.state.retryButtonVisible = false + case .requestTimedOut: + self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestTimedOut + self.state.retryButtonVisible = true + default: + break + } + default: + break + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginFailureViewAction) { + switch viewAction { + case .retry: + callback?(.retry) + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift new file mode 100644 index 000000000..13955611f --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol AuthenticationQRLoginFailureViewModelProtocol { + var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginFailureViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift new file mode 100644 index 000000000..88d7ba391 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift @@ -0,0 +1,103 @@ +// +// 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginFailureCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginFailureCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginFailureCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginFailureCoordinatorParameters + private let onboardingQRLoginFailureHostingController: VectorHostingController + private var onboardingQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginFailureCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginFailureCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginFailureScreen(context: viewModel.context) + onboardingQRLoginFailureViewModel = viewModel + + onboardingQRLoginFailureHostingController = VectorHostingController(rootView: view) + onboardingQRLoginFailureHostingController.vc_removeBackTitle() + onboardingQRLoginFailureHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginFailureHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginFailureCoordinator] did start.") + onboardingQRLoginFailureViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginFailureCoordinator] AuthenticationQRLoginFailureViewModel did complete with result: \(result).") + + switch result { + case .retry: + self.qrLoginService.restart() + case .cancel: + self.qrLoginService.reset() + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginFailureHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopFailure() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startFailure() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopFailure() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift new file mode 100644 index 000000000..5747c86bb --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift @@ -0,0 +1,61 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case invalidQR + case requestDenied + case requestTimedOut + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginFailureScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginFailureScreenState] { + // Each of the presence statuses + [.invalidQR, .requestDenied, .requestTimedOut] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationQRLoginFailureViewModel + + switch self { + case .invalidQR: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .invalidQR))) + case .requestDenied: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestDenied))) + case .requestTimedOut: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut))) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginFailureScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift new file mode 100644 index 000000000..829349d78 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift @@ -0,0 +1,61 @@ +// +// 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginFailureUITests: MockScreenTestCase { + func testInvalidQR() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.invalidQR.title) + + XCTAssertTrue(app.staticTexts["failureLabel"].exists) + + let retryButton = app.buttons["retryButton"] + XCTAssertTrue(retryButton.exists) + XCTAssertTrue(retryButton.isEnabled) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } + + func testRequestDenied() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestDenied.title) + + XCTAssertTrue(app.staticTexts["failureLabel"].exists) + + let retryButton = app.buttons["retryButton"] + XCTAssertFalse(retryButton.exists) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } + + func testRequestTimedOut() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestTimedOut.title) + + XCTAssertTrue(app.staticTexts["failureLabel"].exists) + + let retryButton = app.buttons["retryButton"] + XCTAssertTrue(retryButton.exists) + XCTAssertTrue(retryButton.isEnabled) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift new file mode 100644 index 000000000..e5cb4e5c1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift @@ -0,0 +1,53 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginFailureViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginFailureViewModelProtocol! + var context: AuthenticationQRLoginFailureViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut))) + context = viewModel.context + } + + func testRetry() { + var result: AuthenticationQRLoginFailureViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .retry) + + XCTAssertEqual(result, .retry) + } + + func testCancel() { + var result: AuthenticationQRLoginFailureViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift new file mode 100644 index 000000000..16488fe41 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift @@ -0,0 +1,124 @@ +// +// 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 + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginFailureScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginFailureViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(theme.colors.alert) + Image(Asset.Images.exclamationCircle.name) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .aspectRatio(1.0, contentMode: .fit) + .padding(15) + } + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginFailureTitle) + .font(theme.fonts.title3SB) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + if let failureText = context.viewState.failureText { + Text(failureText) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .accessibilityIdentifier("failureLabel") + } + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 16) { + if context.viewState.retryButtonVisible { + Button(action: retry) { + Text(VectorL10n.authenticationQrLoginFailureRetry) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("retryButton") + } + + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `retry` view action. + func retry() { + context.send(viewAction: .retry) + } + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginFailure_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginFailureScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift new file mode 100644 index 000000000..3ba87311c --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift @@ -0,0 +1,35 @@ +// +// 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginLoadingViewModelResult { + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginLoadingViewState: BindableState { + var loadingText: String? +} + +enum AuthenticationQRLoginLoadingViewAction { + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift new file mode 100644 index 000000000..e49032c1d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift @@ -0,0 +1,72 @@ +// +// 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 + +typealias AuthenticationQRLoginLoadingViewModelType = StateStoreViewModel + +class AuthenticationQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelType, AuthenticationQRLoginLoadingViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginLoadingViewState()) + + updateLoadingText(for: qrLoginService.state) + qrLoginService.callbacks.sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didUpdateState: + self.updateLoadingText(for: qrLoginService.state) + default: + break + } + } + .store(in: &cancellables) + } + + private func updateLoadingText(for state: QRLoginServiceState) { + switch state { + case .connectingToDevice: + self.state.loadingText = VectorL10n.authenticationQrLoginLoadingConnectingDevice + case .waitingForRemoteSignIn: + self.state.loadingText = VectorL10n.authenticationQrLoginLoadingWaitingSignin + case .completed: + self.state.loadingText = VectorL10n.authenticationQrLoginLoadingSignedIn + default: + break + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginLoadingViewAction) { + switch viewAction { + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift new file mode 100644 index 000000000..392dfb36b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol AuthenticationQRLoginLoadingViewModelProtocol { + var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginLoadingViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift new file mode 100644 index 000000000..e518e93d4 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift @@ -0,0 +1,101 @@ +// +// 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginLoadingCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginLoadingCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginLoadingCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginLoadingCoordinatorParameters + private let onboardingQRLoginLoadingHostingController: VectorHostingController + private var onboardingQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginLoadingCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginLoadingCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginLoadingScreen(context: viewModel.context) + onboardingQRLoginLoadingViewModel = viewModel + + onboardingQRLoginLoadingHostingController = VectorHostingController(rootView: view) + onboardingQRLoginLoadingHostingController.vc_removeBackTitle() + onboardingQRLoginLoadingHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginLoadingHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] did start.") + onboardingQRLoginLoadingViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] AuthenticationQRLoginLoadingViewModel did complete with result: \(result).") + + switch result { + case .cancel: + self.qrLoginService.reset() + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginLoadingHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift new file mode 100644 index 000000000..4bd3c03bc --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift @@ -0,0 +1,61 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginLoadingScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case connectingToDevice + case waitingForRemoteSignIn + case completed + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginLoadingScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginLoadingScreenState] { + // Each of the presence statuses + [.connectingToDevice, .waitingForRemoteSignIn, .completed] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationQRLoginLoadingViewModel + + switch self { + case .connectingToDevice: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .connectingToDevice)) + case .waitingForRemoteSignIn: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .waitingForRemoteSignIn)) + case .completed: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .completed(session: "", securityCompleted: true))) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginLoadingScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift new file mode 100644 index 000000000..29da264ad --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift @@ -0,0 +1,30 @@ +// +// 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginLoadingUITests: MockScreenTestCase { + func testCommon() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginLoadingScreenState.connectingToDevice.title) + + XCTAssertTrue(app.staticTexts["loadingLabel"].exists) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift new file mode 100644 index 000000000..e2bf22d3b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift @@ -0,0 +1,41 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginLoadingViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginLoadingViewModelProtocol! + var context: AuthenticationQRLoginLoadingViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: MockQRLoginService(withState: .connectingToDevice)) + context = viewModel.context + } + + func testCancel() { + var result: AuthenticationQRLoginLoadingViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift new file mode 100644 index 000000000..d2c4193c5 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift @@ -0,0 +1,97 @@ +// +// 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 + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginLoadingScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginLoadingViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + loadingText + .padding(.top, 60) + loader + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + @ViewBuilder + var loadingText: some View { + if let code = context.viewState.loadingText { + Text(code) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("loadingLabel") + } + } + + @ViewBuilder + var loader: some View { + ProgressView() + .padding(.top, 64) + .accessibilityIdentifier("loader") + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 8) { + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginLoading_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginLoadingScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift new file mode 100644 index 000000000..c9e53a1f1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift @@ -0,0 +1,54 @@ +// +// 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginScanViewModelResult: Equatable { + case goToSettings + case displayQR + case qrScanned(Data) + + static func == (lhs: AuthenticationQRLoginScanViewModelResult, rhs: AuthenticationQRLoginScanViewModelResult) -> Bool { + switch (lhs, rhs) { + case (.goToSettings, .goToSettings): + return true + case (.displayQR, .displayQR): + return true + case (let .qrScanned(data1), let .qrScanned(data2)): + return data1 == data2 + default: + return false + } + } +} + +// MARK: View + +struct AuthenticationQRLoginScanViewState: BindableState { + var canShowDisplayQRButton: Bool + var serviceState: QRLoginServiceState + var scannerView: AnyView? +} + +enum AuthenticationQRLoginScanViewAction { + case goToSettings + case displayQR +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift new file mode 100644 index 000000000..e043aa2ea --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift @@ -0,0 +1,76 @@ +// +// 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 AuthenticationQRLoginScanViewModelType = StateStoreViewModel + +class AuthenticationQRLoginScanViewModel: AuthenticationQRLoginScanViewModelType, AuthenticationQRLoginScanViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: .init(canShowDisplayQRButton: qrLoginService.canDisplayQR(), + serviceState: .initial)) + + qrLoginService.callbacks.sink { callback in + switch callback { + case .didUpdateState: + self.processServiceState(qrLoginService.state) + case .didScanQR(let data): + self.callback?(.qrScanned(data)) + } + } + .store(in: &cancellables) + + processServiceState(qrLoginService.state) + qrLoginService.startScanning() + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginScanViewAction) { + switch viewAction { + case .goToSettings: + callback?(.goToSettings) + case .displayQR: + callback?(.displayQR) + } + } + + // MARK: - Private + + private func processServiceState(_ state: QRLoginServiceState) { + switch state { + case .scanningQR: + self.state.scannerView = qrLoginService.scannerView() + default: + break + } + self.state.serviceState = state + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift new file mode 100644 index 000000000..dbe36c270 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol AuthenticationQRLoginScanViewModelProtocol { + var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginScanViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift new file mode 100644 index 000000000..dd6692731 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift @@ -0,0 +1,131 @@ +// +// 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 CommonKit +import MatrixSDK +import SwiftUI + +struct AuthenticationQRLoginScanCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginScanCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginScanCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginScanCoordinatorParameters + private let onboardingQRLoginScanHostingController: VectorHostingController + private var onboardingQRLoginScanViewModel: AuthenticationQRLoginScanViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginScanCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginScanCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginScanScreen(context: viewModel.context) + onboardingQRLoginScanViewModel = viewModel + + onboardingQRLoginScanHostingController = VectorHostingController(rootView: view) + onboardingQRLoginScanHostingController.vc_removeBackTitle() + onboardingQRLoginScanHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginScanHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginScanCoordinator] did start.") + onboardingQRLoginScanViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginScanCoordinator] AuthenticationQRLoginScanViewModel did complete with result: \(result).") + + switch result { + case .goToSettings: + self.goToSettings() + case .displayQR: + self.showDisplayQRScreen() + case .qrScanned(let data): + self.qrLoginService.stopScanning(destroy: false) + self.qrLoginService.processScannedQR(data) + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginScanHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + private func goToSettings() { + UIApplication.shared.vc_openSettings() + } + + /// Shows the display QR screen. + private func showDisplayQRScreen() { + MXLog.debug("[AuthenticationQRLoginScanCoordinator] showDisplayQRScreen") + + let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift new file mode 100644 index 000000000..32f24a675 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift @@ -0,0 +1,69 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginScanScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case scanning + case noCameraAvailable + case noCameraAccess + case noCameraAvailableNoDisplayQR + case noCameraAccessNoDisplayQR + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginScanScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginScanScreenState] { + // Each of the presence statuses + [.scanning, .noCameraAvailable, .noCameraAccess, .noCameraAvailableNoDisplayQR, .noCameraAccessNoDisplayQR] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: QRLoginServiceProtocol + + switch self { + case .scanning: + service = MockQRLoginService(withState: .scanningQR) + case .noCameraAvailable: + service = MockQRLoginService(withState: .failed(error: .noCameraAvailable)) + case .noCameraAccess: + service = MockQRLoginService(withState: .failed(error: .noCameraAccess)) + case .noCameraAvailableNoDisplayQR: + service = MockQRLoginService(withState: .failed(error: .noCameraAvailable), canDisplayQR: false) + case .noCameraAccessNoDisplayQR: + service = MockQRLoginService(withState: .failed(error: .noCameraAccess), canDisplayQR: false) + } + + let viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginScanScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift new file mode 100644 index 000000000..374673183 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift @@ -0,0 +1,77 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import RiotSwiftUI +import XCTest + +class AuthenticationQRLoginScanUITests: MockScreenTestCase { + func testScanning() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.scanning.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + } + + func testNoCameraAvailable() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAvailable.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } + + func testNoCameraAccess() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAccess.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let openSettingsButton = app.buttons["openSettingsButton"] + XCTAssertTrue(openSettingsButton.exists) + XCTAssertTrue(openSettingsButton.isEnabled) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } + + func testNoCameraAvailableNoDisplayQR() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAvailableNoDisplayQR.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertFalse(displayQRButton.exists) + } + + func testNoCameraAccessNoDisplayQR() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAccessNoDisplayQR.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let openSettingsButton = app.buttons["openSettingsButton"] + XCTAssertTrue(openSettingsButton.exists) + XCTAssertTrue(openSettingsButton.isEnabled) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertFalse(displayQRButton.exists) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift new file mode 100644 index 000000000..d8eb169bf --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift @@ -0,0 +1,57 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginScanViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginScanViewModelProtocol! + var context: AuthenticationQRLoginScanViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: MockQRLoginService()) + context = viewModel.context + } + + func testDisplayQRButtonVisibility() { + XCTAssertTrue(viewModel.context.viewState.canShowDisplayQRButton) + } + + func testGoToSettings() { + var result: AuthenticationQRLoginScanViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .goToSettings) + + XCTAssertEqual(result, .goToSettings) + } + + func testDisplayQR() { + var result: AuthenticationQRLoginScanViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .displayQR) + + XCTAssertEqual(result, .displayQR) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift new file mode 100644 index 000000000..03bc70c0f --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift @@ -0,0 +1,214 @@ +// +// 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 + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginScanScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + private let overlayBgColor = Color.black.opacity(0.4) + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginScanViewModel.Context + + var body: some View { + switch context.viewState.serviceState { + case .scanningQR: + scanningBody + case .failed(let error): + switch error { + case .noCameraAvailable, .noCameraAccess: + errorBody(for: error) + default: + EmptyView() + } + default: + EmptyView() + } + } + + var scanningBody: some View { + ZStack { + if let scannerView = context.viewState.scannerView { + scannerView + .frame(maxWidth: .infinity) + .background(Color.black) + } + overlayView + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } + + var overlayView: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + VStack { + Spacer() + scanningTitleContent + .padding(.horizontal, 40) + Spacer() + .frame(height: 16) + } + .frame(height: additionalViewHeight(in: geometry)) + .frame(maxWidth: .infinity) + .background(overlayBgColor) + + HStack(spacing: 0) { + overlayBgColor + .frame(width: 40) + Spacer() + overlayBgColor + .frame(width: 40) + } + .frame(maxWidth: .infinity) + + overlayBgColor + .frame(height: additionalViewHeight(in: geometry)) + } + } + .ignoresSafeArea() + } + + /// The screen's title and instructions. + var scanningTitleContent: some View { + VStack(spacing: 24) { + Text(VectorL10n.authenticationQrLoginScanTitle) + .font(theme.fonts.title1B) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginScanSubtitle) + .font(theme.fonts.bodySB) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + func errorBody(for error: QRLoginServiceError) -> some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + errorTitleContent(for: error) + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + } + .readableFrame() + + errorFooterContent(for: error) + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions on error. + func errorTitleContent(for error: QRLoginServiceError) -> some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(theme.colors.accent) + Image(Asset.Images.camera.name) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .aspectRatio(1.0, contentMode: .fit) + .padding(14) + } + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginStartTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(error == .noCameraAccess ? VectorL10n.cameraAccessNotGranted(AppInfo.current.displayName) : VectorL10n.cameraUnavailable) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + /// The screen's footer on error. + func errorFooterContent(for error: QRLoginServiceError) -> some View { + VStack(spacing: 12) { + if error == .noCameraAccess { + Button(action: goToSettings) { + Text(VectorL10n.settings) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.bottom, 8) + .accessibilityIdentifier("openSettingsButton") + } + + if context.viewState.canShowDisplayQRButton { + LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative) + + Button(action: displayQR) { + Text(VectorL10n.authenticationQrLoginStartDisplayQr) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("displayQRButton") + } + } + } + + /// Sends the `goToSettings` view action. + func goToSettings() { + context.send(viewAction: .goToSettings) + } + + /// Sends the `displayQR` view action. + func displayQR() { + context.send(viewAction: .displayQR) + } + + func squareSize(in geometry: GeometryProxy) -> CGFloat { + geometry.size.width - 80 + } + + func additionalViewHeight(in geometry: GeometryProxy) -> CGFloat { + (geometry.size.height - squareSize(in: geometry)) / 2 + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginScan_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginScanScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift new file mode 100644 index 000000000..589dd8e1b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift @@ -0,0 +1,37 @@ +// +// 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginStartViewModelResult { + case scanQR + case displayQR +} + +// MARK: View + +struct AuthenticationQRLoginStartViewState: BindableState { + var canShowDisplayQRButton: Bool +} + +enum AuthenticationQRLoginStartViewAction { + case scanQR + case displayQR +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift new file mode 100644 index 000000000..a61442f78 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift @@ -0,0 +1,49 @@ +// +// 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 + +typealias AuthenticationQRLoginStartViewModelType = StateStoreViewModel + +class AuthenticationQRLoginStartViewModel: AuthenticationQRLoginStartViewModelType, AuthenticationQRLoginStartViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginStartViewState(canShowDisplayQRButton: qrLoginService.canDisplayQR())) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginStartViewAction) { + switch viewAction { + case .scanQR: + callback?(.scanQR) + case .displayQR: + callback?(.displayQR) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift new file mode 100644 index 000000000..9d69a1bd3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol AuthenticationQRLoginStartViewModelProtocol { + var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginStartViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift new file mode 100644 index 000000000..2b5b7e136 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift @@ -0,0 +1,282 @@ +// +// 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginStartCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginStartCoordinatorResult { + /// Login with QR done + case done(session: MXSession, securityCompleted: Bool) +} + +final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginStartCoordinatorParameters + private let onboardingQRLoginStartHostingController: VectorHostingController + private var onboardingQRLoginStartViewModel: AuthenticationQRLoginStartViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + private var cancellables = Set() + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginStartCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginStartCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginStartScreen(context: viewModel.context) + onboardingQRLoginStartViewModel = viewModel + + onboardingQRLoginStartHostingController = VectorHostingController(rootView: view) + onboardingQRLoginStartHostingController.vc_removeBackTitle() + onboardingQRLoginStartHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginStartHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] did start.") + onboardingQRLoginStartViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginStartCoordinator] AuthenticationQRLoginStartViewModel did complete with result: \(result).") + + switch result { + case .scanQR: + self.showScanQRScreen() + case .displayQR: + self.showDisplayQRScreen() + } + } + + qrLoginService.callbacks.sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didUpdateState: + self.processServiceState(self.qrLoginService.state) + default: + break + } + } + .store(in: &cancellables) + } + + func toPresentable() -> UIViewController { + onboardingQRLoginStartHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + private func processServiceState(_ state: QRLoginServiceState) { + switch state { + case .initial: + removeAllChildren() + case .connectingToDevice, .waitingForRemoteSignIn: + showLoadingScreenIfNeeded() + case .waitingForConfirmation: + showConfirmationScreenIfNeeded() + case .failed(let error): + switch error { + case .noCameraAccess, .noCameraAvailable: + break // handled in scanning screen + default: + showFailureScreenIfNeeded() + } + case .completed(let session, let securityCompleted): + guard let session = session as? MXSession else { + showFailureScreenIfNeeded() + return + } + callback?(.done(session: session, securityCompleted: securityCompleted)) + default: + break + } + } + + private func removeAllChildren(animated: Bool = true) { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] removeAllChildren") + + guard !childCoordinators.isEmpty else { + return + } + + for coordinator in childCoordinators.reversed() { + remove(childCoordinator: coordinator) + } + + navigationRouter.popToModule(self, animated: animated) + } + + /// Shows the scan QR screen. + private func showScanQRScreen() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showScanQRScreen") + + let parameters = AuthenticationQRLoginScanCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginScanCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the display QR screen. + private func showDisplayQRScreen() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showDisplayQRScreen") + + removeAllChildren(animated: false) + + let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the loading screen. + private func showLoadingScreenIfNeeded() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showLoadingScreenIfNeeded") + + removeAllChildren(animated: false) + + if let lastCoordinator = childCoordinators.last, + lastCoordinator is AuthenticationQRLoginLoadingCoordinator { + // if the last screen is loading, do nothing. It'll be updated by the service state. + return + } + + let parameters = AuthenticationQRLoginLoadingCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginLoadingCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the confirmation screen. + private func showConfirmationScreenIfNeeded() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showConfirmationScreenIfNeeded") + + removeAllChildren(animated: false) + + if let lastCoordinator = childCoordinators.last, + lastCoordinator is AuthenticationQRLoginConfirmCoordinator { + // if the last screen is confirmation, do nothing. It'll be updated by the service state. + return + } + + let parameters = AuthenticationQRLoginConfirmCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginConfirmCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the failure screen. + private func showFailureScreenIfNeeded() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showFailureScreenIfNeeded") + + removeAllChildren(animated: false) + + if let lastCoordinator = childCoordinators.last, + lastCoordinator is AuthenticationQRLoginFailureCoordinator { + // if the last screen is failure, do nothing. It'll be updated by the service state. + return + } + + let parameters = AuthenticationQRLoginFailureCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginFailureCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift new file mode 100644 index 000000000..ddba6cd62 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift @@ -0,0 +1,60 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginStartScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case displayQREnabled + case displayQRDisabled + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginStartScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginStartScreenState] { + // Each of the presence statuses + [.displayQREnabled, .displayQRDisabled] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: QRLoginServiceProtocol + + switch self { + case .displayQREnabled: + service = MockQRLoginService(canDisplayQR: true) + case .displayQRDisabled: + service = MockQRLoginService(canDisplayQR: false) + } + + let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginStartScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift new file mode 100644 index 000000000..c4695c5f8 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift @@ -0,0 +1,49 @@ +// +// 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginStartUITests: MockScreenTestCase { + func testDisplayQREnabled() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginStartScreenState.displayQREnabled.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let scanQRButton = app.buttons["scanQRButton"] + XCTAssertTrue(scanQRButton.exists) + XCTAssertTrue(scanQRButton.isEnabled) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } + + func testDisplayQRDisabled() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginStartScreenState.displayQRDisabled.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let scanQRButton = app.buttons["scanQRButton"] + XCTAssertTrue(scanQRButton.exists) + XCTAssertTrue(scanQRButton.isEnabled) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertFalse(displayQRButton.exists) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift new file mode 100644 index 000000000..c254b962e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift @@ -0,0 +1,57 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginStartViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginStartViewModelProtocol! + var context: AuthenticationQRLoginStartViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService()) + context = viewModel.context + } + + func testDisplayQRButtonVisibility() { + XCTAssertTrue(viewModel.context.viewState.canShowDisplayQRButton) + } + + func testScanQR() { + var result: AuthenticationQRLoginStartViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .scanQR) + + XCTAssertEqual(result, .scanQR) + } + + func testDisplayQR() { + var result: AuthenticationQRLoginStartViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .displayQR) + + XCTAssertEqual(result, .displayQR) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift new file mode 100644 index 000000000..a5025a321 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift @@ -0,0 +1,159 @@ +// +// 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 + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginStartScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginStartViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + stepsView + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(theme.colors.accent) + Image(Asset.Images.camera.name) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .aspectRatio(1.0, contentMode: .fit) + .padding(14) + } + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginStartTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginStartSubtitle) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 12) { + Button(action: scanQR) { + Text(VectorL10n.authenticationQrLoginStartTitle) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.bottom, 8) + .accessibilityIdentifier("scanQRButton") + + if context.viewState.canShowDisplayQRButton { + LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative) + + Button(action: displayQR) { + Text(VectorL10n.authenticationQrLoginStartDisplayQr) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("displayQRButton") + } + } + } + + /// The buttons used to select a use case for the app. + var stepsView: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(steps) { step in + HStack { + Text(String(step.id)) + .font(theme.fonts.caption2SB) + .foregroundColor(theme.colors.accent) + .padding(6) + .shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle()) + .offset(x: 1, y: 0) + Text(step.description) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + Spacer() + } + } + } + } + + private let steps = [ + QRLoginStartStep(id: 1, description: VectorL10n.authenticationQrLoginStartStep1), + QRLoginStartStep(id: 2, description: VectorL10n.authenticationQrLoginStartStep2), + QRLoginStartStep(id: 3, description: VectorL10n.authenticationQrLoginStartStep3), + QRLoginStartStep(id: 4, description: VectorL10n.authenticationQrLoginStartStep4) + ] + + /// Sends the `scanQR` view action. + func scanQR() { + context.send(viewAction: .scanQR) + } + + /// Sends the `displayQR` view action. + func displayQR() { + context.send(viewAction: .displayQR) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginStart_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginStartScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} + +private struct QRLoginStartStep: Identifiable { + let id: Int + let description: String +} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 05cc50234..e2b3ce30e 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -19,6 +19,8 @@ import Foundation /// The static list of mocked screens in RiotSwiftUI enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockUserSessionNameScreenState.self, + MockUserOtherSessionsScreenState.self, MockUserSessionsOverviewScreenState.self, MockUserSessionDetailsScreenState.self, MockUserSessionOverviewScreenState.self, @@ -34,6 +36,12 @@ enum MockAppScreens { MockAuthenticationForgotPasswordScreenState.self, MockAuthenticationChoosePasswordScreenState.self, MockAuthenticationSoftLogoutScreenState.self, + MockAuthenticationQRLoginStartScreenState.self, + MockAuthenticationQRLoginDisplayScreenState.self, + MockAuthenticationQRLoginScanScreenState.self, + MockAuthenticationQRLoginConfirmScreenState.self, + MockAuthenticationQRLoginLoadingScreenState.self, + MockAuthenticationQRLoginFailureScreenState.self, MockOnboardingCelebrationScreenState.self, MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, @@ -60,6 +68,9 @@ enum MockAppScreens { MockTemplateUserProfileScreenState.self, MockTemplateRoomListScreenState.self, MockTemplateRoomChatScreenState.self, - MockSpaceSelectorScreenState.self + MockSpaceSelectorScreenState.self, + MockComposerScreenState.self, + MockComposerCreateActionListScreenState.self, + MockVoiceBroadcastPlaybackScreenState.self ] } diff --git a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift index 91cf8937f..ee618b8f8 100644 --- a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift +++ b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift @@ -36,6 +36,7 @@ struct ScreenList: View { VStack { TextField("Search", text: $searchQuery) .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() .padding(.horizontal) .accessibilityIdentifier("searchQueryTextField") .onChange(of: searchQuery, perform: search) diff --git a/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift b/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift index a2e7dc2b5..f0912c5bc 100644 --- a/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift +++ b/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift @@ -20,16 +20,33 @@ import XCTest extension XCUIApplication { func goToScreenWithIdentifier(_ identifier: String) { // Search for the screen identifier - textFields["searchQueryTextField"].tap() - typeText(identifier) - + let textField = textFields["searchQueryTextField"] let button = buttons[identifier] - let footer = staticTexts["footerText"] - while !button.isHittable, !footer.isHittable { - tables.firstMatch.swipeUp() + // Sometimes the search gets stuck without showing any results. Try to nudge it along + for _ in 0...10 { + textField.clearAndTypeText(identifier) + if button.exists { + break + } } button.tap() } } + +private extension XCUIElement { + func clearAndTypeText(_ text: String) { + guard let stringValue = value as? String else { + XCTFail("Tried to clear and type text into a non string value") + return + } + + tap() + + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) + + typeText(deleteString) + typeText(text) + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ListBackground.swift b/RiotSwiftUI/Modules/Common/Util/ListBackground.swift new file mode 100644 index 000000000..d4e087da8 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ListBackground.swift @@ -0,0 +1,58 @@ +// +// Copyright 2022 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 Introspect +import SwiftUI + +/// Introspects the view to find a table view on iOS 14/15 or a collection view +/// on iOS 16 and sets the background to the specified color. +struct ListBackgroundModifier: ViewModifier { + /// The background color. + let color: Color + + func body(content: Content) -> some View { + // When using Xcode 13 + #if compiler(<5.7) + // SwiftUI's List is backed by a table view. + content.introspectTableView { $0.backgroundColor = UIColor(color) } + + // When using Xcode 14+ + #else + if #available(iOS 16, *) { + // SwiftUI's List is backed by a collection view on iOS 16. + content + .introspectCollectionView { $0.backgroundColor = UIColor(color) } + .scrollContentBackground(.hidden) + } else { + // SwiftUI's List is backed by a table view on iOS 15 and below. + content.introspectTableView { $0.backgroundColor = UIColor(color) } + } + #endif + } +} + +extension View { + /// Sets the background color of a `List` using introspection. + func listBackgroundColor(_ color: Color) -> some View { + modifier(ListBackgroundModifier(color: color)) + } + + /// Finds a `UICollectionView` from a `SwiftUI.List`, or `SwiftUI.List` child. + /// Stop gap until https://github.com/siteline/SwiftUI-Introspect/pull/169 + func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View { + introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: customize) + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift index ed926fe8e..10369e3ae 100644 --- a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift @@ -19,8 +19,11 @@ import SwiftUI struct PrimaryActionButtonStyle: ButtonStyle { @Environment(\.theme) private var theme @Environment(\.isEnabled) private var isEnabled - + + /// `theme.colors.accent` by default var customColor: Color? + /// `theme.colors.body` by default + var font: Font? private var fontColor: Color { // Always white unless disabled with a dark theme. @@ -40,7 +43,7 @@ struct PrimaryActionButtonStyle: ButtonStyle { .padding(12.0) .frame(maxWidth: .infinity) .foregroundColor(fontColor) - .font(theme.fonts.body) + .font(font ?? theme.fonts.body) .background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed))) .cornerRadius(8.0) } diff --git a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift index 917ad1997..98b31a02b 100644 --- a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift @@ -19,15 +19,18 @@ import SwiftUI struct SecondaryActionButtonStyle: ButtonStyle { @Environment(\.theme) private var theme @Environment(\.isEnabled) private var isEnabled - + + /// `theme.colors.accent` by default var customColor: Color? + /// `theme.fonts.body` by default + var font: Font? func makeBody(configuration: Self.Configuration) -> some View { configuration.label .padding(12.0) .frame(maxWidth: .infinity) .foregroundColor(customColor ?? theme.colors.accent) - .font(theme.fonts.body) + .font(font ?? theme.fonts.body) .background(RoundedRectangle(cornerRadius: 8) .strokeBorder() .foregroundColor(customColor ?? theme.colors.accent)) diff --git a/RiotSwiftUI/Modules/LocationSharing/StartLocationSharing/Coordinator/LocationSharingCoordinator.swift b/RiotSwiftUI/Modules/LocationSharing/StartLocationSharing/Coordinator/LocationSharingCoordinator.swift index 71ec3c776..97cf99d09 100644 --- a/RiotSwiftUI/Modules/LocationSharing/StartLocationSharing/Coordinator/LocationSharingCoordinator.swift +++ b/RiotSwiftUI/Modules/LocationSharing/StartLocationSharing/Coordinator/LocationSharingCoordinator.swift @@ -104,8 +104,10 @@ final class LocationSharingCoordinator: Coordinator, Presentable { self.completion?() case .share(let latitude, let longitude, let coordinateType): self.shareStaticLocation(latitude: latitude, longitude: longitude, coordinateType: coordinateType) + self.completion?() case .shareLiveLocation(let timeout): self.startLiveLocationSharing(with: timeout) + self.completion?() case .checkLiveLocationCanBeStarted(let completion): self.checkLiveLocationCanBeStarted(completion: completion) } @@ -126,43 +128,23 @@ final class LocationSharingCoordinator: Coordinator, Presentable { } private func shareStaticLocation(latitude: Double, longitude: Double, coordinateType: LocationSharingCoordinateType) { - locationSharingViewModel.startLoading() - - parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil, coordinateType: coordinateType.eventAssetType()) { [weak self] _ in - guard let self = self else { return } - - self.locationSharingViewModel.stopLoading() - self.completion?() - } failure: { [weak self] error in - guard let self = self else { return } - + parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil, coordinateType: coordinateType.eventAssetType()) { _ in + } failure: { error in MXLog.error("[LocationSharingCoordinator] Failed sharing location", context: error) - self.locationSharingViewModel.stopLoading(error: .locationSharingError) } } private func startLiveLocationSharing(with timeout: TimeInterval) { guard let locationService = parameters.roomDataSource.mxSession.locationService, let roomId = parameters.roomDataSource.roomId else { - locationSharingViewModel.stopLoading(error: .locationSharingError) return } - locationService.startUserLocationSharing(withRoomId: roomId, description: nil, timeout: timeout) { [weak self] response in - guard let self = self else { return } - + locationService.startUserLocationSharing(withRoomId: roomId, description: nil, timeout: timeout) { response in switch response { case .success: - - DispatchQueue.main.async { - self.locationSharingViewModel.stopLoading() - self.completion?() - } + break case .failure(let error): MXLog.error("[LocationSharingCoordinator] Failed to start live location sharing", context: error) - - DispatchQueue.main.async { - self.locationSharingViewModel.stopLoading(error: .locationSharingError) - } } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListBridgePresenter.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListBridgePresenter.swift new file mode 100644 index 000000000..41b79334d --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListBridgePresenter.swift @@ -0,0 +1,90 @@ +/* + Copyright 2022 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 ComposerCreateActionListBridgePresenterDelegate { + func composerCreateActionListBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter, action: ComposerCreateAction) + func composerCreateActionListBridgePresenterDidDismissInteractively(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter) +} + +/// ComposerCreateActionListBridgePresenter enables to start ComposerCreateActionList from a view controller. +/// This bridge is used while waiting for global usage of coordinator pattern. +/// **WARNING**: This class breaks the Coordinator abstraction and it has been introduced for **Objective-C compatibility only** +/// (mainly for integration in legacy view controllers). Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator. +@objcMembers +final class ComposerCreateActionListBridgePresenter: NSObject { + // MARK: - Constants + + // MARK: - Properties + + // MARK: Private + + private let actions: [ComposerCreateAction] + private var coordinator: ComposerCreateActionListCoordinator? + + // MARK: Public + + weak var delegate: ComposerCreateActionListBridgePresenterDelegate? + + // MARK: - Setup + + init(actions: [Int]) { + self.actions = actions.compactMap { + ComposerCreateAction(rawValue: $0) + } + super.init() + } + + // MARK: - Public + + // NOTE: Default value feature is not compatible with Objective-C. + // func present(from viewController: UIViewController, animated: Bool) { + // self.present(from: viewController, animated: animated) + // } + + func present(from viewController: UIViewController, animated: Bool) { + let composerCreateActionListCoordinator = ComposerCreateActionListCoordinator(actions: actions) + composerCreateActionListCoordinator.callback = { [weak self] action in + guard let self = self else { return } + switch action { + case .done(let composeAction): + self.delegate?.composerCreateActionListBridgePresenterDelegateDidComplete(self, action: composeAction) + case .cancel: + self.delegate?.composerCreateActionListBridgePresenterDidDismissInteractively(self) + } + } + let presentable = composerCreateActionListCoordinator.toPresentable() + viewController.present(presentable, animated: animated, completion: nil) + composerCreateActionListCoordinator.start() + + coordinator = composerCreateActionListCoordinator + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = coordinator else { + return + } + // Dismiss modal + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + if let completion = completion { + completion() + } + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift new file mode 100644 index 000000000..fcc05c1f2 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift @@ -0,0 +1,75 @@ +// +// Copyright 2022 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 + +/// Actions returned by the coordinator callback +enum ComposerCreateActionListCoordinatorAction { + case done(ComposerCreateAction) + case cancel +} + +final class ComposerCreateActionListCoordinator: NSObject, Coordinator, Presentable, UISheetPresentationControllerDelegate { + // MARK: - Properties + + // MARK: Private + + private let hostingController: UIViewController + private var view: ComposerCreateActionList + private var viewModel: ComposerCreateActionListViewModel + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((ComposerCreateActionListCoordinatorAction) -> Void)? + + // MARK: - Setup + + init(actions: [ComposerCreateAction]) { + viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions)) + view = ComposerCreateActionList(viewModel: viewModel.context) + let hostingVC = VectorHostingController(rootView: view) + hostingVC.bottomSheetPreferences = VectorHostingBottomSheetPreferences( + detents: [.medium], + prefersGrabberVisible: true, + cornerRadius: 20 + ) + hostingController = hostingVC + super.init() + hostingVC.presentationController?.delegate = self + } + + // MARK: - Public + + func start() { + MXLog.debug("[ComposerCreateActionListCoordinator] did start.") + viewModel.callback = { result in + switch result { + case .done(let action): + self.callback?(.done(action)) + } + } + } + + func toPresentable() -> UIViewController { + hostingController + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + callback?(.cancel) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift new file mode 100644 index 000000000..31d5b9487 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift @@ -0,0 +1,43 @@ +// +// Copyright 2022 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 MockComposerCreateActionListScreenState: MockScreenState, CaseIterable { + case partialList + case fullList + + var screenType: Any.Type { + ComposerCreateActionList.self + } + + var screenView: ([Any], AnyView) { + let actions: [ComposerCreateAction] + switch self { + case .partialList: + actions = [.photoLibrary, .polls] + case .fullList: + actions = ComposerCreateAction.allCases + } + let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions)) + + return ( + [viewModel], + AnyView(ComposerCreateActionList(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift new file mode 100644 index 000000000..457cc612a --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift @@ -0,0 +1,116 @@ +// +// Copyright 2022 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 + +// MARK: View model + +enum ComposerCreateActionListViewAction { + // The user selected an action + case selectAction(ComposerCreateAction) +} + +enum ComposerCreateActionListViewModelResult: Equatable { + // The user selected an action and is done with the screen + case done(ComposerCreateAction) +} + +// MARK: View + +struct ComposerCreateActionListViewState: BindableState { + /// The list of composer create actions to display to the user + let actions: [ComposerCreateAction] +} + +@objc enum ComposerCreateAction: Int { + /// Upload a photo/video from the media library + case photoLibrary + /// Add a sticker + case stickers + /// Upload an attachment + case attachments + /// Voice broadcast + case voiceBroadcast + /// Create a Poll + case polls + /// Add a location + case location + /// Upload a photo or video from the camera + case camera +} + +extension ComposerCreateAction: Equatable, CaseIterable, Identifiable { + var id: Self { self } +} + +extension ComposerCreateAction { + var title: String { + switch self { + case .photoLibrary: + return VectorL10n.wysiwygComposerStartActionMediaPicker + case .stickers: + return VectorL10n.wysiwygComposerStartActionStickers + case .attachments: + return VectorL10n.wysiwygComposerStartActionAttachments + case .voiceBroadcast: + return VectorL10n.wysiwygComposerStartActionVoiceBroadcast + case .polls: + return VectorL10n.wysiwygComposerStartActionPolls + case .location: + return VectorL10n.wysiwygComposerStartActionLocation + case .camera: + return VectorL10n.wysiwygComposerStartActionCamera + } + } + + var accessibilityIdentifier: String { + switch self { + case .photoLibrary: + return "photoLibraryAction" + case .stickers: + return "stickersAction" + case .attachments: + return "attachmentsAction" + case .voiceBroadcast: + return "voiceBroadcastAction" + case .polls: + return "pollsAction" + case .location: + return "locationAction" + case .camera: + return "cameraAction" + } + } + + var icon: String { + switch self { + case .photoLibrary: + return Asset.Images.actionMediaLibrary.name + case .stickers: + return Asset.Images.actionSticker.name + case .attachments: + return Asset.Images.actionFile.name + case .voiceBroadcast: + return Asset.Images.actionLive.name + case .polls: + return Asset.Images.actionPoll.name + case .location: + return Asset.Images.actionLocation.name + case .camera: + return Asset.Images.actionCamera.name + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Test/UI/ComposerCreateActionListUITests.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Test/UI/ComposerCreateActionListUITests.swift new file mode 100644 index 000000000..64004c045 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Test/UI/ComposerCreateActionListUITests.swift @@ -0,0 +1,34 @@ +// +// Copyright 2022 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 RiotSwiftUI +import XCTest + +class ComposerCreateActionListUITests: MockScreenTestCase { + func testFullList() throws { + app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.fullList.title) + + XCTAssert(app.staticTexts[ComposerCreateAction.photoLibrary.accessibilityIdentifier].exists) + XCTAssert(app.staticTexts[ComposerCreateAction.location.accessibilityIdentifier].exists) + } + + func testPartialList() throws { + app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.partialList.title) + + XCTAssert(app.staticTexts[ComposerCreateAction.photoLibrary.accessibilityIdentifier].exists) + XCTAssertFalse(app.staticTexts[ComposerCreateAction.location.accessibilityIdentifier].exists) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Test/Unit/ComposerCreateActionListTests.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Test/Unit/ComposerCreateActionListTests.swift new file mode 100644 index 000000000..33258467b --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Test/Unit/ComposerCreateActionListTests.swift @@ -0,0 +1,41 @@ +// +// Copyright 2022 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. +// + +@testable import RiotSwiftUI +import SwiftUI +import XCTest + +class ComposerCreateActionListTests: XCTestCase { + var viewModel: ComposerCreateActionListViewModel! + var context: ComposerCreateActionListViewModel.Context! + + override func setUpWithError() throws { + viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: ComposerCreateAction.allCases)) + context = viewModel.context + } + + func testSelection() throws { + let actionToSelect: ComposerCreateAction = .attachments + var result: ComposerCreateActionListViewModelResult? + viewModel.callback = { callbackResult in + result = callbackResult + } + + viewModel.context.send(viewAction: .selectAction(actionToSelect)) + + XCTAssertEqual(result, .done(actionToSelect)) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift new file mode 100644 index 000000000..dbc484372 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 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 ComposerCreateActionList: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: ComposerCreateActionListViewModel.Context + + var body: some View { + VStack { + VStack(alignment: .leading) { + ForEach(viewModel.viewState.actions) { action in + HStack(spacing: 16) { + Image(action.icon) + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + Text(action.title) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.body) + .accessibilityIdentifier(action.accessibilityIdentifier) + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + viewModel.send(viewAction: .selectAction(action)) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + .padding(.top, 8) + Spacer() + }.background(theme.colors.background.ignoresSafeArea()) + } +} + +// MARK: - Previews + +struct ComposerCreateActionList_Previews: PreviewProvider { + static let stateRenderer = MockComposerCreateActionListScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/ViewModel/ComposerCreateActionListVIewModelProtocol.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/ViewModel/ComposerCreateActionListVIewModelProtocol.swift new file mode 100644 index 000000000..8eaa3dacc --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/ViewModel/ComposerCreateActionListVIewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022 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 + +protocol ComposerCreateActionListViewModelProtocol { + var callback: ((ComposerCreateActionListViewModelResult) -> Void)? { get set } + var context: ComposerCreateActionListViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/ViewModel/ComposerCreateActionListViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/ViewModel/ComposerCreateActionListViewModel.swift new file mode 100644 index 000000000..bd063b1b2 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/ViewModel/ComposerCreateActionListViewModel.swift @@ -0,0 +1,40 @@ +// +// 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 + +typealias ComposerCreateActionListViewModelType = StateStoreViewModel + +class ComposerCreateActionListViewModel: ComposerCreateActionListViewModelType, ComposerCreateActionListViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: ((ComposerCreateActionListViewModelResult) -> Void)? + + // MARK: - Setup + + // MARK: - Public + + override func process(viewAction: ComposerCreateActionListViewAction) { + switch viewAction { + case .selectAction(let action): + callback?(.done(action)) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift new file mode 100644 index 000000000..48d7df054 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -0,0 +1,67 @@ +// +// Copyright 2022 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 +import WysiwygComposer + +enum MockComposerScreenState: MockScreenState, CaseIterable { + case send + case edit + case reply + + var screenType: Any.Type { + Composer.self + } + + var screenView: ([Any], AnyView) { + let viewModel: ComposerViewModel + + switch self { + case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState()) + case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit)) + case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply)) + } + + let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxHeight: 360) + + viewModel.callback = { [weak viewModel, weak wysiwygviewModel] result in + guard let viewModel = viewModel else { return } + switch result { + case .cancel: + if viewModel.sendMode == .edit { + wysiwygviewModel?.setHtmlContent("") + } + viewModel.sendMode = .send + default: break + } + } + + return ( + [viewModel, wysiwygviewModel], + AnyView(VStack { + Spacer() + Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, sendMessageAction: { _ in }, showSendMediaActions: { }) + }.frame( + minWidth: 0, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: .topLeading + )) + ) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift new file mode 100644 index 000000000..badcd2b20 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -0,0 +1,140 @@ +// +// Copyright 2022 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 +import WysiwygComposer + +// MARK: View + +/// An item in the toolbar +struct FormatItem { + /// The type of the item + let type: FormatType + /// Whether it is active(highlighted) + let active: Bool + /// Whether it is disabled or enabled + let disabled: Bool +} + +/// The types of formatting actions +enum FormatType { + case bold + case italic + case strikethrough + case underline +} + +extension FormatType: CaseIterable, Identifiable { + var id: Self { self } +} + +extension FormatItem: Identifiable { + var id: FormatType { type } +} + +extension FormatItem { + /// The icon for the item + var icon: String { + switch type { + case .bold: + return Asset.Images.bold.name + case .italic: + return Asset.Images.italic.name + case .strikethrough: + return Asset.Images.strikethrough.name + case .underline: + return Asset.Images.underlined.name + } + } + + var accessibilityIdentifier: String { + switch type { + case .bold: + return "boldButton" + case .italic: + return "italicButton" + case .strikethrough: + return "strikethroughButton" + case .underline: + return "underlineButton" + } + } + + var accessibilityLabel: String { + switch type { + case .bold: + return VectorL10n.wysiwygComposerFormatActionBold + case .italic: + return VectorL10n.wysiwygComposerFormatActionItalic + case .strikethrough: + return VectorL10n.wysiwygComposerFormatActionStrikethrough + case .underline: + return VectorL10n.wysiwygComposerFormatActionUnderline + } + } +} + +extension FormatType { + /// Convenience method to map it to the external ViewModel action + var action: WysiwygAction { + switch self { + case .bold: + return .bold + case .italic: + return .italic + case .strikethrough: + return .strikeThrough + case .underline: + return .underline + } + } + + // TODO: We probably don't need to expose this, clean up. + + /// Convenience method to map it to the external rust binging action + var composerAction: ComposerAction { + switch self { + case .bold: + return .bold + case .italic: + return .italic + case .strikethrough: + return .strikeThrough + case .underline: + return .underline + } + } +} + +enum ComposerSendMode: Equatable { + case send + case edit + case reply + case createDM +} + +enum ComposerViewAction: Equatable { + case cancel + case contentDidChange(isEmpty: Bool) +} + +enum ComposerViewModelResult: Equatable { + case cancel + case contentDidChange(isEmpty: Bool) +} + + diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift new file mode 100644 index 000000000..0f8ad1fdc --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift @@ -0,0 +1,47 @@ +// +// Copyright 2022 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 + +struct ComposerViewState: BindableState { + var eventSenderDisplayName: String? + var sendMode: ComposerSendMode = .send + var placeholder: String? +} + +extension ComposerViewState { + var shouldDisplayContext: Bool { + return sendMode == .edit || sendMode == .reply + } + + var contextDescription: String? { + switch sendMode { + case .reply: + guard let eventSenderDisplayName = eventSenderDisplayName else { return nil } + return VectorL10n.roomMessageReplyingTo(eventSenderDisplayName) + case .edit: return VectorL10n.roomMessageEditing + default: return nil + } + } + + var contextImageName: String? { + switch sendMode { + case .edit: return Asset.Images.inputEditIcon.name + case .reply: return Asset.Images.inputReplyIcon.name + default: return nil + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift new file mode 100644 index 000000000..c80bea819 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift @@ -0,0 +1,125 @@ +// +// Copyright 2022 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 RiotSwiftUI +import XCTest + +final class ComposerUITests: MockScreenTestCase { + func testSendMode() throws { + app.goToScreenWithIdentifier(MockComposerScreenState.send.title) + + XCTAssertFalse(app.buttons["cancelButton"].exists) + let wysiwygTextView = app.textViews.allElementsBoundByIndex[0] + XCTAssertTrue(wysiwygTextView.exists) + let sendButton = app.buttons["sendButton"] + XCTAssertFalse(sendButton.exists) + wysiwygTextView.tap() + wysiwygTextView.typeText("test") + XCTAssertTrue(sendButton.exists) + XCTAssertFalse(app.buttons["editButton"].exists) + + let maximiseButton = app.buttons["maximiseButton"] + let minimiseButton = app.buttons["minimiseButton"] + XCTAssertFalse(minimiseButton.exists) + XCTAssertTrue(maximiseButton.exists) + + maximiseButton.tap() + XCTAssertTrue(minimiseButton.exists) + XCTAssertFalse(maximiseButton.exists) + + minimiseButton.tap() + XCTAssertFalse(minimiseButton.exists) + XCTAssertTrue(maximiseButton.exists) + } + + func testReplyMode() throws { + app.goToScreenWithIdentifier(MockComposerScreenState.reply.title) + + let wysiwygTextView = app.textViews.allElementsBoundByIndex[0] + XCTAssertTrue(wysiwygTextView.exists) + let sendButton = app.buttons["sendButton"] + XCTAssertFalse(sendButton.exists) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + + let contextDescription = app.staticTexts["contextDescription"] + XCTAssertTrue(contextDescription.exists) + XCTAssert(contextDescription.label == VectorL10n.roomMessageReplyingTo("TestUser")) + + wysiwygTextView.tap() + wysiwygTextView.typeText("test") + XCTAssertTrue(sendButton.exists) + XCTAssertFalse(app.buttons["editButton"].exists) + + cancelButton.tap() + let textViewContent = wysiwygTextView.value as! String + XCTAssertFalse(textViewContent.isEmpty) + XCTAssertFalse(cancelButton.exists) + + let maximiseButton = app.buttons["maximiseButton"] + let minimiseButton = app.buttons["minimiseButton"] + XCTAssertFalse(minimiseButton.exists) + XCTAssertTrue(maximiseButton.exists) + + maximiseButton.tap() + XCTAssertTrue(minimiseButton.exists) + XCTAssertFalse(maximiseButton.exists) + + minimiseButton.tap() + XCTAssertFalse(minimiseButton.exists) + XCTAssertTrue(maximiseButton.exists) + } + + func testEditMode() throws { + app.goToScreenWithIdentifier(MockComposerScreenState.edit.title) + + let wysiwygTextView = app.textViews.allElementsBoundByIndex[0] + XCTAssertTrue(wysiwygTextView.exists) + let editButton = app.buttons["editButton"] + XCTAssertFalse(editButton.exists) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + + let contextDescription = app.staticTexts["contextDescription"] + XCTAssertTrue(contextDescription.exists) + XCTAssert(contextDescription.label == VectorL10n.roomMessageEditing) + + wysiwygTextView.tap() + wysiwygTextView.typeText("test") + XCTAssertTrue(editButton.exists) + XCTAssertFalse(app.buttons["sendButton"].exists) + + cancelButton.tap() + let textViewContent = wysiwygTextView.value as! String + XCTAssertTrue(textViewContent.isEmpty) + XCTAssertFalse(cancelButton.exists) + + let maximiseButton = app.buttons["maximiseButton"] + let minimiseButton = app.buttons["minimiseButton"] + XCTAssertFalse(minimiseButton.exists) + XCTAssertTrue(maximiseButton.exists) + + maximiseButton.tap() + XCTAssertTrue(minimiseButton.exists) + XCTAssertFalse(maximiseButton.exists) + + minimiseButton.tap() + XCTAssertFalse(minimiseButton.exists) + XCTAssertTrue(maximiseButton.exists) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift new file mode 100644 index 000000000..5f16cfa42 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift @@ -0,0 +1,72 @@ +// +// Copyright 2022 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. +// + +@testable import RiotSwiftUI +import SwiftUI +import XCTest + +final class ComposerViewModelTests: XCTestCase { + var viewModel: ComposerViewModel! + var context: ComposerViewModel.Context! + + override func setUpWithError() throws { + viewModel = ComposerViewModel(initialViewState: ComposerViewState()) + context = viewModel.context + } + + func testSendState() { + viewModel.sendMode = .send + XCTAssert(context.viewState.sendMode == .send) + XCTAssert(context.viewState.shouldDisplayContext == false) + XCTAssert(context.viewState.eventSenderDisplayName == nil) + XCTAssert(context.viewState.contextImageName == nil) + XCTAssert(context.viewState.contextDescription == nil) + } + + func testEditState() { + viewModel.sendMode = .edit + XCTAssert(context.viewState.sendMode == .edit) + XCTAssert(context.viewState.shouldDisplayContext == true) + XCTAssert(context.viewState.eventSenderDisplayName == nil) + XCTAssert(context.viewState.contextImageName == Asset.Images.inputEditIcon.name) + XCTAssert(context.viewState.contextDescription == VectorL10n.roomMessageEditing) + } + + func testReplyState() { + viewModel.eventSenderDisplayName = "TestUser" + viewModel.sendMode = .reply + XCTAssert(context.viewState.sendMode == .reply) + XCTAssert(context.viewState.shouldDisplayContext == true) + XCTAssert(context.viewState.eventSenderDisplayName == "TestUser") + XCTAssert(context.viewState.contextImageName == Asset.Images.inputReplyIcon.name) + XCTAssert(context.viewState.contextDescription == VectorL10n.roomMessageReplyingTo("TestUser")) + } + + func testCancelTapped() { + var result: ComposerViewModelResult! + viewModel.callback = { value in + result = value + } + context.send(viewAction: .cancel) + XCTAssert(result == .cancel) + } + + func testPlaceholder() { + XCTAssert(context.viewState.placeholder == nil) + viewModel.placeholder = "Placeholder Test" + XCTAssert(context.viewState.placeholder == "Placeholder Test") + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift new file mode 100644 index 000000000..624c84638 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -0,0 +1,205 @@ +// +// Copyright 2022 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 DSBottomSheet +import SwiftUI +import WysiwygComposer + +struct Composer: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @State private var focused = false + @State private var isActionButtonShowing = false + + private let horizontalPadding: CGFloat = 12 + private let borderHeight: CGFloat = 40 + private let minTextViewHeight: CGFloat = 20 + private var verticalPadding: CGFloat { + (borderHeight - minTextViewHeight) / 2 + } + + private var topPadding: CGFloat { + viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding + } + + private var cornerRadius: CGFloat { + if viewModel.viewState.shouldDisplayContext || wysiwygViewModel.idealHeight > minTextViewHeight { + return 14 + } else { + return borderHeight / 2 + } + } + + private var actionButtonAccessibilityIdentifier: String { + viewModel.viewState.sendMode == .edit ? "editButton" : "sendButton" + } + + private var toggleButtonAcccessibilityIdentifier: String { + wysiwygViewModel.maximised ? "minimiseButton" : "maximiseButton" + } + + private var toggleButtonImageName: String { + wysiwygViewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name + } + + private var borderColor: Color { + focused ? theme.colors.quarterlyContent : theme.colors.quinaryContent + } + + private var formatItems: [FormatItem] { + FormatType.allCases.map { type in + FormatItem( + type: type, + active: wysiwygViewModel.reversedActions.contains(type.composerAction), + disabled: wysiwygViewModel.disabledActions.contains(type.composerAction) + ) + } + } + + // MARK: Public + + @ObservedObject var viewModel: ComposerViewModelType.Context + @ObservedObject var wysiwygViewModel: WysiwygComposerViewModel + + let sendMessageAction: (WysiwygComposerContent) -> Void + let showSendMediaActions: () -> Void + + var body: some View { + VStack(spacing: 8) { + let rect = RoundedRectangle(cornerRadius: cornerRadius) + VStack(spacing: 12) { + if viewModel.viewState.shouldDisplayContext { + HStack { + if let imageName = viewModel.viewState.contextImageName { + Image(imageName) + .foregroundColor(theme.colors.tertiaryContent) + } + if let contextDescription = viewModel.viewState.contextDescription { + Text(contextDescription) + .accessibilityIdentifier("contextDescription") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(theme.colors.secondaryContent) + } + Spacer() + Button { + viewModel.send(viewAction: .cancel) + } label: { + Image(Asset.Images.inputCloseIcon.name) + .foregroundColor(theme.colors.tertiaryContent) + } + .accessibilityIdentifier("cancelButton") + } + .padding(.top, 8) + .padding(.horizontal, horizontalPadding) + } + HStack(alignment: .top, spacing: 0) { + WysiwygComposerView( + focused: $focused, + content: wysiwygViewModel.content, + replaceText: wysiwygViewModel.replaceText, + select: wysiwygViewModel.select, + didUpdateText: wysiwygViewModel.didUpdateText + ) + .tintColor(theme.colors.accent) + .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent) + .frame(height: wysiwygViewModel.idealHeight) + .onAppear { + wysiwygViewModel.setup() + } + Button { + wysiwygViewModel.maximised.toggle() + } label: { + Image(toggleButtonImageName) + .resizable() + .foregroundColor(theme.colors.tertiaryContent) + .frame(width: 16, height: 16) + } + .accessibilityIdentifier(toggleButtonAcccessibilityIdentifier) + .padding(.leading, 12) + .padding(.trailing, 4) + } + .padding(.horizontal, horizontalPadding) + .padding(.top, topPadding) + .padding(.bottom, verticalPadding) + } + .clipShape(rect) + .overlay(rect.stroke(borderColor, lineWidth: 1)) + .animation(.easeInOut(duration: 0.1), value: wysiwygViewModel.idealHeight) + .padding(.horizontal, horizontalPadding) + .padding(.top, 8) + .onTapGesture { + if !focused { + focused = true + } + } + HStack(spacing: 0) { + Button { + showSendMediaActions() + } label: { + Image(Asset.Images.startComposeModule.name) + .resizable() + .foregroundColor(theme.colors.tertiaryContent) + .frame(width: 14, height: 14) + } + .frame(width: 36, height: 36) + .background(Circle().fill(theme.colors.system)) + .padding(.trailing, 8) + .accessibilityLabel(VectorL10n.create) + FormattingToolbar(formatItems: formatItems) { type in + wysiwygViewModel.apply(type.action) + } + .frame(height: 44) + Spacer() + Button { + sendMessageAction(wysiwygViewModel.content) + wysiwygViewModel.clearContent() + } label: { + if viewModel.viewState.sendMode == .edit { + Image(Asset.Images.saveIcon.name) + } else { + Image(Asset.Images.sendIcon.name) + } + } + .frame(width: 36, height: 36) + .padding(.leading, 8) + .isHidden(!isActionButtonShowing) + .accessibilityIdentifier(actionButtonAccessibilityIdentifier) + .accessibilityLabel(VectorL10n.send) + .onChange(of: wysiwygViewModel.isContentEmpty) { isEmpty in + viewModel.send(viewAction: .contentDidChange(isEmpty: isEmpty)) + withAnimation(.easeInOut(duration: 0.15)) { + isActionButtonShowing = !isEmpty + } + } + } + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + } +} + +// MARK: Previews + +struct Composer_Previews: PreviewProvider { + static let stateRenderer = MockComposerScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift b/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift new file mode 100644 index 000000000..c721832bb --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift @@ -0,0 +1,66 @@ +// +// Copyright 2022 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 +import WysiwygComposer + +struct FormattingToolbar: View { + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + @Environment(\.theme) private var theme: ThemeSwiftUI + + /// The list of items to render in the toolbar + var formatItems: [FormatItem] + /// The action when an item is selected + var formatAction: (FormatType) -> Void + + var body: some View { + HStack(spacing: 4) { + ForEach(formatItems) { item in + Button { + formatAction(item.type) + } label: { + Image(item.icon) + .renderingMode(.template) + .foregroundColor(item.active ? theme.colors.accent : theme.colors.tertiaryContent) + } + .disabled(item.disabled) + .frame(width: 44, height: 44) + .background(item.active ? theme.colors.accent.opacity(0.1) : theme.colors.background) + .cornerRadius(8) + .accessibilityIdentifier(item.accessibilityIdentifier) + .accessibilityLabel(item.accessibilityLabel) + } + } + } +} + +// MARK: - Previews + +struct FormattingToolbar_Previews: PreviewProvider { + static var previews: some View { + FormattingToolbar(formatItems: [ + FormatItem(type: .bold, active: true, disabled: false), + FormatItem(type: .italic, active: false, disabled: false), + FormatItem(type: .strikethrough, active: true, disabled: false), + FormatItem(type: .underline, active: false, disabled: true) + ], formatAction: { _ in }) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift new file mode 100644 index 000000000..1e44ed049 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift @@ -0,0 +1,67 @@ +// +// Copyright 2022 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 + +typealias ComposerViewModelType = StateStoreViewModel + +final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: ((ComposerViewModelResult) -> Void)? + + var sendMode: ComposerSendMode { + get { + state.sendMode + } + set { + state.sendMode = newValue + } + } + + var eventSenderDisplayName: String? { + get { + state.eventSenderDisplayName + } + set { + state.eventSenderDisplayName = newValue + } + } + + var placeholder: String? { + get { + state.placeholder + } + set { + state.placeholder = newValue + } + } + + // MARK: - Public + + override func process(viewAction: ComposerViewAction) { + switch viewAction { + case .cancel: + callback?(.cancel) + case let .contentDidChange(isEmpty): + callback?(.contentDidChange(isEmpty: isEmpty)) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift new file mode 100644 index 000000000..70d943dc7 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift @@ -0,0 +1,25 @@ +// +// Copyright 2022 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 + +protocol ComposerViewModelProtocol { + var context: ComposerViewModelType.Context { get } + var callback: ((ComposerViewModelResult) -> Void)? { get set } + var sendMode: ComposerSendMode { get set } + var eventSenderDisplayName: String? { get set } + var placeholder: String? { get set } +} diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index a587b23d8..1acd907a4 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -84,8 +84,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel func start() { } func toPresentable() -> UIViewController { - VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context), - forceZeroSafeAreaInsets: true) + VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context)) } func canEndPoll() -> Bool { diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift index 78b1d8ab7..31fb63849 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift @@ -26,13 +26,13 @@ class TimelinePollProvider { /// Create or retrieve the poll timeline coordinator for this event and return /// a view to be displayed in the timeline - func buildTimelinePollViewForEvent(_ event: MXEvent) -> UIView? { + func buildTimelinePollVCForEvent(_ event: MXEvent) -> UIViewController? { guard let session = session, let room = session.room(withRoomId: event.roomId) else { return nil } if let coordinator = coordinatorsForEventIdentifiers[event.eventId] { - return coordinator.toPresentable().view + return coordinator.toPresentable() } let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event) @@ -42,7 +42,7 @@ class TimelinePollProvider { coordinatorsForEventIdentifiers[event.eventId] = coordinator - return coordinator.toPresentable().view + return coordinator.toPresentable() } /// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift index 5e7eaceef..9b363d367 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift @@ -24,36 +24,45 @@ class TimelinePollUITests: MockScreenTestCase { XCTAssert(app.staticTexts["Question"].exists) XCTAssert(app.staticTexts["20 votes cast"].exists) - XCTAssert(app.buttons["First, 10 votes"].exists) - XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%") + + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%") + + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%") - XCTAssert(app.buttons["Second, 5 votes"].exists) - XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%") + app.buttons["PollAnswerOption0"].tap() - XCTAssert(app.buttons["Third, 15 votes"].exists) - XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "11 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "55%") - app.buttons["First, 10 votes"].tap() + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "4 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "20%") - XCTAssert(app.buttons["First, 11 votes"].exists) - XCTAssertEqual(app.buttons["First, 11 votes"].value as! String, "55%") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%") - XCTAssert(app.buttons["Second, 4 votes"].exists) - XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%") + app.buttons["PollAnswerOption2"].tap() - XCTAssert(app.buttons["Third, 15 votes"].exists) - XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%") - app.buttons["Third, 15 votes"].tap() + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "4 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "20%") - XCTAssert(app.buttons["First, 10 votes"].exists) - XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%") - - XCTAssert(app.buttons["Second, 4 votes"].exists) - XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%") - - XCTAssert(app.buttons["Third, 16 votes"].exists) - XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "16 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "80%") } func testOpenUndisclosedPoll() { @@ -62,29 +71,29 @@ class TimelinePollUITests: MockScreenTestCase { XCTAssert(app.staticTexts["Question"].exists) XCTAssert(app.staticTexts["20 votes cast"].exists) - XCTAssert(!app.buttons["First, 10 votes"].exists) - XCTAssert(app.buttons["First"].exists) - XCTAssertTrue((app.buttons["First"].value as! String).isEmpty) - - XCTAssert(!app.buttons["Second, 5 votes"].exists) - XCTAssert(app.buttons["Second"].exists) - XCTAssertTrue((app.buttons["Second"].value as! String).isEmpty) - - XCTAssert(!app.buttons["Third, 15 votes"].exists) - XCTAssert(app.buttons["Third"].exists) - XCTAssertTrue((app.buttons["Third"].value as! String).isEmpty) - - app.buttons["First"].tap() - - XCTAssert(app.buttons["First"].exists) - XCTAssert(app.buttons["Second"].exists) - XCTAssert(app.buttons["Third"].exists) + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssert(!app.staticTexts["PollAnswerOption0Count"].exists) + XCTAssert(!app.progressIndicators["PollAnswerOption0Progress"].exists) - app.buttons["Third"].tap() + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssert(!app.staticTexts["PollAnswerOption1Count"].exists) + XCTAssert(!app.progressIndicators["PollAnswerOption1Progress"].exists) - XCTAssert(app.buttons["First"].exists) - XCTAssert(app.buttons["Second"].exists) - XCTAssert(app.buttons["Third"].exists) + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + XCTAssert(!app.staticTexts["PollAnswerOption2Count"].exists) + XCTAssert(!app.progressIndicators["PollAnswerOption2Progress"].exists) + + app.buttons["PollAnswerOption0"].tap() + + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + + app.buttons["PollAnswerOption2"].tap() + + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") } func testClosedDisclosedPoll() { @@ -100,25 +109,31 @@ class TimelinePollUITests: MockScreenTestCase { private func checkClosedPoll() { XCTAssert(app.staticTexts["Question"].exists) XCTAssert(app.staticTexts["Final results based on 20 votes"].exists) + + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%") - XCTAssert(app.buttons["First, 10 votes"].exists) - XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%") - XCTAssert(app.buttons["Second, 5 votes"].exists) - XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%") - XCTAssert(app.buttons["Third, 15 votes"].exists) - XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%") + app.buttons["PollAnswerOption0"].tap() - app.buttons["First, 10 votes"].tap() + XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First") + XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%") - XCTAssert(app.buttons["First, 10 votes"].exists) - XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second") + XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%") - XCTAssert(app.buttons["Second, 5 votes"].exists) - XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%") - - XCTAssert(app.buttons["Third, 15 votes"].exists) - XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third") + XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes") + XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%") } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift index aaaba7c37..2ffa68be9 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift @@ -41,6 +41,7 @@ struct TimelinePollAnswerOptionButton: View { .overlay(rect.stroke(borderAccentColor, lineWidth: 1.0)) .accentColor(progressViewAccentColor) } + .accessibilityIdentifier("PollAnswerOption\(optionIndex)") } var answerOptionLabel: some View { @@ -53,6 +54,7 @@ struct TimelinePollAnswerOptionButton: View { Text(answerOption.text) .font(theme.fonts.body) .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("PollAnswerOption\(optionIndex)Label") if poll.closed, answerOption.winner { Spacer() @@ -66,11 +68,13 @@ struct TimelinePollAnswerOptionButton: View { total: Double(poll.totalAnswerCount)) .progressViewStyle(LinearProgressViewStyle()) .scaleEffect(x: 1.0, y: 1.2, anchor: .center) + .accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress") if poll.shouldDiscloseResults { Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count))) .font(theme.fonts.footnote) .foregroundColor(poll.closed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent) + .accessibilityIdentifier("PollAnswerOption\(optionIndex)Count") } } } @@ -92,6 +96,10 @@ struct TimelinePollAnswerOptionButton: View { return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent } + + var optionIndex: Int { + poll.answerOptions.firstIndex { $0.id == answerOption.id } ?? Int.max + } } struct TimelinePollAnswerOptionButton_Previews: PreviewProvider { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift index 23b204083..f44744a9c 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift @@ -21,10 +21,7 @@ class UserSuggestionUITests: MockScreenTestCase { func testUserSuggestionScreen() throws { app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title) - XCTAssert(app.tables.firstMatch.waitForExistence(timeout: 1)) - - let firstButton = app.tables.firstMatch.buttons.firstMatch - _ = firstButton.waitForExistence(timeout: 10) - XCTAssert(firstButton.identifier == "displayNameText-userIdText") + let firstButton = app.buttons["displayNameText-userIdText"].firstMatch + XCTAssert(firstButton.waitForExistence(timeout: 10)) } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift new file mode 100644 index 000000000..4184f0d63 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift @@ -0,0 +1,77 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import MatrixSDK +import SwiftUI + +struct VoiceBroadcastPlaybackCoordinatorParameters { + let session: MXSession + let room: MXRoom + let voiceBroadcastStartEvent: MXEvent + let voiceBroadcastState: VoiceBroadcastInfo.State + let senderDisplayName: String? +} + +final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: VoiceBroadcastPlaybackCoordinatorParameters + + private var viewModel: VoiceBroadcastPlaybackViewModelProtocol! + private var cancellables = Set() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + // MARK: - Setup + + init(parameters: VoiceBroadcastPlaybackCoordinatorParameters) throws { + self.parameters = parameters + + let voiceBroadcastAggregator = try VoiceBroadcastAggregator(session: parameters.session, room: parameters.room, voiceBroadcastStartEventId: parameters.voiceBroadcastStartEvent.eventId, voiceBroadcastState: parameters.voiceBroadcastState) + + let details = VoiceBroadcastPlaybackDetails(senderDisplayName: parameters.senderDisplayName) + viewModel = VoiceBroadcastPlaybackViewModel(details: details, + mediaServiceProvider: VoiceMessageMediaServiceProvider.sharedProvider, + cacheManager: VoiceMessageAttachmentCacheManager.sharedManager, + voiceBroadcastAggregator: voiceBroadcastAggregator) + + } + + // MARK: - Public + + func start() { } + + func toPresentable() -> UIViewController { + VectorHostingController(rootView: VoiceBroadcastPlaybackView(viewModel: viewModel.context)) + } + + func canEndVoiceBroadcast() -> Bool { + // TODO: VB check is voicebroadcast stopped + return false + } + + func canEditVoiceBroadcast() -> Bool { + return false + } + + func endVoiceBroadcast() {} +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift new file mode 100644 index 000000000..5167a2364 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift @@ -0,0 +1,73 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastPlaybackProvider { + static let shared = VoiceBroadcastPlaybackProvider() + + var session: MXSession? + var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]() + + private init() { } + + /// Create or retrieve the voiceBroadcast timeline coordinator for this event and return + /// a view to be displayed in the timeline + func buildVoiceBroadcastPlaybackVCForEvent(_ event: MXEvent, senderDisplayName: String?) -> UIViewController? { + guard let session = session, let room = session.room(withRoomId: event.roomId) else { + return nil + } + + if let coordinator = coordinatorsForEventIdentifiers[event.eventId] { + return coordinator.toPresentable() + } + + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() + var voiceBroadcastState = VoiceBroadcastInfo.State.stopped + + room.state { roomState in + if let stateEvent = roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last, + stateEvent.stateKey == event.stateKey, + let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: stateEvent.content), + (stateEvent.eventId == event.eventId || voiceBroadcastInfo.eventId == event.eventId), + let state = VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) { + voiceBroadcastState = state + } + + dispatchGroup.leave() + } + + let parameters = VoiceBroadcastPlaybackCoordinatorParameters(session: session, + room: room, + voiceBroadcastStartEvent: event, + voiceBroadcastState: voiceBroadcastState, + senderDisplayName: senderDisplayName) + guard let coordinator = try? VoiceBroadcastPlaybackCoordinator(parameters: parameters) else { + return nil + } + + coordinatorsForEventIdentifiers[event.eventId] = coordinator + + return coordinator.toPresentable() + + } + + /// Retrieve the voiceBroadcast timeline coordinator for the given event or nil if it hasn't been created yet + func voiceBroadcastPlaybackCoordinatorForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastPlaybackCoordinator? { + coordinatorsForEventIdentifiers[eventIdentifier] + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift new file mode 100644 index 000000000..c27da240e --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift @@ -0,0 +1,334 @@ +// +// Copyright 2022 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 + +// TODO: VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK +// We need a VoiceBroadcastPlaybackServiceProtocol and VoiceBroadcastAggregatorProtocol +import MatrixSDK + +class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + private var voiceBroadcastAggregator: VoiceBroadcastAggregator + private let mediaServiceProvider: VoiceMessageMediaServiceProvider + private let cacheManager: VoiceMessageAttachmentCacheManager + private var audioPlayer: VoiceMessageAudioPlayer? + + private var voiceBroadcastChunkQueue: [VoiceBroadcastChunk] = [] + + private var isLivePlayback = false + + // MARK: Public + + // MARK: - Setup + + init(details: VoiceBroadcastPlaybackDetails, + mediaServiceProvider: VoiceMessageMediaServiceProvider, + cacheManager: VoiceMessageAttachmentCacheManager, + voiceBroadcastAggregator: VoiceBroadcastAggregator) { + self.mediaServiceProvider = mediaServiceProvider + self.cacheManager = cacheManager + self.voiceBroadcastAggregator = voiceBroadcastAggregator + + let viewState = VoiceBroadcastPlaybackViewState(details: details, + broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState), + playbackState: .stopped, + bindings: VoiceBroadcastPlaybackViewStateBindings()) + super.init(initialViewState: viewState) + + self.voiceBroadcastAggregator.delegate = self + } + + private func release() { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] release") + if let audioPlayer = audioPlayer { + audioPlayer.deregisterDelegate(self) + self.audioPlayer = nil + } + } + + // MARK: - Public + + override func process(viewAction: VoiceBroadcastPlaybackViewAction) { + switch viewAction { + case .play: + play() + case .playLive: + playLive() + case .pause: + pause() + } + } + + + // MARK: - Private + + /// Listen voice broadcast + private func play() { + isLivePlayback = false + + if voiceBroadcastAggregator.isStarted == false { + // Start the streaming by fetching broadcast chunks + // The audio player will automatically start the playback on incoming chunks + MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: Start streaming") + state.playbackState = .buffering + voiceBroadcastAggregator.start() + } + else if let audioPlayer = audioPlayer { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: resume") + audioPlayer.play() + } + else { + let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks + MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: restart from the beginning: \(chunks.count) chunks") + + // Reinject all the chuncks we already have and play them + voiceBroadcastChunkQueue.append(contentsOf: chunks) + processPendingVoiceBroadcastChunks() + } + } + + private func playLive() { + guard isLivePlayback == false else { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Already playing live") + return + } + + isLivePlayback = true + + // Flush the current audio player playlist + audioPlayer?.removeAllPlayerItems() + + if voiceBroadcastAggregator.isStarted == false { + // Start the streaming by fetching broadcast chunks + // The audio player will automatically start the playback on incoming chunks + MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Start streaming") + state.playbackState = .buffering + voiceBroadcastAggregator.start() + } + else { + let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks + MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: restart from the last chunk: \(chunks.count) chunks") + + // Reinject all the chuncks we already have and play the last one + voiceBroadcastChunkQueue.append(contentsOf: chunks) + processPendingVoiceBroadcastChunksForLivePlayback() + } + } + + /// Stop voice broadcast + private func pause() { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause") + + isLivePlayback = false + + if let audioPlayer = audioPlayer, audioPlayer.isPlaying { + audioPlayer.pause() + } + } + + private func stopIfVoiceBroadcastOver() { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver") + + // TODO: Check if the broadcast is over before stopping everything + // If not, the player should not stopped. The view state must be move to buffering + stop() + } + + private func stop() { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop") + + isLivePlayback = false + + // Objects will be released on audioPlayerDidStopPlaying + audioPlayer?.stop() + } + + + // MARK: - Voice broadcast chunks playback + + /// Start the playback from the beginning or push more chunks to it + private func processPendingVoiceBroadcastChunks() { + reorderPendingVoiceBroadcastChunks() + processNextVoiceBroadcastChunk() + } + + /// Start the playback from the last known chunk + private func processPendingVoiceBroadcastChunksForLivePlayback() { + let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks)) + if let lastChunk = chunks.last { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] processPendingVoiceBroadcastChunksForLivePlayback. Use the last chunk: sequence: \(lastChunk.sequence) out of the \(voiceBroadcastChunkQueue.count) chunks") + voiceBroadcastChunkQueue = [lastChunk] + } + processNextVoiceBroadcastChunk() + } + + private func reorderPendingVoiceBroadcastChunks() { + // Make sure we download and process chunks in the right order + voiceBroadcastChunkQueue = reorderVoiceBroadcastChunks(chunks: voiceBroadcastChunkQueue) + } + private func reorderVoiceBroadcastChunks(chunks: [VoiceBroadcastChunk]) -> [VoiceBroadcastChunk] { + chunks.sorted(by: {$0.sequence < $1.sequence}) + } + + private func processNextVoiceBroadcastChunk() { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: \(voiceBroadcastChunkQueue.count) chunks remaining") + + guard voiceBroadcastChunkQueue.count > 0 else { + // We cached all chunks. Nothing more to do + return + } + + // TODO: Control the download rate to avoid to download all chunk in mass + // We could synchronise it with the number of chunks in the player playlist (audioPlayer.playerItems) + + let chunk = voiceBroadcastChunkQueue.removeFirst() + + // numberOfSamples is for the equalizer view we do not support yet + cacheManager.loadAttachment(chunk.attachment, numberOfSamples: 1) { [weak self] result in + guard let self = self else { + return + } + + // TODO: Make sure there has no new incoming chunk that should be before this attachment + // Be careful that this new chunk is not older than the chunk being played by the audio player. Else + // we will get an unexecpted rewind. + + switch result { + case .success(let result): + guard result.eventIdentifier == chunk.attachment.eventId else { + return + } + + if let audioPlayer = self.audioPlayer { + // Append the chunk to the current playlist + audioPlayer.addContentFromURL(result.url) + + // Resume the player. Needed after a pause + if audioPlayer.isPlaying == false { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player") + audioPlayer.play() + } + } + else { + // Init and start the player on the first chunk + let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier) + audioPlayer.registerDelegate(self) + + audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName) + audioPlayer.play() + self.audioPlayer = audioPlayer + } + + case .failure (let error): + MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error) + if self.voiceBroadcastChunkQueue.count == 0 { + // No more chunk to try. Go to error + self.state.playbackState = .error + } + } + + self.processNextVoiceBroadcastChunk() + } + } + + private static func getBroadcastState(from state: VoiceBroadcastInfo.State) -> VoiceBroadcastState { + var broadcastState: VoiceBroadcastState + switch state { + case .started: + broadcastState = VoiceBroadcastState.live + case .paused: + broadcastState = VoiceBroadcastState.paused + case .resumed: + broadcastState = VoiceBroadcastState.live + case .stopped: + broadcastState = VoiceBroadcastState.stopped + } + + return broadcastState + } +} + +// MARK: VoiceBroadcastAggregatorDelegate +extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate { + func voiceBroadcastAggregatorDidStartLoading(_ aggregator: VoiceBroadcastAggregator) { + } + + func voiceBroadcastAggregatorDidEndLoading(_ aggregator: VoiceBroadcastAggregator) { + } + + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didFailWithError: Error) { + MXLog.error("[VoiceBroadcastPlaybackViewModel] voiceBroadcastAggregator didFailWithError:", context: didFailWithError) + } + + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveChunk: VoiceBroadcastChunk) { + voiceBroadcastChunkQueue.append(didReceiveChunk) + } + + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State) { + state.broadcastState = VoiceBroadcastPlaybackViewModel.getBroadcastState(from: didReceiveState) + } + + func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) { + if isLivePlayback && state.playbackState == .buffering { + // We started directly with a live playback but there was no known chuncks at that time + // These are the first chunks we get. Start the playback on the latest one + processPendingVoiceBroadcastChunksForLivePlayback() + } + else { + processPendingVoiceBroadcastChunks() + } + } +} + + +// MARK: - VoiceMessageAudioPlayerDelegate +extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate { + func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) { + } + + func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + if isLivePlayback { + state.playbackState = .playingLive + } + else { + state.playbackState = .playing + } + } + + func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + state.playbackState = .paused + } + + func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying") + state.playbackState = .stopped + release() + } + + func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) { + state.playbackState = .error + } + + func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidFinishPlaying: \(audioPlayer.playerItems.count)") + stopIfVoiceBroadcastOver() + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackErrorView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackErrorView.swift new file mode 100644 index 000000000..0ac7822c6 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackErrorView.swift @@ -0,0 +1,51 @@ +// +// Copyright 2022 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 VoiceBroadcastPlaybackErrorView: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var action: (() -> Void)? + + var body: some View { + VStack { + VStack { + Image(uiImage: Asset.Images.errorIcon.image) + .frame(width: 40, height: 40) + Text(VectorL10n.voiceBroadcastPlaybackLoadingError) + .multilineTextAlignment(.center) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.primaryContent) + } + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(theme.colors.system.ignoresSafeArea()) + } +} + +struct VoiceBroadcastPlaybackErrorView_Previews: PreviewProvider { + static var previews: some View { + VoiceBroadcastPlaybackErrorView() + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift new file mode 100644 index 000000000..04ade8a77 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift @@ -0,0 +1,121 @@ +// +// Copyright 2022 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 + +// TODO: To remove +// VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK +#if canImport(MatrixSDK) +typealias VoiceBroadcastPlaybackViewModelImpl = VoiceBroadcastPlaybackViewModel +#else +typealias VoiceBroadcastPlaybackViewModelImpl = MockVoiceBroadcastPlaybackViewModel +#endif + +struct VoiceBroadcastPlaybackView: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + private var backgroundColor: Color { + if viewModel.viewState.playbackState == .playingLive { + return theme.colors.alert + } + return theme.colors.quarterlyContent + } + + // MARK: Public + + @ObservedObject var viewModel: VoiceBroadcastPlaybackViewModelImpl.Context + + var body: some View { + let details = viewModel.viewState.details + + VStack(alignment: .center, spacing: 16.0) { + + HStack { + Text(details.senderDisplayName ?? "") + //Text(VectorL10n.voiceBroadcastInTimelineTitle) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + + if viewModel.viewState.broadcastState == .live { + Button { viewModel.send(viewAction: .playLive) } label: + { + HStack { + Image(uiImage: Asset.Images.voiceBroadcastLive.image) + .renderingMode(.original) + Text("Live") + .font(theme.fonts.bodySB) + .foregroundColor(Color.white) + } + + } + .padding(5.0) + .background(RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(backgroundColor)) + .accessibilityIdentifier("liveButton") + } + } + + if viewModel.viewState.playbackState == .error { + VoiceBroadcastPlaybackErrorView() + } else { + ZStack { + if viewModel.viewState.playbackState == .playing || + viewModel.viewState.playbackState == .playingLive { + Button { viewModel.send(viewAction: .pause) } label: { + Image(uiImage: Asset.Images.voiceBroadcastPause.image) + .renderingMode(.original) + } + .accessibilityIdentifier("pauseButton") + } else { + Button { + if viewModel.viewState.broadcastState == .live && + viewModel.viewState.playbackState == .stopped { + viewModel.send(viewAction: .playLive) + } else { + viewModel.send(viewAction: .play) + } + } label: { + Image(uiImage: Asset.Images.voiceBroadcastPlay.image) + .renderingMode(.original) + } + .disabled(viewModel.viewState.playbackState == .buffering) + .accessibilityIdentifier("playButton") + } + } + .activityIndicator(show: viewModel.viewState.playbackState == .buffering) + } + + } + .padding([.horizontal, .top], 2.0) + .padding([.bottom]) + .alert(item: $viewModel.alertInfo) { info in + info.alert + } + } +} + +// MARK: - Previews + +struct VoiceBroadcastPlaybackView_Previews: PreviewProvider { + static let stateRenderer = MockVoiceBroadcastPlaybackScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift new file mode 100644 index 000000000..09a12b87d --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift @@ -0,0 +1,62 @@ +// +// Copyright 2022 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 VoiceBroadcastPlaybackViewAction { + case play + case playLive + case pause +} + +enum VoiceBroadcastPlaybackState { + case stopped + case buffering + case playing + case playingLive + case paused + case error +} + +struct VoiceBroadcastPlaybackDetails { + let senderDisplayName: String? +} + +enum VoiceBroadcastState { + case unknown + case stopped + case live + case paused +} + +struct VoiceBroadcastPlaybackViewState: BindableState { + var details: VoiceBroadcastPlaybackDetails + var broadcastState: VoiceBroadcastState + var playbackState: VoiceBroadcastPlaybackState + var bindings: VoiceBroadcastPlaybackViewStateBindings +} + +struct VoiceBroadcastPlaybackViewStateBindings { + // TODO: Neeeded? + var alertInfo: AlertInfo? +} + +enum VoiceBroadcastPlaybackAlertType { + // TODO: What is it? + case failedClosingVoiceBroadcast +} + diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift new file mode 100644 index 000000000..72a15185f --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift @@ -0,0 +1,53 @@ +// +// Copyright 2022 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 + +typealias MockVoiceBroadcastPlaybackViewModelType = StateStoreViewModel +class MockVoiceBroadcastPlaybackViewModel: MockVoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol { +} + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case animated + + /// The associated screen + var screenType: Any.Type { + VoiceBroadcastPlaybackView.self + } + + /// A list of screen state definitions + static var allCases: [MockVoiceBroadcastPlaybackScreenState] { + [.animated] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + + let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice") + let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, bindings: VoiceBroadcastPlaybackViewStateBindings())) + + return ( + [false, viewModel], + AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift new file mode 100644 index 000000000..1ad8d64c5 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// Copyright 2022 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 + +typealias VoiceBroadcastPlaybackViewModelType = StateStoreViewModel + +protocol VoiceBroadcastPlaybackViewModelProtocol { + var context: VoiceBroadcastPlaybackViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift new file mode 100644 index 000000000..c13524e13 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift @@ -0,0 +1,67 @@ +// +// Copyright 2022 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 + +struct VoiceBroadcastRecorderCoordinatorParameters { + let session: MXSession + let room: MXRoom + let voiceBroadcastStartEvent: MXEvent + let senderDisplayName: String? +} + +final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: VoiceBroadcastRecorderCoordinatorParameters + + private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol + private var voiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + // MARK: - Setup + + init(parameters: VoiceBroadcastRecorderCoordinatorParameters) { + self.parameters = parameters + + voiceBroadcastRecorderService = VoiceBroadcastRecorderService(session: parameters.session, roomId: parameters.room.matrixItemId) + + let details = VoiceBroadcastRecorderDetails(senderDisplayName: parameters.senderDisplayName) + let viewModel = VoiceBroadcastRecorderViewModel(details: details, + recorderService: voiceBroadcastRecorderService) + voiceBroadcastRecorderViewModel = viewModel + } + + // MARK: - Public + + func start() { } + + func toPresentable() -> UIViewController { + VectorHostingController(rootView: VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context)) + } + + func pauseRecording() { + voiceBroadcastRecorderViewModel.context.send(viewAction: .pause) + } + + // MARK: - Private +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift new file mode 100644 index 000000000..c7bc2b1a0 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift @@ -0,0 +1,77 @@ +// +// Copyright 2022 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 public class VoiceBroadcastRecorderProvider: NSObject { + + // MARK: - Constants + @objc public static let shared = VoiceBroadcastRecorderProvider() + + // MARK: - Properties + // MARK: Public + var session: MXSession? + var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() + + // MARK: Private + private var currentEventIdentifier: String? + + // MARK: - Setup + private override init() { } + + // MARK: - Public + + /// Create or retrieve the voiceBroadcast timeline coordinator for this event and return + /// a view to be displayed in the timeline + func buildVoiceBroadcastRecorderViewForEvent(_ event: MXEvent, senderDisplayName: String?) -> UIView? { + guard let session = session, + let room = session.room(withRoomId: event.roomId) else { + return nil + } + + self.currentEventIdentifier = event.eventId + + if let coordinator = coordinatorsForEventIdentifiers[event.eventId] { + return coordinator.toPresentable().view + } + + let parameters = VoiceBroadcastRecorderCoordinatorParameters(session: session, + room: room, + voiceBroadcastStartEvent: event, + senderDisplayName: senderDisplayName) + let coordinator = VoiceBroadcastRecorderCoordinator(parameters: parameters) + + coordinatorsForEventIdentifiers[event.eventId] = coordinator + + return coordinator.toPresentable().view + } + + /// Pause current voice broadcast recording. + @objc public func pauseRecording() { + voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecording() + } + + // MARK: - Private + + /// Retrieve the voiceBroadcast recorder coordinator for the current event or nil if it hasn't been created yet + private func voiceBroadcastRecorderCoordinatorForCurrentEvent() -> VoiceBroadcastRecorderCoordinator? { + guard let currentEventIdentifier = currentEventIdentifier else { + return nil + } + + return coordinatorsForEventIdentifiers[currentEventIdentifier] + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift new file mode 100644 index 000000000..d75f69830 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift @@ -0,0 +1,274 @@ +// +// Copyright 2022 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 + +class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let roomId: String + private let session: MXSession + private var voiceBroadcastService: VoiceBroadcastService? { + session.voiceBroadcastService + } + + private let audioEngine = AVAudioEngine() + private let audioNodeBus = AVAudioNodeBus(0) + + private var chunkFile: AVAudioFile! = nil + private var chunkFrames: AVAudioFrameCount = 0 + private var chunkFileNumber: Int = 1 + + // MARK: Public + + weak var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? + + // MARK: - Setup + + init(session: MXSession, roomId: String) { + self.session = session + self.roomId = roomId + } + + // MARK: - VoiceBroadcastRecorderServiceProtocol + + func startRecordingVoiceBroadcast() { + let inputNode = audioEngine.inputNode + + let inputFormat = inputNode.inputFormat(forBus: audioNodeBus) + MXLog.debug("[VoiceBroadcastRecorderService] Start recording voice broadcast for bus name : \(String(describing: inputNode.name(forInputBus: audioNodeBus)))") + + inputNode.installTap(onBus: audioNodeBus, + bufferSize: 512, + format: inputFormat) { (buffer, time) -> Void in + DispatchQueue.main.async { + self.writeBuffer(buffer) + } + } + + try? audioEngine.start() + } + + func stopRecordingVoiceBroadcast() { + MXLog.debug("[VoiceBroadcastRecorderService] Stop recording voice broadcast") + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: audioNodeBus) + + resetValues() + + voiceBroadcastService?.stopVoiceBroadcast(success: { [weak self] _ in + MXLog.debug("[VoiceBroadcastRecorderService] Stopped") + + guard let self = self else { return } + + // Update state + self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .stopped) + + // Send current chunk + if self.chunkFile != nil { + self.sendChunkFile(at: self.chunkFile.url, sequence: self.chunkFileNumber) + } + + self.session.tearDownVoiceBroadcastService() + }, failure: { error in + MXLog.error("[VoiceBroadcastRecorderService] Failed to stop voice broadcast", context: error) + }) + } + + func pauseRecordingVoiceBroadcast() { + audioEngine.pause() + + voiceBroadcastService?.pauseVoiceBroadcast(success: { [weak self] _ in + guard let self = self else { return } + + // Send current chunk + self.sendChunkFile(at: self.chunkFile.url, sequence: self.chunkFileNumber) + self.chunkFile = nil + + }, failure: { error in + MXLog.error("[VoiceBroadcastRecorderService] Failed to pause voice broadcast", context: error) + }) + } + + func resumeRecordingVoiceBroadcast() { + try? audioEngine.start() + + voiceBroadcastService?.resumeVoiceBroadcast(success: { [weak self] _ in + guard let self = self else { return } + + // Update state + self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .started) + }, failure: { error in + MXLog.error("[VoiceBroadcastRecorderService] Failed to resume voice broadcast", context: error) + }) + } + + // MARK: - Private + /// Reset chunk values. + private func resetValues() { + chunkFrames = 0 + chunkFileNumber = 1 + } + + /// Write audio buffer to chunk file. + private func writeBuffer(_ buffer: AVAudioPCMBuffer) { + let sampleRate = buffer.format.sampleRate + + if chunkFile == nil { + createNewChunkFile(channelsCount: buffer.format.channelCount, sampleRate: sampleRate) + } + try? chunkFile.write(from: buffer) + + chunkFrames += buffer.frameLength + + if chunkFrames > AVAudioFrameCount(Double(BuildSettings.voiceBroadcastChunkLength) * sampleRate) { + sendChunkFile(at: chunkFile.url, sequence: self.chunkFileNumber) + // Reset chunkFile + chunkFile = nil + } + } + + /// Create new chunk file with sample rate. + private func createNewChunkFile(channelsCount: AVAudioChannelCount, sampleRate: Float64) { + guard let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { + // FIXME: Manage error + return + } + let temporaryFileName = "VoiceBroadcastChunk-\(roomId)-\(chunkFileNumber)" + let fileUrl = directory + .appendingPathComponent(temporaryFileName) + .appendingPathExtension("aac") + MXLog.debug("[VoiceBroadcastRecorderService] Create chunk file to \(fileUrl)") + + let settings: [String: Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: sampleRate, + AVEncoderBitRateKey: 128000, + AVNumberOfChannelsKey: channelsCount, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue] + + chunkFile = try? AVAudioFile(forWriting: fileUrl, settings: settings) + + if chunkFile != nil { + chunkFileNumber += 1 + chunkFrames = 0 + } else { + stopRecordingVoiceBroadcast() + // FIXME: Manage error ? + } + } + + /// Send chunk file to the server. + private func sendChunkFile(at url: URL, sequence: Int) { + guard let voiceBroadcastService = voiceBroadcastService else { + // FIXME: Manage error + return + } + + let dispatchGroup = DispatchGroup() + var duration = 0.0 + + dispatchGroup.enter() + VoiceMessageAudioConverter.mediaDurationAt(url) { result in + switch result { + case .success: + if let someDuration = try? result.get() { + duration = someDuration + } else { + MXLog.error("[VoiceBroadcastRecorderService] Failed to retrieve media duration") + } + case .failure(let error): + MXLog.error("[VoiceBroadcastRecorderService] Failed to get audio duration", context: error) + } + + dispatchGroup.leave() + } + + convertAACToM4A(at: url) { [weak self] convertedUrl in + guard let self = self else { return } + + if let convertedUrl = convertedUrl { + dispatchGroup.notify(queue: .main) { + self.voiceBroadcastService?.sendChunkOfVoiceBroadcast(audioFileLocalURL: convertedUrl, + mimeType: "audio/mp4", + duration: UInt(duration * 1000), + samples: nil, + sequence: UInt(sequence)) { eventId in + MXLog.debug("[VoiceBroadcastRecorderService] Send voice broadcast chunk with success.") + if eventId != nil { + self.deleteRecording(at: url) + } + } failure: { error in + MXLog.error("[VoiceBroadcastRecorderService] Failed to send voice broadcast chunk.", context: error) + } + } + } + } + } + + /// Delete voice broadcast chunk at URL. + private func deleteRecording(at url: URL?) { + guard let url = url else { + return + } + + do { + try FileManager.default.removeItem(at: url) + } catch { + MXLog.error("[VoiceBroadcastRecorderService] Delete chunk file error.", context: error) + } + } + + /// Convert AAC file into m4a one. + private func convertAACToM4A(at url: URL, completion: @escaping (URL?) -> Void) { + // FIXME: Manage errors at completion + let asset = AVURLAsset(url: url) + let updatedPath = url.path.replacingOccurrences(of: ".aac", with: ".m4a") + let outputUrl = URL(string: "file://" + updatedPath) + MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A updatedPath : \(updatedPath).") + + if FileManager.default.fileExists(atPath: updatedPath) { + try? FileManager.default.removeItem(atPath: updatedPath) + } + + guard let exportSession = AVAssetExportSession(asset: asset, + presetName: AVAssetExportPresetPassthrough) else { + completion(nil) + return + } + + exportSession.outputURL = outputUrl + exportSession.outputFileType = AVFileType.m4a + let start = CMTimeMakeWithSeconds(0.0, preferredTimescale: 0) + let range = CMTimeRangeMake(start: start, duration: asset.duration) + exportSession.timeRange = range + exportSession.exportAsynchronously() { + switch exportSession.status { + case .failed: + MXLog.error("[VoiceBroadcastRecorderService] convertAACToM4A error", context: exportSession.error) + completion(nil) + case .completed: + MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A success.") + completion(outputUrl) + default: + MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A other cases.") + completion(nil) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift new file mode 100644 index 000000000..7b97eb83a --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift @@ -0,0 +1,38 @@ +// +// Copyright 2022 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 + +protocol VoiceBroadcastRecorderServiceDelegate: AnyObject { + func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState) +} + +protocol VoiceBroadcastRecorderServiceProtocol { + /// Service delegate + var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? { get set } + + /// Start voice broadcast recording. + func startRecordingVoiceBroadcast() + + /// Stop voice broadcast recording. + func stopRecordingVoiceBroadcast() + + /// Pause voice broadcast recording. + func pauseRecordingVoiceBroadcast() + + /// Resume voice broadcast recording after paused it. + func resumeRecordingVoiceBroadcast() +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift new file mode 100644 index 000000000..71fb41cc1 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift @@ -0,0 +1,83 @@ +// +// Copyright 2022 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 VoiceBroadcastRecorderView: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: VoiceBroadcastRecorderViewModel.Context + + var body: some View { + let details = viewModel.viewState.details + + VStack(alignment: .leading, spacing: 16.0) { + Text(details.senderDisplayName ?? "") + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + + HStack(alignment: .top, spacing: 16.0) { + Button { + switch viewModel.viewState.recordingState { + case .started, .resumed: + viewModel.send(viewAction: .pause) + case .stopped: + viewModel.send(viewAction: .start) + case .paused: + viewModel.send(viewAction: .resume) + } + } label: { + if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed { + Image("voice_broadcast_record_pause") + .renderingMode(.original) + } else { + Image("voice_broadcast_record") + .renderingMode(.original) + } + } + .accessibilityIdentifier("recordButton") + + Button { + viewModel.send(viewAction: .stop) + } label: { + Image("voice_broadcast_stop") + .renderingMode(.original) + } + .accessibilityIdentifier("stopButton") + .disabled(viewModel.viewState.recordingState == .stopped) + .mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0)) + } + } + .padding([.horizontal, .top], 2.0) + .padding([.bottom]) + } +} + + +// MARK: - Previews + +struct VoiceBroadcastRecorderView_Previews: PreviewProvider { + static let stateRenderer = MockVoiceBroadcastRecorderScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift new file mode 100644 index 000000000..b88021bfe --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift @@ -0,0 +1,44 @@ +// +// Copyright 2022 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 VoiceBroadcastRecorderViewAction { + case start + case stop + case pause + case resume +} + +enum VoiceBroadcastRecorderState { + case started + case stopped + case paused + case resumed +} + +struct VoiceBroadcastRecorderDetails { + let senderDisplayName: String? +} + +struct VoiceBroadcastRecorderViewState: BindableState { + var details: VoiceBroadcastRecorderDetails + var recordingState: VoiceBroadcastRecorderState + var bindings: VoiceBroadcastRecorderViewStateBindings +} + +struct VoiceBroadcastRecorderViewStateBindings { +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift new file mode 100644 index 000000000..baa9488f4 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift @@ -0,0 +1,42 @@ +// +// Copyright 2022 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 + +typealias MockVoiceBroadcastRecorderViewModelType = StateStoreViewModel +class MockVoiceBroadcastRecorderViewModel: MockVoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol { + +} + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockVoiceBroadcastRecorderScreenState: MockScreenState, CaseIterable { + + var screenType: Any.Type { + VoiceBroadcastRecorderView.self + } + + var screenView: ([Any], AnyView) { + let details = VoiceBroadcastRecorderDetails(senderDisplayName: "") + let viewModel = MockVoiceBroadcastRecorderViewModel(initialViewState: VoiceBroadcastRecorderViewState(details: details, recordingState: .started, bindings: VoiceBroadcastRecorderViewStateBindings())) + + return ( + [false, viewModel], + AnyView(VoiceBroadcastRecorderView(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift new file mode 100644 index 000000000..6e1444162 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift @@ -0,0 +1,86 @@ +// +// Copyright 2022 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 VoiceBroadcastRecorderViewModelType = StateStoreViewModel + +class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol + + // MARK: Public + + // MARK: - Setup + + init(details: VoiceBroadcastRecorderDetails, + recorderService: VoiceBroadcastRecorderServiceProtocol) { + self.voiceBroadcastRecorderService = recorderService + super.init(initialViewState: VoiceBroadcastRecorderViewState(details: details, + recordingState: .stopped, + bindings: VoiceBroadcastRecorderViewStateBindings())) + + self.voiceBroadcastRecorderService.serviceDelegate = self + process(viewAction: .start) + } + + // MARK: - Public + + override func process(viewAction: VoiceBroadcastRecorderViewAction) { + switch viewAction { + case .start: + start() + case .stop: + stop() + case .pause: + pause() + case .resume: + resume() + } + } + + // MARK: - Private + private func start() { + self.state.recordingState = .started + voiceBroadcastRecorderService.startRecordingVoiceBroadcast() + } + + private func stop() { + self.state.recordingState = .stopped + voiceBroadcastRecorderService.stopRecordingVoiceBroadcast() + } + + private func pause() { + self.state.recordingState = .paused + voiceBroadcastRecorderService.pauseRecordingVoiceBroadcast() + } + + private func resume() { + self.state.recordingState = .resumed + voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast() + } +} + +extension VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderServiceDelegate { + func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState) { + self.state.recordingState = state + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModelProtocol.swift new file mode 100644 index 000000000..ab1e74c89 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModelProtocol.swift @@ -0,0 +1,21 @@ +// +// Copyright 2022 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 + +protocol VoiceBroadcastRecorderViewModelProtocol { + var context: VoiceBroadcastRecorderViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Coordinator/NotificationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Settings/Notifications/Coordinator/NotificationSettingsCoordinator.swift index 7ca59854f..330d7cedd 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Coordinator/NotificationSettingsCoordinator.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Coordinator/NotificationSettingsCoordinator.swift @@ -50,6 +50,7 @@ final class NotificationSettingsCoordinator: NotificationSettingsCoordinatorType } notificationSettingsViewModel = viewModel notificationSettingsViewController = viewController + notificationSettingsViewController.vc_setLargeTitleDisplayMode(.never) } // MARK: - Public methods diff --git a/RiotSwiftUI/Modules/UserSessions/Common/DeviceType.swift b/RiotSwiftUI/Modules/UserSessions/Common/DeviceType.swift index 0f0685778..f3b003ff3 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/DeviceType.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/DeviceType.swift @@ -38,17 +38,15 @@ enum DeviceType { } var name: String { - let appName = AppInfo.current.displayName - switch self { case .desktop: - return VectorL10n.deviceNameDesktop(appName) + return VectorL10n.deviceTypeNameDesktop case .web: - return VectorL10n.deviceNameWeb(appName) + return VectorL10n.deviceTypeNameWeb case .mobile: - return VectorL10n.deviceNameMobile(appName) + return VectorL10n.deviceTypeNameMobile case .unknown: - return VectorL10n.deviceNameUnknown + return VectorL10n.deviceTypeNameUnknown } } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift new file mode 100644 index 000000000..3faef040d --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift @@ -0,0 +1,32 @@ +// +// Copyright 2022 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 + +class InactiveUserSessionLastActivityFormatter { + private static var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + dateFormatter.dateStyle = .medium + dateFormatter.doesRelativeDateFormatting = true + return dateFormatter + }() + + static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: lastActivityTimestamp) + return InactiveUserSessionLastActivityFormatter.dateFormatter.string(from: date) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift b/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift new file mode 100644 index 000000000..c40bb2fa3 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift @@ -0,0 +1,33 @@ +// +// Copyright 2022 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 XCTest + +@testable import RiotSwiftUI + +class UserSessionNameFormatterTests: XCTestCase { + func testSessionDisplayNameTrumpsDeviceTypeName() { + XCTAssertEqual("Johnny's iPhone", UserSessionNameFormatter.sessionName(deviceType: .mobile, sessionDisplayName: "Johnny's iPhone")) + } + + func testEmptySessionDisplayNameFallsBackToDeviceTypeName() { + XCTAssertEqual(DeviceType.mobile.name, UserSessionNameFormatter.sessionName(deviceType: .mobile, sessionDisplayName: "")) + } + + func testNilSessionDisplayNameFallsBackToDeviceTypeName() { + XCTAssertEqual(DeviceType.mobile.name, UserSessionNameFormatter.sessionName(deviceType: .mobile, sessionDisplayName: nil)) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift index 2c0a10487..f74c4fd6c 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift @@ -132,11 +132,9 @@ enum UserAgentParser { if deviceInfoComponents[safe: 1]?.hasPrefix("Android") == true { deviceOS = deviceInfoComponents[safe: 1] } else if deviceInfoComponents.first == "Macintosh" { - var osFull = deviceInfoComponents[safe: 1] - osFull = osFull?.replacingOccurrences(of: "Intel ", with: "") - osFull = osFull?.replacingOccurrences(of: "Mac OS X", with: "macOS") - osFull = osFull?.replacingOccurrences(of: "_", with: ".") - deviceOS = osFull + deviceOS = "macOS" + } else if deviceInfoComponents.first?.hasPrefix("Windows") == true { + deviceOS = "Windows" } else { deviceOS = deviceInfoComponents.first } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift index 79fe49ec2..d3e7690ea 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift @@ -27,8 +27,8 @@ struct UserSessionInfo: Identifiable { /// The device type used by the session let deviceType: DeviceType - /// True to indicate that the session is verified - let isVerified: Bool + /// The current state of verification for the session. + let verificationState: VerificationState /// The IP address where this device was last seen. let lastSeenIP: String? @@ -69,10 +69,48 @@ struct UserSessionInfo: Identifiable { /// True to indicate that this is current user session let isCurrent: Bool + + /// Represents a verification state. + enum VerificationState { + /// The state is unknown (likely because the current session + /// hasn't been set up for cross-signing yet). + case unknown + /// The session has not yet been verified. + case unverified + /// The session has been verified. + case verified + } } +// MARK: - Equatable + extension UserSessionInfo: Equatable { static func == (lhs: UserSessionInfo, rhs: UserSessionInfo) -> Bool { lhs.id == rhs.id } } + +// MARK: - Mocks + +extension UserSessionInfo { + static func mockPhone(verificationState: VerificationState = .verified, + hasTimestamp: Bool = true, + isCurrent: Bool = false) -> UserSessionInfo { + UserSessionInfo(id: "1", + name: "Element Mobile: iOS", + deviceType: .mobile, + verificationState: verificationState, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: hasTimestamp ? Date().timeIntervalSince1970 : nil, + applicationName: "Element iOS", + applicationVersion: "1.9.8", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 16.0.2", + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: isCurrent) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift index 492e27226..db7f2e1e6 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift @@ -20,16 +20,6 @@ import Foundation enum UserSessionNameFormatter { /// Session name with client name and session display name static func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String { - let sessionName: String - - let clientName = deviceType.name - - if let sessionDisplayName = sessionDisplayName { - sessionName = VectorL10n.userSessionName(clientName, sessionDisplayName) - } else { - sessionName = clientName - } - - return sessionName + sessionDisplayName?.vc_nilIfEmpty() ?? deviceType.name } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift index d9ee4961e..0e894961d 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift @@ -22,7 +22,8 @@ struct DeviceAvatarView: View { @Environment(\.theme) var theme: ThemeSwiftUI var viewData: DeviceAvatarViewData - + var isSelected: Bool + var avatarSize: CGFloat = 40 var badgeSize: CGFloat = 24 @@ -31,21 +32,21 @@ struct DeviceAvatarView: View { // Device image VStack(alignment: .center) { viewData.deviceType.image + .renderingMode(isSelected ? .template : .original) + .foregroundColor(isSelected ? theme.colors.background : nil) } .padding() .frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize)) - .background(theme.colors.system) + .background(isSelected ? theme.colors.primaryContent : theme.colors.system) .clipShape(Circle()) // Verification badge - if let isVerified = viewData.isVerified { - Image(isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name) - .frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize)) - .shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle()) - .background(theme.colors.background) - .clipShape(Circle()) - .offset(x: 10, y: 8) - } + Image(viewData.verificationImageName) + .frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize)) + .shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle()) + .background(theme.colors.background) + .clipShape(Circle()) + .offset(x: 10, y: 8) } .frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize)) } @@ -54,20 +55,20 @@ struct DeviceAvatarView: View { struct DeviceAvatarViewListPreview: View { var viewDataList: [DeviceAvatarViewData] { [ - DeviceAvatarViewData(deviceType: .desktop, isVerified: true), - DeviceAvatarViewData(deviceType: .web, isVerified: true), - DeviceAvatarViewData(deviceType: .mobile, isVerified: true), - DeviceAvatarViewData(deviceType: .unknown, isVerified: true) + DeviceAvatarViewData(deviceType: .desktop, verificationState: .verified), + DeviceAvatarViewData(deviceType: .web, verificationState: .verified), + DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified), + DeviceAvatarViewData(deviceType: .unknown, verificationState: .verified) ] } var body: some View { HStack { VStack(alignment: .center, spacing: 20) { - DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, isVerified: true)) - DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, isVerified: false)) - DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, isVerified: true)) - DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, isVerified: false)) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, verificationState: .verified), isSelected: false) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, verificationState: .unverified), isSelected: false) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified), isSelected: false) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, verificationState: .unverified), isSelected: false) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift index 397e945dc..1fcf65cf1 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift @@ -18,7 +18,20 @@ import Foundation import SwiftUI /// View data for DeviceAvatarView -struct DeviceAvatarViewData { +struct DeviceAvatarViewData: Hashable { let deviceType: DeviceType - let isVerified: Bool? + /// The current state of verification for the session. + let verificationState: UserSessionInfo.VerificationState + + /// The name of the shield image to show for the device. + var verificationImageName: String { + switch verificationState { + case .verified: + return Asset.Images.userSessionVerified.name + case .unverified: + return Asset.Images.userSessionUnverified.name + case .unknown: + return Asset.Images.userSessionVerificationUnknown.name + } + } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index 8fa03b02c..864c727d9 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -26,22 +26,6 @@ struct UserSessionCardView: View { var onViewDetailsAction: ((String) -> Void)? var onLearnMoreAction: (() -> Void)? - private var verificationStatusImageName: String { - viewData.isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name - } - - private var verificationStatusText: String { - viewData.isVerified ? VectorL10n.userSessionVerified : VectorL10n.userSessionUnverified - } - - private var verificationStatusColor: Color { - viewData.isVerified ? theme.colors.accent : theme.colors.alert - } - - private var verificationStatusAdditionalInfoText: String { - viewData.isVerified ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userSessionUnverifiedAdditionalInfo - } - private var backgroundShape: RoundedRectangle { RoundedRectangle(cornerRadius: 8) } @@ -52,28 +36,26 @@ struct UserSessionCardView: View { var body: some View { VStack(alignment: .center, spacing: 12) { - DeviceAvatarView(viewData: viewData.deviceAvatarViewData) + DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: false) + .accessibilityHidden(true) Text(viewData.sessionName) .font(theme.fonts.headline) .foregroundColor(theme.colors.primaryContent) .multilineTextAlignment(.center) - HStack { - Image(verificationStatusImageName) - Text(verificationStatusText) - .font(theme.fonts.subheadline) - .foregroundColor(verificationStatusColor) - .multilineTextAlignment(.center) - } + Label(viewData.verificationStatusText, image: viewData.verificationStatusImageName) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors[keyPath: viewData.verificationStatusColor]) + .multilineTextAlignment(.center) if viewData.isCurrentSessionDisplayMode { - Text(verificationStatusAdditionalInfoText) + Text(viewData.verificationStatusAdditionalInfoText) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) .multilineTextAlignment(.center) } else { - InlineTextButton(verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) { + InlineTextButton(viewData.verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) { onLearnMoreAction?() } .font(theme.fonts.footnote) @@ -83,13 +65,18 @@ struct UserSessionCardView: View { if showExtraInformations { VStack(spacing: 2) { - if let lastActivityDateString = viewData.lastActivityDateString, lastActivityDateString.isEmpty == false { - Text(lastActivityDateString) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.secondaryContent) - .multilineTextAlignment(.center) + HStack { + if let lastActivityIcon = viewData.lastActivityIcon { + Image(lastActivityIcon) + .padding(.leading, 2) + } + if let lastActivityDateString = viewData.lastActivityDateString, lastActivityDateString.isEmpty == false { + Text(lastActivityDateString) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .multilineTextAlignment(.center) + } } - if let lastSeenIPInfo = viewData.lastSeenIPInfo, lastSeenIPInfo.isEmpty == false { Text(lastSeenIPInfo) .font(theme.fonts.footnote) @@ -99,7 +86,7 @@ struct UserSessionCardView: View { } } - if viewData.isVerified == false { + if viewData.verificationState == .unverified { Button { onVerifyAction?(viewData.sessionId) } label: { @@ -137,23 +124,23 @@ struct UserSessionCardViewPreview: View { let viewData: UserSessionCardViewData - init(isCurrent: Bool = false) { + init(isCurrent: Bool = false, verificationState: UserSessionInfo.VerificationState = .unverified) { let sessionInfo = UserSessionInfo(id: "alice", - name: "iOS", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "10.0.0.10", - lastSeenTimestamp: nil, - applicationName: "Element iOS", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "iOS 15.5", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: isCurrent) + name: "iOS", + deviceType: .mobile, + verificationState: verificationState, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: "Element iOS", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: isCurrent) viewData = UserSessionCardViewData(sessionInfo: sessionInfo) } @@ -174,6 +161,13 @@ struct UserSessionCardView_Previews: PreviewProvider { UserSessionCardViewPreview(isCurrent: true).theme(.dark).preferredColorScheme(.dark) UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light) UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark) + + UserSessionCardViewPreview(isCurrent: true, verificationState: .verified) + .theme(.light).preferredColorScheme(.light) + UserSessionCardViewPreview(verificationState: .verified) + .theme(.light).preferredColorScheme(.light) + UserSessionCardViewPreview(verificationState: .unknown) + .theme(.light).preferredColorScheme(.light) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift index fff1c6564..d34cda89e 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift @@ -14,7 +14,8 @@ // limitations under the License. // -import Foundation +import DesignKit +import SwiftUI /// View data for UserSessionCardView struct UserSessionCardViewData { @@ -26,10 +27,13 @@ struct UserSessionCardViewData { let sessionName: String - let isVerified: Bool + /// The verification state used to render the card with. + let verificationState: UserSessionInfo.VerificationState let lastActivityDateString: String? + var lastActivityIcon: String? + let lastSeenIPInfo: String? let deviceAvatarViewData: DeviceAvatarViewData @@ -37,26 +41,79 @@ struct UserSessionCardViewData { /// Indicate if the current user session is shown and to adpat the layout let isCurrentSessionDisplayMode: Bool + /// The name of the shield image to show the verification status. + var verificationStatusImageName: String { + switch verificationState { + case .verified: + return Asset.Images.userSessionVerified.name + case .unverified: + return Asset.Images.userSessionUnverified.name + case .unknown: + return Asset.Images.userSessionVerificationUnknown.name + } + } + + /// The text to show alongside the verification shield image. + var verificationStatusText: String { + switch verificationState { + case .verified: + return VectorL10n.userSessionVerified + case .unverified: + return VectorL10n.userSessionUnverified + case .unknown: + return VectorL10n.userSessionVerificationUnknown + } + } + + /// A key path to the theme colour to use for the verification status text. + var verificationStatusColor: KeyPath { + switch verificationState { + case .verified: + return \.accent + case .unverified: + return \.alert + case .unknown: + return \.secondaryContent + } + } + + /// Further information to be shown to explain the verification state to the user. + var verificationStatusAdditionalInfoText: String { + switch verificationState { + case .verified: + return isCurrentSessionDisplayMode ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userOtherSessionVerifiedAdditionalInfo + case .unverified: + return isCurrentSessionDisplayMode ? VectorL10n.userSessionUnverifiedAdditionalInfo : VectorL10n.userOtherSessionUnverifiedAdditionalInfo + case .unknown: + return VectorL10n.userSessionVerificationUnknownAdditionalInfo + } + } + init(sessionId: String, sessionDisplayName: String?, deviceType: DeviceType, - isVerified: Bool, + verificationState: UserSessionInfo.VerificationState, lastActivityTimestamp: TimeInterval?, lastSeenIP: String?, - isCurrentSessionDisplayMode: Bool = false) { + isCurrentSessionDisplayMode: Bool = false, + isActive: Bool) { self.sessionId = sessionId sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) - self.isVerified = isVerified + self.verificationState = verificationState var lastActivityDateString: String? - if let lastActivityTimestamp = lastActivityTimestamp { - lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityTimestamp) + if isActive { + lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityTimestamp) + } else { + let dateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityTimestamp) + lastActivityDateString = VectorL10n.userInactiveSessionItemWithDate(dateString) + lastActivityIcon = Asset.Images.userSessionListItemInactiveSession.name + } } - self.lastActivityDateString = lastActivityDateString lastSeenIPInfo = lastSeenIP - deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: nil) + deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, verificationState: verificationState) self.isCurrentSessionDisplayMode = isCurrentSessionDisplayMode } @@ -67,9 +124,10 @@ extension UserSessionCardViewData { self.init(sessionId: sessionInfo.id, sessionDisplayName: sessionInfo.name, deviceType: sessionInfo.deviceType, - isVerified: sessionInfo.isVerified, + verificationState: sessionInfo.verificationState, lastActivityTimestamp: sessionInfo.lastSeenTimestamp, lastSeenIP: sessionInfo.lastSeenIP, - isCurrentSessionDisplayMode: sessionInfo.isCurrent) + isCurrentSessionDisplayMode: sessionInfo.isCurrent, + isActive: sessionInfo.isActive) } } diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 83dc0361d..ce1671739 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -15,15 +15,26 @@ // import CommonKit +import Foundation struct UserSessionsFlowCoordinatorParameters { let session: MXSession - let router: NavigationRouterType? + let router: NavigationRouterType } final class UserSessionsFlowCoordinator: Coordinator, Presentable { private let parameters: UserSessionsFlowCoordinatorParameters + private let allSessionsService: UserSessionsOverviewService + private let navigationRouter: NavigationRouterType + private var reauthenticationPresenter: ReauthenticationCoordinatorBridgePresenter? + private var signOutFlowPresenter: SignOutFlowPresenter? + private var errorPresenter: MXKErrorPresentation + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + /// The root coordinator for user session management. + private weak var sessionsOverviewCoordinator: UserSessionsOverviewCoordinator? // Must be used only internally var childCoordinators: [Coordinator] = [] @@ -31,7 +42,13 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { init(parameters: UserSessionsFlowCoordinatorParameters) { self.parameters = parameters - navigationRouter = parameters.router ?? NavigationRouter(navigationController: RiotNavigationController()) + + let dataProvider = UserSessionsDataProvider(session: parameters.session) + allSessionsService = UserSessionsOverviewService(dataProvider: dataProvider) + + navigationRouter = parameters.router + errorPresenter = MXKErrorAlertPresentation() + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable()) } // MARK: - Private @@ -47,14 +64,25 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } private func createUserSessionsOverviewCoordinator() -> UserSessionsOverviewCoordinator { - let parameters = UserSessionsOverviewCoordinatorParameters(session: parameters.session) + let parameters = UserSessionsOverviewCoordinatorParameters(session: parameters.session, + service: allSessionsService) let coordinator = UserSessionsOverviewCoordinator(parameters: parameters) coordinator.completion = { [weak self] result in guard let self = self else { return } switch result { + case .verifyCurrentSession: + self.showCompleteSecurity() + case let .renameSession(sessionInfo): + self.showRenameSessionScreen(for: sessionInfo) + case let .logoutOfSession(sessionInfo): + self.showLogoutConfirmation(for: sessionInfo) case let .openSessionOverview(sessionInfo: sessionInfo): self.openSessionOverview(sessionInfo: sessionInfo) + case let .openOtherSessions(sessionInfos: sessionInfos, filter: filter): + self.openOtherSessions(sessionInfos: sessionInfos, filterBy: filter) + case .linkDevice: + self.openQRLoginScreen() } } return coordinator @@ -66,7 +94,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } private func createUserSessionDetailsCoordinator(sessionInfo: UserSessionInfo) -> UserSessionDetailsCoordinator { - let parameters = UserSessionDetailsCoordinatorParameters(session: sessionInfo) + let parameters = UserSessionDetailsCoordinatorParameters(sessionInfo: sessionInfo) return UserSessionDetailsCoordinator(parameters: parameters) } @@ -77,14 +105,223 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { switch result { case let .openSessionDetails(sessionInfo: sessionInfo): self.openSessionDetails(sessionInfo: sessionInfo) + case let .verifySession(sessionInfo): + if sessionInfo.isCurrent { + self.showCompleteSecurity() + } else { + self.showVerification(for: sessionInfo) + } + case let .renameSession(sessionInfo): + self.showRenameSessionScreen(for: sessionInfo) + case let .logoutOfSession(sessionInfo): + self.showLogoutConfirmation(for: sessionInfo) + } + } + pushScreen(with: coordinator) + } + + /// Shows the QR login screen. + private func openQRLoginScreen() { + let service = QRLoginService(client: parameters.session.matrixRestClient, + mode: .authenticated) + let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: service) + let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + pushScreen(with: coordinator) + } + + private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator { + let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session, + sessionInfo: sessionInfo, + sessionsOverviewDataPublisher: allSessionsService.overviewDataPublisher) + return UserSessionOverviewCoordinator(parameters: parameters) + } + + private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: UserOtherSessionsFilter) { + let title = filter == .all ? VectorL10n.userSessionsOverviewOtherSessionsSectionTitle : VectorL10n.userOtherSessionSecurityRecommendationTitle + let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos, + filterBy: filter, + title: title) + coordinator.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case let .openSessionOverview(sessionInfo: session): + self.openSessionOverview(sessionInfo: session) } } pushScreen(with: coordinator) } - private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator { - let parameters = UserSessionOverviewCoordinatorParameters(session: self.parameters.session, sessionInfo: sessionInfo) - return UserSessionOverviewCoordinator(parameters: parameters) + private func createOtherSessionsCoordinator(sessionInfos: [UserSessionInfo], + filterBy filter: UserOtherSessionsFilter, + title: String) -> UserOtherSessionsCoordinator { + let parameters = UserOtherSessionsCoordinatorParameters(sessionInfos: sessionInfos, + filter: filter, + title: title) + return UserOtherSessionsCoordinator(parameters: parameters) + } + + /// Shows a confirmation dialog to the user to sign out of a session. + private func showLogoutConfirmation(for sessionInfo: UserSessionInfo) { + guard !sessionInfo.isCurrent else { + showLogoutConfirmationForCurrentSession() + return + } + + // Use a UIAlertController as we don't have confirmationDialog in SwiftUI on iOS 14. + let alert = UIAlertController(title: VectorL10n.signOutConfirmationMessage, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: VectorL10n.signOut, style: .destructive) { [weak self] _ in + self?.showLogoutAuthentication(for: sessionInfo) + }) + alert.addAction(UIAlertAction(title: VectorL10n.cancel, style: .cancel)) + alert.popoverPresentationController?.sourceView = toPresentable().view + + navigationRouter.present(alert, animated: true) + } + + private func showLogoutConfirmationForCurrentSession() { + let flowPresenter = SignOutFlowPresenter(session: parameters.session, presentingViewController: toPresentable()) + flowPresenter.delegate = self + + flowPresenter.start() + signOutFlowPresenter = flowPresenter + } + + /// Prompts the user to authenticate (if necessary) in order to log out of a specific session. + private func showLogoutAuthentication(for sessionInfo: UserSessionInfo) { + startLoading() + + let deleteDeviceRequest = AuthenticatedEndpointRequest.deleteDevice(sessionInfo.id) + let coordinatorParameters = ReauthenticationCoordinatorParameters(session: parameters.session, + presenter: navigationRouter.toPresentable(), + title: VectorL10n.deviceDetailsDeletePromptTitle, + message: VectorL10n.deviceDetailsDeletePromptMessage, + authenticatedEndpointRequest: deleteDeviceRequest) + let presenter = ReauthenticationCoordinatorBridgePresenter() + presenter.present(with: coordinatorParameters, animated: true) { [weak self] authenticationParameters in + self?.finalizeLogout(of: sessionInfo, with: authenticationParameters) + self?.reauthenticationPresenter = nil + } cancel: { [weak self] in + self?.stopLoading() + self?.reauthenticationPresenter = nil + } failure: { [weak self] error in + guard let self = self else { return } + self.stopLoading() + self.errorPresenter.presentError(from: self.toPresentable(), forError: error, animated: true, handler: { }) + self.reauthenticationPresenter = nil + } + + reauthenticationPresenter = presenter + } + + /// Finishes the logout process by deleting the device from the user's account. + /// - Parameters: + /// - sessionInfo: The `UserSessionInfo` for the session to be removed. + /// - authenticationParameters: The parameters from performing interactive authentication on the `devices` endpoint. + private func finalizeLogout(of sessionInfo: UserSessionInfo, with authenticationParameters: [String: Any]?) { + parameters.session.matrixRestClient.deleteDevice(sessionInfo.id, + authParameters: authenticationParameters ?? [:]) { [weak self] response in + guard let self = self else { return } + + self.stopLoading() + + guard response.isSuccess else { + MXLog.debug("[UserSessionsFlowCoordinator] Delete device (\(sessionInfo.id)) failed") + if let error = response.error { + self.errorPresenter.presentError(from: self.toPresentable(), forError: error, animated: true, handler: { }) + } else { + self.errorPresenter.presentGenericError(from: self.toPresentable(), animated: true, handler: { }) + } + + return + } + + self.popToSessionsOverview() + } + } + + private func showRenameSessionScreen(for sessionInfo: UserSessionInfo) { + let parameters = UserSessionNameCoordinatorParameters(session: parameters.session, sessionInfo: sessionInfo) + let coordinator = UserSessionNameCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + switch result { + case .sessionNameUpdated: + self.allSessionsService.updateOverviewData { [weak self] _ in + self?.navigationRouter.dismissModule(animated: true, completion: nil) + self?.remove(childCoordinator: coordinator) + } + case .cancel: + self.navigationRouter.dismissModule(animated: true, completion: nil) + self.remove(childCoordinator: coordinator) + } + } + + add(childCoordinator: coordinator) + let modalRouter = NavigationRouter(navigationController: RiotNavigationController()) + modalRouter.setRootModule(coordinator) + coordinator.start() + + navigationRouter.present(modalRouter, animated: true) + } + + /// Shows a prompt to the user that it is not possible to verify + /// another session until the current session has been verified. + private func showCannotVerifyOtherSessionPrompt() { + let alert = UIAlertController(title: VectorL10n.securitySettingsCompleteSecurityAlertTitle, + message: VectorL10n.securitySettingsCompleteSecurityAlertMessage, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: VectorL10n.later, style: .cancel)) + alert.addAction(UIAlertAction(title: VectorL10n.ok, style: .default) { [weak self] _ in + self?.showCompleteSecurity() + }) + + navigationRouter.present(alert, animated: true) + } + + /// Shows the Complete Security modal for the user to verify their current session. + private func showCompleteSecurity() { + AppDelegate.theDelegate().presentCompleteSecurity(for: parameters.session) + } + + /// Shows the verification screen for the specified session. + private func showVerification(for sessionInfo: UserSessionInfo) { + if sessionInfo.verificationState == .unknown { + showCannotVerifyOtherSessionPrompt() + return + } + + let coordinator = UserVerificationCoordinator(presenter: toPresentable(), + session: parameters.session, + userId: parameters.session.myUserId, + userDisplayName: nil, + deviceId: sessionInfo.id) + coordinator.delegate = self + + add(childCoordinator: coordinator) + coordinator.start() + } + + /// Pops back to the root coordinator in the session management flow. + private func popToSessionsOverview() { + guard let sessionsOverviewCoordinator = sessionsOverviewCoordinator else { return } + navigationRouter.popToModule(sessionsOverviewCoordinator, animated: true) + } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil } // MARK: - Public @@ -108,9 +345,54 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { self?.completion?() } } + + sessionsOverviewCoordinator = rootCoordinator } func toPresentable() -> UIViewController { navigationRouter.toPresentable() } } + +// MARK: SignOutFlowPresenter + +extension UserSessionsFlowCoordinator: SignOutFlowPresenterDelegate { + func signOutFlowPresenterDidStartLoading(_ presenter: SignOutFlowPresenter) { + startLoading() + } + + func signOutFlowPresenterDidStopLoading(_ presenter: SignOutFlowPresenter) { + stopLoading() + } + + func signOutFlowPresenter(_ presenter: SignOutFlowPresenter, didFailWith error: Error) { + errorPresenter.presentError(from: toPresentable(), forError: error, animated: true, handler: { }) + } +} + +// MARK: CrossSigningSetupCoordinatorDelegate + +extension UserSessionsFlowCoordinator: CrossSigningSetupCoordinatorDelegate { + func crossSigningSetupCoordinatorDidComplete(_ coordinator: CrossSigningSetupCoordinatorType) { + // The service is listening for changes so there's nothing to do here. + remove(childCoordinator: coordinator) + } + + func crossSigningSetupCoordinatorDidCancel(_ coordinator: CrossSigningSetupCoordinatorType) { + remove(childCoordinator: coordinator) + } + + func crossSigningSetupCoordinator(_ coordinator: CrossSigningSetupCoordinatorType, didFailWithError error: Error) { + remove(childCoordinator: coordinator) + errorPresenter.presentError(from: toPresentable(), forError: error, animated: true, handler: { }) + } +} + +// MARK: UserVerificationCoordinatorDelegate + +extension UserSessionsFlowCoordinator: UserVerificationCoordinatorDelegate { + func userVerificationCoordinatorDidComplete(_ coordinator: UserVerificationCoordinatorType) { + // The service is listening for changes so there's nothing to do here. + remove(childCoordinator: coordinator) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinatorBridgePresenter.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinatorBridgePresenter.swift index a2b256177..498b9d391 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinatorBridgePresenter.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinatorBridgePresenter.swift @@ -42,12 +42,8 @@ final class UserSessionsFlowCoordinatorBridgePresenter: NSObject { // MARK: - Private - private func startUserSessionsFlow(mxSession: MXSession, navigationController: UINavigationController?) { - var navigationRouter: NavigationRouterType? - - if let navigationController = navigationController { - navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) - } + private func startUserSessionsFlow(mxSession: MXSession, navigationController: UINavigationController) { + let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) let parameters = UserSessionsFlowCoordinatorParameters(session: mxSession, router: navigationRouter) let coordinator = UserSessionsFlowCoordinator(parameters: parameters) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift new file mode 100644 index 000000000..cdce32f5d --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -0,0 +1,69 @@ +// +// 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 CommonKit +import SwiftUI + +struct UserOtherSessionsCoordinatorParameters { + let sessionInfos: [UserSessionInfo] + let filter: UserOtherSessionsFilter + let title: String +} + +final class UserOtherSessionsCoordinator: Coordinator, Presentable { + private let parameters: UserOtherSessionsCoordinatorParameters + private let userOtherSessionsHostingController: UIViewController + private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserOtherSessionsCoordinatorResult) -> Void)? + + init(parameters: UserOtherSessionsCoordinatorParameters) { + self.parameters = parameters + + let viewModel = UserOtherSessionsViewModel(sessionInfos: parameters.sessionInfos, + filter: parameters.filter, + title: parameters.title) + let view = UserOtherSessions(viewModel: viewModel.context) + userOtherSessionsViewModel = viewModel + userOtherSessionsHostingController = VectorHostingController(rootView: view) + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userOtherSessionsHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[UserOtherSessionsCoordinator] did start.") + userOtherSessionsViewModel.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case let .showUserSessionOverview(sessionInfo: session): + self.completion?(.openSessionOverview(sessionInfo: session)) + } + MXLog.debug("[UserOtherSessionsCoordinator] UserOtherSessionsViewModel did complete with result: \(result).") + } + } + + func toPresentable() -> UIViewController { + userOtherSessionsHostingController + } + + // MARK: - Private +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift new file mode 100644 index 000000000..7b9a6d4fb --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -0,0 +1,308 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + + case all + case inactiveSessions + case unverifiedSessions + case verifiedSessions + + /// The associated screen + var screenType: Any.Type { + UserOtherSessions.self + } + + /// A list of screen state definitions + static var allCases: [MockUserOtherSessionsScreenState] { + // Each of the presence statuses + [.all, .inactiveSessions, .unverifiedSessions, .verifiedSessions] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: UserOtherSessionsViewModel + switch self { + case .all: + viewModel = UserOtherSessionsViewModel(sessionInfos: allSessions(), + filter: .all, + title: VectorL10n.userSessionsOverviewOtherSessionsSectionTitle) + case .inactiveSessions: + viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(), + filter: .inactive, + title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + case .unverifiedSessions: + viewModel = UserOtherSessionsViewModel(sessionInfos: unverifiedSessions(), + filter: .unverified, + title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + case .verifiedSessions: + viewModel = UserOtherSessionsViewModel(sessionInfos: verifiedSessions(), + filter: .verified, + title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(UserOtherSessions(viewModel: viewModel.context)) + ) + } + + private func inactiveSessions() -> [UserSessionInfo] { + [UserSessionInfo(id: "0", + name: "iOS", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: true), + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + verificationState: .verified, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "2", + name: "Firefox on Windows", + deviceType: .web, + verificationState: .verified, + lastSeenIP: "2.0.0.2", + lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "3", + name: "Android", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false)] + } + + private func unverifiedSessions() -> [UserSessionInfo] { + [UserSessionInfo(id: "0", + name: "iOS", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: true), + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + verificationState: .unverified, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: false)] + } + + private func verifiedSessions() -> [UserSessionInfo] { + [UserSessionInfo(id: "0", + name: "iOS", + deviceType: .mobile, + verificationState: .verified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: true), + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + verificationState: .verified, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: false)] + } + + func allSessions() -> [UserSessionInfo] { + [UserSessionInfo(id: "0", + name: "iOS", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: Date().timeIntervalSince1970 - 500_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + verificationState: .verified, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "2", + name: "Firefox on Windows", + deviceType: .web, + verificationState: .verified, + lastSeenIP: "2.0.0.2", + lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "3", + name: "Android", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "4", + name: "iOS", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: Date().timeIntervalSince1970 - 11_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "5", + name: "macOS", + deviceType: .desktop, + verificationState: .verified, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 20_000_000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false)] + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift new file mode 100644 index 000000000..45d43f3b3 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.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 RiotSwiftUI +import XCTest + +class UserOtherSessionsUITests: MockScreenTestCase { + func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) + + XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionFilterMenuInactive].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) + } + + func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) + + XCTAssertTrue(app.buttons["iOS, Inactive for 90+ days"].exists) + } + + func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctHeaderDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title) + + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionUnverifiedShort].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists) + } + + func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctItemsDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title) + + XCTAssertTrue(app.buttons["iOS, Unverified · Your current session"].exists) + } + + func test_whenOtherSessionsWithAllSessionFilterPresented_correctHeaderDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) + + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo].exists) + } + + func test_whenOtherSessionsWithVerifiedSessionFilterPresented_correctHeaderDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.verifiedSessions.title) + + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionVerifiedShort].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle].exists) + } + + func test_whenOtherSessionsMoreMenuButtonSelected_selectSessionsButtonExists() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) + + app.buttons["More"].tap() + XCTAssertTrue(app.buttons["Select sessions"].exists) + } + + func test_whenOtherSessionsSelectSessionsSelected_navBarContainsCorrectButtons() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) + + app.buttons["More"].tap() + app.buttons["Select sessions"].tap() + XCTAssertTrue(app.buttons["Select All"].exists) + XCTAssertTrue(app.buttons["Cancel"].exists) + } + + func test_whenOtherSessionsSelectAllSelected_navBarContainsCorrectButtons() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) + + app.buttons["More"].tap() + app.buttons["Select sessions"].tap() + app.buttons["Select All"].tap() + XCTAssertTrue(app.buttons["Deselect All"].exists) + XCTAssertTrue(app.buttons["Cancel"].exists) + } + + func test_whenAllOtherSessionsAreSelected_navBarContainsCorrectButtons() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) + app.buttons["More"].tap() + app.buttons["Select sessions"].tap() + for i in 0...MockUserOtherSessionsScreenState.all.allSessions().count - 1 { + app.buttons["UserSessionListItem_\(i)"].tap() + } + XCTAssertTrue(app.buttons["Deselect All"].exists) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift new file mode 100644 index 000000000..782bdac4f --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -0,0 +1,305 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class UserOtherSessionsViewModelTests: XCTestCase { + private let unverifiedSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionUnverifiedShort, + subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle, + iconName: Asset.Images.userOtherSessionsUnverified.name) + + private let inactiveSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuInactive, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + + private let allSectionHeader = UserOtherSessionsHeaderViewData(title: nil, + subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo, + iconName: nil) + + private let verifiedSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuVerified, + subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle, + iconName: Asset.Images.userOtherSessionsVerified.name) + + func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() { + let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2") + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + expectedUserSessionInfo] + let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive) + + var modelResult: UserOtherSessionsViewModelResult? + sut.completion = { result in + modelResult = result + } + sut.process(viewAction: .userOtherSessionSelected(sessionId: expectedUserSessionInfo.id)) + XCTAssertEqual(modelResult, .showUserSessionOverview(sessionInfo: expectedUserSessionInfo)) + } + + func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isActive: false), + createUserSessionInfo(sessionId: "session 2", isActive: false)] + let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive) + + let expectedItems = sessionInfos.filter { !$0.isActive }.asViewData() + let bindings = UserOtherSessionsBindings(filter: .inactive, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: expectedItems, + header: inactiveSectionHeader, + emptyItemsTitle: VectorL10n.userOtherSessionNoInactiveSessions, + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenModelCreated_withAllFilter_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + + let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData() + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenModelCreated_withUnverifiedFilter_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified) + + let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData() + let bindings = UserOtherSessionsBindings(filter: .unverified, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: expectedItems, + header: unverifiedSectionHeader, + emptyItemsTitle: VectorL10n.userOtherSessionNoUnverifiedSessions, + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenModelCreated_withVerifiedFilter_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true), + createUserSessionInfo(sessionId: "session 2", isVerified: true)] + let sut = createSUT(sessionInfos: sessionInfos, filter: .verified) + + let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData() + let bindings = UserOtherSessionsBindings(filter: .verified, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: expectedItems, + header: verifiedSectionHeader, + emptyItemsTitle: VectorL10n.userOtherSessionNoVerifiedSessions, + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenModelCreated_withVerifiedFilterWithNoVerifiedSessions_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: false), + createUserSessionInfo(sessionId: "session 2", isVerified: false)] + let sut = createSUT(sessionInfos: sessionInfos, filter: .verified) + let bindings = UserOtherSessionsBindings(filter: .verified, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: [], + header: verifiedSectionHeader, + emptyItemsTitle: VectorL10n.userOtherSessionNoVerifiedSessions, + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenModelCreated_withUnverifiedFilterWithNoUnverifiedSessions_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true), + createUserSessionInfo(sessionId: "session 2", isVerified: true)] + let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified) + let bindings = UserOtherSessionsBindings(filter: .unverified, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: [], + header: unverifiedSectionHeader, + emptyItemsTitle: VectorL10n.userOtherSessionNoUnverifiedSessions, + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenModelCreated_withInactiveFilterWithNoInactiveSessions_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isActive: true), + createUserSessionInfo(sessionId: "session 2", isActive: true)] + let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive) + let bindings = UserOtherSessionsBindings(filter: .inactive, isEditModeEnabled: false) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: "Title", + sessionItems: [], + header: inactiveSectionHeader, + emptyItemsTitle: VectorL10n.userOtherSessionNoInactiveSessions, + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenEditModeEnabledAndAllItemsSelected_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + toggleEditMode(for: sut, value: true) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1")) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 2")) + + let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: true) } + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: VectorL10n.userOtherSessionSelectedCount("2"), + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: true) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenEditModeEnabledAndItemSelectedAndDeselected_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + toggleEditMode(for: sut, value: true) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1")) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1")) + + let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: false) } + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: VectorL10n.userOtherSessionSelectedCount("0"), + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenEditModeEnabledAndNotAllItemsSelected_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + toggleEditMode(for: sut, value: true) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 2")) + + let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: $0.id == "session 2") } + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: VectorL10n.userOtherSessionSelectedCount("1"), + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenEditModeEnabledAndAllItemsSelectedByButton_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + toggleEditMode(for: sut, value: true) + sut.process(viewAction: .toggleAllSelection) + + let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: true) } + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: VectorL10n.userOtherSessionSelectedCount("2"), + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: true) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenEditModeEnabledAndAllItemsDeselectedByButton_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + toggleEditMode(for: sut, value: true) + sut.process(viewAction: .toggleAllSelection) + sut.process(viewAction: .toggleAllSelection) + let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: false) } + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: VectorL10n.userOtherSessionSelectedCount("0"), + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + func test_whenEditModeEnabledDisabledAndEnabled_viewStateIsCorrect() { + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] + let sut = createSUT(sessionInfos: sessionInfos, filter: .all) + toggleEditMode(for: sut, value: true) + sut.process(viewAction: .editModeWasToggled) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1")) + sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 2")) + toggleEditMode(for: sut, value: false) + toggleEditMode(for: sut, value: true) + let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: false) } + let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true) + let expectedState = UserOtherSessionsViewState(bindings: bindings, + title: VectorL10n.userOtherSessionSelectedCount("0"), + sessionItems: expectedItems, + header: allSectionHeader, + emptyItemsTitle: "", + allItemsSelected: false) + XCTAssertEqual(sut.state, expectedState) + } + + private func toggleEditMode(for model: UserOtherSessionsViewModel, value: Bool) { + model.context.isEditModeEnabled = value + model.process(viewAction: .editModeWasToggled) + } + + private func createSUT(sessionInfos: [UserSessionInfo], + filter: UserOtherSessionsFilter, + title: String = "Title") -> UserOtherSessionsViewModel { + UserOtherSessionsViewModel(sessionInfos: sessionInfos, + filter: filter, + title: title) + } + + private func createUserSessionInfo(sessionId: String, + isVerified: Bool = false, + isActive: Bool = true, + isCurrent: Bool = false) -> UserSessionInfo { + UserSessionInfo(id: sessionId, + name: "iOS", + deviceType: .mobile, + verificationState: isVerified ? .verified : .unverified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: Date().timeIntervalSince1970 - 100, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: "iPhone XS", + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: isActive, + isCurrent: isCurrent) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsFilter.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsFilter.swift new file mode 100644 index 000000000..9450c4d74 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsFilter.swift @@ -0,0 +1,40 @@ +// +// Copyright 2022 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 UserOtherSessionsFilter: Identifiable, Equatable, CaseIterable { + var id: Self { self } + case all + case verified + case unverified + case inactive +} + +extension UserOtherSessionsFilter { + var menuLocalizedName: String { + switch self { + case .all: + return VectorL10n.userOtherSessionFilterMenuAll + case .verified: + return VectorL10n.userOtherSessionFilterMenuVerified + case .unverified: + return VectorL10n.userOtherSessionFilterMenuUnverified + case .inactive: + return VectorL10n.userOtherSessionFilterMenuInactive + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift new file mode 100644 index 000000000..8aefc40b9 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift @@ -0,0 +1,53 @@ +// +// 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 + +// MARK: - Coordinator + +enum UserOtherSessionsCoordinatorResult { + case openSessionOverview(sessionInfo: UserSessionInfo) +} + +// MARK: View model + +enum UserOtherSessionsViewModelResult: Equatable { + case showUserSessionOverview(sessionInfo: UserSessionInfo) +} + +// MARK: View + +struct UserOtherSessionsViewState: BindableState, Equatable { + var bindings: UserOtherSessionsBindings + var title: String + var sessionItems: [UserSessionListItemViewData] + var header: UserOtherSessionsHeaderViewData + var emptyItemsTitle: String + var allItemsSelected: Bool +} + +struct UserOtherSessionsBindings: Equatable { + var filter: UserOtherSessionsFilter + var isEditModeEnabled: Bool +} + +enum UserOtherSessionsViewAction { + case userOtherSessionSelected(sessionId: String) + case filterWasChanged + case clearFilter + case editModeWasToggled + case toggleAllSelection +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift new file mode 100644 index 000000000..b0cac5185 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -0,0 +1,169 @@ +// +// 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 + +typealias UserOtherSessionsViewModelType = StateStoreViewModel + +class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol { + var completion: ((UserOtherSessionsViewModelResult) -> Void)? + private let sessionInfos: [UserSessionInfo] + private var selectedSessions: Set = [] + private let defaultTitle: String + + init(sessionInfos: [UserSessionInfo], + filter: UserOtherSessionsFilter, + title: String) { + self.sessionInfos = sessionInfos + defaultTitle = title + let bindings = UserOtherSessionsBindings(filter: filter, isEditModeEnabled: false) + let sessionItems = filter.filterSessionInfos(sessionInfos: sessionInfos, selectedSessions: selectedSessions) + super.init(initialViewState: UserOtherSessionsViewState(bindings: bindings, + title: title, + sessionItems: sessionItems, + header: filter.userOtherSessionsViewHeader, + emptyItemsTitle: filter.userOtherSessionsViewEmptyResultsTitle, + allItemsSelected: false)) + } + + // MARK: - Public + + override func process(viewAction: UserOtherSessionsViewAction) { + switch viewAction { + case let .userOtherSessionSelected(sessionId: sessionId): + if state.bindings.isEditModeEnabled { + updateSelectionForSession(sessionId: sessionId) + updateViewState() + } else { + showUserSessionOverview(sessionId: sessionId) + } + case .filterWasChanged: + updateViewState() + case .clearFilter: + state.bindings.filter = .all + updateViewState() + case .editModeWasToggled: + selectedSessions.removeAll() + updateViewState() + case .toggleAllSelection: + toggleAllSelection() + updateViewState() + } + } + + // MARK: - Private + + private func showUserSessionOverview(sessionId: String) { + guard let session = sessionInfos.first(where: { $0.id == sessionId }) else { + assertionFailure("Session should exist in the array.") + return + } + completion?(.showUserSessionOverview(sessionInfo: session)) + } + + private func updateSelectionForSession(sessionId: String) { + if selectedSessions.contains(sessionId) { + selectedSessions.remove(sessionId) + } else { + selectedSessions.insert(sessionId) + } + } + + private func updateViewState() { + let currentFilter = state.bindings.filter + + state.sessionItems = currentFilter.filterSessionInfos(sessionInfos: sessionInfos, selectedSessions: selectedSessions) + state.header = currentFilter.userOtherSessionsViewHeader + + if state.bindings.isEditModeEnabled { + state.title = VectorL10n.userOtherSessionSelectedCount(String(selectedSessions.count)) + } else { + state.title = defaultTitle + } + + state.emptyItemsTitle = currentFilter.userOtherSessionsViewEmptyResultsTitle + + state.allItemsSelected = sessionInfos.count == selectedSessions.count + } + + private func toggleAllSelection() { + if state.allItemsSelected { + selectedSessions.removeAll() + } else { + sessionInfos.forEach { sessionInfo in + selectedSessions.insert(sessionInfo.id) + } + } + } +} + +private extension UserOtherSessionsFilter { + var userOtherSessionsViewHeader: UserOtherSessionsHeaderViewData { + switch self { + case .all: + return UserOtherSessionsHeaderViewData(title: nil, + subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo, + iconName: nil) + case .inactive: + return UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuInactive, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + case .unverified: + return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionUnverifiedShort, + subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle, + iconName: Asset.Images.userOtherSessionsUnverified.name) + case .verified: + return UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuVerified, + subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle, + iconName: Asset.Images.userOtherSessionsVerified.name) + } + } + + var userOtherSessionsViewEmptyResultsTitle: String { + switch self { + case .all: + return "" + case .verified: + return VectorL10n.userOtherSessionNoVerifiedSessions + case .unverified: + return VectorL10n.userOtherSessionNoUnverifiedSessions + case .inactive: + return VectorL10n.userOtherSessionNoInactiveSessions + } + } + + func filterSessionsInfos(_ sessionInfos: [UserSessionInfo]) -> [UserSessionInfo] { + switch self { + case .all: + return sessionInfos.filter { !$0.isCurrent } + case .inactive: + return sessionInfos.filter { !$0.isActive } + case .unverified: + return sessionInfos.filter { $0.verificationState != .verified } + case .verified: + return sessionInfos.filter { $0.verificationState == .verified } + } + } + + func filterSessionInfos(sessionInfos: [UserSessionInfo], selectedSessions: Set) -> [UserSessionListItemViewData] { + filterSessionsInfos(sessionInfos) + .map { + UserSessionListItemViewDataFactory().create(from: $0, + highlightSessionDetails: self == .unverified && $0.isCurrent, + isSelected: selectedSessions.contains($0.id)) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModelProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModelProtocol.swift new file mode 100644 index 000000000..444fe1fc8 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// 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 + +protocol UserOtherSessionsViewModelProtocol { + var completion: ((UserOtherSessionsViewModelResult) -> Void)? { get set } + var context: UserOtherSessionsViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift new file mode 100644 index 000000000..b8f390a05 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -0,0 +1,103 @@ +// +// 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 UserOtherSessions: View { + @Environment(\.theme) private var theme + + @ObservedObject var viewModel: UserOtherSessionsViewModel.Context + + var body: some View { + ScrollView { + SwiftUI.Section { + if viewModel.viewState.sessionItems.isEmpty { + noItemsView() + } else { + itemsView() + } + } header: { + UserOtherSessionsHeaderView(viewData: viewModel.viewState.header) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24.0) + } + } + .onChange(of: viewModel.isEditModeEnabled) { _ in + viewModel.send(viewAction: .editModeWasToggled) + } + .onChange(of: viewModel.filter) { _ in + viewModel.send(viewAction: .filterWasChanged) + } + .background(theme.colors.system.ignoresSafeArea()) + .frame(maxHeight: .infinity) + .navigationTitle(viewModel.viewState.title) + .toolbar { + UserOtherSessionsToolbar(isEditModeEnabled: $viewModel.isEditModeEnabled, + filter: $viewModel.filter, + allItemsSelected: viewModel.viewState.allItemsSelected) { + viewModel.send(viewAction: .toggleAllSelection) + } + } + .navigationBarBackButtonHidden(viewModel.isEditModeEnabled) + .accentColor(theme.colors.accent) + } + + private func noItemsView() -> some View { + VStack { + Text(viewModel.viewState.emptyItemsTitle) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.primaryContent) + .padding(.bottom, 20) + Button { + viewModel.send(viewAction: .clearFilter) + } label: { + VStack(spacing: 0) { + SeparatorLine() + Text(VectorL10n.userOtherSessionClearFilter) + .font(theme.fonts.body) + .foregroundColor(theme.colors.accent) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 11) + SeparatorLine() + } + .background(theme.colors.background) + } + } + } + + private func itemsView() -> some View { + LazyVStack(spacing: 0) { + ForEach(viewModel.viewState.sessionItems) { viewData in + UserSessionListItem(viewData: viewData, + isEditModeEnabled: viewModel.isEditModeEnabled, + onBackgroundTap: { sessionId in viewModel.send(viewAction: .userOtherSessionSelected(sessionId: sessionId)) }, + onBackgroundLongPress: { _ in viewModel.isEditModeEnabled = true }) + } + } + .background(theme.colors.background) + } +} + +// MARK: - Previews + +struct UserOtherSessions_Previews: PreviewProvider { + static let stateRenderer = MockUserOtherSessionsScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift new file mode 100644 index 000000000..9cdfb6995 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -0,0 +1,98 @@ +// +// Copyright 2022 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 UserOtherSessionsHeaderViewData: Hashable { + var title: String? + let subtitle: String + var iconName: String? +} + +struct UserOtherSessionsHeaderView: View { + private var backgroundShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 8) + } + + @Environment(\.theme) private var theme + + let viewData: UserOtherSessionsHeaderViewData + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if let iconName = viewData.iconName { + Image(iconName) + .frame(width: 40, height: 40) + .background(theme.colors.background) + .clipShape(backgroundShape) + .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape) + .padding(.trailing, 16) + } + VStack(alignment: .leading, spacing: 0, content: { + if let title = viewData.title { + Text(title) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.primaryContent) + .padding(.vertical, 9.0) + } + Text(viewData.subtitle) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 20.0) + }) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + } +} + +// MARK: - Previews + +struct UserOtherSessionsHeaderView_Previews: PreviewProvider { + private static let headerWithTitleSubtitleIcon = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + + private static let headerWithSubtitle = UserOtherSessionsHeaderViewData(title: nil, + subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo, + iconName: nil) + + private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + static var previews: some View { + Group { + VStack { + Divider() + UserOtherSessionsHeaderView(viewData: self.headerWithTitleSubtitleIcon) + Divider() + UserOtherSessionsHeaderView(viewData: self.headerWithSubtitle) + Divider() + } + .theme(.light) + .preferredColorScheme(.light) + VStack { + Divider() + UserOtherSessionsHeaderView(viewData: self.headerWithTitleSubtitleIcon) + Divider() + UserOtherSessionsHeaderView(viewData: self.headerWithSubtitle) + Divider() + } + .theme(.dark) + .preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift new file mode 100644 index 000000000..244e1473e --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift @@ -0,0 +1,94 @@ +// +// Copyright 2022 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 UserOtherSessionsToolbar: ToolbarContent { + @Environment(\.theme) private var theme + + @Binding var isEditModeEnabled: Bool + @Binding var filter: UserOtherSessionsFilter + var allItemsSelected: Bool + let onToggleSelection: () -> Void + + var body: some ToolbarContent { + navigationBarLeading() + navigationBarTrailing() + } + + private func navigationBarLeading() -> some ToolbarContent { + ToolbarItemGroup(placement: .navigationBarLeading) { + if isEditModeEnabled { + Button(allItemsSelected ? VectorL10n.deselectAll : VectorL10n.selectAll, action: { + onToggleSelection() + }) + } + } + } + + private func navigationBarTrailing() -> some ToolbarContent { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if isEditModeEnabled { + cancelButton() + } else { + filterMenuButton() + .offset(x: 12) + optionsMenu() + } + } + } + + private func cancelButton() -> some View { + Button(VectorL10n.cancel) { + isEditModeEnabled = false + } + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.accent) + } + + private func filterMenuButton() -> some View { + Button { } label: { + Menu { + Picker("", selection: $filter) { + ForEach(UserOtherSessionsFilter.allCases) { filter in + Text(filter.menuLocalizedName).tag(filter) + } + } + .labelsHidden() + } label: { + Image(filter == .all ? Asset.Images.userOtherSessionsFilter.name : Asset.Images.userOtherSessionsFilterSelected.name) + } + .accessibilityLabel(VectorL10n.userOtherSessionFilter) + } + } + + private func optionsMenu() -> some View { + Button { } label: { + Menu { + Button { + isEditModeEnabled = true + } label: { + Label(VectorL10n.userOtherSessionMenuSelectSessions, systemImage: "checkmark.circle") + } + + } label: { + Image(systemName: "ellipsis") + .padding(.horizontal, 4) + .padding(.vertical, 12) + } + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift index bffc0ed56..20aaee153 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift @@ -18,7 +18,7 @@ import CommonKit import SwiftUI struct UserSessionDetailsCoordinatorParameters { - let session: UserSessionInfo + let sessionInfo: UserSessionInfo } final class UserSessionDetailsCoordinator: Coordinator, Presentable { @@ -40,7 +40,7 @@ final class UserSessionDetailsCoordinator: Coordinator, Presentable { init(parameters: UserSessionDetailsCoordinatorParameters) { self.parameters = parameters - let viewModel = UserSessionDetailsViewModel(session: parameters.session) + let viewModel = UserSessionDetailsViewModel(sessionInfo: parameters.sessionInfo) let view = UserSessionDetails(viewModel: viewModel.context) userSessionDetailsViewModel = viewModel userSessionDetailsHostingController = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index 90fa84a36..b3efa0669 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -38,49 +38,49 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let session: UserSessionInfo + let sessionInfo: UserSessionInfo switch self { case .allSections: - session = UserSessionInfo(id: "alice", - name: "iOS", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "10.0.0.10", - lastSeenTimestamp: nil, - applicationName: "Element iOS", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "iOS 15.5", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: true) + sessionInfo = UserSessionInfo(id: "alice", + name: "iOS", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: "Element iOS", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: true) case .sessionSectionOnly: - session = UserSessionInfo(id: "3", - name: "Android", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "3.0.0.3", - lastSeenTimestamp: Date().timeIntervalSince1970 - 10, - applicationName: "Element Android", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "Android 4.0", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: false) + sessionInfo = UserSessionInfo(id: "3", + name: "Android", + deviceType: .mobile, + verificationState: .unverified, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10, + applicationName: "Element Android", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "Android 4.0", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: false) } - let viewModel = UserSessionDetailsViewModel(session: session) + let viewModel = UserSessionDetailsViewModel(sessionInfo: sessionInfo) // can simulate service and viewModel actions here if needs be. return ( - [session], + [sessionInfo], AnyView(UserSessionDetails(viewModel: viewModel.context)) ) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift index 6eb573fe9..ea2133dd8 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift @@ -18,7 +18,7 @@ import RiotSwiftUI import XCTest class UserSessionDetailsUITests: MockScreenTestCase { - func test_longPressDetailsCell_CopiesValueToClipboard() throws { + func disabled_broken_xcode14_test_longPressDetailsCell_CopiesValueToClipboard() throws { app.goToScreenWithIdentifier(MockUserSessionDetailsScreenState.allSections.title) UIPasteboard.general.string = "" diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift index b07751c10..227b3ff53 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift @@ -19,6 +19,12 @@ import XCTest @testable import RiotSwiftUI class UserSessionDetailsViewModelTests: XCTestCase { + private static var lastSeenDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EE, d MMM · HH:mm" + return dateFormatter + }() + func test_whenSessionNameAndLastSeenIPNil_viewStateCorrect() { let userSessionInfo = createUserSessionInfo(id: "session", name: nil, @@ -35,7 +41,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { ] let expectedModel = UserSessionDetailsViewState(sections: sections) - let sut = UserSessionDetailsViewModel(session: userSessionInfo) + let sut = UserSessionDetailsViewModel(sessionInfo: userSessionInfo) XCTAssertEqual(sut.state, expectedModel) } @@ -57,21 +63,24 @@ class UserSessionDetailsViewModelTests: XCTestCase { ] let expectedModel = UserSessionDetailsViewState(sections: sections) - let sut = UserSessionDetailsViewModel(session: userSessionInfo) + let sut = UserSessionDetailsViewModel(sessionInfo: userSessionInfo) XCTAssertEqual(sut.state, expectedModel) } func test_whenUserSessionInfoContainsAllValues_viewStateCorrect() { + let lastSeenTimestamp = Date().timeIntervalSince1970 - 1_000_000 let userSessionInfo = createUserSessionInfo(id: "session", name: "session name", lastSeenIP: "0.0.0.0", + lastSeenTimestamp: lastSeenTimestamp, applicationName: "Element iOS", applicationVersion: "1.0.0") let sessionItems = [ sessionNameItem(sessionName: "session name"), - sessionIdItem(sessionId: "session") + sessionIdItem(sessionId: "session"), + sessionLastActivity(lastSeen: lastSeenTimestamp) ] let appItems = [ appNameItem(appName: "Element iOS"), @@ -94,7 +103,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { ] let expectedModel = UserSessionDetailsViewState(sections: sections) - let sut = UserSessionDetailsViewModel(session: userSessionInfo) + let sut = UserSessionDetailsViewModel(sessionInfo: userSessionInfo) XCTAssertEqual(sut.state, expectedModel) } @@ -106,7 +115,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { deviceType: DeviceType = .mobile, isVerified: Bool = false, lastSeenIP: String?, - lastSeenTimestamp: TimeInterval = Date().timeIntervalSince1970, + lastSeenTimestamp: TimeInterval? = nil, applicationName: String? = nil, applicationVersion: String? = nil, applicationURL: String? = nil, @@ -120,7 +129,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { UserSessionInfo(id: id, name: name, deviceType: deviceType, - isVerified: isVerified, + verificationState: isVerified ? .verified : .unverified, lastSeenIP: lastSeenIP, lastSeenTimestamp: lastSeenTimestamp, applicationName: applicationName, @@ -144,6 +153,11 @@ class UserSessionDetailsViewModelTests: XCTestCase { .init(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, value: sessionId) } + + private func sessionLastActivity(lastSeen: TimeInterval) -> UserSessionDetailsSectionItemViewData { + .init(title: VectorL10n.userSessionDetailsLastActivity, + value: Self.lastSeenDateFormatter.string(from: Date(timeIntervalSince1970: lastSeen))) + } private func appNameItem(appName: String) -> UserSessionDetailsSectionItemViewData { .init(title: VectorL10n.userSessionDetailsApplicationName, diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift index f08ecdd8e..29a55da55 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift @@ -19,61 +19,73 @@ import Foundation typealias UserSessionDetailsViewModelType = StateStoreViewModel class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionDetailsViewModelProtocol { + private static var lastSeenDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EE, d MMM · HH:mm" + return dateFormatter + }() + var completion: ((UserSessionDetailsViewModelResult) -> Void)? - init(session: UserSessionInfo) { + init(sessionInfo: UserSessionInfo) { super.init(initialViewState: UserSessionDetailsViewState(sections: [])) - updateViewState(session: session) + updateViewState(sessionInfo: sessionInfo) } // MARK: - Public // MARK: - Private - private func updateViewState(session: UserSessionInfo) { + private func updateViewState(sessionInfo: UserSessionInfo) { var sections = [UserSessionDetailsSectionViewData]() - sections.append(sessionSection(session: session)) + sections.append(sessionSection(sessionInfo: sessionInfo)) - if let applicationSection = applicationSection(session: session) { + if let applicationSection = applicationSection(sessionInfo: sessionInfo) { sections.append(applicationSection) } - if let deviceSection = deviceSection(session: session) { + if let deviceSection = deviceSection(sessionInfo: sessionInfo) { sections.append(deviceSection) } state = UserSessionDetailsViewState(sections: sections) } - private func sessionSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData { + private func sessionSection(sessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData { var sessionItems: [UserSessionDetailsSectionItemViewData] = [] - if let sessionName = session.name { + if let sessionName = sessionInfo.name { sessionItems.append(.init(title: VectorL10n.userSessionDetailsSessionName, value: sessionName)) } sessionItems.append(.init(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, - value: session.id)) + value: sessionInfo.id)) + + if let lastSeenTimestamp = sessionInfo.lastSeenTimestamp { + let date = Date(timeIntervalSince1970: lastSeenTimestamp) + sessionItems.append(.init(title: VectorL10n.userSessionDetailsLastActivity, + value: Self.lastSeenDateFormatter.string(from: date))) + } return .init(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), footer: VectorL10n.userSessionDetailsSessionSectionFooter, items: sessionItems) } - private func applicationSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? { + private func applicationSection(sessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData? { var sessionItems: [UserSessionDetailsSectionItemViewData] = [] - if let name = session.applicationName { + if let name = sessionInfo.applicationName, !name.isEmpty { sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationName, value: name)) } - if let version = session.applicationVersion { + if let version = sessionInfo.applicationVersion, !version.isEmpty { sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationVersion, value: version)) } - if let url = session.applicationURL { + if let url = sessionInfo.applicationURL, !url.isEmpty { sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationUrl, value: url)) } @@ -86,28 +98,28 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD items: sessionItems) } - private func deviceSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? { + private func deviceSection(sessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData? { var deviceSectionItems = [UserSessionDetailsSectionItemViewData]() - if let model = session.deviceModel { + if let model = sessionInfo.deviceModel { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceModel, value: model)) } - if session.deviceType == .web, - let clientName = session.clientName, - let clientVersion = session.clientVersion { + if sessionInfo.deviceType == .web, + let clientName = sessionInfo.clientName, + let clientVersion = sessionInfo.clientVersion { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceBrowser, value: "\(clientName) \(clientVersion)")) } - if let deviceOS = session.deviceOS { + if let deviceOS = sessionInfo.deviceOS { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceOs, value: deviceOS)) } - if let lastSeenIP = session.lastSeenIP { + if let lastSeenIP = sessionInfo.lastSeenIP { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpAddress, value: lastSeenIP)) } - if let lastSeenIPLocation = session.lastSeenIPLocation { + if let lastSeenIPLocation = sessionInfo.lastSeenIPLocation { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpLocation, value: lastSeenIPLocation)) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift new file mode 100644 index 000000000..8d8890b2c --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift @@ -0,0 +1,98 @@ +// +// 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 CommonKit +import SwiftUI + +struct UserSessionNameCoordinatorParameters { + let session: MXSession + let sessionInfo: UserSessionInfo +} + +final class UserSessionNameCoordinator: Coordinator, Presentable { + private let parameters: UserSessionNameCoordinatorParameters + private let userSessionNameHostingController: UIViewController + private var userSessionNameViewModel: UserSessionNameViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserSessionNameCoordinatorResult) -> Void)? + + init(parameters: UserSessionNameCoordinatorParameters) { + self.parameters = parameters + + let viewModel = UserSessionNameViewModel(sessionInfo: parameters.sessionInfo) + let view = UserSessionName(viewModel: viewModel.context) + userSessionNameViewModel = viewModel + userSessionNameHostingController = VectorHostingController(rootView: view) + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userSessionNameHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[UserSessionNameCoordinator] did start.") + userSessionNameViewModel.completion = { [weak self] result in + guard let self = self else { return } + + MXLog.debug("[UserSessionNameCoordinator] UserSessionNameViewModel did complete with result: \(result).") + switch result { + case .updateName(let newName): + self.updateName(newName) + case .cancel: + self.completion?(.cancel) + } + } + } + + func toPresentable() -> UIViewController { userSessionNameHostingController } + + // MARK: - Private + + /// Updates the name of the device, completing the screen's presentation if successful. + private func updateName(_ newName: String) { + startLoading() + parameters.session.matrixRestClient.setDeviceName(newName, forDevice: parameters.sessionInfo.id) { [weak self] response in + guard let self = self else { return } + + guard response.isSuccess else { + MXLog.debug("[UserSessionNameCoordinator] Rename device (\(self.parameters.sessionInfo.id)) failed") + self.userSessionNameViewModel.processError(response.error as NSError?) + return + } + + self.stopLoading() + self.completion?(.sessionNameUpdated) + } + } + + /// Show an activity indicator whilst loading. + /// - Parameters: + /// - label: The label to show on the indicator. + /// - isInteractionBlocking: Whether the indicator should block any user interaction. + private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) { + loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/MockUserSessionNameScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/MockUserSessionNameScreenState.swift new file mode 100644 index 000000000..1b35eb2b0 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/MockUserSessionNameScreenState.swift @@ -0,0 +1,51 @@ +// +// 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 + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockUserSessionNameScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case initialName + case empty + case changedName + + /// The associated screen + var screenType: Any.Type { + UserSessionName.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: UserSessionNameViewModel + switch self { + case .initialName: + viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone()) + case .empty: + viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone()) + viewModel.state.bindings.sessionName = "" + case .changedName: + viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone()) + viewModel.state.bindings.sessionName = "iPhone SE" + } + + return ([viewModel], AnyView(UserSessionName(viewModel: viewModel.context))) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift new file mode 100644 index 000000000..1603c9994 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift @@ -0,0 +1,44 @@ +// +// 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 RiotSwiftUI +import XCTest + +class UserSessionNameUITests: MockScreenTestCase { + func testUserSessionNameInitialState() { + app.goToScreenWithIdentifier(MockUserSessionNameScreenState.initialName.title) + + let doneButton = app.buttons[VectorL10n.done] + XCTAssertTrue(doneButton.exists) + XCTAssertFalse(doneButton.isEnabled) + } + + func testUserSessionNameEmptyState() { + app.goToScreenWithIdentifier(MockUserSessionNameScreenState.empty.title) + + let doneButton = app.buttons[VectorL10n.done] + XCTAssertTrue(doneButton.exists) + XCTAssertFalse(doneButton.isEnabled) + } + + func testUserSessionNameChangedState() { + app.goToScreenWithIdentifier(MockUserSessionNameScreenState.changedName.title) + + let doneButton = app.buttons[VectorL10n.done] + XCTAssertTrue(doneButton.exists) + XCTAssertTrue(doneButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift new file mode 100644 index 000000000..5e76f4989 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift @@ -0,0 +1,51 @@ +// +// 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 XCTest + +@testable import RiotSwiftUI + +class UserSessionNameViewModelTests: XCTestCase { + var viewModel: UserSessionNameViewModelProtocol! + var context: UserSessionNameViewModelType.Context! + + override func setUpWithError() throws { + viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone()) + context = viewModel.context + } + + func testClearingName() { + // Given an unedited name. + XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.") + + // When clearing the name. + context.sessionName = "" + + // Then the done button should remain be disabled. + XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name is empty.") + } + + func testChangingName() { + // Given an unedited name. + XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.") + + // When changing the name. + context.sessionName = "Alice's iPhone" + + // Then the done button should be enabled. + XCTAssertTrue(context.viewState.canUpdateName, "The done button should be enabled when the name has been changed.") + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift new file mode 100644 index 000000000..ebe909e84 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift @@ -0,0 +1,64 @@ +// +// 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 + +// MARK: - Coordinator + +enum UserSessionNameCoordinatorResult { + /// The user cancelled the rename operation. + case cancel + /// The user successfully updated the name of the session. + case sessionNameUpdated +} + +// MARK: View model + +enum UserSessionNameViewModelResult { + /// The user cancelled the rename operation. + case cancel + /// Update the session name to the supplied string. + case updateName(String) +} + +// MARK: View + +struct UserSessionNameViewState: BindableState { + var bindings: UserSessionNameBindings + /// The current name of the session before any updates are made. + let currentName: String + + /// Whether or not to allow the user to update the session name. + var canUpdateName: Bool { + !bindings.sessionName.isEmpty && bindings.sessionName != currentName + } +} + +struct UserSessionNameBindings { + /// The name input by the user. + var sessionName: String + /// The currently displayed alert's info value otherwise `nil`. + var alertInfo: AlertInfo? +} + +enum UserSessionNameViewAction { + /// The user tapped the done button to update the session name. + case done + /// The user tapped the cancel button. + case cancel + /// The user tapped the Learn More link. + case learnMore +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift new file mode 100644 index 000000000..ad2b8d7cd --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift @@ -0,0 +1,45 @@ +// +// 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 + +typealias UserSessionNameViewModelType = StateStoreViewModel + +class UserSessionNameViewModel: UserSessionNameViewModelType, UserSessionNameViewModelProtocol { + var completion: ((UserSessionNameViewModelResult) -> Void)? + + init(sessionInfo: UserSessionInfo) { + super.init(initialViewState: UserSessionNameViewState(bindings: .init(sessionName: sessionInfo.name ?? ""), + currentName: sessionInfo.name ?? "")) + } + + // MARK: - Public + + override func process(viewAction: UserSessionNameViewAction) { + switch viewAction { + case .done: + completion?(.updateName(state.bindings.sessionName)) + case .cancel: + completion?(.cancel) + case .learnMore: + #warning("To be implemented as part of PSG-714.") + } + } + + func processError(_ error: NSError?) { + state.bindings.alertInfo = AlertInfo(error: error) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModelProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModelProtocol.swift new file mode 100644 index 000000000..e39f83cf5 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModelProtocol.swift @@ -0,0 +1,26 @@ +// +// 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 + +protocol UserSessionNameViewModelProtocol { + var completion: ((UserSessionNameViewModelResult) -> Void)? { get set } + var context: UserSessionNameViewModelType.Context { get } + + /// Update the view model to show that an error has occurred. + /// - Parameter error: The error to be displayed or `nil` to display a generic alert. + func processError(_ error: NSError?) +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift new file mode 100644 index 000000000..fa78292ea --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct UserSessionName: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + @ObservedObject var viewModel: UserSessionNameViewModel.Context + + var body: some View { + List { + SwiftUI.Section { + TextField(VectorL10n.manageSessionName, text: $viewModel.sessionName) + .autocapitalization(.words) + .listRowBackground(theme.colors.background) + .introspectTextField { + $0.becomeFirstResponder() + $0.clearButtonMode = .whileEditing + } + } header: { + Text(VectorL10n.manageSessionName) + .foregroundColor(theme.colors.secondaryContent) + } footer: { + textFieldFooter + } + } + .background(theme.colors.system.ignoresSafeArea()) + .frame(maxHeight: .infinity) + .listStyle(.grouped) + .listBackgroundColor(theme.colors.system) + .navigationTitle(VectorL10n.manageSessionRename) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + .accentColor(theme.colors.accent) + } + + private var textFieldFooter: some View { + VStack(alignment: .leading, spacing: 16) { + Text(VectorL10n.manageSessionNameHint) + .foregroundColor(theme.colors.secondaryContent) + + InlineTextButton(VectorL10n.manageSessionNameInfo("%@"), + tappableText: VectorL10n.manageSessionNameInfoLink) { + viewModel.send(viewAction: .learnMore) + } + .foregroundColor(theme.colors.secondaryContent) + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button(VectorL10n.cancel) { + viewModel.send(viewAction: .cancel) + } + } + + ToolbarItem(placement: .confirmationAction) { + Button(VectorL10n.done) { + viewModel.send(viewAction: .done) + } + .disabled(!viewModel.viewState.canUpdateName) + } + } +} + +// MARK: - Previews + +struct UserSessionName_Previews: PreviewProvider { + static let stateRenderer = MockUserSessionNameScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light) + .preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark) + .preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift index cc6507213..aa9cf4e95 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift @@ -14,12 +14,14 @@ // limitations under the License. // +import Combine import CommonKit import SwiftUI struct UserSessionOverviewCoordinatorParameters { let session: MXSession let sessionInfo: UserSessionInfo + let sessionsOverviewDataPublisher: CurrentValueSubject } final class UserSessionOverviewCoordinator: Coordinator, Presentable { @@ -42,9 +44,13 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable { self.parameters = parameters let service = UserSessionOverviewService(session: parameters.session, sessionInfo: parameters.sessionInfo) - viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo, service: service) + viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo, + service: service, + sessionsOverviewDataPublisher: parameters.sessionsOverviewDataPublisher) hostingController = VectorHostingController(rootView: UserSessionOverview(viewModel: viewModel.context)) + hostingController.vc_setLargeTitleDisplayMode(.never) + hostingController.vc_removeBackTitle() indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingController) } @@ -55,12 +61,17 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable { MXLog.debug("[UserSessionOverviewCoordinator] did start.") viewModel.completion = { [weak self] result in guard let self = self else { return } + MXLog.debug("[UserSessionOverviewCoordinator] UserSessionOverviewViewModel did complete with result: \(result).") switch result { - case .verifyCurrentSession: - break // TODO: + case let .verifySession(sessionInfo): + self.completion?(.verifySession(sessionInfo)) case let .showSessionDetails(sessionInfo: sessionInfo): self.completion?(.openSessionDetails(sessionInfo: sessionInfo)) + case let .renameSession(sessionInfo): + self.completion?(.renameSession(sessionInfo)) + case let .logoutOfSession(sessionInfo): + self.completion?(.logoutOfSession(sessionInfo)) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift index ebf382ad7..c831a5585 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift @@ -51,7 +51,7 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { session = UserSessionInfo(id: "alice", name: "iOS", deviceType: .mobile, - isVerified: false, + verificationState: .unverified, lastSeenIP: "10.0.0.10", lastSeenTimestamp: nil, applicationName: "Element iOS", @@ -69,14 +69,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { session = UserSessionInfo(id: "1", name: "macOS", deviceType: .desktop, - isVerified: true, + verificationState: .verified, lastSeenIP: "1.0.0.1", lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, deviceModel: nil, - deviceOS: "macOS 12.5.1", + deviceOS: "macOS", lastSeenIPLocation: nil, clientName: "Electron", clientVersion: "20.1.1", @@ -87,14 +87,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { session = UserSessionInfo(id: "1", name: "macOS", deviceType: .desktop, - isVerified: true, + verificationState: .verified, lastSeenIP: "1.0.0.1", lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, deviceModel: nil, - deviceOS: "macOS 12.5.1", + deviceOS: "macOS", lastSeenIPLocation: nil, clientName: "My Mac", clientVersion: "1.0.0", @@ -105,14 +105,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { session = UserSessionInfo(id: "1", name: "macOS", deviceType: .desktop, - isVerified: true, + verificationState: .verified, lastSeenIP: "1.0.0.1", lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, deviceModel: nil, - deviceOS: "macOS 12.5.1", + deviceOS: "macOS", lastSeenIPLocation: nil, clientName: "My Mac", clientVersion: "1.0.0", diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift index 170e2ef2c..857eef371 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift @@ -18,7 +18,6 @@ import Combine import MatrixSDK class UserSessionOverviewService: UserSessionOverviewServiceProtocol { - // MARK: - Members private(set) var pusherEnabledSubject: CurrentValueSubject @@ -29,54 +28,97 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { private let session: MXSession private let sessionInfo: UserSessionInfo private var pusher: MXPusher? + private var localNotificationSettings: [String: Any]? // MARK: - Setup init(session: MXSession, sessionInfo: UserSessionInfo) { self.session = session self.sessionInfo = sessionInfo - self.pusherEnabledSubject = CurrentValueSubject(nil) - self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false) - - checkServerVersions { [weak self] in - self?.checkPusher() + pusherEnabledSubject = CurrentValueSubject(nil) + remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false) + + localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id) + + if let localNotificationSettings = localNotificationSettings, let isSilenced = localNotificationSettings[kMXAccountDataIsSilencedKey] as? Bool { + remotelyTogglingPushersAvailableSubject.send(true) + pusherEnabledSubject.send(!isSilenced) + } + + checkPusher { [weak self] in + guard self?.pusher != nil else { + return + } + + self?.checkServerVersions() } } // MARK: - UserSessionOverviewServiceProtocol func togglePushNotifications() { - guard let pusher = pusher, let enabled = pusher.enabled?.boolValue, self.remotelyTogglingPushersAvailableSubject.value else { + guard let pusher = pusher, let enabled = pusher.enabled?.boolValue else { + updateLocalNotification() return } - - let data = pusher.data.jsonDictionary() as? [String: Any] ?? [:] - - self.session.matrixRestClient.setPusher(pushKey: pusher.pushkey, - kind: MXPusherKind(value: pusher.kind), - appId: pusher.appId, - appDisplayName:pusher.appDisplayName, - deviceDisplayName: pusher.deviceDisplayName, - profileTag: pusher.profileTag ?? "", - lang: pusher.lang, - data: data, - append: false, - enabled: !enabled) { [weak self] response in - guard let self = self else { return } - - switch response { - case .success: - self.checkPusher() - case .failure(let error): - MXLog.warning("[UserSessionOverviewService] togglePushNotifications failed due to error: \(error)") - self.pusherEnabledSubject.send(enabled) - } - } + + toggle(pusher, enabled: !enabled) } // MARK: - Private - private func checkServerVersions(_ completion: @escaping () -> Void) { + private func toggle(_ pusher: MXPusher, enabled: Bool) { + guard remotelyTogglingPushersAvailableSubject.value else { + MXLog.warning("[UserSessionOverviewService] toggle pusher canceled: remotely toggling pushers not available") + return + } + + MXLog.debug("[UserSessionOverviewService] remotely toggling pusher") + let data = pusher.data.jsonDictionary() as? [String: Any] ?? [:] + + session.matrixRestClient.setPusher(pushKey: pusher.pushkey, + kind: MXPusherKind(value: pusher.kind), + appId: pusher.appId, + appDisplayName: pusher.appDisplayName, + deviceDisplayName: pusher.deviceDisplayName, + profileTag: pusher.profileTag ?? "", + lang: pusher.lang, + data: data, + append: false, + enabled: enabled) { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + if let account = MXKAccountManager.shared().activeAccounts.first, account.device?.deviceId == pusher.deviceId { + account.loadCurrentPusher(nil) + } + + self.checkPusher() + case .failure(let error): + MXLog.warning("[UserSessionOverviewService] togglePusher failed due to error: \(error)") + self.pusherEnabledSubject.send(!enabled) + } + } + } + + private func updateLocalNotification() { + guard var localNotificationSettings = localNotificationSettings, let isSilenced = localNotificationSettings[kMXAccountDataIsSilencedKey] as? Bool else { + MXLog.warning("[UserSessionOverviewService] updateLocalNotification canceled: \"\(kMXAccountDataIsSilencedKey)\" notification property not found") + return + } + + localNotificationSettings[kMXAccountDataIsSilencedKey] = !isSilenced + session.setAccountData(localNotificationSettings, forType: MXAccountData.localNotificationSettingsKeyForDevice(withId: sessionInfo.id)) { [weak self] in + self?.localNotificationSettings = localNotificationSettings + self?.pusherEnabledSubject.send(isSilenced) + } failure: { [weak self] error in + MXLog.warning("[UserSessionOverviewService] updateLocalNotification failed due to error: \(String(describing: error))") + self?.pusherEnabledSubject.send(!isSilenced) + } + } + + private func checkServerVersions() { session.supportedMatrixVersions { [weak self] response in switch response { case .success(let versions): @@ -84,11 +126,10 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { case .failure(let error): MXLog.warning("[UserSessionOverviewService] checkServerVersions failed due to error: \(error)") } - completion() } } - private func checkPusher() { + private func checkPusher(_ completion: (() -> Void)? = nil) { session.matrixRestClient.pushers { [weak self] response in switch response { case .success(let pushers): @@ -96,6 +137,7 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { case .failure(let error): MXLog.warning("[UserSessionOverviewService] checkPusher failed due to error: \(error)") } + completion?() } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift index ccd6f63dd..f5447e6cb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift @@ -18,18 +18,16 @@ import Combine import Foundation class MockUserSessionOverviewService: UserSessionOverviewServiceProtocol { - - var pusherEnabledSubject: CurrentValueSubject var remotelyTogglingPushersAvailableSubject: CurrentValueSubject init(pusherEnabled: Bool? = nil, remotelyTogglingPushersAvailable: Bool = true) { - self.pusherEnabledSubject = CurrentValueSubject(pusherEnabled) - self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable) + pusherEnabledSubject = CurrentValueSubject(pusherEnabled) + remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable) } func togglePushNotifications() { - guard let enabled = pusherEnabledSubject.value, self.remotelyTogglingPushersAvailableSubject.value else { + guard let enabled = pusherEnabledSubject.value, remotelyTogglingPushersAvailableSubject.value else { return } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift index a2d0e7807..48d325db4 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift @@ -20,23 +20,22 @@ import XCTest @testable import RiotSwiftUI class UserSessionOverviewViewModelTests: XCTestCase { - var sut: UserSessionOverviewViewModel! - func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() { - sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService()) + let sessionInfo = createUserSessionInfo() + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService()) XCTAssertEqual(sut.state.isPusherEnabled, nil) var modelResult: UserSessionOverviewViewModelResult? sut.completion = { result in modelResult = result } - sut.process(viewAction: .verifyCurrentSession) - XCTAssertEqual(modelResult, .verifyCurrentSession) + sut.process(viewAction: .verifySession) + XCTAssertEqual(modelResult, .verifySession(sessionInfo)) } func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() { let sessionInfo = createUserSessionInfo() - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService()) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService()) XCTAssertEqual(sut.state.isPusherEnabled, nil) var modelResult: UserSessionOverviewViewModelResult? @@ -46,11 +45,11 @@ class UserSessionOverviewViewModelTests: XCTestCase { sut.process(viewAction: .viewSessionDetails) XCTAssertEqual(modelResult, .showSessionDetails(sessionInfo: sessionInfo)) } - + func test_whenViewSessionDetailsProcessed_toggleAvailablePusher() { let sessionInfo = createUserSessionInfo() let service = MockUserSessionOverviewService(pusherEnabled: true) - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) XCTAssertTrue(sut.state.remotelyTogglingPushersAvailable) XCTAssertEqual(sut.state.isPusherEnabled, true) @@ -63,7 +62,7 @@ class UserSessionOverviewViewModelTests: XCTestCase { func test_whenViewSessionDetailsProcessed_toggleNoPusher() { let sessionInfo = createUserSessionInfo() let service = MockUserSessionOverviewService(pusherEnabled: nil) - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) XCTAssertTrue(sut.state.remotelyTogglingPushersAvailable) XCTAssertEqual(sut.state.isPusherEnabled, nil) @@ -76,7 +75,7 @@ class UserSessionOverviewViewModelTests: XCTestCase { func test_whenViewSessionDetailsProcessed_remotelyTogglingPushersNotAvailable() { let sessionInfo = createUserSessionInfo() let service = MockUserSessionOverviewService(pusherEnabled: true, remotelyTogglingPushersAvailable: false) - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) XCTAssertFalse(sut.state.remotelyTogglingPushersAvailable) XCTAssertEqual(sut.state.isPusherEnabled, true) @@ -90,7 +89,7 @@ class UserSessionOverviewViewModelTests: XCTestCase { UserSessionInfo(id: "session", name: "iOS", deviceType: .mobile, - isVerified: false, + verificationState: .unverified, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, applicationName: "Element iOS", diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift index 366da4f71..46377e13e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift @@ -20,19 +20,24 @@ import Foundation enum UserSessionOverviewCoordinatorResult { case openSessionDetails(sessionInfo: UserSessionInfo) + case verifySession(UserSessionInfo) + case renameSession(UserSessionInfo) + case logoutOfSession(UserSessionInfo) } // MARK: View model enum UserSessionOverviewViewModelResult: Equatable { case showSessionDetails(sessionInfo: UserSessionInfo) - case verifyCurrentSession + case verifySession(UserSessionInfo) + case renameSession(UserSessionInfo) + case logoutOfSession(UserSessionInfo) } // MARK: View struct UserSessionOverviewViewState: BindableState { - let cardViewData: UserSessionCardViewData + var cardViewData: UserSessionCardViewData let isCurrentSession: Bool var isPusherEnabled: Bool? var remotelyTogglingPushersAvailable: Bool @@ -40,7 +45,9 @@ struct UserSessionOverviewViewState: BindableState { } enum UserSessionOverviewViewAction { - case verifyCurrentSession + case verifySession case viewSessionDetails case togglePushNotifications + case renameSession + case logoutOfSession } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift index a5c8f7d99..35b9a97eb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import SwiftUI typealias UserSessionOverviewViewModelType = StateStoreViewModel @@ -26,7 +27,13 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio // MARK: - Setup - init(sessionInfo: UserSessionInfo, service: UserSessionOverviewServiceProtocol) { + init(sessionInfo: UserSessionInfo, + service: UserSessionOverviewServiceProtocol, + sessionsOverviewDataPublisher: CurrentValueSubject = .init(.init(currentSession: nil, + unverifiedSessions: [], + inactiveSessions: [], + otherSessions: [], + linkDeviceEnabled: false))) { self.sessionInfo = sessionInfo self.service = service @@ -39,6 +46,21 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio super.init(initialViewState: state) startObservingService() + + sessionsOverviewDataPublisher.sink { [weak self] overviewData in + guard let self = self else { return } + + var updatedInfo: UserSessionInfo? + if let currentSession = overviewData.currentSession, currentSession.id == sessionInfo.id { + updatedInfo = currentSession + } else if let otherSession = overviewData.otherSessions.first(where: { $0.id == sessionInfo.id }) { + updatedInfo = otherSession + } + + guard let updatedInfo = updatedInfo else { return } + self.state.cardViewData = UserSessionCardViewData(sessionInfo: updatedInfo) + } + .store(in: &cancellables) } private func startObservingService() { @@ -62,13 +84,17 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio override func process(viewAction: UserSessionOverviewViewAction) { switch viewAction { - case .verifyCurrentSession: - completion?(.verifyCurrentSession) + case .verifySession: + completion?(.verifySession(sessionInfo)) case .viewSessionDetails: completion?(.showSessionDetails(sessionInfo: sessionInfo)) case .togglePushNotifications: - self.state.showLoadingIndicator = true + state.showLoadingIndicator = true service.togglePushNotifications() + case .renameSession: + completion?(.renameSession(sessionInfo)) + case .logoutOfSession: + completion?(.logoutOfSession(sessionInfo)) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift index b2170f0d2..884825f2e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift @@ -24,16 +24,18 @@ struct UserSessionOverview: View { var body: some View { ScrollView { UserSessionCardView(viewData: viewModel.viewState.cardViewData, onVerifyAction: { _ in - viewModel.send(viewAction: .verifyCurrentSession) + viewModel.send(viewAction: .verifySession) }, onViewDetailsAction: { _ in viewModel.send(viewAction: .viewSessionDetails) }) .padding(16) SwiftUI.Section { - UserSessionOverviewDisclosureCell(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, onBackgroundTap: { + UserSessionOverviewItem(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, + showsChevron: true) { viewModel.send(viewAction: .viewSessionDetails) - }) + } + if let enabled = viewModel.viewState.isPusherEnabled { UserSessionOverviewToggleCell(title: VectorL10n.userSessionPushNotifications, message: VectorL10n.userSessionPushNotificationsMessage, @@ -42,6 +44,14 @@ struct UserSessionOverview: View { } } } + + SwiftUI.Section { + UserSessionOverviewItem(title: VectorL10n.manageSessionSignOut, + alignment: .center, + isDestructive: true) { + viewModel.send(viewAction: .logoutOfSession) + } + } } .background(theme.colors.system.ignoresSafeArea()) .frame(maxHeight: .infinity) @@ -49,6 +59,22 @@ struct UserSessionOverview: View { .navigationTitle(viewModel.viewState.isCurrentSession ? VectorL10n.userSessionOverviewCurrentSessionTitle : VectorL10n.userSessionOverviewSessionTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { viewModel.send(viewAction: .renameSession) } label: { + Label(VectorL10n.manageSessionRename, systemImage: "pencil") + } + } label: { + Image(systemName: "ellipsis") + .padding(.horizontal, 4) + .padding(.vertical, 12) + } + .offset(x: 4) // Re-align the symbol after applying padding. + } + } + .accentColor(theme.colors.accent) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewDisclosureCell.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift similarity index 55% rename from RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewDisclosureCell.swift rename to RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift index 6fa8b00ca..b54d23a99 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewDisclosureCell.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift @@ -16,10 +16,13 @@ import SwiftUI -struct UserSessionOverviewDisclosureCell: View { +struct UserSessionOverviewItem: View { @Environment(\.theme) private var theme: ThemeSwiftUI let title: String + var alignment: Alignment = .leading + var showsChevron = false + var isDestructive = false var onBackgroundTap: (() -> Void)? var body: some View { @@ -29,9 +32,12 @@ struct UserSessionOverviewDisclosureCell: View { HStack { Text(title) .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - .frame(maxWidth: .infinity, alignment: .leading) - Image(Asset.Images.chevron.name) + .foregroundColor(textColor) + .frame(maxWidth: .infinity, alignment: alignment) + + if showsChevron { + Image(Asset.Images.chevron.name) + } } .padding(.vertical, 15) .padding(.horizontal, 16) @@ -40,17 +46,27 @@ struct UserSessionOverviewDisclosureCell: View { .background(theme.colors.background) } } + + var textColor: Color { + isDestructive ? theme.colors.alert : theme.colors.primaryContent + } } -struct UserSessionOverviewDisclosureCell_Previews: PreviewProvider { +struct UserSessionOverviewItem_Previews: PreviewProvider { + static var buttons: some View { + NavigationView { + ScrollView { + UserSessionOverviewItem(title: "Nav item", showsChevron: true) + UserSessionOverviewItem(title: "Button") + UserSessionOverviewItem(title: "Button", isDestructive: true) + } + } + } + static var previews: some View { Group { - UserSessionOverviewDisclosureCell(title: "Title") - .theme(.light) - .preferredColorScheme(.light) - UserSessionOverviewDisclosureCell(title: "Title") - .theme(.dark) - .preferredColorScheme(.dark) + buttons.theme(.light).preferredColorScheme(.light) + buttons.theme(.dark).preferredColorScheme(.dark) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 1a72a45c6..790d3c5dc 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -19,6 +19,7 @@ import SwiftUI struct UserSessionsOverviewCoordinatorParameters { let session: MXSession + let service: UserSessionsOverviewService } final class UserSessionsOverviewCoordinator: Coordinator, Presentable { @@ -36,11 +37,14 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { init(parameters: UserSessionsOverviewCoordinatorParameters) { self.parameters = parameters + service = parameters.service + + viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: parameters.service) - let dataProvider = UserSessionsDataProvider(session: parameters.session) - service = UserSessionsOverviewService(dataProvider: dataProvider) - viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context)) + hostingViewController.vc_setLargeTitleDisplayMode(.never) + hostingViewController.vc_removeBackTitle() + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController) } @@ -53,18 +57,20 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).") switch result { - case .showAllUnverifiedSessions: - self.showAllUnverifiedSessions() - case .showAllInactiveSessions: - self.showAllInactiveSessions() + case let .showOtherSessions(sessionInfos: sessionInfos, filter: filter): + self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter) case .verifyCurrentSession: - self.startVerifyCurrentSession() + self.completion?(.verifyCurrentSession) + case .renameSession(let sessionInfo): + self.completion?(.renameSession(sessionInfo)) + case .logoutOfSession(let sessionInfo): + self.completion?(.logoutOfSession(sessionInfo)) case let .showCurrentSessionOverview(sessionInfo): self.showCurrentSessionOverview(sessionInfo: sessionInfo) - case .showAllOtherSessions: - self.showAllOtherSessions() case let .showUserSessionOverview(sessionInfo): self.showUserSessionOverview(sessionInfo: sessionInfo) + case .linkDevice: + self.completion?(.linkDevice) } } } @@ -88,12 +94,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { loadingIndicator = nil } - private func showAllUnverifiedSessions() { - // TODO: - } - - private func showAllInactiveSessions() { - // TODO: + private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: UserOtherSessionsFilter) { + completion?(.openOtherSessions(sessionInfos: sessionInfos, filter: filter)) } private func startVerifyCurrentSession() { @@ -103,12 +105,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { private func showCurrentSessionOverview(sessionInfo: UserSessionInfo) { completion?(.openSessionOverview(sessionInfo: sessionInfo)) } - + private func showUserSessionOverview(sessionInfo: UserSessionInfo) { completion?(.openSessionOverview(sessionInfo: sessionInfo)) } - - private func showAllOtherSessions() { - // TODO: - } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift index a8d36cc4e..1028dd3cb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift @@ -44,7 +44,23 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol { session.crypto.device(withDeviceId: deviceId, ofUser: userId) } + func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState { + guard let deviceInfo = deviceInfo else { return .unknown } + + guard session.crypto?.crossSigning.canCrossSign == true else { + return deviceInfo.deviceId == session.myDeviceId ? .unverified : .unknown + } + + return deviceInfo.trustLevel.isVerified ? .verified : .unverified + } + func accountData(for eventType: String) -> [AnyHashable: Any]? { session.accountData.accountData(forEventType: eventType) } + + func qrLoginAvailable() async throws -> Bool { + let service = QRLoginService(client: session.matrixRestClient, + mode: .authenticated) + return try await service.isServiceAvailable() + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift index e97310a40..ab56d5b8c 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift @@ -28,5 +28,9 @@ protocol UserSessionsDataProviderProtocol { func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? + func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState + func accountData(for eventType: String) -> [AnyHashable: Any]? + + func qrLoginAvailable() async throws -> Bool } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 273072ea7..0523ad75f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -14,7 +14,7 @@ // limitations under the License. // -import Foundation +import Combine import MatrixSDK class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { @@ -22,18 +22,22 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400 private let dataProvider: UserSessionsDataProviderProtocol + private var cancellables: Set = [] - private(set) var overviewData: UserSessionsOverviewData + private(set) var overviewDataPublisher: CurrentValueSubject + private(set) var sessionInfos: [UserSessionInfo] init(dataProvider: UserSessionsDataProviderProtocol) { self.dataProvider = dataProvider - overviewData = UserSessionsOverviewData(currentSession: nil, - unverifiedSessions: [], - inactiveSessions: [], - otherSessions: []) - + overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil, + unverifiedSessions: [], + inactiveSessions: [], + otherSessions: [], + linkDeviceEnabled: false)) + sessionInfos = [] setupInitialOverviewData() + listenForSessionUpdates() } // MARK: - Public @@ -42,8 +46,14 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { dataProvider.devices { response in switch response { case .success(let devices): - self.overviewData = self.sessionsOverviewData(from: devices) - completion(.success(self.overviewData)) + self.sessionInfos = self.sortedSessionInfos(from: devices) + Task { @MainActor in + let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable() + let overviewData = self.sessionsOverviewData(from: self.sessionInfos, + linkDeviceEnabled: linkDeviceEnabled ?? false) + self.overviewDataPublisher.send(overviewData) + completion(.success(overviewData)) + } case .failure(let error): completion(.failure(error)) } @@ -51,24 +61,43 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { } func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? { - if overviewData.currentSession?.id == sessionId { - return overviewData.currentSession + if currentSession?.id == sessionId { + return currentSession } - return overviewData.otherSessions.first(where: { $0.id == sessionId }) + return otherSessions.first(where: { $0.id == sessionId }) } - + // MARK: - Private + private func listenForSessionUpdates() { + NotificationCenter.default.publisher(for: .MXDeviceInfoTrustLevelDidChange) + .sink { [weak self] _ in + self?.updateOverviewData { _ in } + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: .MXDeviceListDidUpdateUsersDevices) + .sink { [weak self] _ in + self?.updateOverviewData { _ in } + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: .MXCrossSigningInfoTrustLevelDidChange) + .sink { [weak self] _ in + self?.updateOverviewData { _ in } + } + .store(in: &cancellables) + } + private func setupInitialOverviewData() { guard let currentSessionInfo = getCurrentSessionInfo() else { return } - overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo, - unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo], - inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo], - otherSessions: []) + overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: currentSessionInfo, + unverifiedSessions: currentSessionInfo.verificationState == .verified ? [] : [currentSessionInfo], + inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo], + otherSessions: [], + linkDeviceEnabled: false)) } private func getCurrentSessionInfo() -> UserSessionInfo? { @@ -78,20 +107,25 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { } return sessionInfo(from: device, isCurrentSession: true) } - - private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData { - let allSessions = devices + + private func sortedSessionInfos(from devices: [MXDevice]) -> [UserSessionInfo] { + devices .sorted { $0.lastSeenTs > $1.lastSeenTs } .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) } - - return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, - unverifiedSessions: allSessions.filter { !$0.isVerified }, - inactiveSessions: allSessions.filter { !$0.isActive }, - otherSessions: allSessions.filter { !$0.isCurrent }) + } + + private func sessionsOverviewData(from allSessions: [UserSessionInfo], + linkDeviceEnabled: Bool) -> UserSessionsOverviewData { + UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, + unverifiedSessions: allSessions.filter { $0.verificationState == .unverified && !$0.isCurrent }, + inactiveSessions: allSessions.filter { !$0.isActive }, + otherSessions: allSessions.filter { !$0.isCurrent }, + linkDeviceEnabled: linkDeviceEnabled) } private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo { - let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false + let deviceInfo = deviceInfo(for: device.deviceId) + let verificationState = dataProvider.verificationState(for: deviceInfo) let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId let appData = dataProvider.accountData(for: eventType) @@ -110,7 +144,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { return UserSessionInfo(withDevice: device, applicationData: appData as? [String: String], userAgent: userAgent, - isSessionVerified: isSessionVerified, + verificationState: verificationState, isActive: isSessionActive, isCurrent: isCurrentSession) } @@ -128,13 +162,13 @@ extension UserSessionInfo { init(withDevice device: MXDevice, applicationData: [String: String]?, userAgent: UserAgent?, - isSessionVerified: Bool, + verificationState: VerificationState, isActive: Bool, isCurrent: Bool) { self.init(id: device.deviceId, name: device.displayName, deviceType: userAgent?.deviceType ?? .unknown, - isVerified: isSessionVerified, + verificationState: verificationState, lastSeenIP: device.lastSeenIp, lastSeenTimestamp: device.lastSeenTs > 0 ? TimeInterval(device.lastSeenTs / 1000) : nil, applicationName: applicationData?["name"], diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index a37607786..a578157f2 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -14,7 +14,7 @@ // limitations under the License. // -import Foundation +import Combine class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { enum Mode { @@ -27,15 +27,17 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { private let mode: Mode - var overviewData: UserSessionsOverviewData + var overviewDataPublisher: CurrentValueSubject + var sessionInfos = [UserSessionInfo]() init(mode: Mode = .currentSessionUnverified) { self.mode = mode - overviewData = UserSessionsOverviewData(currentSession: nil, - unverifiedSessions: [], - inactiveSessions: [], - otherSessions: []) + overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil, + unverifiedSessions: [], + inactiveSessions: [], + otherSessions: [], + linkDeviceEnabled: false)) } func updateOverviewData(completion: @escaping (Result) -> Void) { @@ -44,43 +46,47 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { switch mode { case .noOtherSessions: - overviewData = UserSessionsOverviewData(currentSession: currentSession, - unverifiedSessions: [], - inactiveSessions: [], - otherSessions: []) + overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession, + unverifiedSessions: [], + inactiveSessions: [], + otherSessions: [], + linkDeviceEnabled: false)) case .onlyUnverifiedSessions: - overviewData = UserSessionsOverviewData(currentSession: currentSession, - unverifiedSessions: unverifiedSessions + [currentSession], - inactiveSessions: [], - otherSessions: unverifiedSessions) + overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession, + unverifiedSessions: unverifiedSessions + [mockCurrentSession], + inactiveSessions: [], + otherSessions: unverifiedSessions, + linkDeviceEnabled: false)) case .onlyInactiveSessions: - overviewData = UserSessionsOverviewData(currentSession: currentSession, - unverifiedSessions: [], - inactiveSessions: inactiveSessions, - otherSessions: inactiveSessions) + overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession, + unverifiedSessions: [], + inactiveSessions: inactiveSessions, + otherSessions: inactiveSessions, + linkDeviceEnabled: false)) default: let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true) - overviewData = UserSessionsOverviewData(currentSession: currentSession, - unverifiedSessions: unverifiedSessions, - inactiveSessions: inactiveSessions, - otherSessions: otherSessions) + overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession, + unverifiedSessions: unverifiedSessions, + inactiveSessions: inactiveSessions, + otherSessions: otherSessions, + linkDeviceEnabled: true)) } - completion(.success(overviewData)) + completion(.success(overviewDataPublisher.value)) } func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? { - overviewData.otherSessions.first { $0.id == sessionId } + otherSessions.first { $0.id == sessionId } } - + // MARK: - Private - private var currentSession: UserSessionInfo { + private var mockCurrentSession: UserSessionInfo { UserSessionInfo(id: "alice", name: "iOS", deviceType: .mobile, - isVerified: mode == .currentSessionVerified, + verificationState: mode == .currentSessionVerified ? .verified : .unverified, lastSeenIP: "10.0.0.10", lastSeenTimestamp: nil, applicationName: "Element iOS", @@ -99,14 +105,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { [UserSessionInfo(id: "1 verified: \(verified) active: \(active)", name: "macOS verified: \(verified) active: \(active)", deviceType: .desktop, - isVerified: verified, + verificationState: verified ? .verified : .unverified, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, deviceModel: nil, - deviceOS: "macOS 12.5.1", + deviceOS: "macOS", lastSeenIPLocation: nil, clientName: "Electron", clientVersion: "20.0.0", @@ -115,14 +121,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { UserSessionInfo(id: "2 verified: \(verified) active: \(active)", name: "Firefox on Windows verified: \(verified) active: \(active)", deviceType: .web, - isVerified: verified, + verificationState: verified ? .verified : .unverified, lastSeenIP: "2.0.0.2", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, applicationName: "Element Web", applicationVersion: "1.0.0", applicationURL: nil, deviceModel: nil, - deviceOS: "Windows 10", + deviceOS: "Windows", lastSeenIPLocation: nil, clientName: "Firefox", clientVersion: "39.0", @@ -131,7 +137,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { UserSessionInfo(id: "3 verified: \(verified) active: \(active)", name: "Android verified: \(verified) active: \(active)", deviceType: .mobile, - isVerified: verified, + verificationState: verified ? .verified : .unverified, lastSeenIP: "3.0.0.3", lastSeenTimestamp: Date().timeIntervalSince1970 - 10, applicationName: "Element Android", diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift index b1bc5f001..b5224d3ce 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift @@ -14,19 +14,34 @@ // limitations under the License. // -import Foundation +import Combine struct UserSessionsOverviewData { let currentSession: UserSessionInfo? let unverifiedSessions: [UserSessionInfo] let inactiveSessions: [UserSessionInfo] let otherSessions: [UserSessionInfo] + let linkDeviceEnabled: Bool } protocol UserSessionsOverviewServiceProtocol { - var overviewData: UserSessionsOverviewData { get } + var overviewDataPublisher: CurrentValueSubject { get } + var sessionInfos: [UserSessionInfo] { get } func updateOverviewData(completion: @escaping (Result) -> Void) -> Void func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? } + +extension UserSessionsOverviewServiceProtocol { + /// The user's current session. + var currentSession: UserSessionInfo? { overviewDataPublisher.value.currentSession } + /// Any unverified sessions on the user's account. + var unverifiedSessions: [UserSessionInfo] { overviewDataPublisher.value.unverifiedSessions } + /// Any inactive sessions on the user's account (not seen for a while). + var inactiveSessions: [UserSessionInfo] { overviewDataPublisher.value.inactiveSessions } + /// Any sessions that are verified and have been seen recently. + var otherSessions: [UserSessionInfo] { overviewDataPublisher.value.otherSessions } + /// Whether it is possible to link a new device via a QR code. + var linkDeviceEnabled: Bool { overviewDataPublisher.value.linkDeviceEnabled } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift index f05203cb7..1be446b4b 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift @@ -23,6 +23,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) + + verifyLinkDeviceButtonStatus(true) } func testCurrentSessionVerified() { @@ -30,6 +32,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) + + verifyLinkDeviceButtonStatus(true) } func testOnlyUnverifiedSessions() { @@ -37,6 +41,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) } func testOnlyInactiveSessions() { @@ -44,6 +50,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) } func testNoOtherSessions() { @@ -51,5 +59,32 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) + } + + func verifyLinkDeviceButtonStatus(_ enabled: Bool) { +// if enabled { +// let linkDeviceButton = app.buttons["linkDeviceButton"] +// XCTAssertTrue(linkDeviceButton.exists) +// XCTAssertTrue(linkDeviceButton.isEnabled) +// } else { +// let linkDeviceButton = app.buttons["linkDeviceButton"] +// XCTAssertFalse(linkDeviceButton.exists) +// } + } + + func testWhenMoreThan5OtherSessionsThenViewAllButtonVisible() { + app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionUnverified.title) + app.swipeUp() + + XCTAssertTrue(app.buttons["ViewAllButton"].exists) + } + + func testWhenLessThan5OtherSessionsThenViewAllButtonHidden() { + app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyUnverifiedSessions.title) + app.swipeUp() + + XCTAssertFalse(app.buttons["ViewAllButton"].exists) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionListItemViewDataFactoryTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionListItemViewDataFactoryTests.swift new file mode 100644 index 000000000..21b2e584d --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionListItemViewDataFactoryTests.swift @@ -0,0 +1,114 @@ +// +// Copyright 2022 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 UserSessionListItemViewDataFactoryTests: XCTestCase { + let factory = UserSessionListItemViewDataFactory() + + func testSessionDetailsWithTimestamp() { + // Given other devices in each of the verification states. + let sessionInfoVerified = UserSessionInfo.mockPhone(verificationState: .verified) + let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified) + let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown) + + // When getting session details for each of them. + let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails + let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails + let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails + + // Then the details should be formatted correctly. + let lastActivityString = UserSessionLastActivityFormatter.lastActivityDateString(from: sessionInfoVerified.lastSeenTimestamp!) + XCTAssertEqual(sessionDetailsVerified, + VectorL10n.userSessionItemDetails(VectorL10n.userSessionVerifiedShort, VectorL10n.userSessionItemDetailsLastActivity(lastActivityString)), + "The details should show as verified with a last activity string when verified.") + XCTAssertEqual(sessionDetailsUnverified, + VectorL10n.userSessionItemDetails(VectorL10n.userSessionUnverifiedShort, VectorL10n.userSessionItemDetailsLastActivity(lastActivityString)), + "The details should show as unverified with a last activity string when unverified.") + XCTAssertEqual(sessionDetailsUnknown, + VectorL10n.userSessionItemDetailsLastActivity(lastActivityString), + "The details should only show the last activity string when verification is unknown.") + } + + func testSessionDetailsVerifiedWithoutTimestamp() { + // Given a verified other device + let sessionInfoVerified = UserSessionInfo.mockPhone(hasTimestamp: false) + let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified, hasTimestamp: false) + let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown, hasTimestamp: false) + + // When getting session details + let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails + let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails + let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails + + // Then the details should contain the verification state and a last seen date. + XCTAssertEqual(sessionDetailsVerified, VectorL10n.userSessionVerifiedShort, + "The details should only show the verification state when no timestamp exists.") + XCTAssertEqual(sessionDetailsUnverified, VectorL10n.userSessionUnverifiedShort, + "The details should only show the verification state when no timestamp exists.") + XCTAssertEqual(sessionDetailsUnknown, VectorL10n.userSessionVerificationUnknownShort, + "The details should only show the verification state when no timestamp exists.") + } + + func testCurrentSessionDetailsWithTimestamp() { + // Given other devices in each of the verification states. + let sessionInfoVerified = UserSessionInfo.mockPhone(verificationState: .verified, isCurrent: true) + let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified, isCurrent: true) + let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown, isCurrent: true) + + // When getting session details for each of them. + let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails + let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails + let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails + + // Then the details should be formatted correctly. + XCTAssertEqual(sessionDetailsVerified, + VectorL10n.userSessionItemDetails(VectorL10n.userSessionVerifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails), + "The details should show as verified with a current session string when verified.") + XCTAssertEqual(sessionDetailsUnverified, + VectorL10n.userSessionItemDetails(VectorL10n.userSessionUnverifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails), + "The details should show as unverified with a current session string when unverified.") + XCTAssertEqual(sessionDetailsUnknown, + VectorL10n.userOtherSessionCurrentSessionDetails, + "The details should only show the current session string when verification is unknown.") + } + + func testCurrentSessionDetailsVerifiedWithoutTimestamp() { + // Given a verified other device + let sessionInfoVerified = UserSessionInfo.mockPhone(hasTimestamp: false, isCurrent: true) + let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified, hasTimestamp: false, isCurrent: true) + let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown, hasTimestamp: false, isCurrent: true) + + // When getting session details + let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails + let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails + let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails + + // Then the details should contain the verification state and a last seen date. + XCTAssertEqual(sessionDetailsVerified, + VectorL10n.userSessionItemDetails(VectorL10n.userSessionVerifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails), + "The details should show as verified with a current session string when verified.") + XCTAssertEqual(sessionDetailsUnverified, + VectorL10n.userSessionItemDetails(VectorL10n.userSessionUnverifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails), + "The details should show as unverified with a current session string when unverified.") + XCTAssertEqual(sessionDetailsUnknown, + VectorL10n.userOtherSessionCurrentSessionDetails, + "The details should only show the current session string when verification is unknown.") + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift index 21389eafd..30baea54d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift @@ -27,6 +27,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty) XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty) XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty) + XCTAssertFalse(viewModel.state.linkDeviceButtonVisible) } func testLoadOnDidAppear() { @@ -37,6 +38,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty) XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty) XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty) + XCTAssertTrue(viewModel.state.linkDeviceButtonVisible) } func testSimpleActionProcessing() { @@ -49,15 +51,18 @@ class UserSessionsOverviewViewModelTests: XCTestCase { viewModel.process(viewAction: .verifyCurrentSession) XCTAssertEqual(result, .verifyCurrentSession) - - viewModel.process(viewAction: .viewAllUnverifiedSessions) - XCTAssertEqual(result, .showAllUnverifiedSessions) + result = nil viewModel.process(viewAction: .viewAllInactiveSessions) - XCTAssertEqual(result, .showAllInactiveSessions) - + XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive)) + + result = nil viewModel.process(viewAction: .viewAllOtherSessions) - XCTAssertEqual(result, .showAllOtherSessions) + XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .all)) + + result = nil + viewModel.process(viewAction: .linkDevice) + XCTAssertEqual(result, .linkDevice) } func testShowSessionDetails() { @@ -71,20 +76,20 @@ class UserSessionsOverviewViewModelTests: XCTestCase { result = action } - guard let currentSession = service.overviewData.currentSession else { + guard let currentSession = service.currentSession else { XCTFail("The current session should be valid at this point") return } viewModel.process(viewAction: .viewCurrentSessionDetails) - XCTAssertEqual(result, .showCurrentSessionOverview(session: currentSession)) + XCTAssertEqual(result, .showCurrentSessionOverview(sessionInfo: currentSession)) - guard let randomSession = service.overviewData.otherSessions.randomElement() else { + guard let randomSession = service.otherSessions.randomElement() else { XCTFail("There should be other sessions") return } viewModel.process(viewAction: .tapUserSession(randomSession.id)) - XCTAssertEqual(result, .showUserSessionOverview(session: randomSession)) + XCTAssertEqual(result, .showUserSessionOverview(sessionInfo: randomSession)) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index 812554a3c..0abbb93cd 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -19,18 +19,24 @@ import Foundation // MARK: - Coordinator enum UserSessionsOverviewCoordinatorResult { + case verifyCurrentSession + case renameSession(UserSessionInfo) + case logoutOfSession(UserSessionInfo) case openSessionOverview(sessionInfo: UserSessionInfo) + case openOtherSessions(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter) + case linkDevice } // MARK: View model enum UserSessionsOverviewViewModelResult: Equatable { - case showAllUnverifiedSessions - case showAllInactiveSessions + case showOtherSessions(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter) case verifyCurrentSession - case showCurrentSessionOverview(session: UserSessionInfo) - case showAllOtherSessions - case showUserSessionOverview(session: UserSessionInfo) + case renameSession(UserSessionInfo) + case logoutOfSession(UserSessionInfo) + case showCurrentSessionOverview(sessionInfo: UserSessionInfo) + case showUserSessionOverview(sessionInfo: UserSessionInfo) + case linkDevice } // MARK: View @@ -45,14 +51,19 @@ struct UserSessionsOverviewViewState: BindableState { var otherSessionsViewData = [UserSessionListItemViewData]() var showLoadingIndicator = false + + var linkDeviceButtonVisible = false } enum UserSessionsOverviewViewAction { case viewAppeared case verifyCurrentSession + case renameCurrentSession + case logoutOfCurrentSession case viewCurrentSessionDetails case viewAllUnverifiedSessions case viewAllInactiveSessions case viewAllOtherSessions case tapUserSession(_ sessionId: String) + case linkDevice } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index c44c2b6fa..7aeae122b 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -20,7 +20,7 @@ typealias UserSessionsOverviewViewModelType = StateStoreViewModel Void)? init(userSessionsOverviewService: UserSessionsOverviewServiceProtocol) { @@ -28,7 +28,12 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess super.init(initialViewState: .init()) - updateViewState(with: userSessionsOverviewService.overviewData) + userSessionsOverviewService.overviewDataPublisher.sink { [weak self] overviewData in + self?.updateViewState(with: overviewData) + } + .store(in: &cancellables) + + updateViewState(with: userSessionsOverviewService.overviewDataPublisher.value) } // MARK: - Public @@ -39,24 +44,38 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess loadData() case .verifyCurrentSession: completion?(.verifyCurrentSession) - case .viewCurrentSessionDetails: - guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else { + case .renameCurrentSession: + guard let currentSessionInfo = userSessionsOverviewService.currentSession else { assertionFailure("Missing current session") return } - completion?(.showCurrentSessionOverview(session: currentSessionInfo)) + completion?(.renameSession(currentSessionInfo)) + case .logoutOfCurrentSession: + guard let currentSessionInfo = userSessionsOverviewService.currentSession else { + assertionFailure("Missing current session") + return + } + completion?(.logoutOfSession(currentSessionInfo)) + case .viewCurrentSessionDetails: + guard let currentSessionInfo = userSessionsOverviewService.currentSession else { + assertionFailure("Missing current session") + return + } + completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo)) case .viewAllUnverifiedSessions: - completion?(.showAllUnverifiedSessions) + showSessions(filteredBy: .unverified) case .viewAllInactiveSessions: - completion?(.showAllInactiveSessions) + showSessions(filteredBy: .inactive) case .viewAllOtherSessions: - completion?(.showAllOtherSessions) + showSessions(filteredBy: .all) case .tapUserSession(let sessionId): guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else { assertionFailure("Missing session info") return } - completion?(.showUserSessionOverview(session: session)) + completion?(.showUserSessionOverview(sessionInfo: session)) + case .linkDevice: + completion?(.linkDevice) } } @@ -70,31 +89,33 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess if let currentSessionInfo = userSessionsViewData.currentSession { state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo) } + state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled } private func loadData() { state.showLoadingIndicator = true userSessionsOverviewService.updateOverviewData { [weak self] result in - guard let self = self else { - return - } + guard let self = self else { return } self.state.showLoadingIndicator = false - switch result { - case .success(let overViewData): - self.updateViewState(with: overViewData) - case .failure(let error): + if case let .failure(error) = result { // TODO: - break } + + // No need to consume .success as there's a subscription on the data. } } + + private func showSessions(filteredBy filter: UserOtherSessionsFilter) { + completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos, + filter: filter)) + } } -private extension Collection where Element == UserSessionInfo { +extension Collection where Element == UserSessionInfo { func asViewData() -> [UserSessionListItemViewData] { - map { UserSessionListItemViewData(session: $0) } + map { UserSessionListItemViewDataFactory().create(from: $0) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift index 51a698541..0705c8c54 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift @@ -25,54 +25,76 @@ struct UserSessionListItem: View { } @Environment(\.theme) private var theme: ThemeSwiftUI - + let viewData: UserSessionListItemViewData + var isEditModeEnabled = false + var onBackgroundTap: ((String) -> Void)? + var onBackgroundLongPress: ((String) -> Void)? var body: some View { - Button { - onBackgroundTap?(viewData.sessionId) - } label: { - VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) { - HStack(spacing: LayoutConstants.avatarRightMargin) { - DeviceAvatarView(viewData: viewData.deviceAvatarViewData) - VStack(alignment: .leading, spacing: 2) { - Text(viewData.sessionName) - .font(theme.fonts.bodySB) - .foregroundColor(theme.colors.primaryContent) - .multilineTextAlignment(.leading) - - Text(viewData.sessionDetails) - .font(theme.fonts.caption1) - .foregroundColor(theme.colors.secondaryContent) - .multilineTextAlignment(.leading) - } + Button { } label: { + ZStack { + if viewData.isSelected { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(theme.colors.system) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(4) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, LayoutConstants.horizontalPadding) - - // Separator - // Note: Separator leading is matching the text leading, we could use alignment guide in the future - SeparatorLine() - .padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth) + VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) { + HStack(spacing: LayoutConstants.avatarRightMargin) { + if isEditModeEnabled { + Image(viewData.isSelected ? Asset.Images.userSessionListItemSelected.name : Asset.Images.userSessionListItemNotSelected.name) + } + DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: viewData.isSelected) + VStack(alignment: .leading, spacing: 2) { + Text(viewData.sessionName) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + .multilineTextAlignment(.leading) + HStack { + if let sessionDetailsIcon = viewData.sessionDetailsIcon { + Image(sessionDetailsIcon) + .padding(.leading, 2) + } + Text(viewData.sessionDetails) + .font(theme.fonts.caption1) + .foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent) + .multilineTextAlignment(.leading) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, LayoutConstants.horizontalPadding) + + // Separator + // Note: Separator leading is matching the text leading, we could use alignment guide in the future + SeparatorLine() + .padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth) + } + .padding(.top, LayoutConstants.verticalPadding) + }.onTapGesture { + onBackgroundTap?(viewData.sessionId) + } + .onLongPressGesture { + onBackgroundLongPress?(viewData.sessionId) } - .padding(.top, LayoutConstants.verticalPadding) } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("UserSessionListItem_\(viewData.sessionId)") } } struct UserSessionListPreview: View { let userSessionsOverviewService: UserSessionsOverviewServiceProtocol = MockUserSessionsOverviewService() + var isEditModeEnabled = false var body: some View { VStack(alignment: .leading, spacing: 0) { - ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in - let viewData = UserSessionListItemViewData(session: userSessionInfo) - - UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in - + ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in + let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo) + UserSessionListItem(viewData: viewData, isEditModeEnabled: isEditModeEnabled, onBackgroundTap: { _ in }) } } @@ -84,6 +106,8 @@ struct UserSessionListItem_Previews: PreviewProvider { Group { UserSessionListPreview().theme(.light).preferredColorScheme(.light) UserSessionListPreview().theme(.dark).preferredColorScheme(.dark) + UserSessionListPreview(isEditModeEnabled: true).theme(.light).preferredColorScheme(.light) + UserSessionListPreview(isEditModeEnabled: true).theme(.dark).preferredColorScheme(.dark) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift index af13e572b..5122e0895 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -16,60 +16,25 @@ import Foundation +typealias SessionId = String + /// View data for UserSessionListItem -struct UserSessionListItemViewData: Identifiable { +struct UserSessionListItemViewData: Identifiable, Hashable { var id: String { sessionId } - let sessionId: String - + let sessionId: SessionId + let sessionName: String let sessionDetails: String + let highlightSessionDetails: Bool + let deviceAvatarViewData: DeviceAvatarViewData - - init(sessionId: String, - sessionDisplayName: String?, - deviceType: DeviceType, - isVerified: Bool, - lastActivityDate: TimeInterval?) { - self.sessionId = sessionId - sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) - sessionDetails = Self.buildSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate) - deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: isVerified) - } - - // MARK: - Private - - private static func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String { - let sessionDetailsString: String - - let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort - - var lastActivityDateString: String? - - if let lastActivityDate = lastActivityDate { - lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) - } - if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { - sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString) - } else { - sessionDetailsString = sessionStatusText - } - - return sessionDetailsString - } -} - -extension UserSessionListItemViewData { - init(session: UserSessionInfo) { - self.init(sessionId: session.id, - sessionDisplayName: session.name, - deviceType: session.deviceType, - isVerified: session.isVerified, - lastActivityDate: session.lastSeenTimestamp) - } + let sessionDetailsIcon: String? + + let isSelected: Bool } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift new file mode 100644 index 000000000..5486073a7 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -0,0 +1,95 @@ +// +// Copyright 2022 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 + +struct UserSessionListItemViewDataFactory { + func create(from sessionInfo: UserSessionInfo, + highlightSessionDetails: Bool = false, + isSelected: Bool = false) -> UserSessionListItemViewData { + let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType, + sessionDisplayName: sessionInfo.name) + let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo) + let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType, + verificationState: sessionInfo.verificationState) + return UserSessionListItemViewData(sessionId: sessionInfo.id, + sessionName: sessionName, + sessionDetails: sessionDetails, + highlightSessionDetails: highlightSessionDetails, + deviceAvatarViewData: deviceAvatarViewData, + sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive), + isSelected: isSelected) + } + + private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String { + if sessionInfo.isActive { + return activeSessionDetails(sessionInfo: sessionInfo) + } else { + return inactiveSessionDetails(sessionInfo: sessionInfo) + } + } + + private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String { + if let lastActivityDate = sessionInfo.lastSeenTimestamp { + let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) + return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString) + } + return VectorL10n.userInactiveSessionItem + } + + private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String { + // Start by creating the main part of the details string. + var sessionDetailsString = "" + + var lastActivityDateString: String? + if let lastActivityDate = sessionInfo.lastSeenTimestamp { + lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) + } + + if sessionInfo.isCurrent { + sessionDetailsString = VectorL10n.userOtherSessionCurrentSessionDetails + } else if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { + sessionDetailsString = VectorL10n.userSessionItemDetailsLastActivity(lastActivityDateString) + } + + // Prepend the verification state if one is known. + let sessionStatusText: String? + switch sessionInfo.verificationState { + case .verified: + sessionStatusText = VectorL10n.userSessionVerifiedShort + case .unverified: + sessionStatusText = VectorL10n.userSessionUnverifiedShort + case .unknown: + sessionStatusText = nil + } + + if let sessionStatusText = sessionStatusText { + if sessionDetailsString.isEmpty { + sessionDetailsString = sessionStatusText + } else { + sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, sessionDetailsString) + } + } else if sessionDetailsString.isEmpty { + sessionDetailsString = VectorL10n.userSessionVerificationUnknownShort + } + + return sessionDetailsString + } + + private func getSessionDetailsIcon(isActive: Bool) -> String? { + isActive ? nil : Asset.Images.userSessionListItemInactiveSession.name + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift new file mode 100644 index 000000000..e3816314c --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 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 UserSessionsListViewAllView: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + let count: Int + + var onBackgroundTap: (() -> Void)? + + var body: some View { + Button { + onBackgroundTap?() + } label: { + Button(action: { onBackgroundTap?() }) { + VStack(spacing: 0) { + HStack { + Text("View all (\(count))") + .font(theme.fonts.body) + .foregroundColor(theme.colors.accent) + .frame(maxWidth: .infinity, alignment: .leading) + Image(Asset.Images.chevron.name) + } + .padding(.vertical, 15) + .padding(.trailing, 20) + SeparatorLine() + } + .background(theme.colors.background) + .padding(.leading, 72) + } + } + .accessibilityIdentifier("ViewAllButton") + } +} + +struct UserSessionsListViewAllView_Previews: PreviewProvider { + static var previews: some View { + Group { + UserSessionsListViewAllView(count: 8) + .previewLayout(PreviewLayout.sizeThatFits) + .theme(.light) + .preferredColorScheme(.light) + + UserSessionsListViewAllView(count: 8) + .previewLayout(PreviewLayout.sizeThatFits) + .theme(.dark) + .preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index 6823c9253..45d38ee79 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -21,22 +21,36 @@ struct UserSessionsOverview: View { @ObservedObject var viewModel: UserSessionsOverviewViewModel.Context + private let maxOtherSessionsToDisplay = 5 + var body: some View { - ScrollView { - if hasSecurityRecommendations { - securityRecommendationsSection - } - - currentSessionsSection - - if !viewModel.viewState.otherSessionsViewData.isEmpty { - otherSessionsSection + GeometryReader { _ in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + if hasSecurityRecommendations { + securityRecommendationsSection + } + + currentSessionsSection + + if !viewModel.viewState.otherSessionsViewData.isEmpty { + otherSessionsSection + } + } + .readableFrame() + +// if viewModel.viewState.linkDeviceButtonVisible { +// linkDeviceView +// .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) +// } } } .background(theme.colors.system.ignoresSafeArea()) .frame(maxHeight: .infinity) .navigationTitle(VectorL10n.userSessionsOverviewTitle) + .navigationBarTitleDisplayMode(.inline) .activityIndicator(show: viewModel.viewState.showLoadingIndicator) + .accentColor(theme.colors.accent) .onAppear { viewModel.send(viewAction: .viewAppeared) } @@ -91,26 +105,61 @@ struct UserSessionsOverview: View { viewModel.send(viewAction: .viewCurrentSessionDetails) }) } header: { - Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle) - .textCase(.uppercase) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.secondaryContent) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 12.0) - .padding(.top, 24.0) + HStack(alignment: .firstTextBaseline) { + Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle) + .textCase(.uppercase) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 12.0) + .padding(.top, 24.0) + + currentSessionMenu + } } .padding(.horizontal, 16) } } + private var currentSessionMenu: some View { + Menu { + SwiftUI.Section { + Button { viewModel.send(viewAction: .renameCurrentSession) } label: { + Label(VectorL10n.manageSessionRename, systemImage: "pencil") + } + } + + if #available(iOS 15, *) { + Button(role: .destructive) { viewModel.send(viewAction: .logoutOfCurrentSession) } label: { + Label(VectorL10n.signOut, systemImage: "rectangle.portrait.and.arrow.right.fill") + } + } else { + Button { viewModel.send(viewAction: .logoutOfCurrentSession) } label: { + Label(VectorL10n.signOut, systemImage: "rectangle.righthalf.inset.fill.arrow.right") + } + } + } label: { + Image(systemName: "ellipsis") + .foregroundColor(theme.colors.secondaryContent) + .padding(.horizontal, 8) + .padding(.vertical, 12) + } + .offset(x: 8) // Re-align the symbol after applying padding. + } + private var otherSessionsSection: some View { SwiftUI.Section { LazyVStack(spacing: 0) { - ForEach(viewModel.viewState.otherSessionsViewData) { viewData in + ForEach(viewModel.viewState.otherSessionsViewData.prefix(maxOtherSessionsToDisplay)) { viewData in UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in viewModel.send(viewAction: .tapUserSession(sessionId)) }) } + if viewModel.viewState.otherSessionsViewData.count > maxOtherSessionsToDisplay { + UserSessionsListViewAllView(count: viewModel.viewState.otherSessionsViewData.count) { + viewModel.send(viewAction: .viewAllOtherSessions) + } + } } .background(theme.colors.background) } header: { @@ -132,6 +181,23 @@ struct UserSessionsOverview: View { } .accessibilityIdentifier("userSessionsOverviewOtherSection") } + + /// The footer view containing link device button. + var linkDeviceView: some View { + VStack { + Button { + viewModel.send(viewAction: .linkDevice) + } label: { + Text(VectorL10n.userSessionsOverviewLinkDevice) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.top, 28) + .padding(.bottom, 12) + .padding(.horizontal, 16) + .accessibilityIdentifier("linkDeviceButton") + } + .background(theme.colors.system.ignoresSafeArea()) + } } // MARK: - Previews diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index 7c7e349ef..4a99f763c 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -35,6 +35,7 @@ targets: dependencies: - target: DesignKit - package: Mapbox + - package: WysiwygComposer sources: - path: . excludes: @@ -60,9 +61,11 @@ targets: - path: ../Riot/Categories/UISearchBar.swift - path: ../Riot/Categories/UIView.swift - path: ../Riot/Categories/UIApplication.swift + - path: ../Riot/Categories/Codable.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift + - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets @@ -77,10 +80,4 @@ targets: - name: 🧹 SwiftFormat runOnlyWhenInstalling: false shell: /bin/sh - script: | - export PATH="$PATH:/opt/homebrew/bin" - if which swiftformat >/dev/null; then - swiftformat --lint --lenient "$PROJECT_DIR" - else - echo "warning: SwiftFormat not installed, download from https://github.com/nicklockwood/SwiftFormat" - fi + script: "\"${PODS_ROOT}/SwiftFormat/CommandLineTool/swiftformat\" --lint --lenient \"$PROJECT_DIR\"\n" diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index 02d9045be..c4712e3d6 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -35,7 +35,8 @@ targets: dependencies: - target: RiotSwiftUI - + - package: WysiwygComposer + settings: base: TEST_TARGET_NAME: RiotSwiftUI @@ -69,9 +70,11 @@ targets: - path: ../Riot/Categories/UISearchBar.swift - path: ../Riot/Categories/UIView.swift - path: ../Riot/Categories/UIApplication.swift + - path: ../Riot/Categories/Codable.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift + - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift index 3dff46b7b..56c2f751b 100644 --- a/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift +++ b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift @@ -136,6 +136,10 @@ class MockAuthenticationRestClient: AuthenticationRestClient { throw MockError.unhandled } + func generateLoginToken() async throws -> MXLoginToken { + throw MockError.unhandled + } + // MARK: - Registration var registerFallbackURL: URL { @@ -208,4 +212,10 @@ class MockAuthenticationRestClient: AuthenticationRestClient { func resetPassword(parameters: [String : Any]) async throws { throw MockError.unhandled } + + // MARK: Versions + + func supportedMatrixVersions() async throws -> MXMatrixVersions { + return MXMatrixVersions() + } } diff --git a/RiotTests/RendezvousServiceTests.swift b/RiotTests/RendezvousServiceTests.swift new file mode 100644 index 000000000..36297f778 --- /dev/null +++ b/RiotTests/RendezvousServiceTests.swift @@ -0,0 +1,63 @@ +// +// Copyright 2022 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 XCTest +@testable import Element + +@MainActor +class RendezvousServiceTests: XCTestCase { + func testEnd2End() async { + let mockTransport = MockRendezvousTransport() + + let aliceService = RendezvousService(transport: mockTransport) + + guard case .success(let rendezvousDetails) = await aliceService.createRendezvous(), + let alicePublicKey = rendezvousDetails.key else { + XCTFail("Rendezvous creation failed") + return + } + + XCTAssertNotNil(mockTransport.rendezvousURL) + + let bobService = RendezvousService(transport: mockTransport) + + guard case .success = await bobService.joinRendezvous(withPublicKey: alicePublicKey) else { + XCTFail("Bob failed to join") + return + } + + guard case .success = await aliceService.waitForInterlocutor() else { + XCTFail("Alice failed to establish connection") + return + } + + guard let messageData = "Hello from alice".data(using: .utf8) else { + fatalError() + } + + guard case .success = await aliceService.send(data: messageData) else { + XCTFail("Alice failed to send message") + return + } + + guard case .success(let data) = await bobService.receive() else { + XCTFail("Bob failed to receive message") + return + } + + XCTAssertEqual(messageData, data) + } +} diff --git a/RiotTests/String+Element.swift b/RiotTests/String+Element.swift index fdf45f44c..c532e37c0 100644 --- a/RiotTests/String+Element.swift +++ b/RiotTests/String+Element.swift @@ -45,4 +45,10 @@ class String_Element: XCTestCase { let string3 = "ab" XCTAssertEqual(string3.vc_reversed(), "ba") } + + func testNilIfEmpty() { + XCTAssertNil("".vc_nilIfEmpty()) + XCTAssertNotNil(" ".vc_nilIfEmpty()) + XCTAssertNotNil("Johnny was here".vc_nilIfEmpty()) + } } diff --git a/RiotTests/UserAgentParserTests.swift b/RiotTests/UserAgentParserTests.swift index e268deda6..e7704b3de 100644 --- a/RiotTests/UserAgentParserTests.swift +++ b/RiotTests/UserAgentParserTests.swift @@ -122,12 +122,12 @@ class UserAgentParserTests: XCTestCase { let expected = [ UserAgent(deviceType: .desktop, deviceModel: nil, - deviceOS: "macOS 10.15.7", + deviceOS: "macOS", clientName: "Electron", clientVersion: "20.1.1"), UserAgent(deviceType: .desktop, deviceModel: nil, - deviceOS: "Windows NT 10.0", + deviceOS: "Windows", clientName: "Electron", clientVersion: "20.1.1") ] @@ -148,22 +148,22 @@ class UserAgentParserTests: XCTestCase { let expected = [ UserAgent(deviceType: .web, deviceModel: nil, - deviceOS: "macOS 10.15.7", + deviceOS: "macOS", clientName: "Chrome", clientVersion: "104.0.5112.102"), UserAgent(deviceType: .web, deviceModel: nil, - deviceOS: "Windows NT 10.0", + deviceOS: "Windows", clientName: "Chrome", clientVersion: "104.0.5112.102"), UserAgent(deviceType: .web, deviceModel: nil, - deviceOS: "macOS 10.10", + deviceOS: "macOS", clientName: "Firefox", clientVersion: "39.0"), UserAgent(deviceType: .web, deviceModel: nil, - deviceOS: "macOS 10.10.2", + deviceOS: "macOS", clientName: "Safari", clientVersion: "8.0.3"), UserAgent(deviceType: .web, diff --git a/RiotTests/UserSessionsDataProviderTests.swift b/RiotTests/UserSessionsDataProviderTests.swift new file mode 100644 index 000000000..3780dcd65 --- /dev/null +++ b/RiotTests/UserSessionsDataProviderTests.swift @@ -0,0 +1,161 @@ +// +// Copyright 2022 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 XCTest + +@testable import Element + +class UserSessionCardViewDataTests: XCTestCase { + func testOtherSessionsWithCrossSigning() { + // Given a data provider for a session that can cross sign. + let mxSession = MockSession(canCrossSign: true) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + // When the verification state of other sessions is requested. + let deviceA = MockDeviceInfo(deviceID: .otherDeviceA, verified: true) + let deviceB = MockDeviceInfo(deviceID: .otherDeviceB, verified: false) + let verificationStateA = dataProvider.verificationState(for: deviceA) + let verificationStateB = dataProvider.verificationState(for: deviceB) + + // Then they should match the verification state from the device info. + XCTAssertEqual(verificationStateA, .verified) + XCTAssertEqual(verificationStateB, .unverified) + } + + func testOtherSessionsWithoutCrossSigning() { + // Given a data provider for a session that can't cross sign. + let mxSession = MockSession(canCrossSign: false) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + // When the verification state of other sessions is requested. + let deviceA = MockDeviceInfo(deviceID: .otherDeviceA, verified: true) + let deviceB = MockDeviceInfo(deviceID: .otherDeviceB, verified: false) + let verificationStateA = dataProvider.verificationState(for: deviceA) + let verificationStateB = dataProvider.verificationState(for: deviceB) + + // Then they should return an unknown verification state. + XCTAssertEqual(verificationStateA, .unknown) + XCTAssertEqual(verificationStateB, .unknown) + } + + func testCurrentDeviceWithCrossSigning() { + // Given a data provider for a session that can cross sign. + let mxSession = MockSession(canCrossSign: true) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + // When the verification state of the same session is requested. + let currentDeviceVerified = MockDeviceInfo(deviceID: .currentDevice, verified: true) + let currentDeviceUnverified = MockDeviceInfo(deviceID: .currentDevice, verified: false) + let verificationStateVerified = dataProvider.verificationState(for: currentDeviceVerified) + let verificationStateUnverified = dataProvider.verificationState(for: currentDeviceUnverified) + + // Then the verification state should be unknown. + XCTAssertEqual(verificationStateVerified, .verified) + XCTAssertEqual(verificationStateUnverified, .unverified) + } + + func testCurrentDeviceWithoutCrossSigning() { + // Given a data provider for a session that can't cross sign. + let mxSession = MockSession(canCrossSign: false) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + // When the verification state of the same session is requested. + let currentDeviceVerified = MockDeviceInfo(deviceID: .currentDevice, verified: true) + let currentDeviceUnverified = MockDeviceInfo(deviceID: .currentDevice, verified: false) + let verificationStateVerified = dataProvider.verificationState(for: currentDeviceVerified) + let verificationStateUnverified = dataProvider.verificationState(for: currentDeviceUnverified) + + // Then the verification state should be unknown. + XCTAssertEqual(verificationStateVerified, .unverified) + XCTAssertEqual(verificationStateUnverified, .unverified) + } +} + +// MARK: Mocks + +// Device ID constants. +private extension String { + static var otherDeviceA: String { "abcdef" } + static var otherDeviceB: String { "ghijkl" } + static var currentDevice: String { "uvwxyz" } +} + +/// A mock `MXSession` that can override the `canCrossSign` state. +private class MockSession: MXSession { + let canCrossSign: Bool + override var myDeviceId: String! { .currentDevice } + + override var crypto: MXCrypto! { + get { MockCrypto(canCrossSign: canCrossSign) } + set { } + } + + init(canCrossSign: Bool) { + self.canCrossSign = canCrossSign + super.init() + } + +} + +/// A mock `MXCrypto` that can override the `canCrossSign` state. +private class MockCrypto: MXLegacyCrypto { + let canCrossSign: Bool + override var crossSigning: MXCrossSigning { MockCrossSigning(canCrossSign: canCrossSign) } + + init(canCrossSign: Bool) { + self.canCrossSign = canCrossSign + super.init() + } + +} + +/// A mock `MXCrossSigning` with an overridden `canCrossSign` property. +private class MockCrossSigning: MXLegacyCrossSigning { + let canCrossSignMock: Bool + override var canCrossSign: Bool { canCrossSignMock } + + init(canCrossSign: Bool) { + self.canCrossSignMock = canCrossSign + super.init() + } + +} + +/// A mock `MXDeviceInfo` that can override the `isVerified` state. +private class MockDeviceInfo: MXDeviceInfo { + private let verified: Bool + override var trustLevel: MXDeviceTrustLevel! { MockDeviceTrustLevel(verified: verified) } + + init(deviceID: String, verified: Bool) { + self.verified = verified + super.init(deviceId: deviceID) + } + + required init?(coder: NSCoder) { fatalError() } +} + +/// A mock `MXDeviceTrustLevel` with an overridden `isVerified` property. +private class MockDeviceTrustLevel: MXDeviceTrustLevel { + private let verified: Bool + override var isVerified: Bool { verified } + + init(verified: Bool) { + self.verified = verified + super.init() + } + + required init?(coder: NSCoder) { fatalError() } +} diff --git a/RiotTests/UserSessionsOverviewServiceTests.swift b/RiotTests/UserSessionsOverviewServiceTests.swift index d52ca31d9..27fe29cc6 100644 --- a/RiotTests/UserSessionsOverviewServiceTests.swift +++ b/RiotTests/UserSessionsOverviewServiceTests.swift @@ -27,72 +27,86 @@ class UserSessionsOverviewServiceTests: XCTestCase { let dataProvider = MockUserSessionsDataProvider(mode: .currentSessionUnverified) let service = UserSessionsOverviewService(dataProvider: dataProvider) - XCTAssertNotNil(service.overviewData.currentSession) - XCTAssertFalse(service.overviewData.currentSession?.isVerified ?? false) - XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) - XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) - XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) + XCTAssertNotNil(service.currentSession) + XCTAssertEqual(service.currentSession?.verificationState, .unverified) + XCTAssertTrue(service.currentSession?.isActive ?? false) + XCTAssertFalse(service.unverifiedSessions.isEmpty) + XCTAssertTrue(service.inactiveSessions.isEmpty) + XCTAssertFalse(service.linkDeviceEnabled) - XCTAssertEqual(service.sessionForIdentifier(currentDeviceId), service.overviewData.currentSession) + XCTAssertEqual(service.sessionForIdentifier(currentDeviceId), service.currentSession) } func testInitialSessionVerified() { let dataProvider = MockUserSessionsDataProvider(mode: .currentSessionVerified) let service = UserSessionsOverviewService(dataProvider: dataProvider) - XCTAssertNotNil(service.overviewData.currentSession) - XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false) - XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) - XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) - XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) + XCTAssertNotNil(service.currentSession) + XCTAssertEqual(service.currentSession?.verificationState, .verified) + XCTAssertTrue(service.currentSession?.isActive ?? false) + XCTAssertTrue(service.unverifiedSessions.isEmpty) + XCTAssertTrue(service.inactiveSessions.isEmpty) + XCTAssertFalse(service.linkDeviceEnabled) } func testWithAllSessionsVerified() { let service = setupServiceWithMode(.allOtherSessionsValid) - XCTAssertNotNil(service.overviewData.currentSession) - XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false) - XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) + XCTAssertNotNil(service.currentSession) + XCTAssertEqual(service.currentSession?.verificationState, .verified) + XCTAssertTrue(service.currentSession?.isActive ?? false) - XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) - XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) - XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.unverifiedSessions.isEmpty) + XCTAssertTrue(service.inactiveSessions.isEmpty) + XCTAssertFalse(service.otherSessions.isEmpty) + XCTAssertTrue(service.linkDeviceEnabled) + + XCTAssertEqual(service.sessionInfos.count, 2) } func testWithSomeUnverifiedSessions() { let service = setupServiceWithMode(.someUnverifiedSessions) - XCTAssertNotNil(service.overviewData.currentSession) - XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false) - XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) + XCTAssertNotNil(service.currentSession) + XCTAssertEqual(service.currentSession?.verificationState, .verified) + XCTAssertTrue(service.currentSession?.isActive ?? false) - XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) - XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) - XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertFalse(service.unverifiedSessions.isEmpty) + XCTAssertTrue(service.inactiveSessions.isEmpty) + XCTAssertFalse(service.otherSessions.isEmpty) + XCTAssertTrue(service.linkDeviceEnabled) + + XCTAssertEqual(service.sessionInfos.count, 3) } func testWithSomeInactiveSessions() { let service = setupServiceWithMode(.someInactiveSessions) - XCTAssertNotNil(service.overviewData.currentSession) - XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false) - XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) + XCTAssertNotNil(service.currentSession) + XCTAssertEqual(service.currentSession?.verificationState, .verified) + XCTAssertTrue(service.currentSession?.isActive ?? false) - XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) - XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) - XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.unverifiedSessions.isEmpty) + XCTAssertFalse(service.inactiveSessions.isEmpty) + XCTAssertFalse(service.otherSessions.isEmpty) + XCTAssertTrue(service.linkDeviceEnabled) + + XCTAssertEqual(service.sessionInfos.count, 3) } func testWithSomeUnverifiedAndInactiveSessions() { let service = setupServiceWithMode(.someUnverifiedAndInactiveSessions) - XCTAssertNotNil(service.overviewData.currentSession) - XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false) - XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) + XCTAssertNotNil(service.currentSession) + XCTAssertEqual(service.currentSession?.verificationState, .verified) + XCTAssertTrue(service.currentSession?.isActive ?? false) - XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) - XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) - XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertFalse(service.unverifiedSessions.isEmpty) + XCTAssertFalse(service.inactiveSessions.isEmpty) + XCTAssertFalse(service.otherSessions.isEmpty) + XCTAssertTrue(service.linkDeviceEnabled) + + XCTAssertEqual(service.sessionInfos.count, 4) } // MARK: - Private @@ -157,20 +171,37 @@ private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol { func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? { guard deviceId == currentDeviceId else { - return MockDeviceInfo(verified: deviceId != unverifiedDeviceId) + return MockDeviceInfo(deviceID: deviceId, + verified: deviceId != unverifiedDeviceId) } switch mode { case .currentSessionUnverified: - return MockDeviceInfo(verified: false) + return MockDeviceInfo(deviceID: deviceId, verified: false) default: - return MockDeviceInfo(verified: true) + return MockDeviceInfo(deviceID: deviceId, verified: true) } } + func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState { + guard let deviceInfo = deviceInfo else { return .unknown } + + if let currentSession = device(withDeviceId: currentDeviceId, ofUser: currentUserId), + !currentSession.trustLevel.isVerified { + // When the current session is unverified we can't determine verification for other sessions. + return deviceInfo.deviceId == currentDeviceId ? .unverified : .unknown + } + + return deviceInfo.trustLevel.isVerified ? .verified : .unverified + } + func accountData(for eventType: String) -> [AnyHashable : Any]? { [:] } + + func qrLoginAvailable() async throws -> Bool { + true + } // MARK: - Private @@ -235,9 +266,9 @@ private class MockDevice: MXDevice { private class MockDeviceInfo: MXDeviceInfo { private let verified: Bool - init(verified: Bool) { + init(deviceID: String, verified: Bool) { self.verified = verified - super.init() + super.init(deviceId: deviceID) } required init?(coder: NSCoder) { diff --git a/RiotTests/target.yml b/RiotTests/target.yml index 0c042516f..611a0314b 100644 --- a/RiotTests/target.yml +++ b/RiotTests/target.yml @@ -75,3 +75,4 @@ targets: - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - path: ../Riot/Modules/Room/EventMenu/EventMenuBuilder.swift - path: ../Riot/Modules/Room/EventMenu/EventMenuItemType.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift diff --git a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m index 84d9dee63..34ebb66e9 100644 --- a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m +++ b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m @@ -118,7 +118,10 @@ self.selectedRoom = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; // Do not warn for unknown devices. We have cross-signing now - session.crypto.warnOnUnknowDevices = NO; + if ([session.crypto isKindOfClass:[MXLegacyCrypto class]]) + { + ((MXLegacyCrypto *)session.crypto).warnOnUnknowDevices = NO; + } MXWeakify(self); [self.selectedRoom sendTextMessage:intent.content diff --git a/Tools/Templates/README.md b/Tools/Templates/README.md index dc1bb15b6..3f83b42c4 100644 --- a/Tools/Templates/README.md +++ b/Tools/Templates/README.md @@ -33,6 +33,39 @@ To use it (before it becomes an Xcode template): - Import created files in the Xcode project +# SwiftUISimpleScreenTemplate +This is the boilerplate to create a simple SwiftUI screen including view model, screen coordinator, unit and UI tests. + +To create a screen from this template (before it becomes an Xcode template): + +- `./createSwiftUISimpleScreen.sh ScreenFolder MyScreenName` +- Import created files in the Xcode project + +This will create `ScreenFolder` within the `RiotSwiftUI/Modules`. Files inside will be named `MyScreenNameXxx`. + + +# SwiftUISingleScreenTempalte +This is the boilerplate to create a simple SwiftUI screen including view model, screen coordinator, service, unit and UI tests. + +To create a screen from this template (before it becomes an Xcode template): + +- `./createSwiftUISingleScreen.sh ScreenFolder MyScreenName` +- Import created files in the Xcode project + +This will create `ScreenFolder` within the `RiotSwiftUI/Modules`. Files inside will be named `MyScreenNameXxx`. + + +# SwiftUITwoScreenTemplate +This is the boilerplate to create two single SwiftUI screens (including view models, screen coordinators, services, unit and UI tests) and a flow coordinator. + +To create screens from this template (before it becomes an Xcode template): + +- `./createSwiftUITwoScreen.sh TwoScreenFolder MyRootCoordinator FirstScreenName SecondScreenName` +- Import created files in the Xcode project + +This will create `TwoScreenFolder` within the `RiotSwiftUI/Modules`. + + # Usage example Following commands: diff --git a/codecov.yml b/codecov.yml index 64701d167..170733741 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,4 +9,13 @@ coverage: patch: false ignore: - - "Riot/Generated" # ignore the folder and all its contents \ No newline at end of file + - "Riot/Generated" # ignore the folder and all its contents + +flag_management: + default_rules: + carryforward: true + statuses: + - name_prefix: project- + type: project + target: auto + threshold: 1% \ No newline at end of file diff --git a/project.yml b/project.yml index 9520d6127..00a4743ff 100644 --- a/project.yml +++ b/project.yml @@ -57,6 +57,9 @@ packages: url: https://github.com/airbnb/lottie-ios.git minVersion: 3.5.0 maxVersion: 3.5.0 + WysiwygComposer: + url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift + revision: d5ef7054fb43924d5b92d5d627347ca2bc333717 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0