diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 291360fd2..130326b3d 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -53,23 +53,10 @@ jobs: contains(github.event.issue.labels.*.name, 'O-Frequent')) || contains(github.event.issue.labels.*.name, 'A11y')) steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc0sUA" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/18 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} add_product_issues_to_project: name: X-Needs-Product to Design project board @@ -77,138 +64,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'X-Needs-Product') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AAg6N" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - Delight_issues_to_board: - name: Spaces issues to Delight project board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'Team: Delight') || - contains(github.event.issue.labels.*.name, 'Z-AppLayout') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc1HvQ" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - move_voice-message_issues: - name: A-Voice Messages to voice message board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'A-Voice Messages') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc2KCw" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - move_message_bubble_issues: - name: A-Message-Bubbles to Message bubble board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc3m-g" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - move_FTUE_issues: - name: Z-FTUE to FTUE board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'Z-FTUE') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AAqVx" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - - move_WTF_issues: - name: Z-WTF to WTF board - runs-on: ubuntu-latest - if: > - contains(github.event.issue.labels.*.name, 'Z-WTF') - steps: - - uses: octokit/graphql-action@v2.x - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AArk0" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/28 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ex_plorers: name: Add labelled issues to X-Plorer project @@ -216,23 +75,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: Element X Feature') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4ALoFY" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/73 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features1: name: Add labelled issues to PS features team 1 @@ -245,23 +91,10 @@ jobs: (contains(github.event.issue.labels.*.name, 'A-Session-Mgmt') && contains(github.event.issue.labels.*.name, 'A-User-Settings')) steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AHJKF" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/56 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features2: name: Add labelled issues to PS features team 2 @@ -270,23 +103,10 @@ jobs: contains(github.event.issue.labels.*.name, 'A-DM-Start') || contains(github.event.issue.labels.*.name, 'A-Broadcast') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AHJKd" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/58 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features3: name: Add labelled issues to PS features team 3 @@ -294,23 +114,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4AHJKW" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/57 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} voip: name: Add labelled issues to VoIP project board @@ -318,20 +125,7 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: VoIP') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { - item { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - PROJECT_ID: "PVT_kwDOAM0swc4ABMIk" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/41 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/CHANGES.md b/CHANGES.md index 033842289..7e942b526 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,61 @@ +## Changes in 1.10.12 (2023-05-16) + +✨ Features + +- Add composer suggestions for slash commands ([#7493](https://github.com/vector-im/element-ios/issues/7493)) + +🙌 Improvements + +- Crypto: Deprecate MXLegacyCrypto ([#7508](https://github.com/vector-im/element-ios/pull/7508)) +- Add a flag in the build settings to force the user to define a homeserver instead of using the default one. ([#7541](https://github.com/vector-im/element-ios/pull/7541)) +- Upgrade MatrixSDK version ([v0.26.10](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.10)). +- Add an audio alert when the voice broadcast recording is automatically paused ([#7504](https://github.com/vector-im/element-ios/issues/7504)) +- Timeline: Remove the matrix ID displayed when someone has changed its display name. ([#7517](https://github.com/vector-im/element-ios/issues/7517)) + +🐛 Bugfixes + +- Fix an issue where the Secrets Reset screen would open twice. ([#7404](https://github.com/vector-im/element-ios/pull/7404)) +- Make sure to use the chosen language for the VoiceOver voice too. ([#7493](https://github.com/vector-im/element-ios/pull/7493)) +- Fix the position of the send confirmation icon. ([#7512](https://github.com/vector-im/element-ios/pull/7512)) +- Disable accessibility for emojis during session verification. ([#7521](https://github.com/vector-im/element-ios/pull/7521)) +- Fix accessibility when entering the PIN to unlock the app. ([#7522](https://github.com/vector-im/element-ios/pull/7522)) +- Fix voiceover order of room creation header and message composer. ([#7543](https://github.com/vector-im/element-ios/pull/7543)) +- Fix: The last event description text color now matches the active theme. ([#7545](https://github.com/vector-im/element-ios/pull/7545)) +- Fix mention pills display in thread list ([#7322](https://github.com/vector-im/element-ios/issues/7322)) +- Poll: The timeline sometimes displayed closed polls in the wrong order. ([#7497](https://github.com/vector-im/element-ios/issues/7497)) +- Fix a flickering issue when the timeline datasource is reloaded. ([#7523](https://github.com/vector-im/element-ios/issues/7523)) +- Fix the position of the marker highlighting an event. ([#7526](https://github.com/vector-im/element-ios/issues/7526)) +- Fix application crashing when opening a thread with RTE enabled ([#7530](https://github.com/vector-im/element-ios/issues/7530)) +- Labs: Rich Text Editor: Fix partial text messages not being saved for each room ([#7535](https://github.com/vector-im/element-ios/issues/7535)) + + +## Changes in 1.10.11 (2023-04-18) + +🙌 Improvements + +- Upgrade MatrixSDK version ([v0.26.9](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.9)). +- Labs: Rich Text Editor: Integrate version 2.0.0 with mention Pills support. ([#7442](https://github.com/vector-im/element-ios/issues/7442)) + +🐛 Bugfixes + +- Continue to display pills for matrix.to permalinks if a custom permalinkBaseUrl is set. ([#7482](https://github.com/vector-im/element-ios/pull/7482)) +- Add a foreground color attribute for the unformattable event error message. ([#7501](https://github.com/vector-im/element-ios/pull/7501)) +- Fixed a bug that prevented audio messages that were not .mp4 to be played in the timeline ([#7451](https://github.com/vector-im/element-ios/issues/7451)) +- Fix user suggestion list item height on iOS 16+ ([#7492](https://github.com/vector-im/element-ios/issues/7492)) + +🧱 Build + +- Pinned used Xcode version to 14.2 as newer version fail ASC validation ([#7476](https://github.com/vector-im/element-ios/issues/7476)) + + +## Changes in 1.10.10 (2023-04-12) + +🙌 Improvements + +- Crypto: Enable Rust Crypto for all users ([#7485](https://github.com/vector-im/element-ios/pull/7485)) +- Upgrade MatrixSDK version ([v0.26.7](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.7)). + + ## Changes in 1.10.9 (2023-04-04) 🙌 Improvements diff --git a/CHANGES_BWI.md b/CHANGES_BWI.md index 964217202..c107588ba 100644 --- a/CHANGES_BWI.md +++ b/CHANGES_BWI.md @@ -1,4 +1,41 @@ -Changes in BWI project 2.5.0 (2023-05-09) +Changes in BWI project 2.7.0 (2023-07-04) +=================================================== + +Upstream merge ✨: + +- v1.10.12 + +Features ✨: +- New show participants toggle for polls (#4393) + +Improvements 🙌: +- Roomavatar can now be deleted (#4743) +- Remove "black" theme (#4744) +- Open links in system browser (#1678) +- Add imprint (#4682) +- Text changes for DM creation (#4736) +- Add accessibility statement (#4772) +- Matomo tracking of poll creation (#4795) +- Matomo tracking of voice messages (#4795) +- Matomo tracking of forwarding messages (#4795) + +Bugfix 🐛: +- Disable logout when there is no internet connection (#3539) +- Disable permalink sharing for private rooms (#4390) +- Fix manual verification (#4710) +- Fix QR code scanning (#4748) +- Show app logo in pin code screen (#4828) +- Update "all chats" filter on logout/login (#4573) + +Translations 🗣 : +- English translations passphrase (#4706) + +SDK API changes ⚠️: + +Build 🧱: + + +Changes in BWI project 2.6.0 (2023-05-09) =================================================== Upstream merge ✨: diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 4fcd0456f..d2e9637b9 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -16,5 +16,5 @@ // // Version -MARKETING_VERSION = 2.6.0 +MARKETING_VERSION = 2.7.0 CURRENT_PROJECT_VERSION = 20220714163152 diff --git a/Config/BWIBuildSettings.swift b/Config/BWIBuildSettings.swift index 0bb015b1c..5702aae6d 100644 --- a/Config/BWIBuildSettings.swift +++ b/Config/BWIBuildSettings.swift @@ -124,6 +124,8 @@ class BWIBuildSettings: NSObject { var bwiUserLabelParticipantSorting = true var bwiShowClosedPolls = true + var bwiPollShowParticipantsToggle = true + var bwiPollVisibleVotes = 5 var bwiShowThreads = false var bwiShowRoomCreationSectionFooter = false @@ -185,7 +187,16 @@ class BWIBuildSettings: NSObject { "4d5b6dcf02396274be58a69c4bbeba975b529f6b19c504fc99a37892ee1cf0b5", "0d157119821bd9d76ac4f24c7f44f56e6bb5b766a6d5ee7dad6634420e79271a", "e3573fe09d518cce80cececedf80f8e0020cbc150f22db8b64827bff2e27abd9", - "b76a62ccd8ea70d01c3a35ec3839e49ed2c83c8e3276f40a1b2c2cdf7cd77d01" + "b76a62ccd8ea70d01c3a35ec3839e49ed2c83c8e3276f40a1b2c2cdf7cd77d01", + "4a610a4d5fd3d8a1e1fd5669abdf8e0c5f7f5ff0c6b559e0f360cfa092ecb115", + "32752f6d21f3005587941415cd64892ee28c19e6e01ed307edf9ddf4f6a91583", + "704c6eaa107b13ef0694eb7ddd043bb6f595b53670a2e0c3c16e199947a9e013", + "6921f031357cf63fb8538d9a1d1973efae95899907fdbf05a05082b6d1a6d0fb", + "9f960fc663f5eaae67eecff75b137dea130b3ab1cf889c45fc74c688a48aea30", + "160c35279484a027031b131583f3f203b1166306bab214355b00cf28502bce11", + "d5a7298dde23aa0269c4cbd3b1a543e6ede94ce78fc20e4bfb888eb6057b5c52", + "00136d830dd2acd5047efcf8429e939ef7ef97a84bef1930df86aace3f855265", + "64cbbeea37237814445b65c941d010b9d5d024e4c584a476864b00c7c9909bce" ] // use a different badge color if the user was mentioned in a room @@ -436,8 +447,9 @@ class BWIBuildSettings: NSObject { var authScreenShowTestServerOptions = true var authScreenShowSocialLoginSection = false - // MARK: - Cross-signing (bwi=true) - var disableSelfUserVerification = false + // MARK: - Self Verification not crosssigning (bwi=true) + var disableSelfUserVerification = true + var disableCrosssigning = false var additionalSelfVerfificationAlert = false var showNoOtherDeviceError = false @@ -489,7 +501,7 @@ class BWIBuildSettings: NSObject { var passwordIndicatorOnLogin = true // MARK: Displays the element base version on the settings screen - var elementBaseVersion = "1.10.9" + var elementBaseVersion = "1.10.12" var showElementBaseVersion = true @@ -617,6 +629,22 @@ class BWIBuildSettings: NSObject { // MARK: Rust Encryption var useRustEncryption = false + // MARK: Color Theme + var useNewBumColors = false + // MARK: Sessions Manager var enableNewSessionManagerByDefault = false + + // MARK: Accessibility declaration + // bwi flag for showing accessibility declaration on login screen and in settings + var bwiShowAccessibilityDeclaration = false + // internal markdown file for accessibility declaration in en and de. + var accessibilityDeclarationFileDe = "" + var accessibilityDeclarationFileEn = "" + + // MARK: Voice Broadcast + var enableLabFeatureVoiceBroadcasts = false + + // MARK: WYSIWYG + var enableLabFeatureWYSIWYG = false } diff --git a/Config/BuM-Beta/BWIBuildSettings+BuM-Beta.swift b/Config/BuM-Beta/BWIBuildSettings+BuM-Beta.swift index 67dc93639..732aefc93 100644 --- a/Config/BuM-Beta/BWIBuildSettings+BuM-Beta.swift +++ b/Config/BuM-Beta/BWIBuildSettings+BuM-Beta.swift @@ -20,6 +20,7 @@ import Foundation extension BWIBuildSettings { func overrideTargetSpecificSettings() { + useNewBumColors = true secondaryAppName = "BundesMessenger" settingsScreenShowLabSettings = true authScreenShowRegister = true @@ -32,6 +33,10 @@ extension BWIBuildSettings { bwiLocationShareButtonVisible = false bwiLoginFlowLayout = false useRustEncryption = true + + enableLabFeatureVoiceBroadcasts = true + enableNewSessionManagerByDefault = true + enableLabFeatureWYSIWYG = true } } diff --git a/Config/BuM-Open/AppIdentifiers-bum-open.xcconfig b/Config/BuM-Open/AppIdentifiers-bum-open.xcconfig new file mode 100644 index 000000000..cecebe704 --- /dev/null +++ b/Config/BuM-Open/AppIdentifiers-bum-open.xcconfig @@ -0,0 +1,39 @@ +// +// Copyright 2021 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +// App identity +BUNDLE_DISPLAY_NAME = BuM-Open +BASE_BUNDLE_IDENTIFIER = de.bwi.messenger-open +APPLICATION_GROUP_IDENTIFIER = group.de.messenger-open +APPLICATION_SCHEME = element + +// Team +DEVELOPMENT_TEAM = Q111Q11QQ1 + + +// Provisioning profiles +RIOT_PROVISIONING_PROFILE_SPECIFIER = Vector App Store +RIOT_PROVISIONING_PROFILE = 7579fa6f-9887-415e-90fc-2c7acd8812e6 + +NSE_PROVISIONING_PROFILE_SPECIFIER = "Vector NSE: App Store" +NSE_PROVISIONING_PROFILE = e73107b2-1bfe-4615-be3e-39fd4dcb2af0 + +SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER = "Vector Share Extension: App Store" +SHARE_EXTENSION_PROVISIONING_PROFILE = 8c797ca0-0440-49bd-be8d-11d761152995 + +SIRI_INTENTS_PROVISIONING_PROFILE_SPECIFIER = "Vector Siri Intents: App Store" +SIRI_INTENTS_PROVISIONING_PROFILE = 1690e81a-5ad3-4d99-b578-02693579be71 diff --git a/Config/BuM-Open/BWIBuildSettings+BuM-Open.swift b/Config/BuM-Open/BWIBuildSettings+BuM-Open.swift new file mode 100644 index 000000000..823147272 --- /dev/null +++ b/Config/BuM-Open/BWIBuildSettings+BuM-Open.swift @@ -0,0 +1,34 @@ +// +/* + * Copyright (c) 2023 BWI GmbH + * + * 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 BWIBuildSettings { + + func overrideTargetSpecificSettings() { + secondaryAppName = "BundesMessenger" + locationSharingEnabled = false + bwiLocationShareButtonVisible = false + bwiLoginFlowLayout = false + authScreenShowTestServerOptions = false + + enableNewSessionManagerByDefault = true + + bwiEnableLoginProtection = false + } + +} diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 8773f2b68..44c008e0b 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -98,10 +98,15 @@ final class BuildSettings: NSObject { // MARK: - Server configuration - // Default servers proposed on the authentication screen + /// Force the user to set a homeserver instead of using the default one + static let forceHomeserverSelection = false + + /// Default server proposed on the authentication screen static let serverConfigDefaultHomeserverUrlString = "https://matrix.org" - static let serverConfigDefaultIdentityServerUrlString = "https://vector.im" + /// Default identity server + static let serverConfigDefaultIdentityServerUrlString = "https://vector.im" + static let serverConfigSygnalAPIUrlString = "https://matrix.org/_matrix/push/v1/notify" diff --git a/Config/copyOpenConfig.sh b/Config/copyOpenConfig.sh new file mode 100755 index 000000000..392f83a60 --- /dev/null +++ b/Config/copyOpenConfig.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# setConfig.sh +# +# Copyright (c) 2023 BWI GmbH +# +# 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. +# + +cp -vf ../Config/BuM-Open/AppIdentifiers-bum-open.xcconfig ../Config/AppIdentifiers.xcconfig diff --git a/Podfile b/Podfile index f6f109c9d..94d6653a4 100644 --- a/Podfile +++ b/Podfile @@ -16,7 +16,7 @@ use_frameworks! # - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI # # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it -$matrixSDKVersion = '= 0.26.6' +$matrixSDKVersion = '= 0.26.10' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } @@ -43,7 +43,7 @@ when String # specific MatrixSDK released version $matrixSDKVersionSpec = $matrixSDKVersion end -$matrixSDKVersionSpec = { :git => 'https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk', :tag => 'v0.26.6.1_bwi' } +$matrixSDKVersionSpec = { :git => 'https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk', :tag => 'v0.26.10_bwi' } # Method to import the MatrixSDK def import_MatrixSDK @@ -155,6 +155,26 @@ abstract_target 'RiotPods' do pod 'FLEX', '~> 4.5.0', :configurations => ['Debug'] end + + target "BuM-Open" do + import_MatrixSDK + import_MatrixKit_pods + + import_SwiftUI_pods + + pod 'UICollectionViewLeftAlignedLayout', '~> 1.0.2' + pod 'UICollectionViewRightAlignedLayout', '~> 0.0.3' + pod 'KTCenterFlowLayout', '~> 1.3.1' + pod 'FlowCommoniOS', '~> 1.12.0' + pod 'DTTJailbreakDetection', '~> 0.4.0' + pod 'ReadMoreTextView', '~> 3.0.1' + pod 'SwiftBase32', '~> 0.9.0' + pod 'SwiftJWT', '~> 3.6.200' + pod 'SideMenu', '~> 6.5' + pod 'DSWaveformImage', '~> 6.1.1' + + pod 'FLEX', '~> 4.5.0', :configurations => ['Debug'] + end target "RiotSwiftUI" do import_SwiftUI_pods diff --git a/Podfile.lock b/Podfile.lock index fcb75095b..d88d17f49 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -20,6 +20,24 @@ PODS: - Down (0.11.0) - DSBottomSheet (0.3.0) - DSWaveformImage (6.1.1) + - DTCoreText (1.6.26): + - DTCoreText/Core (= 1.6.26) + - DTFoundation/Core (~> 1.7.5) + - DTFoundation/DTAnimatedGIF (~> 1.7.5) + - DTFoundation/DTHTMLParser (~> 1.7.5) + - DTFoundation/UIKit (~> 1.7.5) + - DTCoreText/Core (1.6.26): + - DTFoundation/Core (~> 1.7.5) + - DTFoundation/DTAnimatedGIF (~> 1.7.5) + - DTFoundation/DTHTMLParser (~> 1.7.5) + - DTFoundation/UIKit (~> 1.7.5) + - DTFoundation/Core (1.7.18) + - DTFoundation/DTAnimatedGIF (1.7.18) + - DTFoundation/DTHTMLParser (1.7.18): + - DTFoundation/Core + - DTFoundation/UIKit (1.7.18): + - DTFoundation/Core + - DTTJailbreakDetection (0.4.0) - FLEX (4.5.0) - FlowCommoniOS (1.12.2) - GBDeviceInfo (7.1.0): @@ -39,20 +57,23 @@ PODS: - LoggerAPI (1.9.200): - Logging (~> 1.1) - Logging (1.4.0) - - MatrixSDK (0.26.6): - - MatrixSDK/Core (= 0.26.6) - - MatrixSDK/Core (0.26.6): + - MatomoTracker (7.5.2): + - MatomoTracker/Core (= 7.5.2) + - MatomoTracker/Core (7.5.2) + - MatrixSDK (0.26.10): + - MatrixSDK/Core (= 0.26.10) + - MatrixSDK/Core (0.26.10): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) - - MatrixSDKCrypto (= 0.3.2) + - MatrixSDKCrypto (= 0.3.4) - OLMKit (~> 3.2.5) - Realm (= 10.27.0) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/JingleCallStack (0.26.6): + - MatrixSDK/JingleCallStack (0.26.10): - JitsiMeetSDKLite (= 7.0.1-lite) - MatrixSDK/Core - - MatrixSDKCrypto (0.3.2) + - MatrixSDKCrypto (0.3.4) - OLMKit (3.2.12): - OLMKit/olmc (= 3.2.12) - OLMKit/olmcpp (= 3.2.12) @@ -95,6 +116,8 @@ DEPENDENCIES: - Down (~> 0.11.0) - DSBottomSheet (~> 0.3) - DSWaveformImage (~> 6.1.1) + - DTCoreText (= 1.6.26) + - DTTJailbreakDetection (~> 0.4.0) - FLEX (~> 4.5.0) - FlowCommoniOS (~> 1.12.0) - GBDeviceInfo (~> 7.1.0) @@ -102,8 +125,9 @@ DEPENDENCIES: - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatrixSDK (= 0.26.6) - - MatrixSDK/JingleCallStack (= 0.26.6) + - MatomoTracker (~> 7.5.2) + - MatrixSDK (from `https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk`, tag `v0.26.10_bwi_beta`) + - MatrixSDK/JingleCallStack (from `https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk`, tag `v0.26.10_bwi_beta`) - OLMKit - PostHog (~> 2.0.0) - ReadMoreTextView (~> 3.0.1) @@ -122,8 +146,20 @@ DEPENDENCIES: - ZXingObjC (~> 3.6.5) SPEC REPOS: - trunk: + https://github.com/CocoaPods/Specs.git: - AFNetworking + - DTCoreText + - DTFoundation + - DTTJailbreakDetection + - GZIP + - JitsiMeetSDKLite + - JitsiWebRTC + - libbase58 + - MatomoTracker + - MatrixSDKCrypto + - Realm + - SwiftyBeaver + trunk: - BlueCryptor - BlueECC - BlueRSA @@ -133,23 +169,16 @@ SPEC REPOS: - FLEX - FlowCommoniOS - GBDeviceInfo - - GZIP - Introspect - - JitsiMeetSDKLite - - JitsiWebRTC - KeychainAccess - KituraContracts - KTCenterFlowLayout - - libbase58 - libPhoneNumber-iOS - LoggerAPI - Logging - - MatrixSDK - - MatrixSDKCrypto - OLMKit - PostHog - ReadMoreTextView - - Realm - Reusable - Sentry - SideMenu @@ -158,13 +187,22 @@ SPEC REPOS: - SwiftGen - SwiftJWT - SwiftLint - - SwiftyBeaver - UICollectionViewLeftAlignedLayout - UICollectionViewRightAlignedLayout - WeakDictionary - zxcvbn-ios - ZXingObjC +EXTERNAL SOURCES: + MatrixSDK: + :git: https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk + :tag: v0.26.10_bwi_beta + +CHECKOUT OPTIONS: + MatrixSDK: + :git: https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk + :tag: v0.26.10_bwi_beta + SPEC CHECKSUMS: AFNetworking: 3bd23d814e976cd148d7d44c3ab78017b744cd58 BlueCryptor: b0aee3d9b8f367b49b30de11cda90e1735571c24 @@ -173,6 +211,9 @@ SPEC CHECKSUMS: Down: b6ba1bc985c9d2f4e15e3b293d2207766fa12612 DSBottomSheet: ca0ac37eb5af2dd54663f86b84382ed90a59be2a DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce + DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce + DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536 + DTTJailbreakDetection: 5e356c5badc17995f65a83ed9483f787a0057b71 FLEX: e51461dd6f0bfb00643c262acdfea5d5d12c596b FlowCommoniOS: ca92071ab526dc89905495a37844fd7e78d1a7f2 GBDeviceInfo: 5d62fa85bdcce3ed288d83c28789adf1173e4376 @@ -187,8 +228,9 @@ SPEC CHECKSUMS: libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b - MatrixSDK: 8179c184d819782282f47dab16ce6c2b68ef8a74 - MatrixSDKCrypto: 7073c382c484cb8ba7dba0a83e112ead96d3bbfd + MatomoTracker: 1d98ddc58322fd9d65e1a6886b8e41363047bd13 + MatrixSDK: 68e39c246ff8d80c5788d5fc46e93fcbb24703fa + MatrixSDKCrypto: ac805c22c24f79f349cdbfa065855c73a4c81b51 OLMKit: da115f16582e47626616874e20f7bb92222c7a51 PostHog: 660ec6c9d80cec17b685e148f17f6785a88b597d ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d @@ -208,6 +250,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 54848168ab5303c9126626395886cd85f27a44b3 +PODFILE CHECKSUM: 6bd4e9aff1b435c22f06ad6a4497af49acca8a27 COCOAPODS: 1.11.3 diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0968cfb53..0c7580209 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "addf90f3e2a6ab46bd2b2febe117d9cddb646e7d", - "version" : "1.1.1" + "revision" : "ff5e8054da60212051cb0dec244500ca0f441bac", + "version" : "2.1.0" } }, { diff --git a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/Contents.json b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/Contents.json index 80cb64990..f8ae0d35e 100644 --- a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/Contents.json @@ -1,19 +1,8 @@ { "images" : [ { - "filename" : "launch_bwi.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "launch_bwi@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "launch_bwi@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename" : "bundesmessenger-logo.svg", + "idiom" : "universal" } ], "info" : { @@ -21,6 +10,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } diff --git a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/bundesmessenger-logo.svg b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/bundesmessenger-logo.svg new file mode 100644 index 000000000..2bd1791f6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/bundesmessenger-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi.png b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi.png deleted file mode 100644 index 1e06d2ded..000000000 Binary files a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi@2x.png b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi@2x.png deleted file mode 100644 index 6e0c03e29..000000000 Binary files a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi@3x.png b/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi@3x.png deleted file mode 100644 index c48e8d72f..000000000 Binary files a/Riot/Assets/Images.xcassets/launch_screen_logo.imageset/launch_bwi@3x.png and /dev/null differ diff --git a/Riot/Assets/Sounds/vberror.mp3 b/Riot/Assets/Sounds/vberror.mp3 new file mode 100644 index 000000000..14c710595 Binary files /dev/null and b/Riot/Assets/Sounds/vberror.mp3 differ diff --git a/Riot/Assets/de.lproj/Bwi.strings b/Riot/Assets/de.lproj/Bwi.strings index 448710eb4..7feabc31e 100644 --- a/Riot/Assets/de.lproj/Bwi.strings +++ b/Riot/Assets/de.lproj/Bwi.strings @@ -205,6 +205,7 @@ "settings_password_has_no_lowercase_letter" = "Das Passwort muss mindestens einen Kleinbuchstaben enthalten"; "settings_about" = "Erweitert"; "bwi_settings_ignored_users_text" = "Ignorierte Nutzer"; +"settings_imprint" = "Impressum"; // MARK: - Room Details @@ -387,7 +388,7 @@ // Mark: - Room Creation -"room_intro_cell_information_dm_sentence1_part1" = "Das ist der Anfang deiner Direktnachricht mit "; +"room_intro_cell_information_dm_sentence1_part1" = "Dies ist der Beginn deiner Direktnachrichten mit "; "room_avatar_view_accessibility_hint" = "Raumbild ändern"; "room_avatar_view_accessibility_label" = "Profilbild"; "room_details_permalink" = "Link zum Raum kopieren"; @@ -501,6 +502,7 @@ "bwi_error_invite_already_in_room" = "%@ ist bereits im Raum."; "bwi_error_invite_banned_in_room" = "%@ ist vom Raum gebannt."; "bwi_error_invite_general" = "%@ konnte nicht eingeladen werden."; +"bwi_error_logout_offline" = "Abmelden ist ohne Internetverbindung nicht möglich."; // MARK: - Matomo @@ -519,7 +521,7 @@ "bwi_settings_new_features_show_features" = "Neue Funktionen anzeigen"; "bwi_feature_banner_header" = "Neue Funktionen"; "bwi_feature_banner_show_more_button" = "Erfahre mehr"; -"bwi_feature_banner_advertisement_text" = "Du kannst jetzt aktive und vergangene Umfragen gesammelt in den Raumdetails einsehen (erreichbar unter Raumdetails, im Bereich \"Umfrageverlauf\")."; +"bwi_feature_banner_advertisement_text" = "Neue Umfragen können vom Ersteller so konfiguriert werden, dass angezeigt wird, wer für welche Option gestimmt hat."; // MARK: - Onboarding "onboarding_splash_login_button_title" = "Loslegen"; @@ -540,6 +542,10 @@ "poll_edit_form_poll_type" = "Umfragetyp"; "poll_edit_form_poll_type_closed" = "Versteckte Umfrage"; "poll_edit_form_poll_type_open" = "Offene Umfrage"; +"poll_edit_form_participant_toggle" = "Anzeigen, wer für welche Option gestimmt hat."; +"poll_timeline_show_participants_button" = "Stimmen anzeigen"; +"poll_participant_details_show_more" = "Alle ansehen (%lu weitere)"; +"poll_participant_details_title" = "Umfragedetails"; // MARK: - Welcome Experience "welcome_experience_title1" = "Willkommen beim BundesMessenger"; @@ -592,6 +598,8 @@ // MARK: - Permalink "settings_permalink_prefix_picker_title" = "Permalink Prefix"; +"bwi_error_room_not_available_title" = "Link ungültig"; +"bwi_error_room_not_available_message" = "Der Raum wurde bereits geschlossen, daher kannst Du nicht mehr beitreten."; // MARK: - Notification Settings "settings_notify_me_for" = "Benachrichtige mich für"; @@ -612,7 +620,11 @@ // MARK: - Device Manager "user_session_verified_session_description" = "Du hast deine Sitzung durch Eingabe des Wiederherstellungsschlüssels oder durch die Verifizierung mit einem anderen Gerät bestätigt. Dies bedeutet, dass du alle Schlüssel zum Entschlüsseln deiner Nachrichten hast und anderen bestätigst, dieser Sitzung zu vertrauen."; "user_session_button_view_all" = "Alle anzeigen (%d)"; +"user_verification_session_details_verify_action_current_user" = "Sitzung verifizieren"; // MARK: - Voice Over "textfield_reveal_secret" = "Texteingabe anzeigen"; "textfield_hide_secret" = "Texteingabe verbergen"; + +// MARK: - Accessibility declaration +"bwi_accessibility_declaration_button_title" = "Barrierefreiheitserklärung"; diff --git a/Riot/Assets/de.lproj/Vector.strings b/Riot/Assets/de.lproj/Vector.strings index 3afd654fc..d00ab8bbb 100644 --- a/Riot/Assets/de.lproj/Vector.strings +++ b/Riot/Assets/de.lproj/Vector.strings @@ -2742,3 +2742,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Synchronisiere deine Unterhaltungen"; +"pill_message_in" = "Nachricht in %@"; +"pill_message_from" = "Nachricht von %@"; +"pill_message" = "Nachricht"; + +// Pills +"pill_room_fallback_display_name" = "Space/Raum"; +"key_verification_self_verify_security_upgrade_alert_message" = "Verschlüsselte Kommunikation wurde mit der neuesten Aktualisierung verbessert. Bitte verifiziere deine Geräte erneut."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "App aktualisiert"; +"settings_acceptable_use" = "Nutzungsbedingungen"; diff --git a/Riot/Assets/en.lproj/Bwi.strings b/Riot/Assets/en.lproj/Bwi.strings index d50de826c..f1fef0bd9 100644 --- a/Riot/Assets/en.lproj/Bwi.strings +++ b/Riot/Assets/en.lproj/Bwi.strings @@ -161,6 +161,7 @@ "settings_password_has_no_lowercase_letter" = "The password must include at least one lowercase letter"; "settings_deactivate_my_account" = "Deactivate my account"; "settings_enable_inapp_notifications" = "Enable In-App notifications"; +"settings_imprint" = "Imprint"; // MARK: - Room Details @@ -309,7 +310,7 @@ // Mark: - Room Creation -"room_intro_cell_information_dm_sentence1_part1" = "This is the beginning of your conversation with "; +"room_intro_cell_information_dm_sentence1_part1" = "This is the beginning of your direct message history with "; // MARK: - Notification Times @@ -410,6 +411,7 @@ "bwi_error_invite_already_in_room" = "%@ is already in the room."; "bwi_error_invite_banned_in_room" = "%@ is banned from the room."; "bwi_error_invite_general" = "%@ could not be invited."; +"bwi_error_logout_offline" = "Logout not possible without internet connection."; // MARK: - Matomo @@ -428,7 +430,7 @@ "bwi_settings_new_features_show_features" = "Show new features"; "bwi_feature_banner_header" = "New Features"; "bwi_feature_banner_show_more_button" = "Learn more"; -"bwi_feature_banner_advertisement_text" = "You can now see a poll history in the room details."; +"bwi_feature_banner_advertisement_text" = "New polls can be configured by the creator to show who voted for which option."; // MARK: - Onboarding "onboarding_splash_login_button_title" = "Let's go"; @@ -445,6 +447,10 @@ "poll_edit_form_poll_type" = "Poll type"; "poll_edit_form_poll_type_closed" = "Hidden Poll"; "poll_edit_form_poll_type_open" = "Open poll"; +"poll_edit_form_participant_toggle" = "Show who voted for which option"; +"poll_timeline_show_participants_button" = "Show votes"; +"poll_participant_details_show_more" = "Show all (%lu more)"; +"poll_participant_details_title" = "Poll details"; // MARK: - Welcome Experience "welcome_experience_title1" = "Welcome to BundesMessenger"; @@ -502,6 +508,8 @@ // MARK: - Permalink "settings_permalink_prefix_picker_title" = "Permalink Prefix"; +"bwi_error_room_not_available_title" = "Link invalid"; +"bwi_error_room_not_available_message" = "The room has already been closed, you can no longer join."; // MARK: - Notification Settings "settings_notify_me_for" = "Notify me for"; @@ -522,8 +530,11 @@ // MARK: - Device Manager "user_session_verified_session_description" = "You have confirmed your session by entering the recovery key or verifying with another device. This means that you have all the keys to decrypt your messages and are confirming to others to trust this session."; "user_session_button_view_all" = "View all (%d)"; +"user_verification_session_details_verify_action_current_user" = "Verify session"; // MARK: - Voice Over "textfield_reveal_secret" = "reveal text input"; "textfield_hide_secret" = "hide text input"; +// MARK: - Accessibility declaration +"bwi_accessibility_declaration_button_title" = "Accessibility declaration"; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 8a42809d9..247f3346a 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -614,6 +614,21 @@ Tap the + to start adding people."; "room_join_group_call" = "Join"; "room_no_privileges_to_create_group_call" = "You need to be an admin or a moderator to start a call."; +// Room commands descriptions +"room_command_change_display_name_description" = "Changes your display nickname"; +"room_command_emote_description" = "Displays action"; +"room_command_join_room_description" = "Joins room with given address"; +"room_command_part_room_description" = "Leave room"; +"room_command_invite_user_description" = "Invites user with given id to current room"; +"room_command_kick_user_description" = "Removes user with given id from this room"; +"room_command_ban_user_description" = "Bans user with given id"; +"room_command_unban_user_description" = "Unbans user with given id"; +"room_command_set_user_power_level_description" = "Define the power level of a user"; +"room_command_reset_user_power_level_description" = "Deops user with given id"; +"room_command_change_room_topic_description" = "Sets the room topic"; +"room_command_discard_session_description" = "Forces the current outbound group session in an encrypted room to be discarded"; +"room_command_error_unknown_command" = "Invalid or unhandled command"; + // MARK: Threads "room_thread_title" = "Thread"; "thread_copy_link_to_thread" = "Copy link to thread"; @@ -2393,6 +2408,8 @@ Tap the + to start adding people."; "poll_timeline_reply_ended_poll" = "Ended poll"; +"poll_timeline_loading" = "Loading..."; + // MARK: - Location sharing "location_sharing_title" = "Location"; @@ -2972,6 +2989,7 @@ To enable access, tap Settings> Location and select Always"; "notice_avatar_url_changed" = "%@ changed their avatar"; "notice_display_name_set" = "%@ set their display name to %@"; "notice_display_name_changed_from" = "%@ changed their display name from %@ to %@"; +"notice_display_name_changed_to" = "%@ changed their display name to %@"; "notice_display_name_removed" = "%@ removed their display name"; "notice_topic_changed" = "%@ changed the topic to \"%@\"."; "notice_room_name_changed" = "%@ changed the room name to %@."; diff --git a/Riot/Assets/et.lproj/Vector.strings b/Riot/Assets/et.lproj/Vector.strings index a3750917e..12a8f9557 100644 --- a/Riot/Assets/et.lproj/Vector.strings +++ b/Riot/Assets/et.lproj/Vector.strings @@ -2680,3 +2680,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Sinu vestlused on sünkroniseerimisel"; +"pill_message_in" = "Sõnum jututoas %@"; +"pill_message_from" = "Sõnum kasutajalt %@"; +"pill_message" = "Sõnum"; + +// Pills +"pill_room_fallback_display_name" = "Kogukond/jututuba"; +"settings_acceptable_use" = "Vastuvõetava kasutamise põhimõtted"; +"key_verification_self_verify_security_upgrade_alert_message" = "Turvalisele sõnumivahetusele on lisandunud palju täiendusi. Palun verifitseeri oma seade uuesti."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Rakendus on uuendatud"; diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings index c3f99464a..a77322f08 100644 --- a/Riot/Assets/hu.lproj/Vector.strings +++ b/Riot/Assets/hu.lproj/Vector.strings @@ -2654,7 +2654,6 @@ "voice_broadcast_stop_alert_title" = "Megszakítod az élő közvetítést?"; "voice_broadcast_buffering" = "Pufferelés…"; "voice_broadcast_time_left" = "%@ van vissza"; - "password_policy_pwd_in_dict_error" = "Ez a jelszó megtalálható a szótárban ezért nem engedélyezett."; "password_policy_weak_pwd_error" = "Ez a jelszó túl gyenge. Legalább 8 karakternek kell lennie és minden típusból legalább egy: nagybetű, kisbetű, szám és speciális karakter."; @@ -2701,7 +2700,6 @@ "poll_history_no_past_poll_period_text" = "%@ napja nincs aktív szavazás. További szavazások betöltése az előző havi szavazások megjelenítéséhez"; "poll_history_no_active_poll_period_text" = "%@ napja nincs aktív szavazás. További szavazások betöltése az előző havi szavazások megjelenítéséhez"; "poll_history_loading_text" = "Szavazások megjelenítése"; - "settings_labs_disable_crypto_sdk" = "Rust végpontok közötti titkosítás (kikapcsoláshoz kijelentkezés szükséges)"; "settings_labs_confirm_crypto_sdk" = "Ez a funkció még kísérleti fázisban van. Lehet, hogy nem az elvártnak megfelelően fog működni és előre nem látható következménye lehet. A funkció kikapcsolásához egyszerű ki-, és bejelentkezés szükséges. Használata csak saját felelősségre."; "settings_labs_enable_crypto_sdk" = "Rust végpontok közötti titkosítás"; @@ -2720,3 +2718,25 @@ "device_verification_self_verify_wait_recover_secrets_additional_help" = "Nem férsz hozzá létező munkamenethez, %@?"; "device_verification_self_verify_open_on_other_device_title" = "Nyisd meg ezt: %@ a másik eszközön"; "room_creation_only_one_email_invite" = "E-mail meghívóból egyszerre csak egy küldhető"; +"pill_message_in" = "Üzenet itt: %@"; +"pill_message_from" = "Üzenet tőle: %@"; +"pill_message" = "Üzenet"; + +// Pills +"pill_room_fallback_display_name" = "Tér/Szoba"; +"launch_loading_delay_warning" = "Ez egy kicsit tovább tarthat.\nKöszönjük a türelmet."; + +// MARK: - Launch loading + +"launch_loading_generic" = "Beszélgetések szinkronizálása"; +"key_verification_scan_qr_code_information_new_session" = "Az új munkameneted ellenőrzéséhez irányítsd a kamerádat a másik eszközödön megjelenő QR kódra"; +"key_verification_scan_qr_code_information_other_session" = "A munkameneted ellenőrzéséhez irányítsd a kamerádat a másik eszközödön megjelenő QR kódra"; +"key_verification_scan_qr_code_information_other_device" = "A munkamenet ellenőrzéséhez irányítsd a kamerádat a másik eszközödön megjelenő QR kódra"; +"key_verification_scan_qr_code_information_other_user" = "A munkamenetük ellenőrzéséhez irányítsd a kamerádat az eszközükön megjelenő QR kódra"; +"device_verification_self_verify_open_on_other_device_information" = "Ennek a munkamenetnek az ellenőrzésére szükséged van a régi titkosított üzenetek olvasásához.\n\nNyisd meg az Elementet egy másik eszközödön és kövesd az utasításokat."; +"key_verification_self_verify_security_upgrade_alert_message" = "A biztonságos üzenetküldés a legutolsó fejlesztésekkel frissült. Kérjük ellenőrizzed újra az eszközt."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Alkalmazás frissítve"; +"settings_acceptable_use" = "Elfogadható felhasználói feltételek"; diff --git a/Riot/Assets/id.lproj/Vector.strings b/Riot/Assets/id.lproj/Vector.strings index 5990734f3..1aea826f3 100644 --- a/Riot/Assets/id.lproj/Vector.strings +++ b/Riot/Assets/id.lproj/Vector.strings @@ -2935,3 +2935,15 @@ "device_verification_self_verify_open_on_other_device_information" = "Anda harus memverifikasi sesi ini supaya dapat membaca riwayat pesan aman Anda.\n\nBuka Element di salah satu perangkat Anda yang lain dan ikuti petunjuknya."; "device_verification_self_verify_open_on_other_device_title" = "Buka %@ di perangkat Anda yang lain"; "room_creation_only_one_email_invite" = "Amda hanya dapat mengundang satu surel satu-satu"; +"pill_message_in" = "Pesan di %@"; +"pill_message_from" = "Pesan dari %@"; +"pill_message" = "Pesan"; + +// Pills +"pill_room_fallback_display_name" = "Space/Ruangan"; +"key_verification_self_verify_security_upgrade_alert_message" = "Perpesanan aman telah ditingkatkan dengan pembaruan terkini. Silakan verifikasi ulang perangkat Anda."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Aplikasi diperbarui"; +"settings_acceptable_use" = "Kebijakan Penggunaan yang Dapat Diterima"; diff --git a/Riot/Assets/it.lproj/Vector.strings b/Riot/Assets/it.lproj/Vector.strings index 930ace277..d07f51b64 100644 --- a/Riot/Assets/it.lproj/Vector.strings +++ b/Riot/Assets/it.lproj/Vector.strings @@ -2708,3 +2708,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Sincronizzazione delle tue conversazioni"; +"pill_message_in" = "Messaggio in %@"; +"pill_message_from" = "Messaggio da %@"; +"pill_message" = "Messaggio"; + +// Pills +"pill_room_fallback_display_name" = "Spazio/Stanza"; +"key_verification_self_verify_security_upgrade_alert_message" = "La messaggistica sicura è stata migliorata con l'aggiornamento più recente. Ri-verifica il tuo dispositivo."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "App aggiornata"; +"settings_acceptable_use" = "Politica di utilizzo accettabile"; diff --git a/Riot/Assets/new_features.html b/Riot/Assets/new_features.html index d11191bfa..ef84e86ac 100644 --- a/Riot/Assets/new_features.html +++ b/Riot/Assets/new_features.html @@ -26,6 +26,35 @@ +
+

+ Version 2.7.0 +

+ +

+ Neue Funktionen +

+

+ +

+ Verbesserungen +

+

+ +

+ Behobene Bugs +

+

+

Version 2.6.0 diff --git a/Riot/Assets/ru.lproj/Vector.strings b/Riot/Assets/ru.lproj/Vector.strings index 857845707..0f2f21fd4 100644 --- a/Riot/Assets/ru.lproj/Vector.strings +++ b/Riot/Assets/ru.lproj/Vector.strings @@ -239,7 +239,7 @@ "settings_ignored_users" = "ИГНОРИРУЕМЫЕ ПОЛЬЗОВАТЕЛИ"; "settings_contacts" = "КОНТАКТЫ УСТРОЙСТВА"; "settings_advanced" = "ДОПОЛНИТЕЛЬНО"; -"settings_other" = "ДРУГИЕ"; +"settings_other" = "Другие"; "settings_labs" = "ЛАБОРАТОРИЯ"; "settings_devices" = "СЕАНСЫ"; "settings_cryptography" = "КРИПТОГРАФИЯ"; @@ -272,11 +272,11 @@ "settings_send_crash_report" = "Отправка данных о сбоях и использовании"; "settings_clear_cache" = "Очистить кэш"; "settings_change_password" = "Изменить пароль"; -"settings_old_password" = "старый пароль"; -"settings_new_password" = "новый пароль"; -"settings_confirm_password" = "подтвердить пароль"; -"settings_fail_to_update_password" = "Не удалось обновить пароль"; -"settings_password_updated" = "Ваш пароль был обновлен"; +"settings_old_password" = "Старый пароль"; +"settings_new_password" = "Новый пароль"; +"settings_confirm_password" = "Подтвердить пароль"; +"settings_fail_to_update_password" = "Не удалось обновить пароль аккаунта Matrix"; +"settings_password_updated" = "Ваш пароль аккаунта Matrix был обновлен"; "settings_crypto_device_name" = "Имя сеанса: "; "settings_crypto_device_id" = "\nID сеанса: "; "settings_crypto_device_key" = "\nКлюч сеанса:\n"; @@ -512,7 +512,7 @@ "room_action_send_photo_or_video" = "Отправить фото или видео"; "room_action_send_sticker" = "Отправить стикер"; "settings_deactivate_account" = "ДЕАКТИВАЦИЯ АККАУНТА"; -"settings_deactivate_my_account" = "Деактивировать мой аккаунт"; +"settings_deactivate_my_account" = "Деактивировать аккаунт навсегда"; "widget_sticker_picker_no_stickerpacks_alert_add_now" = "Добавить сейчас?"; "deactivate_account_title" = "Деактивировать аккаунт"; "deactivate_account_informations_part1" = "Это действие сделает вашу учетную запись непригодной для дальнейшего использования. Вы не сможете войти в систему и никто другой не сможет заново зарегистрировать учетную запись с вашим идентификатором. Также, это приведет к тому, что вы покинете все комнаты, в которых участвовали и данные о вашей учетной записи будут удалены с сервера идентификации. "; @@ -525,7 +525,7 @@ "deactivate_account_forget_messages_information_part3" = ": будущие участники увидят неполное представление разговоров)"; "deactivate_account_validate_action" = "Деактивировать аккаунт"; "deactivate_account_password_alert_title" = "Деактивировать аккаунт"; -"deactivate_account_password_alert_message" = "Чтобы продолжить, введите пароль"; +"deactivate_account_password_alert_message" = "Чтобы продолжить, введите пароль аккаунта Matrix"; "widget_sticker_picker_no_stickerpacks_alert" = "У вас пока нет включенных пакетов стикеров."; "event_formatter_rerequest_keys_part1_link" = "Повторно запросить ключи шифрования"; "event_formatter_rerequest_keys_part2" = " из других ваших сеансов."; @@ -583,7 +583,7 @@ "key_backup_setup_intro_title" = "Никогда не теряйте зашифрованных сообщений"; "key_backup_setup_intro_info" = "Сообщения в зашифрованных комнатах защищены сквозным шифрованием. Только вы и получатель(и) имеют ключи для чтения этих сообщений.\n\nСохраните ключи надежно, чтобы не потерять их."; "key_backup_setup_intro_setup_action" = "Настроить"; -"key_backup_setup_passphrase_info" = "Мы будем хранить зашифрованную копию ваших ключей на нашем сервере. Для безопасности, защитите резервную копию секретной фразой.\n\nДля обеспечения максимальной безопасности она должна отличаться от пароля учетной записи."; +"key_backup_setup_passphrase_info" = "Мы будем хранить зашифрованную копию ваших ключей на нашем сервере. Для безопасности, защитите резервную копию секретной фразой.\n\nДля обеспечения максимальной безопасности она должна отличаться от пароля учётной записи Matrix."; "key_backup_setup_passphrase_passphrase_title" = "Ввод"; "key_backup_setup_passphrase_passphrase_placeholder" = "Введите секретную фразу"; "key_backup_setup_passphrase_passphrase_valid" = "Отлично!"; @@ -864,7 +864,7 @@ "identity_server_settings_alert_error_invalid_identity_server" = "%@ не является действительным сервером идентификации."; "settings_add_3pid_password_title_email" = "Добавить адрес электронной почты"; "settings_add_3pid_password_title_msidsn" = "Добавить номер телефона"; -"settings_add_3pid_password_message" = "Для продолжения, задайте пароль"; +"settings_add_3pid_password_message" = "Для продолжения, введите пароль аккаунта Matrix"; "settings_add_3pid_invalid_password_message" = "Недействительные данные"; "settings_discovery_three_pid_details_title_phone_number" = "Управление номера телефона"; "settings_identity_server_no_is" = "Сервер идентификации не настроен"; @@ -915,7 +915,7 @@ "security_settings_title" = "Безопасность"; "security_settings_crypto_sessions" = "МОИ СЕАНСЫ"; "security_settings_crypto_sessions_loading" = "Загрузка сеансов…"; -"security_settings_crypto_sessions_description_2" = "Если вы не узнали логин, измените пароль и сбросьте Безопасное резервное копирование."; +"security_settings_crypto_sessions_description_2" = "Если вы не узнали логин, измените пароль аккаунта Matrix и сбросьте Безопасное резервное копирование."; "security_settings_secure_backup" = "БЕЗОПАСНОЕ РЕЗЕРВНОЕ КОПИРОВАНИЕ"; "security_settings_secure_backup_description" = "Сделайте резервную копию ключей шифрования с данными вашей учетной записи на случай, если вы потеряете доступ к своим сеансам. Ваши ключи будут защищены уникальным электронным ключом."; "security_settings_secure_backup_setup" = "Настроить"; @@ -938,7 +938,7 @@ "security_settings_complete_security_alert_title" = "Завершите настройку безопасности"; "security_settings_complete_security_alert_message" = "Сначала вы должны завершить настройку безопасности текущего сеанса."; "security_settings_coming_soon" = "Извините. Это действие пока недоступно в %@ iOS. Пожалуйста, используйте другой клиент Matrix для его настройки. %@ iOS будет его использовать."; -"security_settings_user_password_description" = "Подтвердите свою личность, введя пароль учетной записи"; +"security_settings_user_password_description" = "Подтвердите свою личность, введя пароль учётной записи Matrix"; // Manage session "manage_session_title" = "Управление сеансами"; "manage_session_info" = "ИНФОРМАЦИЯ О СЕАНСЕ"; @@ -1130,7 +1130,7 @@ "secrets_setup_recovery_key_storage_alert_message" = "✓ Распечатайте и храните в безопасном месте\n✓ Сохраните его на USB-носителе или резервном носителе\n✓ Скопируйте его в свое личное облачное хранилище"; "secrets_setup_recovery_passphrase_title" = "Задайте мнемоническую фразу"; "secrets_setup_recovery_passphrase_information" = "Введите секретную фразу, известную только вам, для защиты данных на вашем сервере."; -"secrets_setup_recovery_passphrase_additional_information" = "Не используйте пароль своей учетной записи."; +"secrets_setup_recovery_passphrase_additional_information" = "Не используйте пароль своей учётной записи Matrix."; "secrets_setup_recovery_passphrase_validate_action" = "Готово"; "secrets_setup_recovery_passphrase_confirm_information" = "Введите мнемоническую фразу ещё раз, чтобы подтвердить её."; "secrets_setup_recovery_passphrase_confirm_passphrase_title" = "Подтвердить"; @@ -1183,7 +1183,7 @@ "searchable_directory_x_network" = "%@ Сеть"; "searchable_directory_search_placeholder" = "Имя или ID"; "create_room_title" = "Новая комната"; -"create_room_section_header_name" = "Имя комнаты"; +"create_room_section_header_name" = "НАЗВАНИЕ"; "create_room_placeholder_name" = "Имя"; "create_room_section_header_topic" = "Тема комнаты (опционально)"; "create_room_placeholder_topic" = "Тема"; @@ -1218,7 +1218,7 @@ "room_details_advanced_e2e_encryption_enabled_for_dm" = "Шифрование включено"; "room_details_advanced_e2e_encryption_disabled_for_dm" = "Шифрование не включено."; "pin_protection_kick_user_alert_message" = "Слишком много ошибок, вы вышли из системы"; -"secrets_reset_authentication_message" = "Введите пароль своей учётной записи для подтверждения"; +"secrets_reset_authentication_message" = "Введите пароль своей учётной записи Matrix для подтверждения"; "secrets_reset_reset_action" = "Сброс"; "secrets_reset_warning_message" = "Вы перезапустите приложение без истории, сообщений, доверенных устройств или доверенных пользователей."; "secrets_reset_warning_title" = "Если сбросить все"; @@ -2224,3 +2224,40 @@ "threads_notice_information" = "Все потоки созданные во время экспериментального периода теперь отображаются как обычные ответы.

Это разовый переход, так как потоки теперь часть спецификации Matrix."; "authentication_qr_login_failure_device_not_supported" = "Связь с этим устройством не поддерживается."; "accessibility_selected" = "выбранный"; +"room_access_settings_screen_message" = "Решите, кто может найти и присоединиться к %@."; +"room_access_settings_screen_title" = "Кто может получить доступ к этой комнате?"; +"room_details_promote_room_suggest_title" = "Предложить участникам пространства"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Учитывайте, что имена сессий также видны людям, с которыми вы общаетесь. %@"; +"settings_labs_enable_new_client_info_feature" = "Запишите имя клиента, версию и URL-адрес, чтобы упростить распознавание сеансов в диспетчере сеансов"; +"sign_out_confirmation_message" = "Вы уверены, что хотите выйти?"; +"share_extension_send_now" = "Отправить сейчас"; +"share_extension_low_quality_video_title" = "Видео будет отправлено в низком качестве"; +"analytics_prompt_stop" = "Прекратить делиться"; +"analytics_prompt_not_now" = "Не сейчас"; +"analytics_prompt_point_3" = "Вы можете отключить это в любое время в настройках"; +/* Note: The word "don't" is formatted in bold */ +"analytics_prompt_point_2" = "Мы не передаем информацию третьим лицам"; +/* Note: The word "don't" is formatted in bold */ +"analytics_prompt_point_1" = "Мы не записываем и не профилируем никакие данные учётной записи"; +"analytics_prompt_message_upgrade" = "Ранее вы дали согласие на передачу нам анонимных данных об использовании. Теперь, чтобы помочь понять, как люди используют несколько устройств, мы сгенерируем случайный идентификатор, общий для всех ваших устройств."; +"analytics_prompt_message_new_user" = "Помогите нам выявить проблемы и улучшить %@, поделившись анонимными данными об использовании. Чтобы понять, как люди используют несколько устройств, мы сгенерируем случайный идентификатор, общий для всех ваших устройств."; + +// Analytics +"analytics_prompt_title" = "Помогите улучшить %@"; +"call_jitsi_unable_to_start" = "Невозможно начать конференц-звонок"; +"network_offline_message" = "Вы не в сети, проверьте ваше соединение."; +"network_offline_title" = "Вы не в сети"; +"event_formatter_message_deleted" = "Сообщение удалено"; +"room_access_space_chooser_other_spaces_section" = "Другие пространства или комнаты"; +"room_access_settings_screen_setting_room_access" = "Настройка доступа к комнате"; +"room_access_settings_screen_upgrade_alert_auto_invite_switch" = "Автоматически приглашать участников в новую комнату"; +"room_access_settings_screen_upgrade_alert_note" = "Обратите внимание, что при обновлении будет создана новая версия комнаты. Все текущие сообщения останутся в этой архивной комнате."; +"room_access_settings_screen_upgrade_alert_message_no_param" = "Любой в родительском пространстве сможет найти и присоединиться к этой комнате - нет необходимости вручную приглашать всех вручную. Вы сможете изменить это в настройках комнаты в любое время."; +"room_access_settings_screen_upgrade_alert_message" = "Любой человек в %@ сможет найти и присоединиться к этой комнате - нет необходимости вручную приглашать всех. Вы сможете изменить это в настройках комнаты в любое время."; +"room_access_settings_screen_public_message" = "Любой желающий может найти и присоединиться."; +"room_access_settings_screen_edit_spaces" = "Редактировать пространства"; +"room_access_settings_screen_restricted_message" = "Позволяет всем, кто находится в пространстве, найти его и присоединиться.\nВам будет предложено подтвердить к каким пространствам."; +"room_access_settings_screen_private_message" = "Только приглашенные люди могут найти и присоединиться."; +"manage_session_name_hint" = "Индивидуальные имена сеансов помогут Вам легче распознавать свои устройства."; +"settings_labs_confirm_crypto_sdk" = "Имейте ввиду, что эта функция все ещё на экспериментальной стадии, поэтому она может работать не так, как ожидается, и потенциально может иметь непредвиденные последствия. Для отмены функции выйдите из системы и войдите снова. Используйте её по своему усмотрению и с осторожностью."; diff --git a/Riot/Assets/sk.lproj/Vector.strings b/Riot/Assets/sk.lproj/Vector.strings index 7f2255f4b..8399cd6ff 100644 --- a/Riot/Assets/sk.lproj/Vector.strings +++ b/Riot/Assets/sk.lproj/Vector.strings @@ -2931,3 +2931,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Synchronizácia vašich konverzácií"; +"pill_message_in" = "Správa v %@"; +"pill_message_from" = "Správa od %@"; +"pill_message" = "Správa"; + +// Pills +"pill_room_fallback_display_name" = "Priestor/miestnosť"; +"key_verification_self_verify_security_upgrade_alert_message" = "Najnovšou aktualizáciou sa zlepšilo bezpečné zasielanie správ. Overte prosím znova svoje zariadenie."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Aplikácia bola aktualizovaná"; +"settings_acceptable_use" = "Zásady prijateľného používania"; diff --git a/Riot/Assets/sq.lproj/Vector.strings b/Riot/Assets/sq.lproj/Vector.strings index 4274f5dc5..fa2860895 100644 --- a/Riot/Assets/sq.lproj/Vector.strings +++ b/Riot/Assets/sq.lproj/Vector.strings @@ -2718,3 +2718,16 @@ "device_verification_self_verify_open_on_other_device_information" = "Lypset të verifikoni këtë sesion, që të mund të lexoni historikun e mesazheve tuaj të siguruar.\n\nHapeni Element-in në një nga pajisjet tuaja të tjera dhe ndiqni udhëzimet."; "device_verification_self_verify_open_on_other_device_title" = "Hapeni %@ në pajisjen tuaj tjetër"; "room_creation_only_one_email_invite" = "Mund të ftoni vetëm një email në herë"; +"pill_message_in" = "Mesazh te %@"; +"pill_message_from" = "Mesazh nga %@"; +"pill_message" = "Mesazh"; + +// Pills +"pill_room_fallback_display_name" = "Hapësirë/Dhomë"; +"key_verification_self_verify_security_upgrade_alert_message" = "Me përditësimin e fundit shkëmbimi i siguruar i mesazheve është përmirësuar. Ju lutemi, riverifikoni pajisjen tuaj."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Aplikacioni u përditësua"; +"settings_acceptable_use" = "Rregull Përdorimi të Pranueshëm"; +"accessibility_selected" = "përzgjedhur"; diff --git a/Riot/Assets/uk.lproj/Vector.strings b/Riot/Assets/uk.lproj/Vector.strings index 7b321d90b..65ff758cb 100644 --- a/Riot/Assets/uk.lproj/Vector.strings +++ b/Riot/Assets/uk.lproj/Vector.strings @@ -2933,3 +2933,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Синхронізація ваших розмов"; +"pill_message_in" = "Повідомлення у %@"; +"pill_message_from" = "Повідомлення від %@"; +"pill_message" = "Повідомлення"; + +// Pills +"pill_room_fallback_display_name" = "Простір/кімната"; +"key_verification_self_verify_security_upgrade_alert_message" = "В останньому оновленні було вдосконалено захищений обмін повідомленнями. Перевірте свій пристрій ще раз."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Застосунок оновлено"; +"settings_acceptable_use" = "Політика прийнятного користування"; diff --git a/Riot/Assets/zh_Hant.lproj/InfoPlist.strings b/Riot/Assets/zh_Hant.lproj/InfoPlist.strings index e236e4a3d..30c871e37 100644 --- a/Riot/Assets/zh_Hant.lproj/InfoPlist.strings +++ b/Riot/Assets/zh_Hant.lproj/InfoPlist.strings @@ -1,8 +1,8 @@ // Permissions usage explanations "NSCameraUsageDescription" = "給予相機權限會用來進行視訊通話或是拍攝並上傳照片與影片。"; -"NSPhotoLibraryUsageDescription" = "同意使用圖片的權限會用來上傳您圖庫的照片與影片。"; -"NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來接受通話、拍攝影片以及錄製語音訊息。"; -"NSContactsUsageDescription" = "他們會與您的身分伺服器共享以找到您在Matrix上的聯絡人。"; +"NSPhotoLibraryUsageDescription" = "請允許存取「照片」,來上傳圖庫當中的照片或影片。"; +"NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來通話、拍攝影片以及錄製語音訊息。"; +"NSContactsUsageDescription" = "會將此資訊分享給您的身分伺服器,以幫助您尋找 Matrix 聯絡人。"; "NSLocationAlwaysAndWhenInUseUsageDescription" = "當您與其他人分享您的位置,Element 需要權限將位置顯示在地圖上。"; "NSLocationWhenInUseUsageDescription" = "當您與其他人分享您的位置,Element 需要權限將位置顯示在地圖上。"; "NSFaceIDUsageDescription" = "您可以使用 Face ID 來登入您的應用程式。"; diff --git a/Riot/Assets/zh_Hant.lproj/Localizable.strings b/Riot/Assets/zh_Hant.lproj/Localizable.strings index 07a5f408b..9bc6bfb82 100644 --- a/Riot/Assets/zh_Hant.lproj/Localizable.strings +++ b/Riot/Assets/zh_Hant.lproj/Localizable.strings @@ -75,15 +75,15 @@ "USER_MEMBERSHIP_UPDATED" = "%@ 更新了個人資料"; /* A user has change their name to a new name which we don't know */ -"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ 變更了名字"; +"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ 變更了名稱"; /** Membership Updates **/ /* A user has change their name to a new name */ -"USER_UPDATED_DISPLAYNAME" = "%@ 把名稱變更為 %@"; +"USER_UPDATED_DISPLAYNAME" = "%@ 將名稱變更為 %@"; /* A user has change their avatar */ -"USER_UPDATED_AVATAR" = "%@ 變更了他們的頭像"; +"USER_UPDATED_AVATAR" = "%@ 變更了大頭照"; /* A user has reacted to a message, but the reaction content is unknown */ "GENERIC_REACTION_FROM_USER" = "%@ 送出了一個反應"; diff --git a/Riot/Assets/zh_Hant.lproj/Vector.strings b/Riot/Assets/zh_Hant.lproj/Vector.strings index 91fc0beee..167652a1d 100644 --- a/Riot/Assets/zh_Hant.lproj/Vector.strings +++ b/Riot/Assets/zh_Hant.lproj/Vector.strings @@ -98,7 +98,7 @@ "directory_title" = "目錄"; "auth_recaptcha_message" = "這個家伺服器想要確認您不是機器人"; "auth_reset_password_missing_email" = "必須輸入和您帳號綁定的電子郵件地址。"; -"auth_reset_password_missing_password" = "必須輸入一個新密碼。"; +"auth_reset_password_missing_password" = "必須輸入新密碼。"; "auth_reset_password_next_step_button" = "我已經驗證了電子郵件地址"; "auth_reset_password_error_unauthorized" = "電子郵件地址驗證失敗:請確認您已點擊郵件中的連結"; "auth_reset_password_error_not_found" = "您的電子郵件地址似乎並未與這台家伺服器上的任何 Matrix ID 相關聯。"; @@ -384,9 +384,9 @@ "room_details_photo" = "聊天室圖片"; "room_details_room_name" = "聊天室名稱"; "room_details_mute_notifs" = "將通知靜音"; -"room_details_access_section_no_address_warning" = "要連結一個聊天室,該聊天室必須要有位址"; +"room_details_access_section_no_address_warning" = "要連結聊天室,該聊天室必須要有位址"; "room_details_access_section_directory_toggle" = "將此聊天室列入聊天室目錄"; -"room_details_history_section_prompt_msg" = "對可閱讀歷史訊息的使用者的變更,將僅適用於此聊天室的新訊息。現有訊息的顯示狀態將保持不變。"; +"room_details_history_section_prompt_msg" = "對可閱讀訊息紀錄的使用者的變更,僅適用於此聊天室的新訊息。現有訊息的顯示狀態將保持不變。"; "room_details_new_address" = "新增位址"; "room_details_new_address_placeholder" = "新增位址(例如 #foo%@)"; "room_details_addresses_invalid_address_prompt_msg" = "%@ 不是有效的別名格式"; @@ -454,7 +454,7 @@ "directory_server_type_homeserver" = "輸入一個家伺服器來列出所有公開聊天室"; "directory_server_placeholder" = "matrix.org"; // Events formatter -"event_formatter_member_updates" = "變更 %tu 成員身分"; +"event_formatter_member_updates" = "%tu 筆成員狀態變更"; "event_formatter_widget_added" = "%@ 小工具已由 %@ 新增"; "event_formatter_widget_removed" = "%@ 小工具已由 %@ 移除"; "event_formatter_jitsi_widget_added" = "VoIP 群組通話已由 %@ 新增"; @@ -526,7 +526,7 @@ "room_message_reply_to_placeholder" = "傳送回覆(未加密)…"; "encrypted_room_message_reply_to_placeholder" = "傳送加密的回覆…"; "room_message_reply_to_short_placeholder" = "傳送回覆…"; -"room_event_action_view_decrypted_source" = "檢視已解密的來源"; +"room_event_action_view_decrypted_source" = "檢視解密的原始碼"; "room_predecessor_link" = "點擊此處以檢視更早以前的訊息。"; "room_replacement_information" = "這個聊天室已被取代,且不再使用。"; "room_replacement_link" = "對話在此繼續。"; @@ -622,7 +622,7 @@ "store_full_description" = "Element 是一套新型的通訊和協作應用程式,它提供下列功能:\n\n1. 您可以自行掌控隱私\n2. 可以與 Matrix 網路中的任何人進行通訊,甚至可以與 Slack 等應用程式整合\n3. 保護您免受廣告、資料探勘、後門和封閉平台的侵害\n4. 透過端到端加密和交叉簽署來驗證彼此,互相確保安全\n\nElement 是去中心化的開源軟體,因此與其他通訊和協作應用程式完全不同。\n\nElement 允許您自行架設(或選擇託管)伺服器,使您可針對隱私權,所有權以及對資料和對話內容的完整控制權。您可以連線到所有開放的網路,所以您不是只能與其他 Element 使用者聊天。而且還非常安全。\n\nElement 之所以能夠做到所有這些目標,是因為它使用 Matrix(一套開放、去中心化的通訊標準)運作。\n\nElement 讓您可以自行選擇要將對話放在哪一台伺服器來讓您可自行控制自己的訊息和資料。在 Element 應用程式中,您可以選擇以不同方式託管您的訊息:\n\n1. 在 matrix.org 公開伺服器註冊免費帳號\n2. 使用自行架設的硬體主機上的伺服器來註冊帳號\n3. 訂閱 Element Matrix Services 代管平台,註冊自己的伺服器\n\n為什麼要選擇 Element?\n\n自己擁有自己資料:由您決定將資料與訊息保留在何處。您自己擁有並管理這些資料,而不用讓某些「超大型企業」來探勘您的資料,或將資料提供給第三方。\n\n開放的通訊與協作機制:您可以與 Matrix 網路中的任何人聊天,不管他們使用的是 Element 還是其他 Matrix 應用程式,甚至他們也可以使用像 Slack 、IRC 或 XMPP 之類的其他通訊系統。\n\n超級安全:真正的端對端加密(只有對話中的人才能解開訊息內容),並進行交叉簽署以驗證對話參與者的設備。\n\n完整的通訊:傳訊息、進行語音或視訊通話、分享檔案、畫面,還有大量整合、機器人與小工具。建立聊天室、社群,保持聯繫並完成工作。\n\n無論您身在何處都可保持聯繫:無論您身在何處,都可以透過 https://element.io/app 在所有裝置與網路取得完全同步的訊息記錄來保持聯繫。"; // String for App Store "store_short_description" = "去中心化的安全通訊/VoIP 軟體"; -"settings_three_pids_management_information_part1" = "在此管理您用作登入或恢復帳號的電子郵件地址或電話號碼。您也可控制誰可以用這些資料找到您 "; +"settings_three_pids_management_information_part1" = "您可以在 "; "external_link_confirmation_message" = "此連結 %@ 將帶您到另一網頁:%@\n\n確定要前往嗎?"; "external_link_confirmation_title" = "請確認此連結"; "media_type_accessibility_sticker" = "貼圖"; @@ -826,7 +826,7 @@ "event_formatter_call_answer" = "接聽"; "event_formatter_call_back" = "回撥"; "event_formatter_call_has_ended" = "通話結束"; -"event_formatter_call_connecting" = "正在連接…"; +"event_formatter_call_connecting" = "連線中…"; "event_formatter_message_edited_mention" = "(已編輯)"; "image_picker_action_library" = "從媒體庫挑選"; @@ -881,14 +881,14 @@ "settings_messages_containing_keywords" = "關鍵字"; "settings_messages_containing_user_name" = "我的使用者名稱"; "settings_messages_containing_display_name" = "我的顯示名稱"; -"settings_encrypted_group_messages" = "已加密的群組訊息"; +"settings_encrypted_group_messages" = "加密的群組訊息"; "settings_group_messages" = "群組訊息"; -"settings_encrypted_direct_messages" = "已加密的私人訊息"; +"settings_encrypted_direct_messages" = "加密的私人訊息"; "settings_direct_messages" = "私人訊息"; "settings_default" = "預設通知"; "settings_notifications_disabled_alert_title" = "已停用通知"; "settings_device_notifications" = "裝置通知"; -"settings_three_pids_management_information_part3" = "。"; +"settings_three_pids_management_information_part3" = "管理您用作登入或恢復帳號的電子郵件地址或電話號碼。您也可控制誰可以用這些資料找到您。"; "room_join_group_call" = "加入"; "room_place_voice_call" = "語音通話"; "room_accessibility_video_call" = "視訊通話"; @@ -924,7 +924,7 @@ "room_event_encryption_info_event" = "事件資訊\n"; "room_event_encryption_info_event_user_id" = "使用者 ID\n"; "room_event_encryption_info_event_identity_key" = "Curve25519 身分認證金鑰\n"; -"room_event_encryption_info_event_fingerprint_key" = "已聲請之 Ed25519 指紋金鑰\n"; +"room_event_encryption_info_event_fingerprint_key" = "聲稱的 Ed25519 指紋金鑰\n"; "room_event_encryption_info_event_algorithm" = "演算法\n"; "room_event_encryption_info_event_session_id" = "工作階段 ID\n"; "room_event_encryption_info_event_decryption_error" = "解密錯誤\n"; @@ -993,14 +993,14 @@ "notice_room_ban" = "%@ 已封鎖 %@"; "notice_room_withdraw" = "%@ 已撤回 %@ 的邀請"; "notice_room_reason" = ",原因:%@"; -"notice_avatar_url_changed" = "%@ 已變更大頭照"; -"notice_display_name_set" = "%@ 已將他們的顯示名稱設定為 %@"; -"notice_display_name_changed_from" = "%@ 將自己的顯示名稱從 %@ 改為 %@"; -"notice_display_name_removed" = "%@ 已移除他們的顯示名稱"; -"notice_topic_changed" = "%@ 已經將主題變更為:%@。"; +"notice_avatar_url_changed" = "%@ 變更了大頭照"; +"notice_display_name_set" = "%@ 將顯示名稱設定為 %@"; +"notice_display_name_changed_from" = "%@ 將顯示名稱從 %@ 改為 %@"; +"notice_display_name_removed" = "%@ 移除了顯示名稱"; +"notice_topic_changed" = "%@ 將主題變更為「%@」。"; "notice_room_name_changed" = "%@ 將聊天室名稱變更為 %@。"; -"notice_placed_voice_call" = "%@ 已播出語音通話"; -"notice_placed_video_call" = "%@ 已播出視訊通話"; +"notice_placed_voice_call" = "%@ 已撥出語音通話"; +"notice_placed_video_call" = "%@ 已撥出視訊通話"; "notice_answered_video_call" = "%@ 已接聽通話"; "notice_ended_video_call" = "%@ 已結束通話"; "notice_conference_call_request" = "%@ 已請求 VoIP 會議"; @@ -1098,7 +1098,7 @@ "notice_room_topic_removed" = "%@ 移除了該主題"; "notice_event_redacted_by" = " 由 %@"; "notice_event_redacted_reason" = " [理由:%@]"; -"notice_profile_change_redacted" = "%@ 已更新他的個人檔案 %@"; +"notice_profile_change_redacted" = "%@ 更新了個人檔案 %@"; "notice_room_created" = "%@ 已建立並設定該聊天室。"; "notice_room_join_rule" = "加入規則: %@"; "notice_room_power_level_intro" = "聊天室成員們的權限级别是:"; @@ -1138,7 +1138,7 @@ "notice_error_unexpected_event" = "意外事件"; "notice_error_unknown_event_type" = "未知的事件類型"; "notice_room_history_visible_to_anyone" = "%@ 讓任何人都能看到未來的聊天室歷史記錄。"; -"notice_room_history_visible_to_members" = "%@ 讓所有聊天室成員都能看到聊天室之後的歷史記錄。"; +"notice_room_history_visible_to_members" = "%@ 讓所有成員都能看到聊天室之後的歷史記錄。"; "stop" = "停止"; "joining" = "正在加入"; "enable" = "啟用"; @@ -1291,18 +1291,18 @@ // Settings keys // call string -"call_connecting" = "正在連接…"; +"call_connecting" = "連線中…"; "notification_settings_notify_all_other" = "其他訊息/聊天室的通知"; "notification_settings_by_default" = "按預設…"; "notification_settings_suppress_from_bots" = "限制來自機器人的通知"; "notification_settings_receive_a_call" = "當我收到通話時,請通知我"; "notification_settings_people_join_leave_rooms" = "有人加入或離開聊天室時,請通知我"; -"notification_settings_invite_to_a_new_room" = "當我被邀請到一個全新的聊天室時,請通知我"; -"notification_settings_just_sent_to_me" = "當收到只寄給我的訊息時,請用聲音通知我"; -"notification_settings_contain_my_display_name" = "訊息出現我的顯示名稱時,請用聲音通知我"; -"notification_settings_contain_my_user_name" = "訊息出現我的使用者名稱時,請用聲音通知我"; +"notification_settings_invite_to_a_new_room" = "當我被邀請到全新的聊天室時,請通知我"; +"notification_settings_just_sent_to_me" = "當收到只寄給我的訊息時,請用音效通知我"; +"notification_settings_contain_my_display_name" = "訊息出現我的顯示名稱時,請用音效通知我"; +"notification_settings_contain_my_user_name" = "訊息出現我的使用者名稱時,請用音效通知我"; "notification_settings_other_alerts" = "其他警告"; -"notification_settings_select_room" = "選擇一個聊天室"; +"notification_settings_select_room" = "請選擇聊天室"; "notification_settings_sender_hint" = "@user:domain.com"; "notification_settings_per_sender_notifications" = "寄件人通知"; "notification_settings_per_room_notifications" = "聊天室的通知"; @@ -1346,11 +1346,11 @@ "login_error_already_logged_in" = "已經登入"; "message_unsaved_changes" = "還有變更未儲存。現在離開的話,您將會放棄這些變動。"; "notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "您讓所有人在加入後,就能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_joined_point_by_you" = "您讓所有聊天室成員在加入後,都能看到未來的聊天室歷史紀錄。"; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "您讓所有成員在加入後,都能看到未來的聊天室歷史紀錄。"; "notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "您讓所有人收到邀請後,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_invited_point_by_you" = "您讓所有聊天室成員被邀請後,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_by_you_for_dm" = "您讓所有聊天室成員都能看到聊天室未來的歷史記錄。"; -"notice_room_history_visible_to_members_by_you" = "您讓所有聊天室成員都能看到聊天室未來的歷史記錄。"; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "您讓所有成員被邀請後,都能看到未來的聊天室歷史紀錄。"; +"notice_room_history_visible_to_members_by_you_for_dm" = "您讓所有成員都能看到聊天室未來的歷史記錄。"; +"notice_room_history_visible_to_members_by_you" = "您讓所有成員都能看到聊天室未來的歷史記錄。"; "notice_room_history_visible_to_anyone_by_you" = "您讓任何人都能看到未來的聊天室歷史記錄。"; "notice_redaction_by_you" = "您已取消一个事件(id: %@)"; "notice_encryption_enabled_unknown_algorithm_by_you" = "您已開啟端到端加密(無法識別的演算法 %@)。"; @@ -1366,14 +1366,14 @@ "notice_declined_video_call_by_you" = "您已拒絕此通話"; "notice_ended_video_call_by_you" = "您已結束通話"; "notice_answered_video_call_by_you" = "您已接聽此通話"; -"notice_placed_video_call_by_you" = "您已播出視訊通話"; -"notice_placed_voice_call_by_you" = "您已播出語音通話"; -"notice_room_name_changed_by_you_for_dm" = "您已將名稱變更為 %@。"; -"notice_room_name_changed_by_you" = "您已將聊天室名稱變更為 %@。"; -"notice_topic_changed_by_you" = "您已經將主題變更為:%@。"; +"notice_placed_video_call_by_you" = "您已撥出視訊通話"; +"notice_placed_voice_call_by_you" = "您已撥出語音通話"; +"notice_room_name_changed_by_you_for_dm" = "您將名稱變更為 %@。"; +"notice_room_name_changed_by_you" = "您將聊天室名稱變更為 %@。"; +"notice_topic_changed_by_you" = "您將主題更改為:「%@」。"; "notice_display_name_removed_by_you" = "您已移除自己的顯示名稱"; "notice_display_name_changed_from_by_you" = "您已將顯示名稱從 %@ 變更為 %@"; -"notice_display_name_set_by_you" = "您已將顯示名稱設定為 %@"; +"notice_display_name_set_by_you" = "您將您的顯示名稱設定為 %@"; "notice_avatar_url_changed_by_you" = "您已變更您的大頭照"; "notice_room_withdraw_by_you" = "您已撤回 %@ 的邀請"; "notice_room_ban_by_you" = "您已封鎖 %@"; @@ -1392,12 +1392,12 @@ // Notice Events with "You" "notice_room_invite_by_you" = "您已邀請 %@"; "notice_declined_video_call" = "%@ 已拒絕此通話"; -"notice_room_name_changed_for_dm" = "%@ 把名稱變更為 %@。"; +"notice_room_name_changed_for_dm" = "%@ 將名稱變更為 %@。"; "notice_room_third_party_revoked_invite_for_dm" = "%@ 已撤銷對 %@ 的邀請"; "notice_room_third_party_revoked_invite" = "%@ 已撤銷對 %@ 加入聊天室的邀請"; "notice_room_third_party_invite_for_dm" = "%@ 已邀請 %@"; "microphone_access_not_granted_for_voice_message" = "語音簡訊需要使用麥克風的權限,但是 %@ 沒有存取權限"; -"error_common_message" = "發生了一個錯誤。請重新再試。"; +"error_common_message" = "發生錯誤。請稍後再試。"; "e2e_passphrase_create" = "建立安全密語"; "e2e_passphrase_too_short" = "安全密語太短(至少要 %d 字母的長度)"; "e2e_passphrase_confirm" = "確認安全密語"; @@ -1445,7 +1445,7 @@ "room_member_ignore_prompt" = "您確定要隱藏所有來自此使用者的訊息嗎?"; "message_reply_to_message_to_reply_to_prefix" = "回覆給"; "message_reply_to_sender_sent_their_live_location" = "即時位置。"; -"message_reply_to_sender_sent_their_location" = "已經分享了他們的位置。"; +"message_reply_to_sender_sent_their_location" = "分享了他們的位置。"; "message_reply_to_sender_sent_a_file" = "已傳送檔案。"; "message_reply_to_sender_sent_a_voice_message" = "已傳送語音訊息。"; "message_reply_to_sender_sent_an_audio_file" = "已傳送音訊檔。"; @@ -1531,7 +1531,7 @@ "user_session_rename_session_title" = "正在重新命名工作階段"; "user_session_inactive_session_description" = "不活躍的工作階段是您有一段時間未使用的工作階段,但它們會繼續接收加密金鑰。\n\n移除不活躍的工作階段可以改善安全性與效能,並讓您可以更容易地識別新的工作階段是否可疑。"; "user_session_inactive_session_title" = "不活躍的工作階段"; -"user_session_permanently_unverified_session_description" = "此工作階段無法對此對話進行加密,因此無法驗證。\n\n您無法進入已加密的聊天室中。\n\n為了安全與隱私,建議使用支援加密的 Matrix 客戶端。"; +"user_session_permanently_unverified_session_description" = "此工作階段不支援加密功能,所以無法驗證。\n\n您無法使用此工作階段進入有開啟加密的聊天室中。\n\n為了安全與隱私,建議使用支援加密的 Matrix 客戶端。"; "user_session_unverified_session_description" = "未驗證的工作階段是使用您的憑證登入但交叉叉驗證的工作階段。\n\n您應特別確定您可以識別這些工作階段,因為它們可能代表未經授權使用您的帳號。"; "user_session_unverified_session_title" = "未經驗證的工作階段"; "user_session_verified_session_description" = "已驗證的工作階段,是您輸入安全密語或透過另一個已驗證工作階段確認您的身分後,使用此 Element 帳號的任何地方。\n\n這代表了您擁有解鎖加密訊息,並向其他使用者確認您信任此工作階段所需的所有金鑰。"; @@ -1638,8 +1638,8 @@ "poll_timeline_total_one_vote_not_voted" = "已投 1 票。投票後即可檢視結果"; "poll_timeline_total_votes" = "共計 %lu 票"; "poll_timeline_total_one_vote" = "共計 1 票"; -"poll_timeline_total_no_votes" = "尚未投票"; -"poll_timeline_votes_count" = "%lu 張票"; +"poll_timeline_total_no_votes" = "尚無投票"; +"poll_timeline_votes_count" = "%lu 票"; "poll_timeline_one_vote" = "1 票"; "poll_edit_form_poll_type_closed_description" = "結果僅在您結束投票後顯示"; "poll_edit_form_poll_type_closed" = "秘密投票"; @@ -1683,12 +1683,12 @@ "all_chats_nothing_found_placeholder_title" = "找不到任何結果。"; "all_chats_empty_unreads_placeholder_message" = "當您有一些未讀的訊息時,這裡會顯示您的未讀訊息。"; "all_chats_empty_list_placeholder_title" = "您都看完了。"; -"all_chats_empty_view_information" = "適用於團隊、朋友與組織的多合一安全聊天應用程式。建立聊天室,或加入一個既有的聊天室。"; +"all_chats_empty_view_information" = "適用於團隊、朋友與組織的多合一安全聊天應用程式。建立聊天室,或加入現有的聊天室。"; "all_chats_empty_space_information" = "聊天空間是一種為聊天室與人們分組的新方式。使用右下角的按鈕新增既有的聊天室或建立新的。"; "all_chats_empty_view_title" = "%@\n看起來有點空。"; "all_chats_all_filter" = "全部"; -"all_chats_edit_layout_alphabetical_order" = "按 A-Z 排列"; -"all_chats_edit_layout_activity_order" = "按活動排列"; +"all_chats_edit_layout_alphabetical_order" = "按名稱 A-Z 排序"; +"all_chats_edit_layout_activity_order" = "按頻道最新活動排列"; "all_chats_edit_layout_show_filters" = "顯示過濾條件"; "all_chats_edit_layout_show_recents" = "顯示最近的"; "all_chats_edit_layout_sorting_options_title" = "分類您的訊息"; @@ -1870,7 +1870,7 @@ "home_context_menu_normal_priority" = "一般優先度"; "home_context_menu_low_priority" = "低優先度"; "home_context_menu_unfavourite" = "從我的最愛移除"; -"home_context_menu_favourite" = "我的最愛"; +"home_context_menu_favourite" = "加入我的最愛"; "home_context_menu_unmute" = "解除靜音"; "home_context_menu_mute" = "靜音"; "home_context_menu_notifications" = "通知"; @@ -1996,10 +1996,10 @@ "notice_crypto_error_unknown_inbound_session_id" = "傳送者的工作階段,尚未傳送傳給我們這則訊息的金鑰。"; "notice_crypto_unable_to_decrypt" = "** 無法解密:%@ **"; "notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ 您讓所有人被邀請後,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_joined_point" = "%@ 讓所有聊天室成員被邀請後開始,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ 從他們被邀請開始,讓未來的聊天室歷史紀錄顯示給所有聊天室成員。"; -"notice_room_history_visible_to_members_from_invited_point" = "%@ 從他們被邀請開始,讓未來的聊天室歷史紀錄顯示給所有聊天室成員。"; -"notice_room_history_visible_to_members_for_dm" = "%@ 讓所有聊天室成員都能看到聊天室之後的歷史記錄。"; +"notice_room_history_visible_to_members_from_joined_point" = "%@ 讓所有成員被邀請後開始,都能看到未來的聊天紀錄。"; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ 從他們被邀請開始,讓未來的聊天紀錄顯示給所有成員。"; +"notice_room_history_visible_to_members_from_invited_point" = "%@ 從他們被邀請開始,讓未來的聊天紀錄顯示給所有成員。"; +"notice_room_history_visible_to_members_for_dm" = "%@ 讓所有成員都能看到聊天室之後的歷史記錄。"; "notice_error_unformattable_event" = "** 無法顯示這則訊息。請回報此錯誤"; "notice_encryption_enabled_unknown_algorithm" = "%1$@ 已開啟端到端加密(無法識別的演算法 %2$@)。"; "notice_encryption_enabled_ok" = "%@ 已開啟端到端加密。"; @@ -2009,11 +2009,11 @@ "notice_room_join_rule_public_by_you" = "您已公開此聊天室。"; "notice_room_join_rule_public_for_dm" = "%@ 公開這個。"; "notice_room_join_rule_public" = "%@ 公開此聊天室。"; -"notice_room_join_rule_invite_by_you_for_dm" = "您讓此變為邀請制。"; +"notice_room_join_rule_invite_by_you_for_dm" = "您將此處變為邀請制。"; "notice_room_join_rule_invite_by_you" = "您讓聊天室變為邀請才可加入。"; -"notice_room_join_rule_invite_for_dm" = "%@讓此變為邀請制。"; +"notice_room_join_rule_invite_for_dm" = "%@ 將此處變為邀請制。"; // New -"notice_room_join_rule_invite" = "%@讓聊天室變為邀請才可加入。"; +"notice_room_join_rule_invite" = "%@ 將聊天室變為邀請制。"; "notice_room_created_for_dm" = "%@ 已加入。"; "notice_room_name_removed_for_dm" = "%@ 移除了該聊天室的名稱"; "ignore_user" = "忽略使用者"; @@ -2114,7 +2114,7 @@ // Generic errors -"error_invite_3pid_with_no_identity_server" = "在設定加入一個身分伺服器,才能用電子郵件寄送邀請。"; +"error_invite_3pid_with_no_identity_server" = "在設定加入身分伺服器後,才能用電子郵件寄送邀請。"; "emoji_picker_flags_category" = "旗幟"; "emoji_picker_symbols_category" = "符號"; "emoji_picker_places_category" = "旅遊與景點"; @@ -2128,7 +2128,7 @@ // User -"key_verification_verified_user_information" = "與此使用者的訊息是端到端加密的,無法被第三方讀取。"; +"key_verification_verified_user_information" = "與此使用者的訊息有端對端加密,無法被第三方讀取。"; "key_verification_verified_this_session_information" = "您現在可以在此裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。"; "key_verification_verified_new_session_information" = "您現在也可以在新的裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。"; "key_verification_verified_other_session_information" = "您現在也可以在其他的工作階段閱讀您的加密訊息,其他使用者也會知道他們能夠信任此工作階段。"; @@ -2300,7 +2300,7 @@ "secure_key_backup_setup_intro_use_security_passphrase_info" = "輸入只有您知道的安全密語,並產生備份的金鑰。"; "secure_key_backup_setup_intro_use_security_passphrase_title" = "使用安全密語"; "secure_key_backup_setup_intro_use_security_key_info" = "產生安全金鑰後,請儲存在密碼管理員或保險箱等安全的地方。"; -"secure_key_backup_setup_intro_info" = "透過備份您伺服器上的加密金鑰,來防止失去對您已加密的訊息與資料的存取權。"; +"secure_key_backup_setup_intro_info" = "透過備份您伺服器上的加密金鑰,來防止失去對加密訊息與資料的存取權。"; "service_terms_modal_information_description_integration_manager" = "整合管理員能夠讓您加入第三方服務的功能。"; "service_terms_modal_information_description_identity_server" = "身分伺服器讓您能夠用電話或電子郵件,查詢您的聯絡人是否已經申請帳號。"; "service_terms_modal_information_title_integration_manager" = "整合管理員"; @@ -2309,7 +2309,7 @@ "service_terms_modal_information_title_identity_server" = "身分伺服器"; "service_terms_modal_description_integration_manager" = "這會讓您可以使用聊天機器人、橋接、小工具和貼圖包。"; "service_terms_modal_description_identity_server" = "這會讓手機上儲存您電話或電子郵件的人能找到您。"; -"service_terms_modal_table_header_integration_manager" = "管理整合服務使用條款"; +"service_terms_modal_table_header_integration_manager" = "整合管理員使用條款"; "service_terms_modal_table_header_identity_server" = "身分伺服器條款"; "service_terms_modal_footer" = "您可以隨時在設定中取消。"; @@ -2362,10 +2362,10 @@ "leave_space_action" = "離開聊天空間"; "spaces_add_room_missing_permission_message" = "您沒有權限在此聊天空間中新增聊天室。"; -"spaces_creation_in_one_space" = "在一個聊天空間"; +"spaces_creation_in_one_space" = "在 1 個聊天空間"; "spaces_creation_in_many_spaces" = "在 %@ 個聊天空間"; "spaces_creation_in_spacename_plus_many" = "在 %@ 加入 %@ 個聊天空間"; -"spaces_creation_in_spacename_plus_one" = "在 %@ 加入一個聊天空間"; +"spaces_creation_in_spacename_plus_one" = "在 %@ 加入 1 個聊天空間"; "spaces_creation_in_spacename" = "在 %@"; "spaces_creation_post_process_inviting_users" = "邀請 %@ 位使用者"; "spaces_creation_post_process_adding_rooms" = "加入 %@ 個聊天室"; @@ -2424,7 +2424,7 @@ "room_notifs_settings_mentions_and_keywords" = "僅提及和關鍵字"; // Room Notification Settings -"room_notifs_settings_notify_me_for" = "通知我"; +"room_notifs_settings_notify_me_for" = "收到下列訊息時通知我"; "room_suggestion_settings_screen_message" = "將向聊天空間中的成員推薦建議的聊天室。"; "room_suggestion_settings_screen_title" = "將聊天室設為聊天空間中的建議聊天室"; @@ -2437,7 +2437,7 @@ "room_access_settings_screen_upgrade_alert_upgrading" = "升級聊天室"; "room_access_settings_screen_upgrade_alert_upgrade_button" = "升級"; "room_access_settings_screen_upgrade_alert_auto_invite_switch" = "自動邀請成員到新的聊天室"; -"room_access_settings_screen_upgrade_alert_note" = "請注意,升級會創造一個新版本的聊天室。目前所有的訊息都會放在已封存的聊天室。"; +"room_access_settings_screen_upgrade_alert_note" = "請注意,升級會建立新版的聊天室。目前的所有訊息都將封存在此聊天室中。"; "room_access_settings_screen_upgrade_alert_message_no_param" = "母聊天空間中的任何人都可以找到並加入此聊天室,不需要手動邀請所有人。您隨時都可以在聊天室設定中變更此設定。"; "room_access_settings_screen_upgrade_alert_message" = "任何在 %@ 的人都能找到並加入此聊天室,不需手動邀請所有人。您可以在聊天室的設定中隨時變更此設定。"; "room_access_settings_screen_upgrade_alert_title" = "升級聊天室"; @@ -2474,7 +2474,7 @@ "identity_server_settings_alert_change_title" = "變更身分伺服器"; "identity_server_settings_alert_no_terms" = "您選擇的身分伺服器沒有任何服務條款。僅在您信任服務擁有者時才繼續。"; "identity_server_settings_alert_no_terms_title" = "身分伺服器無使用條款"; -"identity_server_settings_disconnect_info" = "如果您未連線到您的身分伺服器,其他的使用者將無法找到您,您也無法經由電子郵件和電話找到其他使用者。"; +"identity_server_settings_disconnect_info" = "與您的身分伺服器中斷連線後,其他使用者就無法再探索到您,您也不能透過電子郵件地址或電話號碼邀請其他人。"; "identity_server_settings_place_holder" = "輸入一個身分伺服器"; "identity_server_settings_no_is_description" = "您目前未使用身分伺服器。若想要尋找或被您認識的聯絡人找到,請在上方加入伺服器。"; "identity_server_settings_description" = "您正在使用 %@ 來讓其他現有的聯絡人和您能夠找到彼此。"; @@ -2531,9 +2531,9 @@ "settings_discovery_three_pid_details_revoke_action" = "撤回"; "settings_discovery_three_pid_details_information_phone_number" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電話號碼偏好設定。您可以在「帳號」中加入或刪除電話號碼。"; "settings_discovery_three_pid_details_information_email" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電子郵件地址偏好設定。您可以在「帳號」中加入或刪除電子郵件地址。"; -"settings_discovery_three_pids_management_information_part1" = "選擇您希望其他使用者用哪一個電子郵件(或電話)聯絡您,以及邀請您進入聊天室。您可以在此清單加入/移除電子郵件(或電話)。 "; +"settings_discovery_three_pids_management_information_part1" = "選擇您希望其他使用者用哪一個電子郵件地址(或電話號碼)聯絡您,以及邀請您進入聊天室。您可以在此清單加入/移除電子郵件地址(或電話號碼)。 "; "settings_discovery_accept_terms" = "同意身分伺服器的使用條款"; -"settings_discovery_terms_not_signed" = "同意身分伺服器(%@)的使用條款,讓其他人可以用您的電子郵件或電話號碼找到您。"; +"settings_discovery_terms_not_signed" = "需同意身分伺服器(%@)的使用條款,讓其他人可以用電子郵件地址或電話號碼找到您。"; "settings_discovery_no_identity_server" = "您目前未使用身分伺服器。若想要被您認識的聯絡人找到,請加入伺服器。"; "settings_devices_description" = "所有與您通訊的聯絡人都能看到此工作階段的公開名稱"; "settings_key_backup_delete_confirmation_prompt_msg" = "您確定嗎?如果您的金鑰沒有正確備份的話,將會遺失所有加密訊息。"; @@ -2576,7 +2576,7 @@ // Sessions list "user_verification_sessions_list_user_trust_level_trusted_title" = "受信任"; -"user_verification_start_additional_information" = "要確定安全,請面對面進行或使用其他方式來通訊。"; +"user_verification_start_additional_information" = "為了確保安全,請面對面進行驗證,或使用其他方式來通訊。"; "user_verification_start_waiting_partner" = "正在等待 %@…"; "user_verification_start_information_part2" = " 雙方裝置上顯示的單次驗證碼。"; "user_verification_start_information_part1" = "為了加強安全性,請確認 "; @@ -2648,8 +2648,8 @@ "settings_call_invitations" = "通話邀請"; "settings_room_invitations" = "聊天室邀請"; "settings_messages_containing_at_room" = "@room"; -"settings_notify_me_for" = "通知我"; -"settings_mentions_and_keywords" = "僅有被提及與出現關鍵字時"; +"settings_notify_me_for" = "收到下列訊息時通知我"; +"settings_mentions_and_keywords" = "提及與關鍵字"; "settings_notifications_disabled_alert_message" = "如需啟用通知,請前往裝置設定。"; "settings_security" = "安全性"; "settings_confirm_media_size_description" = "開啟此選項後,傳送檔案前,會先向您確認準備傳送的圖片與影片大小。"; @@ -2733,7 +2733,7 @@ // Social login -"social_login_list_title_continue" = "繼續"; +"social_login_list_title_continue" = "使用下列方式繼續"; "network_offline_message" = "您已離線,請確認您的網路連線。"; "network_offline_title" = "您已離線"; "event_formatter_jitsi_widget_removed_by_you" = "您已刪除 VoIP 會議"; @@ -2743,7 +2743,7 @@ // Events formatter with you "event_formatter_widget_added_by_you" = "您新增了小工具:%@"; "event_formatter_message_deleted" = "訊息已刪除"; -"event_formatter_group_call_incoming" = "%@ 在 %@"; +"event_formatter_group_call_incoming" = "%@ (來自 %@)"; "event_formatter_call_decline" = "拒絕"; "event_formatter_call_connection_failed" = "連線失敗"; "event_formatter_call_missed_video" = "未接聽的視訊通話"; @@ -2824,8 +2824,8 @@ "wysiwyg_composer_format_action_unordered_list" = "切換項目符號清單"; "wysiwyg_composer_format_action_inline_code" = "套用內嵌程式碼格式"; "user_other_session_security_recommendation_title" = "其他工作階段"; -"poll_timeline_reply_ended_poll" = "結束投票"; -"poll_timeline_ended_text" = "結束投票"; +"poll_timeline_reply_ended_poll" = "已結束投票"; +"poll_timeline_ended_text" = "投票已結束"; "poll_timeline_decryption_error" = "因為解密錯誤,不會計算部份投票"; "poll_history_load_more" = "載入更多投票"; "poll_history_detail_view_in_timeline" = "在時間軸中檢視投票"; @@ -2856,3 +2856,15 @@ // MARK: - Launch loading "launch_loading_generic" = "正在同步對話"; +"pill_message_in" = "在 %@ 的訊息"; +"pill_message_from" = "來自 %@ 的訊息"; +"pill_message" = "訊息"; + +// Pills +"pill_room_fallback_display_name" = "聊天空間/聊天室"; +"key_verification_self_verify_security_upgrade_alert_message" = "最新版本中已改進加密訊息傳輸功能,請重新驗證您的裝置。"; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "已更新程式"; +"settings_acceptable_use" = "可接受使用政策"; diff --git a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m index a7bfd69f1..9164a61d7 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m @@ -256,40 +256,18 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = if (componentIndex < bubbleComponents.count) { - MXKRoomBubbleComponent *component = bubbleComponents[componentIndex]; - - // Define the marker frame - CGFloat markPosY = component.position.y + self.msgTextViewTopConstraint.constant; - - NSInteger mostRecentComponentIndex = bubbleComponents.count - 1; - if ([bubbleData isKindOfClass:RoomBubbleCellData.class]) + CGRect componentFrame = [self componentFrameInContentViewForIndex:componentIndex]; + if (CGRectIsEmpty(componentFrame)) { - mostRecentComponentIndex = ((RoomBubbleCellData*)bubbleData).mostRecentComponentIndex; - } - - // Compute the mark height. - // Use the rest of the cell height by default. - CGFloat markHeight = self.contentView.frame.size.height - markPosY; - if (componentIndex != mostRecentComponentIndex) - { - // There is another component (with display) after this component in the cell. - // Stop the marker height to the top of this component. - for (NSInteger index = componentIndex + 1; index < bubbleComponents.count; index ++) - { - MXKRoomBubbleComponent *nextComponent = bubbleComponents[index]; - - if (nextComponent.attributedTextMessage) - { - markHeight = nextComponent.position.y - component.position.y; - break; - } - } + return; } - UIView *markerView = [[UIView alloc] initWithFrame:CGRectMake(VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_X, - markPosY, - VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_WIDTH, - markHeight)]; + CGRect markerFrame = CGRectMake(VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_X, + CGRectGetMinY(componentFrame), + VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_WIDTH, + CGRectGetHeight(componentFrame)); + + UIView *markerView = [[UIView alloc] initWithFrame:markerFrame]; markerView.backgroundColor = ThemeService.shared.theme.tintColor; [markerView setTranslatesAutoresizingMaskIntoConstraints:NO]; @@ -303,28 +281,28 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = toItem:self.contentView attribute:NSLayoutAttributeLeading multiplier:1.0 - constant:VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_X]; + constant:CGRectGetMinX(markerFrame)]; NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:markerView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeTop multiplier:1.0 - constant:markPosY]; + constant:CGRectGetMinY(markerFrame)]; NSLayoutConstraint *widthConstraint = [NSLayoutConstraint constraintWithItem:markerView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 - constant:VECTOR_ROOMBUBBLETABLEVIEWCELL_MARK_WIDTH]; + constant:CGRectGetWidth(markerFrame)]; NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:markerView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 - constant:markHeight]; + constant:CGRectGetHeight(markerFrame)]; // Available on iOS 8 and later [NSLayoutConstraint activateConstraints:@[leftConstraint, topConstraint, widthConstraint, heightConstraint]]; @@ -600,36 +578,47 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = } else if (roomBubbleTableViewCell.messageTextView) { + // Force the textView used underneath to layout its frame properly + [roomBubbleTableViewCell setNeedsLayout]; + [roomBubbleTableViewCell layoutIfNeeded]; + + // Compute the height CGFloat textMessageHeight = 0; - if ([bubbleCellData isKindOfClass:[RoomBubbleCellData class]]) { RoomBubbleCellData *roomBubbleCellData = (RoomBubbleCellData*)bubbleCellData; if (!roomBubbleCellData.attachment && selectedComponent.attributedTextMessage) { - textMessageHeight = [roomBubbleCellData rawTextHeight:selectedComponent.attributedTextMessage]; + // Get the width of messageTextView to compute the needed height + CGFloat maxTextWidth = CGRectGetWidth(roomBubbleTableViewCell.messageTextView.bounds); + + // Compute text message height + textMessageHeight = [roomBubbleCellData rawTextHeight:selectedComponent.attributedTextMessage withMaxWidth:maxTextWidth]; } } - - selectedComponentPositionY = selectedComponent.position.y; - + + // Get the messageText frame in the cell content view (as the messageTextView may be inside a stackView and not directly a child of the tableViewCell) + UITextView *messageTextView = roomBubbleTableViewCell.messageTextView; + CGRect messageTextViewFrame = [messageTextView convertRect:messageTextView.bounds toView:roomBubbleTableViewCell.contentView]; + if (textMessageHeight > 0) { selectedComponentHeight = textMessageHeight; } else { - selectedComponentHeight = roomBubbleTableViewCell.frame.size.height - selectedComponentPositionY; + // if we don't have a height, use the messageTextView height without the text container vertical insets to stay aligned with the text. + selectedComponentHeight = CGRectGetHeight(messageTextViewFrame) - messageTextView.textContainerInset.top - messageTextView.textContainerInset.bottom; } - // Force the textView used underneath to layout its frame properly - [roomBubbleTableViewCell setNeedsLayout]; - [roomBubbleTableViewCell layoutIfNeeded]; - - selectedComponenContentViewYOffset = roomBubbleTableViewCell.messageTextView.frame.origin.y; + // Get the vertical position of the messageTextView relative to the contentView + selectedComponenContentViewYOffset = CGRectGetMinY(messageTextViewFrame); + + // Get the position of the component inside the messageTextView + selectedComponentPositionY = selectedComponent.position.y; } - + if (roomBubbleTableViewCell.attachmentView || roomBubbleTableViewCell.messageTextView) { CGFloat x = 0; @@ -801,8 +790,7 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = - (void)addTickView:(UIView *)tickView atIndex:(NSInteger)index { - CGRect componentFrame = [self componentFrameInContentViewForIndex: index]; - + CGRect componentFrame = [self componentFrameInContentViewForIndex:index]; tickView.frame = CGRectMake(self.contentView.bounds.size.width - tickView.frame.size.width - 2 * PlainRoomCellLayoutConstants.readReceiptsViewRightMargin, CGRectGetMaxY(componentFrame) - tickView.frame.size.height, tickView.frame.size.width, tickView.frame.size.height); [self.contentView addSubview:tickView]; diff --git a/Riot/Categories/MXRoom+Riot.m b/Riot/Categories/MXRoom+Riot.m index 04538ba80..b81da1759 100644 --- a/Riot/Categories/MXRoom+Riot.m +++ b/Riot/Categories/MXRoom+Riot.m @@ -20,7 +20,7 @@ #import "AvatarGenerator.h" #import "MatrixKit.h" - +#import "GeneratedInterface-Swift.h" #import @implementation MXRoom (Riot) @@ -331,30 +331,10 @@ { [self.mxSession.crypto trustLevelSummaryForUserIds:@[userId] forceDownload:NO success:^(MXUsersTrustLevelSummary *usersTrustLevelSummary) { - UserEncryptionTrustLevel userEncryptionTrustLevel; - double trustedDevicesPercentage = usersTrustLevelSummary.trustedDevicesProgress.fractionCompleted; - - if (trustedDevicesPercentage >= 1.0) - { - userEncryptionTrustLevel = UserEncryptionTrustLevelTrusted; - } - else if (trustedDevicesPercentage == 0.0) - { - // Verify if the user has the user has cross-signing enabled - if ([self.mxSession.crypto.crossSigning crossSigningKeysForUser:userId]) - { - userEncryptionTrustLevel = UserEncryptionTrustLevelNotVerified; - } - else - { - userEncryptionTrustLevel = UserEncryptionTrustLevelNoCrossSigning; - } - } - else - { - userEncryptionTrustLevel = UserEncryptionTrustLevelWarning; - } - + MXCrossSigningInfo *crossSigningInfo = [self.mxSession.crypto.crossSigning crossSigningKeysForUser:userId]; + EncryptionTrustLevel *encryption = [[EncryptionTrustLevel alloc] init]; + UserEncryptionTrustLevel userEncryptionTrustLevel = [encryption userTrustLevelWithCrossSigning:crossSigningInfo + trustedDevicesProgress:usersTrustLevelSummary.trustedDevicesProgress]; onComplete(userEncryptionTrustLevel); } failure:^(NSError *error) { diff --git a/Riot/Categories/MXRoomSummary+Riot.h b/Riot/Categories/MXRoomSummary+Riot.h index d25cdee5f..324a7f369 100644 --- a/Riot/Categories/MXRoomSummary+Riot.h +++ b/Riot/Categories/MXRoomSummary+Riot.h @@ -15,17 +15,7 @@ */ #import "MatrixKit.h" - -/** - RoomEncryptionTrustLevel represents the trust level in an encrypted room. - */ -typedef NS_ENUM(NSUInteger, RoomEncryptionTrustLevel) { - RoomEncryptionTrustLevelTrusted, - RoomEncryptionTrustLevelWarning, - RoomEncryptionTrustLevelNormal, - RoomEncryptionTrustLevelUnknown -}; - +#import "RoomEncryptionTrustLevel.h" /** Define a `MXRoomSummary` category at Riot level. diff --git a/Riot/Categories/MXRoomSummary+Riot.m b/Riot/Categories/MXRoomSummary+Riot.m index c6a55a230..b2c1eeb40 100644 --- a/Riot/Categories/MXRoomSummary+Riot.m +++ b/Riot/Categories/MXRoomSummary+Riot.m @@ -33,32 +33,15 @@ - (RoomEncryptionTrustLevel)roomEncryptionTrustLevel { - RoomEncryptionTrustLevel roomEncryptionTrustLevel = RoomEncryptionTrustLevelUnknown; - if (self.trust) + MXUsersTrustLevelSummary *trust = self.trust; + if (!trust) { - double trustedUsersPercentage = self.trust.trustedUsersProgress.fractionCompleted; - double trustedDevicesPercentage = self.trust.trustedDevicesProgress.fractionCompleted; - - if (trustedUsersPercentage >= 1.0) - { - if (trustedDevicesPercentage >= 1.0) - { - roomEncryptionTrustLevel = RoomEncryptionTrustLevelTrusted; - } - else - { - roomEncryptionTrustLevel = RoomEncryptionTrustLevelWarning; - } - } - else - { - roomEncryptionTrustLevel = RoomEncryptionTrustLevelNormal; - } - - roomEncryptionTrustLevel = roomEncryptionTrustLevel; + MXLogError(@"[MXRoomSummary] roomEncryptionTrustLevel: trust is missing"); + return RoomEncryptionTrustLevelUnknown; } - return roomEncryptionTrustLevel; + EncryptionTrustLevel *encryption = [[EncryptionTrustLevel alloc] init]; + return [encryption roomTrustLevelWithSummary:trust]; } - (BOOL)isJoined diff --git a/Riot/Categories/NSAttributedString+Theme.swift b/Riot/Categories/NSAttributedString+Theme.swift new file mode 100644 index 000000000..9a0e01c93 --- /dev/null +++ b/Riot/Categories/NSAttributedString+Theme.swift @@ -0,0 +1,64 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Custom NSAttributedString.Key to specify the theme +let themeIdentifierAttributeName = NSAttributedString.Key("ThemeIdentifier") +/// Custom NSAttributedString.Key to specify a theme color by its name +let themeColorNameAttributeName = NSAttributedString.Key("ThemeColorName") + +extension NSAttributedString { + /// Fix foreground color attributes if this attributed string contains the `themeIdentifierAttributeName` and `foregroundColorNameAttributeName` attributes + /// - Returns: a new attributed string with updated colors + @objc func fixForegroundColor() -> NSAttributedString { + let activeTheme = ThemeService.shared().theme + + // Check if a theme is defined for this attributed string + var needUpdate = false + self.vc_enumerateAttribute(themeIdentifierAttributeName) { (themeIdentifier: String, range: NSRange, _) in + needUpdate = themeIdentifier != activeTheme.identifier + } + + guard needUpdate else { + return self + } + + // Build a new attributedString with the proper colors if possible + let mutableAttributedString = NSMutableAttributedString(attributedString: self) + mutableAttributedString.vc_enumerateAttribute(themeColorNameAttributeName) { (colorName: String, range: NSRange, _) in + if let color = ThemeColorResolver.getColorByName(colorName) { + mutableAttributedString.addAttribute(.foregroundColor, value: color, range: range) + } + } + return mutableAttributedString + } +} + +extension NSMutableAttributedString { + /// Adds a theme color name attribute + /// - Parameters: + /// - colorName: color name + /// - range:range for this attribute + @objc func addThemeColorNameAttribute(_ colorName: String, range: NSRange) { + self.addAttribute(themeColorNameAttributeName, value: colorName, range: range) + } + + /// Adds a theme identifier attribute + @objc func addThemeIdentifierAttribute() { + self.addAttribute(themeIdentifierAttributeName, value: ThemeService.shared().theme.identifier, range: .init(location: 0, length: length)) + } +} diff --git a/Riot/Experiments/CryptoSDKFeature.swift b/Riot/Experiments/CryptoSDKFeature.swift index bd159ebfd..1e151fe79 100644 --- a/Riot/Experiments/CryptoSDKFeature.swift +++ b/Riot/Experiments/CryptoSDKFeature.swift @@ -52,7 +52,7 @@ import MatrixSDKCrypto init( remoteFeature: RemoteFeaturesClientProtocol = PostHogAnalyticsClient.shared, - localTargetPercentage: Double = 0.5 + localTargetPercentage: Double = 1 ) { var targetPercentage = 0.0 if BWIBuildSettings.shared.useRustEncryption { diff --git a/Riot/Generated/BWIStrings.swift b/Riot/Generated/BWIStrings.swift index cc09adf87..2ecb68314 100644 --- a/Riot/Generated/BWIStrings.swift +++ b/Riot/Generated/BWIStrings.swift @@ -99,6 +99,10 @@ public class BWIL10n: NSObject { public static var bumAutheticationTitle: String { return BWIL10n.tr("Bwi", "bum_authetication_title") } + /// Barrierefreiheitserklärung + public static var bwiAccessibilityDeclarationButtonTitle: String { + return BWIL10n.tr("Bwi", "bwi_accessibility_declaration_button_title") + } /// Wir brauchen Deine Hilfe, um Fehler im %@ besser analysieren zu können. Dazu würden wir gerne anonymisierte Diagnosedaten erfassen. Es werden keine Daten an Dritte übermittelt. Details findest Du in der Datenschutzerklärung.\n\nFalls Du nicht mehr mithelfen möchtest, kannst Du dies in den Einstellungen jederzeit wieder deaktivieren.\n\nMöchtest du bei der Fehler-Analyse unterstützen? public static func bwiAnalyticsAlertBody(_ p1: String) -> String { return BWIL10n.tr("Bwi", "bwi_analytics_alert_body", p1) @@ -207,7 +211,19 @@ public class BWIL10n: NSObject { public static func bwiErrorInviteGeneral(_ p1: String) -> String { return BWIL10n.tr("Bwi", "bwi_error_invite_general", p1) } - /// Du kannst jetzt aktive und vergangene Umfragen gesammelt in den Raumdetails einsehen (erreichbar unter Raumdetails, im Bereich "Umfrageverlauf"). + /// Abmelden ist ohne Internetverbindung nicht möglich. + public static var bwiErrorLogoutOffline: String { + return BWIL10n.tr("Bwi", "bwi_error_logout_offline") + } + /// Der Raum wurde bereits geschlossen, daher kannst Du nicht mehr beitreten. + public static var bwiErrorRoomNotAvailableMessage: String { + return BWIL10n.tr("Bwi", "bwi_error_room_not_available_message") + } + /// Link ungültig + public static var bwiErrorRoomNotAvailableTitle: String { + return BWIL10n.tr("Bwi", "bwi_error_room_not_available_title") + } + /// Neue Umfragen können vom Ersteller so konfiguriert werden, dass angezeigt wird, wer für welche Option gestimmt hat. public static var bwiFeatureBannerAdvertisementText: String { return BWIL10n.tr("Bwi", "bwi_feature_banner_advertisement_text") } @@ -967,6 +983,10 @@ public class BWIL10n: NSObject { public static var pollEditFormCreatePoll: String { return BWIL10n.tr("Bwi", "poll_edit_form_create_poll") } + /// Anzeigen, wer für welche Option gestimmt hat. + public static var pollEditFormParticipantToggle: String { + return BWIL10n.tr("Bwi", "poll_edit_form_participant_toggle") + } /// Umfragetyp public static var pollEditFormPollType: String { return BWIL10n.tr("Bwi", "poll_edit_form_poll_type") @@ -979,6 +999,18 @@ public class BWIL10n: NSObject { public static var pollEditFormPollTypeOpen: String { return BWIL10n.tr("Bwi", "poll_edit_form_poll_type_open") } + /// Alle ansehen (%lu weitere) + public static func pollParticipantDetailsShowMore(_ p1: Int) -> String { + return BWIL10n.tr("Bwi", "poll_participant_details_show_more", p1) + } + /// Umfragedetails + public static var pollParticipantDetailsTitle: String { + return BWIL10n.tr("Bwi", "poll_participant_details_title") + } + /// Stimmen anzeigen + public static var pollTimelineShowParticipantsButton: String { + return BWIL10n.tr("Bwi", "poll_timeline_show_participants_button") + } /// Wiederholen public static var retry: String { return BWIL10n.tr("Bwi", "retry") @@ -1067,7 +1099,7 @@ public class BWIL10n: NSObject { public static var roomEventActionRemovePoll: String { return BWIL10n.tr("Bwi", "room_event_action_remove_poll") } - /// Das ist der Anfang deiner Direktnachricht mit + /// Dies ist der Beginn deiner Direktnachrichten mit public static var roomIntroCellInformationDmSentence1Part1: String { return BWIL10n.tr("Bwi", "room_intro_cell_information_dm_sentence1_part1") } @@ -1491,6 +1523,10 @@ public class BWIL10n: NSObject { public static var settingsGroupMessages: String { return BWIL10n.tr("Bwi", "settings_group_messages") } + /// Impressum + public static var settingsImprint: String { + return BWIL10n.tr("Bwi", "settings_imprint") + } /// Mentions and Keywords public static var settingsMentionsAndKeywords: String { return BWIL10n.tr("Bwi", "settings_mentions_and_keywords") @@ -1687,6 +1723,10 @@ public class BWIL10n: NSObject { public static var userSessionVerifiedSessionDescription: String { return BWIL10n.tr("Bwi", "user_session_verified_session_description") } + /// Sitzung verifizieren + public static var userVerificationSessionDetailsVerifyActionCurrentUser: String { + return BWIL10n.tr("Bwi", "user_verification_session_details_verify_action_current_user") + } /// Ansehen public static var view: String { return BWIL10n.tr("Bwi", "view") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 3f4a5f01f..e26d57d37 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -3899,6 +3899,10 @@ public class VectorL10n: NSObject { public static func noticeDisplayNameChangedFromByYou(_ p1: String, _ p2: String) -> String { return VectorL10n.tr("Vector", "notice_display_name_changed_from_by_you", p1, p2) } + /// %@ changed their display name to %@ + public static func noticeDisplayNameChangedTo(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "notice_display_name_changed_to", p1, p2) + } /// %@ removed their display name public static func noticeDisplayNameRemoved(_ p1: String) -> String { return VectorL10n.tr("Vector", "notice_display_name_removed", p1) @@ -4923,6 +4927,10 @@ public class VectorL10n: NSObject { public static var pollTimelineEndedText: String { return VectorL10n.tr("Vector", "poll_timeline_ended_text") } + /// Loading... + public static var pollTimelineLoading: String { + return VectorL10n.tr("Vector", "poll_timeline_loading") + } /// Please try again public static var pollTimelineNotClosedSubtitle: String { return VectorL10n.tr("Vector", "poll_timeline_not_closed_subtitle") @@ -5211,6 +5219,58 @@ public class VectorL10n: NSObject { public static var roomAvatarViewAccessibilityLabel: String { return VectorL10n.tr("Vector", "room_avatar_view_accessibility_label") } + /// Bans user with given id + public static var roomCommandBanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_ban_user_description") + } + /// Changes your display nickname + public static var roomCommandChangeDisplayNameDescription: String { + return VectorL10n.tr("Vector", "room_command_change_display_name_description") + } + /// Sets the room topic + public static var roomCommandChangeRoomTopicDescription: String { + return VectorL10n.tr("Vector", "room_command_change_room_topic_description") + } + /// Forces the current outbound group session in an encrypted room to be discarded + public static var roomCommandDiscardSessionDescription: String { + return VectorL10n.tr("Vector", "room_command_discard_session_description") + } + /// Displays action + public static var roomCommandEmoteDescription: String { + return VectorL10n.tr("Vector", "room_command_emote_description") + } + /// Invalid or unhandled command + public static var roomCommandErrorUnknownCommand: String { + return VectorL10n.tr("Vector", "room_command_error_unknown_command") + } + /// Invites user with given id to current room + public static var roomCommandInviteUserDescription: String { + return VectorL10n.tr("Vector", "room_command_invite_user_description") + } + /// Joins room with given address + public static var roomCommandJoinRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_join_room_description") + } + /// Removes user with given id from this room + public static var roomCommandKickUserDescription: String { + return VectorL10n.tr("Vector", "room_command_kick_user_description") + } + /// Leave room + public static var roomCommandPartRoomDescription: String { + return VectorL10n.tr("Vector", "room_command_part_room_description") + } + /// Deops user with given id + public static var roomCommandResetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_reset_user_power_level_description") + } + /// Define the power level of a user + public static var roomCommandSetUserPowerLevelDescription: String { + return VectorL10n.tr("Vector", "room_command_set_user_power_level_description") + } + /// Unbans user with given id + public static var roomCommandUnbanUserDescription: String { + return VectorL10n.tr("Vector", "room_command_unban_user_description") + } /// You need permission to manage conference call in this room public static var roomConferenceCallNoPower: String { return VectorL10n.tr("Vector", "room_conference_call_no_power") diff --git a/Riot/Managers/Theme/ThemeIdentifier.swift b/Riot/Managers/Theme/ThemeIdentifier.swift index 76b19e57d..0286e08cd 100644 --- a/Riot/Managers/Theme/ThemeIdentifier.swift +++ b/Riot/Managers/Theme/ThemeIdentifier.swift @@ -28,7 +28,7 @@ enum ThemeIdentifier: String, RawRepresentable { case "dark": self = .dark case "black": - self = .black + self = .dark // bwi: 4744 (map previous set black theme to dark) default: return nil } diff --git a/Riot/Managers/Theme/ThemeService.m b/Riot/Managers/Theme/ThemeService.m index 9d544ddce..a47892fb4 100644 --- a/Riot/Managers/Theme/ThemeService.m +++ b/Riot/Managers/Theme/ThemeService.m @@ -80,7 +80,7 @@ NSString *const kThemeServiceDidChangeThemeNotification = @"kThemeServiceDidChan } else if ([themeId isEqualToString:@"black"]) { - theme = [BlackTheme new]; + theme = [DarkTheme new]; // bwi: 4744 (map previous set black theme to dark) } else { diff --git a/Riot/Managers/Theme/ThemeService.swift b/Riot/Managers/Theme/ThemeService.swift index 209812111..3ce421d01 100644 --- a/Riot/Managers/Theme/ThemeService.swift +++ b/Riot/Managers/Theme/ThemeService.swift @@ -23,5 +23,5 @@ extension ThemeService { return nil } return ThemeIdentifier(rawValue: themeId) - } + } } diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index b82c950b0..076b04fc6 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -401,6 +401,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } [NSBundle mxk_setLanguage:language]; [NSBundle mxk_setFallbackLanguage:@"en"]; + UIApplication.sharedApplication.accessibilityLanguage = language; if (BuildSettings.disableRightToLeftLayout) { @@ -796,7 +797,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [application keyWindow].accessibilityIgnoresInvertColors = YES; [BWIAnalytics.sharedTracker firstCall]; - [BWIAnalytics.sharedTracker trackEvent:@"Session" action:@"PinLogin"]; + [BWIAnalytics.sharedTracker trackEventWithCategory:@"Session" action:@"PinLogin" name:@"pin_login_default" number:nil]; self.isPinUnlocked = true; @@ -1677,7 +1678,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [self peekInRoomWithNavigationParameters:roomPreviewNavigationParameters pathParams:pathParams]; } } failure:^(NSError *error) { - [self peekInRoomWithNavigationParameters:roomPreviewNavigationParameters pathParams:pathParams]; + [self peekInRoomWithNavigationParameters:roomPreviewNavigationParameters pathParams:pathParams]; }]; } @@ -2259,7 +2260,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [topVC startActivityIndicator]; } - [BWIAnalytics.sharedTracker trackEvent:@"Session" action:@"Logout"]; + [BWIAnalytics.sharedTracker trackEventWithCategory:@"Session" action:@"Logout" name:@"logout_default" number:nil]; [BWIAnalytics.sharedTracker dispatchAll]; [self logoutSendingRequestServer:YES completion:^(BOOL isLoggedOut) { @@ -2271,6 +2272,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [BWIBuildSettings.shared reset]; [BWIAnalytics.sharedTracker resetUserdefaults]; + // bwi #4573 reset chatsfilter on logout + [AllChatsLayoutSettingsManager.shared reset]; } completion (YES); } @@ -2340,7 +2343,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [topVC startActivityIndicator]; } - [BWIAnalytics.sharedTracker trackEvent:@"Session" action:@"Logout"]; + [BWIAnalytics.sharedTracker trackEventWithCategory:@"Session" action:@"Logout" name:@"logout_default" number:nil]; [BWIAnalytics.sharedTracker dispatchAll]; [self logoutSendingRequestServer:YES completion:^(BOOL isLoggedOut) { @@ -2586,6 +2589,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni { MXLogDebug(@"[AppDelegate] showLaunchAnimation"); + /* bwi: 4782 removed by nv UIView *launchLoadingView; if (MXSDKOptions.sharedInstance.enableStartupProgress) { @@ -2593,15 +2597,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni { launchLoadingView = [BUMLaunchLoadingViewController makeView]; } else { - if (MXSDKOptions.sharedInstance.enableStartupProgress) - { - MXSession *mainSession = self.mxSessions.firstObject; - launchLoadingView = [LaunchLoadingView instantiateWithStartupProgress:mainSession.startupProgress]; - } - else - { - launchLoadingView = [LaunchLoadingView instantiateWithStartupProgress:nil]; - } + MXSession *mainSession = self.mxSessions.firstObject; + launchLoadingView = [LaunchLoadingView instantiateWithStartupProgress:mainSession.startupProgress]; [(LaunchLoadingView *) launchLoadingView updateWithTheme:ThemeService.shared.theme]; } @@ -2610,9 +2607,21 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [window addSubview:launchLoadingView]; } - - launchAnimationContainerView = launchLoadingView; + */ + + /* bwi: 4782 - new code from nv + MXSession *mainSession = self.mxSessions.firstObject; + LaunchLoadingView *launchLoadingView = [LaunchLoadingView instantiateWithStartupProgress:mainSession.startupProgress]; + + launchLoadingView.frame = window.bounds; + [launchLoadingView updateWithTheme:ThemeService.shared.theme]; + launchLoadingView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [window addSubview:launchLoadingView]; + + + launchAnimationContainerView = launchLoadingView; + */ [MXSDKOptions.sharedInstance.profiler startMeasuringTaskWithName:MXTaskProfileNameStartupLaunchScreen]; } } diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 6f31a5415..dbe9ea62e 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -635,8 +635,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc loadingViewController.modalPresentationStyle = .fullScreen navigationRouter.setRootModule(loadingViewController) } else { - let startupProgress: MXSessionStartupProgress? = MXSDKOptions.sharedInstance().enableStartupProgress ? session?.startupProgress : nil - let loadingViewController = LaunchLoadingViewController(startupProgress: startupProgress) + let loadingViewController = LaunchLoadingViewController(startupProgress: session?.startupProgress) loadingViewController.modalPresentationStyle = .fullScreen // Replace the navigation stack with the loading animation diff --git a/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m b/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m index 6da9da77f..b4c3821ca 100644 --- a/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/Legacy/AuthenticationViewController.m @@ -1320,7 +1320,14 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; [self saveCustomServerInputs]; // Restore default configuration - [self setHomeServerTextFieldText:self.defaultHomeServerUrl]; + if (BuildSettings.forceHomeserverSelection) + { + [self setHomeServerTextFieldText:nil]; + } + else + { + [self setHomeServerTextFieldText:self.defaultHomeServerUrl]; + } [self setIdentityServerTextFieldText:self.defaultIdentityServerUrl]; [self.customServersTickButton setImage:AssetImages.selectionUntick.image forState:UIControlStateNormal]; diff --git a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift index 5b13dc810..763d971e2 100644 --- a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift @@ -121,8 +121,7 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator loadingViewController.modalPresentationStyle = .fullScreen navigationRouter.setRootModule(loadingViewController) } else { - let startupProgress: MXSessionStartupProgress? = MXSDKOptions.sharedInstance().enableStartupProgress ? session?.startupProgress : nil - let loadingViewController = LaunchLoadingViewController(startupProgress: startupProgress) + let loadingViewController = LaunchLoadingViewController(startupProgress: session?.startupProgress) loadingViewController.modalPresentationStyle = .fullScreen // Replace the navigation stack with the loading animation diff --git a/Riot/Modules/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/AvatarView.swift index 0febb3f51..6ffade6c9 100644 --- a/Riot/Modules/Common/Avatar/AvatarView.swift +++ b/Riot/Modules/Common/Avatar/AvatarView.swift @@ -103,6 +103,7 @@ class AvatarView: UIView, Themable { func updateAvatarImageView(with viewData: AvatarViewDataProtocol) { guard let avatarImageView = self.avatarImageView else { + MXLog.warning("[AvatarView] avatar not updated because avatarImageView is nil.") return } @@ -120,6 +121,10 @@ class AvatarView: UIView, Themable { let (defaultAvatarImage, defaultAvatarImageContentMode) = viewData.fallbackImageParameters() ?? (nil, .scaleAspectFill) updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode) + if defaultAvatarImage == nil { + MXLog.warning("[AvatarView] defaultAvatarImage is nil") + } + if let avatarUrl = viewData.avatarUrl { avatarImageView.setImageURI(avatarUrl, withType: nil, @@ -129,6 +134,10 @@ class AvatarView: UIView, Themable { previewImage: defaultAvatarImage, mediaManager: viewData.mediaManager) updateAvatarContentMode(contentMode: .scaleAspectFill) + + if avatarImageView.frame.size.width < 8 || avatarImageView.frame.size.height < 8 { + MXLog.warning("[AvatarView] small avatarImageView frame: \(avatarImageView.frame)") + } } else { updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode) } diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m index c98d5606e..4c0ff4342 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m @@ -81,7 +81,8 @@ // Manage lastEventAttributedTextMessage optional property if (!roomCellData.roomSummary.spaceChildInfo && [roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) { - self.lastEventDescription.attributedText = roomCellData.lastEventAttributedTextMessage; + // Attempt to correct the attributed string colors to match the current theme + self.lastEventDescription.attributedText = [roomCellData.lastEventAttributedTextMessage fixForegroundColor]; } else { diff --git a/Riot/Modules/Common/Recents/Views/RecentsInvitesTableViewCell.swift b/Riot/Modules/Common/Recents/Views/RecentsInvitesTableViewCell.swift index dea15153e..41de727aa 100644 --- a/Riot/Modules/Common/Recents/Views/RecentsInvitesTableViewCell.swift +++ b/Riot/Modules/Common/Recents/Views/RecentsInvitesTableViewCell.swift @@ -63,7 +63,7 @@ class RecentsInvitesTableViewCell: UITableViewCell, NibReusable, Themable { badgeLabel.textColor = theme.colors.background badgeLabel.font = theme.fonts.footnoteSB - titleLabel.textColor = theme.colors.accent + titleLabel.textColor = BWIBuildSettings.shared.useNewBumColors ? theme.tintColor : theme.colors.accent // bwi: 4769 } // MARK: - Private diff --git a/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift b/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift index 43a3e3398..93758b77b 100644 --- a/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift +++ b/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift @@ -47,7 +47,7 @@ class AllChatsFilterOptionListView: UIView, Themable { // MARK: - Private - private let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + private let backgroundView = UIView() // bwi: 4769 private let separator = UIView() private let tabListView = TabListView() @@ -90,6 +90,10 @@ class AllChatsFilterOptionListView: UIView, Themable { // MARK: - Themable func update(theme: Theme) { + + // bwi: 4769 + backgroundView.backgroundColor = ThemeService.shared().theme.backgroundColor + backgroundColor = theme.colors.background.withAlphaComponent(0.7) tabListView.itemFont = theme.fonts.calloutSB @@ -102,6 +106,9 @@ class AllChatsFilterOptionListView: UIView, Themable { // MARK: - Private private func setupView() { + + // bwi: 4769 + backgroundView.backgroundColor = ThemeService.shared().theme.backgroundColor vc_addSubViewMatchingParent(backgroundView) addSubview(separator) diff --git a/Riot/Modules/EncryptionInfo/EncryptionInfoView.h b/Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.h similarity index 100% rename from Riot/Modules/EncryptionInfo/EncryptionInfoView.h rename to Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.h diff --git a/Riot/Modules/EncryptionInfo/EncryptionInfoView.m b/Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.m similarity index 100% rename from Riot/Modules/EncryptionInfo/EncryptionInfoView.m rename to Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.m diff --git a/Riot/Modules/EncryptionInfo/EncryptionInfoView.xib b/Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.xib similarity index 100% rename from Riot/Modules/EncryptionInfo/EncryptionInfoView.xib rename to Riot/Modules/Encryption/EncryptionInfo/EncryptionInfoView.xib diff --git a/Riot/Modules/Encryption/EncryptionTrustLevel.swift b/Riot/Modules/Encryption/EncryptionTrustLevel.swift new file mode 100644 index 000000000..275d74ffc --- /dev/null +++ b/Riot/Modules/Encryption/EncryptionTrustLevel.swift @@ -0,0 +1,68 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Object responsible for calculating user and room trust level +/// +/// For legacy reasons, the trust of multiple items is represented as `Progress` object, +/// where `completedUnitCount` represents the number of trusted users / devices. +@objc class EncryptionTrustLevel: NSObject { + struct TrustSummary { + let totalCount: Int64 + let trustedCount: Int64 + let areAllTrusted: Bool + + init(progress: Progress) { + totalCount = max(progress.totalUnitCount, progress.completedUnitCount) + trustedCount = progress.completedUnitCount + areAllTrusted = trustedCount == totalCount + } + } + + + /// Calculate trust level for a single user given their cross-signing info + @objc func userTrustLevel( + crossSigning: MXCrossSigningInfo?, + trustedDevicesProgress: Progress + ) -> UserEncryptionTrustLevel { + let devices = TrustSummary(progress: trustedDevicesProgress) + + // If we could cross-sign but we haven't, the user is simply not verified + if let crossSigning, !crossSigning.trustLevel.isVerified { + return .notVerified + + // If we cannot cross-sign the user (legacy behaviour) and have not signed + // any devices manually, the user is not verified + } else if crossSigning == nil && devices.trustedCount == 0 { + return .notVerified + } + + // In all other cases we check devices for trust level + return devices.areAllTrusted ? .trusted : .warning + } + + /// Calculate trust level for a room given trust level of users and their devices + @objc func roomTrustLevel(summary: MXUsersTrustLevelSummary) -> RoomEncryptionTrustLevel { + let users = TrustSummary(progress: summary.trustedUsersProgress) + let devices = TrustSummary(progress: summary.trustedDevicesProgress) + + guard users.totalCount > 0 && users.areAllTrusted else { + return .normal + } + return devices.areAllTrusted ? .trusted : .warning + } +} diff --git a/Riot/Utils/EncryptionTrustLevelBadgeImageHelper.swift b/Riot/Modules/Encryption/EncryptionTrustLevelBadgeImageHelper.swift similarity index 100% rename from Riot/Utils/EncryptionTrustLevelBadgeImageHelper.swift rename to Riot/Modules/Encryption/EncryptionTrustLevelBadgeImageHelper.swift diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h similarity index 63% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift rename to Riot/Modules/Encryption/RoomEncryptionTrustLevel.h index 1d89ca9b4..a942f5360 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/Riot/Modules/Encryption/RoomEncryptionTrustLevel.h @@ -1,5 +1,5 @@ -// -// Copyright 2021 New Vector Ltd +// +// Copyright 2023 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,8 +14,12 @@ // limitations under the License. // -import Foundation - -protocol UserSuggestionViewModelProtocol { - var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } -} +/** + RoomEncryptionTrustLevel represents the trust level in an encrypted room. + */ +typedef NS_ENUM(NSUInteger, RoomEncryptionTrustLevel) { + RoomEncryptionTrustLevelTrusted, + RoomEncryptionTrustLevelWarning, + RoomEncryptionTrustLevelNormal, + RoomEncryptionTrustLevelUnknown +}; diff --git a/Riot/Modules/Room/Members/Detail/UserEncryptionTrustLevel.h b/Riot/Modules/Encryption/UserEncryptionTrustLevel.h similarity index 100% rename from Riot/Modules/Room/Members/Detail/UserEncryptionTrustLevel.h rename to Riot/Modules/Encryption/UserEncryptionTrustLevel.h diff --git a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift index f8ecbc8f1..371e26334 100644 --- a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift +++ b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift @@ -422,7 +422,11 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol { } private func updateAvatarButtonItem() { + MXLog.info("[AllChatsCoordinator] updating avatar button item.") if let avatar = userAvatarViewData(from: currentMatrixSession) { + if avatarMenuView == nil { + MXLog.warning("[AllChatsCoordinator] updateAvatarButtonItem: avatarMenuView is nil.") + } avatarMenuView?.fill(with: avatar) avatarMenuButton?.setImage(nil, for: .normal) } else { diff --git a/Riot/Modules/Home/AllChats/AllChatsLayoutSettingsManager.swift b/Riot/Modules/Home/AllChats/AllChatsLayoutSettingsManager.swift index c6ff7e76d..27c04a2f8 100644 --- a/Riot/Modules/Home/AllChats/AllChatsLayoutSettingsManager.swift +++ b/Riot/Modules/Home/AllChats/AllChatsLayoutSettingsManager.swift @@ -51,6 +51,11 @@ final class AllChatsLayoutSettingsManager: NSObject { // MARK: - Public + // bwi #4573 reset filters for logout + @objc func reset() { + activeFilters = .all + } + var activeFilters: AllChatsLayoutFilterType { get { guard let value = RiotSettings.defaults.object(forKey: Constants.activeFiltersKey) as? NSNumber else { diff --git a/Riot/Modules/Home/AllChats/AllChatsViewController.swift b/Riot/Modules/Home/AllChats/AllChatsViewController.swift index eccd71e12..1c728b76d 100644 --- a/Riot/Modules/Home/AllChats/AllChatsViewController.swift +++ b/Riot/Modules/Home/AllChats/AllChatsViewController.swift @@ -137,7 +137,13 @@ class AllChatsViewController: HomeViewController { emptyViewBottomAnchor = toolbar.topAnchor // bwi: 4179 - toolbar.tintColor = ThemeService.shared().theme.tintColor + + if BWIBuildSettings.shared.useNewBumColors { // bwi: #4883 + toolbar.tintColor = ThemeService.shared().theme.tintColor + toolbar.barTintColor = ThemeService.shared().theme.backgroundColor + } else { + toolbar.tintColor = theme.colors.accent + } updateUI() @@ -150,12 +156,22 @@ class AllChatsViewController: HomeViewController { NotificationCenter.default.addObserver(self, selector: #selector(self.setupEditOptions), name: AllChatsLayoutSettingsManager.didUpdateSettings, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.updateBadgeButton), name: MXSpaceNotificationCounter.didUpdateNotificationCount, object: nil) + + // bwi: 4769 + self.registerThemeServiceDidChangeThemeNotification() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - self.toolbar.tintColor = theme.colors.accent + // bwi: 4769 + if BWIBuildSettings.shared.useNewBumColors { + self.toolbar.tintColor = theme.tintColor + self.toolbar.barTintColor = theme.backgroundColor + } else { + self.toolbar.tintColor = theme.colors.accent + } + if self.navigationItem.searchController == nil { self.navigationItem.searchController = searchController } @@ -205,6 +221,17 @@ class AllChatsViewController: HomeViewController { } } + // bwi: 4769 + private func registerThemeServiceDidChangeThemeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil) + } + + // bwi: 4769 + @objc private func themeDidChange() { + self.update(with: ThemeService.shared().theme) + } + + // MARK: - Public func switchSpace(withId spaceId: String?) { @@ -316,15 +343,10 @@ class AllChatsViewController: HomeViewController { alert.addAction(UIAlertAction(title: BWIL10n.bwiAnalyticsAlertInfoButton, style: .default, handler: { [self] action in - if let webViewController = WebViewViewController(url: BWIBuildSettings.shared.applicationPrivacyPolicyWithMatomoSectionUrlString) { - navigationBar = UINavigationController(rootViewController: webViewController) - webViewController.navigationItem.setLeftBarButton(UIBarButtonItem(title: VectorL10n.close, style: .plain, target: self, action: #selector(self.bwiCloseModal)), animated: false) - webViewController.title = VectorL10n.settingsPrivacyPolicy - navigationBar?.presentationController?.delegate = self + if let url = URL(string: BWIBuildSettings.shared.applicationPrivacyPolicyWithMatomoSectionUrlString) { + UIApplication.shared.open(url) + } showMatomoConsentAlertOnCloseModal = true - present(navigationBar ?? webViewController, animated: true, completion: nil) - } - })) alert.addAction(UIAlertAction(title: BWIL10n.bwiAnalyticsAlertCancelButton, @@ -544,7 +566,16 @@ class AllChatsViewController: HomeViewController { } private func update(with theme: Theme) { - self.navigationController?.toolbar?.tintColor = theme.colors.accent + // bwi: 4769 + if BWIBuildSettings.shared.useNewBumColors { + toolbar.tintColor = ThemeService.shared().theme.tintColor + toolbar.barTintColor = ThemeService.shared().theme.backgroundColor + + UIToolbar.appearance().tintColor = ThemeService.shared().theme.tintColor + UIToolbar.appearance().barTintColor = ThemeService.shared().theme.backgroundColor + } else { + self.navigationController?.toolbar?.tintColor = theme.colors.accent + } } // MARK: - Private @@ -628,8 +659,8 @@ class AllChatsViewController: HomeViewController { // bwi: 4179 var allChatsEditButton = UIBarButtonItem(image: Asset.Images.allChatsEditIcon.image, menu: menu) - allChatsEditButton.tintColor = ThemeService.shared().theme.tintColor - + allChatsEditButton.tintColor = ThemeService.shared().theme.tintColor // bwi: #4883 + if BWIBuildSettings.shared.enableSpaces { self.toolbar.items = [ spacesButton, diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift index 0b7622a54..9220ae9a1 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift @@ -137,7 +137,7 @@ final class KeyVerificationCoordinator: KeyVerificationCoordinatorType { case .incomingSASTransaction(let incomingSASTransaction): rootCoordinator = self.createDataLoadingScreenCoordinator(otherUserId: incomingSASTransaction.otherUserId, otherDeviceId: incomingSASTransaction.otherDeviceId) case .completeSecurity(let isNewSignIn): - if BWIBuildSettings.shared.disableSelfUserVerification { + if BWIBuildSettings.shared.disableCrosssigning { let coordinator = self.createSecretsRecoveryCoordinator(with: .passphraseOrKey) rootCoordinator = coordinator } else { diff --git a/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift b/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift index e609f6b38..df8b3359e 100644 --- a/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift +++ b/Riot/Modules/KeyVerification/Common/Verify/SAS/Views/VerifyEmojiCollectionViewCell.swift @@ -24,4 +24,9 @@ class VerifyEmojiCollectionViewCell: UICollectionViewCell, Reusable, Themable { func update(theme: Theme) { name.textColor = theme.textPrimaryColor } + + override func awakeFromNib() { + super.awakeFromNib() + emoji.isAccessibilityElement = false + } } diff --git a/Riot/Modules/KeyVerification/User/SessionStatus/UserVerificationSessionStatusViewController.swift b/Riot/Modules/KeyVerification/User/SessionStatus/UserVerificationSessionStatusViewController.swift index 0331b504d..42f382ef5 100644 --- a/Riot/Modules/KeyVerification/User/SessionStatus/UserVerificationSessionStatusViewController.swift +++ b/Riot/Modules/KeyVerification/User/SessionStatus/UserVerificationSessionStatusViewController.swift @@ -175,7 +175,7 @@ final class UserVerificationSessionStatusViewController: UIViewController { if viewData.isCurrentUser { unstrustedInformationText = VectorL10n.userVerificationSessionDetailsAdditionalInformationUntrustedCurrentUser - verifyButtonTitle = VectorL10n.userVerificationSessionDetailsVerifyActionCurrentUser + verifyButtonTitle = BWIL10n.userVerificationSessionDetailsVerifyActionCurrentUser } else { unstrustedInformationText = VectorL10n.userVerificationSessionDetailsAdditionalInformationUntrustedOtherUser verifyButtonTitle = VectorL10n.userVerificationSessionDetailsVerifyActionOtherUser diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 new file mode 100644 index 000000000..14c710595 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/vberror.mp3 differ diff --git a/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m index 17e09796b..08189aec0 100644 --- a/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m +++ b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m @@ -350,4 +350,18 @@ NSString *const kMXKWebViewViewControllerJavaScriptEnableLog = } } +#pragma mark - BWI: WebViewLinkPolicy +-(void)webView:(WKWebView *)webview decidePolicyForNavigationAction:(nonnull WKNavigationAction *)navigationAction decisionHandler:(nonnull void (^)(WKNavigationActionPolicy))decisionHandler +{ + if (navigationAction.navigationType == WKNavigationTypeLinkActivated) { + // bwi: clicked links should be opened in system browser + [[UIApplication sharedApplication] openURL:navigationAction.request.URL options:@{} completionHandler:nil]; + decisionHandler(WKNavigationActionPolicyCancel); + } else { + // bwi: Open url in webview + decisionHandler(WKNavigationActionPolicyAllow); + } +} + + @end diff --git a/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h b/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h index 635a2037c..195729055 100644 --- a/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h +++ b/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h @@ -17,3 +17,4 @@ #import "MXKRoomBubbleCellData.h" #import "UserIndicatorCancel.h" #import "VoiceBroadcastInfo.h" +#import "MXKSoundPlayer.h" diff --git a/Riot/Modules/MatrixKit/MatrixKit.h b/Riot/Modules/MatrixKit/MatrixKit.h index 2bb02223b..ce6ea5f1e 100644 --- a/Riot/Modules/MatrixKit/MatrixKit.h +++ b/Riot/Modules/MatrixKit/MatrixKit.h @@ -145,5 +145,3 @@ #import "MXKCountryPickerViewController.h" #import "MXKLanguagePickerViewController.h" - -#import "MXKSlashCommands.h" diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h index e934567b7..df9d12900 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h @@ -144,6 +144,15 @@ */ - (CGFloat)rawTextHeight:(NSAttributedString*)attributedText; +/** + Return the raw height of the provided text by removing any vertical margin/inset and constraining the width. + + @param attributedText the attributed text to measure + @param maxTextViewWidth the maximum text width + @return the computed height + */ +- (CGFloat)rawTextHeight:(NSAttributedString*)attributedText withMaxWidth:(CGFloat)maxTextViewWidth; + /** Return the content size of a text view initialized with the provided attributed text. CAUTION: This method runs only on main thread. diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m index 9e9cbfc15..f14e27c77 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -500,23 +500,34 @@ // Return the raw height of the provided text by removing any margin - (CGFloat)rawTextHeight: (NSAttributedString*)attributedText +{ + return [self rawTextHeight:attributedText withMaxWidth:_maxTextViewWidth]; +} + +// Return the raw height of the provided text by removing any vertical margin/inset and constraining the width. +- (CGFloat)rawTextHeight: (NSAttributedString*)attributedText withMaxWidth:(CGFloat)maxTextViewWidth { __block CGSize textSize; if ([NSThread currentThread] != [NSThread mainThread]) { dispatch_sync(dispatch_get_main_queue(), ^{ - textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + textSize = [self textContentSize:attributedText removeVerticalInset:YES maxTextViewWidth:maxTextViewWidth]; }); } else { - textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + textSize = [self textContentSize:attributedText removeVerticalInset:YES maxTextViewWidth:maxTextViewWidth]; } return textSize.height; } - (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset +{ + return [self textContentSize:attributedText removeVerticalInset:removeVerticalInset maxTextViewWidth:_maxTextViewWidth]; +} + +- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset maxTextViewWidth:(CGFloat)maxTextViewWidth { static UITextView* measurementTextView = nil; static UITextView* measurementTextViewWithoutInset = nil; @@ -535,7 +546,7 @@ // Select the right text view for measurement UITextView *selectedTextView = (removeVerticalInset ? measurementTextViewWithoutInset : measurementTextView); - selectedTextView.frame = CGRectMake(0, 0, _maxTextViewWidth, 0); + selectedTextView.frame = CGRectMake(0, 0, maxTextViewWidth, 0); selectedTextView.attributedText = attributedText; // Force the layout manager to layout the text, fixes problems starting iOS 16 diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index 0998122ae..033aa9361 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -31,8 +31,6 @@ #import "MXKAppSettings.h" -#import "MXKSlashCommands.h" - #import "GeneratedInterface-Swift.h" const BOOL USE_THREAD_TIMELINE = YES; @@ -316,7 +314,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { _filterMessagesWithURL = NO; - emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote]; + emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]; // Set default data and view classes // Cell data @@ -458,11 +456,6 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { } - (void)reset -{ - [self resetNotifying:YES]; -} - -- (void)resetNotifying:(BOOL)notify { if (roomDidFlushDataNotificationObserver) { @@ -558,12 +551,6 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { } _serverSyncEventCount = 0; - - // Notify the delegate to reload its tableview - if (notify && self.delegate) - { - [self.delegate dataSource:self didCellChange:nil]; - } } - (void)reload @@ -577,10 +564,16 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { [self setState:MXKDataSourceStatePreparing]; - [self resetNotifying:notify]; + [self reset]; // Reload [self didMXSessionStateChange]; + + // Notify the delegate to refresh the tableview + if (notify && self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } } - (void)destroy diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h deleted file mode 100644 index ef9c71783..000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - Copyright 2018 New Vector Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -@import Foundation; - -/** - Slash commands used to perform actions from a room. - */ - -FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeDisplayName; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdEmote; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdJoinRoom; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdPartRoom; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdInviteUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdKickUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdBanUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdUnbanUser; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdSetUserPowerLevel; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdResetUserPowerLevel; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeRoomTopic; -FOUNDATION_EXPORT NSString *const kMXKSlashCmdDiscardSession; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m deleted file mode 100644 index e9d483d9b..000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m +++ /dev/null @@ -1,30 +0,0 @@ -/* - Copyright 2018 New Vector Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -#import "MXKSlashCommands.h" - -NSString *const kMXKSlashCmdChangeDisplayName = @"/nick"; -NSString *const kMXKSlashCmdEmote = @"/me"; -NSString *const kMXKSlashCmdJoinRoom = @"/join"; -NSString *const kMXKSlashCmdPartRoom = @"/part"; -NSString *const kMXKSlashCmdInviteUser = @"/invite"; -NSString *const kMXKSlashCmdKickUser = @"/kick"; -NSString *const kMXKSlashCmdBanUser = @"/ban"; -NSString *const kMXKSlashCmdUnbanUser = @"/unban"; -NSString *const kMXKSlashCmdSetUserPowerLevel = @"/op"; -NSString *const kMXKSlashCmdResetUserPowerLevel = @"/deop"; -NSString *const kMXKSlashCmdChangeRoomTopic = @"/topic"; -NSString *const kMXKSlashCmdDiscardSession = @"/discardsession"; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift new file mode 100644 index 000000000..54ab1ab3c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.swift @@ -0,0 +1,101 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@objc final class MXKSlashCommandsHelper: NSObject { + @objc static func commandNameFor(_ slashCommand: MXKSlashCommand) -> String { + slashCommand.cmd + } + + @objc static func commandUsageFor(_ slashCommand: MXKSlashCommand) -> String { + "Usage: \(slashCommand.cmd) \(slashCommand.parametersFormat)" + } +} + +@objc enum MXKSlashCommand: Int, CaseIterable { + case changeDisplayName + case emote + case joinRoom + case partRoom + case inviteUser + case kickUser + case banUser + case unbanUser + case setUserPowerLevel + case resetUserPowerLevel + case changeRoomTopic + case discardSession + + var cmd: String { + switch self { + case .changeDisplayName: + return "/nick" + case .emote: + return "/me" + case .joinRoom: + return "/join" + case .partRoom: + return "/part" + case .inviteUser: + return "/invite" + case .kickUser: + return "/kick" + case .banUser: + return "/ban" + case .unbanUser: + return "/unban" + case .setUserPowerLevel: + return "/op" + case .resetUserPowerLevel: + return "/deop" + case .changeRoomTopic: + return "/topic" + case .discardSession: + return "/discardsession" + } + } + + // Note: not localized for consistency, as commands are in english + // also translating these parameters could lead to inconsistency in + // the UI in case of languages with overlength translation. + var parametersFormat: String { + switch self { + case .changeDisplayName: + return "" + case .emote: + return "" + case .joinRoom: + return "" + case .partRoom: + return "[]" + case .inviteUser: + return "" + case .kickUser: + return " []" + case .banUser: + return " []" + case .unbanUser: + return "" + case .setUserPowerLevel: + return " " + case .resetUserPowerLevel: + return "" + case .changeRoomTopic: + return "" + case .discardSession: + return "" + } + } +} diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m index a1239e184..f4f109ff5 100644 --- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -344,7 +344,7 @@ static NSString *const kRepliedTextPattern = @".*

.*
(.* if ((RiotSettings.shared.enableThreads && [mxSession.threadingService isEventThreadRoot:event]) || _settings.showRedactionsInRoomHistory) { - MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.description, event.redactedBecause); + MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.eventId, event.redactedBecause); NSString *redactorId = event.redactedBecause[@"sender"]; NSString *redactedBy = @""; @@ -571,7 +571,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* } else { - displayText = [VectorL10n noticeDisplayNameChangedFrom:event.sender :prevDisplayname :displayname]; + displayText = [VectorL10n noticeDisplayNameChangedTo:prevDisplayname :displayname]; } } } @@ -1316,7 +1316,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* // Check attachment validity if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); body = [VectorL10n noticeInvalidAttachment]; *error = MXKEventFormatterErrorUnsupported; } @@ -1326,7 +1326,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* body = body? body : [VectorL10n noticeAudioAttachment]; if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) { body = [VectorL10n noticeInvalidAttachment]; @@ -1343,7 +1343,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* body = body? body : [VectorL10n noticeVideoAttachment]; if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) { body = [VectorL10n noticeInvalidAttachment]; @@ -1374,14 +1374,14 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* } else { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format: %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format in event: %@", event.eventId); *error = MXKEventFormatterErrorUnsupported; } } } else { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); body = [VectorL10n noticeInvalidAttachment]; *error = MXKEventFormatterErrorUnsupported; } @@ -1620,7 +1620,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* // Check sticker validity if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker in event %@", event.eventId); body = [VectorL10n noticeInvalidAttachment]; *error = MXKEventFormatterErrorUnsupported; } @@ -1674,7 +1674,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* if (!attributedDisplayText) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.eventId); if (_settings.showUnsupportedEventsInRoomHistory) { if (MXKEventFormatterErrorNone == *error) @@ -1914,7 +1914,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* // No message content in a non-redacted event. Formatter should use fallback. if (!repliedEventContent) { - MXLogWarning(@"[MXKEventFormatter] Unable to retrieve content from replied event %@", repliedEvent.description) + MXLogWarning(@"[MXKEventFormatter] Unable to retrieve content from replied event %@", repliedEvent.eventId) return nil; } } @@ -1949,7 +1949,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* } else { - MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.description) + MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.eventId) } return html; diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index d7bf9d8fc..b18e93690 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -102,6 +102,14 @@ typedef enum : NSUInteger */ - (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText; +/** + Tells the delegate that the user wants to send a command. + + @param toolbarView the room input toolbar view. + @param commandText the command to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText; + /** Tells the delegate that the user wants to display the send media actions. @@ -205,6 +213,15 @@ typedef enum : NSUInteger */ - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating; +/** + Tells the delegate that the partial content of the composer has changed + and should be stored to allow restoring it later if needed. + + @param toolbarView the room input toolbar view + @param partialAttributedTextMessage the partial content to store + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView shouldStorePartialContent:(NSAttributedString*)partialAttributedTextMessage; + @end /** @@ -382,6 +399,16 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; +/** + Sets the partial text message to apply to the current message composer. + */ +- (void)setPartialContent:(NSAttributedString *)attributedTextMessage; + +/** + Default font for the message composer. + */ +@property (nonatomic, readonly, nonnull) UIFont *defaultFont; + - (void)dismissValidationView:(MXKImageView*)validationView; @end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index 9581df2a7..b5b15d4b8 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -358,6 +358,10 @@ self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; } +- (UIFont *)defaultFont +{ + return [UIFont systemFontOfSize:15.f]; +} #pragma mark - MXKFileSizes @@ -1401,4 +1405,9 @@ NSString* MXKFileSizes_description(MXKFileSizes sizes) return NO; } +- (void)setPartialContent:(NSAttributedString *)attributedTextMessage +{ + self.attributedTextMessage = attributedTextMessage; +} + @end diff --git a/Riot/Modules/People/Views/InviteRecentTableViewCell.m b/Riot/Modules/People/Views/InviteRecentTableViewCell.m index ea42b74a4..9e07c6ec1 100644 --- a/Riot/Modules/People/Views/InviteRecentTableViewCell.m +++ b/Riot/Modules/People/Views/InviteRecentTableViewCell.m @@ -65,6 +65,11 @@ NSString *const kInviteRecentTableViewCellRoomKey = @"kInviteRecentTableViewCell self.rightButton.backgroundColor = ThemeService.shared.theme.tintColor; self.rightButton.titleLabel.font = ThemeService.shared.theme.fonts.body; + + // bwi: 4769 + if(BWIBuildSettings.shared.useNewBumColors) { + [self.rightButton setTitleColor:ThemeService.shared.theme.backgroundColor forState:UIControlStateNormal]; + } } - (void)prepareForReuse diff --git a/Riot/Modules/Pills/PillAttachmentViewProvider.swift b/Riot/Modules/Pills/PillAttachmentViewProvider.swift index ae02b019f..5d57bb3b2 100644 --- a/Riot/Modules/Pills/PillAttachmentViewProvider.swift +++ b/Riot/Modules/Pills/PillAttachmentViewProvider.swift @@ -25,25 +25,29 @@ import UIKit avatarLeading: 2.0, avatarSideLength: 16.0, itemSpacing: 4) - private weak var messageTextView: MXKMessageTextView? + private weak var messageTextView: UITextView? + private var pillViewFlusher: PillViewFlusher? { + messageTextView as? PillViewFlusher + } // MARK: - Override override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) { super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location) - self.messageTextView = parentView?.superview as? MXKMessageTextView + // Keep a reference to the parent text view for size adjustments and pills flushing. + messageTextView = parentView?.superview as? UITextView } override func loadView() { super.loadView() guard let textAttachment = self.textAttachment as? PillTextAttachment else { - MXLog.debug("[PillAttachmentViewProvider]: attachment is missing or not of expected class") + MXLog.failure("[PillAttachmentViewProvider]: attachment is missing or not of expected class") return } guard var pillData = textAttachment.data else { - MXLog.debug("[PillAttachmentViewProvider]: attachment misses pill data") + MXLog.failure("[PillAttachmentViewProvider]: attachment misses pill data") return } @@ -59,6 +63,11 @@ import UIKit mediaManager: mainSession?.mediaManager, andPillData: pillData) view = pillView - messageTextView?.registerPillView(pillView) + + if let pillViewFlusher { + pillViewFlusher.registerPillView(pillView) + } else { + MXLog.failure("[PillAttachmentViewProvider]: no handler found, pill will not be flushed properly") + } } } diff --git a/Riot/Modules/Pills/PillProvider.swift b/Riot/Modules/Pills/PillProvider.swift index 60363bc47..1941f8af1 100644 --- a/Riot/Modules/Pills/PillProvider.swift +++ b/Riot/Modules/Pills/PillProvider.swift @@ -26,14 +26,14 @@ private enum PillAttachmentKind { struct PillProvider { private let session: MXSession private let eventFormatter: MXKEventFormatter - private let event: MXEvent + private let event: MXEvent? private let roomState: MXRoomState private let latestRoomState: MXRoomState? private let isEditMode: Bool init(withSession session: MXSession, eventFormatter: MXKEventFormatter, - event: MXEvent, + event: MXEvent?, roomState: MXRoomState, andLatestRoomState latestRoomState: MXRoomState?, isEditMode: Bool) { @@ -46,7 +46,7 @@ struct PillProvider { self.isEditMode = isEditMode } - func pillTextAttachmentString(forUrl url: URL, withLabel label: String, event: MXEvent) -> NSAttributedString? { + func pillTextAttachmentString(forUrl url: URL, withLabel label: String) -> NSAttributedString? { // Try to get a pill from this url guard let pillType = PillType.from(url: url) else { @@ -133,6 +133,10 @@ struct PillProvider { let avatarUrl = roomMember?.avatarUrl ?? user?.avatarUrl let displayName = roomMember?.displayname ?? user?.displayName ?? userId let isHighlighted = userId == session.myUserId + // No actual event means it is a composer Pill. No highlight + && event != nil + // No highlight on self-mentions + && event?.sender != session.myUserId let avatar: PillTextAttachmentItem if roomMember == nil && user == nil { diff --git a/Riot/Modules/Pills/PillViewFlusher.swift b/Riot/Modules/Pills/PillViewFlusher.swift new file mode 100644 index 000000000..44a4d7cbf --- /dev/null +++ b/Riot/Modules/Pills/PillViewFlusher.swift @@ -0,0 +1,39 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import WysiwygComposer + +/// Defines behaviour for an object that is able to manage views created +/// by a `NSTextAttachmentViewProvider`. This can be implemented +/// by an `UITextView` that would keep track of views in order to +/// (internally) clear them when required (e.g. when setting a new attributed text). +/// +/// Note: It is necessary to clear views manually due to a bug in iOS. See `MXKMessageTextView`. +@available(iOS 15.0, *) +protocol PillViewFlusher: AnyObject { + /// Register a pill view that has been added through `NSTextAttachmentViewProvider`. + /// Should be called within the `loadView` function in order to clear the pills properly on text updates. + /// + /// - Parameter pillView: View to register. + func registerPillView(_ pillView: UIView) +} + +@available(iOS 15.0, *) +extension MXKMessageTextView: PillViewFlusher { } + +@available(iOS 15.0, *) +extension WysiwygTextView: PillViewFlusher { } diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index a9df99fd4..1b6256835 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -65,7 +65,7 @@ class PillsFormatter: NSObject { // try to get a mention pill from the url let label = Range(range, in: newAttr.string).flatMap { String(newAttr.string[$0]) } - if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "", event: event) { + if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "") { // replace the url with the pill newAttr.replaceCharacters(in: range, with: attachmentString) } @@ -74,6 +74,41 @@ class PillsFormatter: NSObject { return newAttr } + /// Insert text attachments for pills inside given attributed string containing markdown. + /// + /// - Parameters: + /// - markdownString: An attributed string with markdown formatting + /// - roomState: The current room state + /// - font: The font to use for the pill text + /// - Returns: A new attributed string with pills. + static func insertPills(in markdownString: NSAttributedString, + withSession session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState, + font: UIFont) -> NSAttributedString { + let matches = markdownLinks(in: markdownString) + + // If we have some matches, replace permalinks by a pill version. + guard !matches.isEmpty else { return markdownString } + + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) + + let mutable = NSMutableAttributedString(attributedString: markdownString) + + matches.reversed().forEach { + if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: $0.url, withLabel: $0.label) { + mutable.replaceCharacters(in: $0.range, with: attachmentString) + } + } + + return mutable + } + /// Creates a string with all pills of given attributed string replaced by display names. /// /// - Parameters: @@ -123,6 +158,20 @@ class PillsFormatter: NSObject { } return attributedStringWithAttachment(attachment, link: url, font: font) } + + static func mentionPill(withUrl url: URL, + andLabel label: String, + session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState) -> NSAttributedString? { + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) + return pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label) + } /// Update alpha of all `PillTextAttachment` contained in given attributed string. /// @@ -160,12 +209,45 @@ class PillsFormatter: NSObject { } } } - } // MARK: - Private Methods @available (iOS 15.0, *) extension PillsFormatter { + struct MarkdownLinkResult: Equatable { + let url: URL + let label: String + let range: NSRange + } + + static func markdownLinks(in attributedString: NSAttributedString) -> [MarkdownLinkResult] { + // Create a regexp that detects markdown links. + // Pattern source: https://gist.github.com/hugocf/66d6cd241eff921e0e02 + let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)" + guard let regExp = try? NSRegularExpression(pattern: pattern) else { return [] } + + let matches = regExp.matches(in: attributedString.string, + range: .init(location: 0, length: attributedString.length)) + + return matches.compactMap { match in + let labelRange = match.range(at: 1) + let urlRange = match.range(at: 2) + let label = attributedString.attributedSubstring(from: labelRange).string + var url = attributedString.attributedSubstring(from: urlRange).string + + // Note: a valid markdown link can be written with + // enclosing <..>, remove them for userId detection. + if url.first == "<" && url.last == ">" { + url = String(url[url.index(after: url.startIndex)...url.index(url.endIndex, offsetBy: -2)]) + } + + if let url = URL(string: url) { + return MarkdownLinkResult(url: url, label: label, range: match.range) + } else { + return nil + } + } + } static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString { let string = NSMutableAttributedString(attachment: attachment) diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.swift b/Riot/Modules/Room/DataSources/RoomDataSource.swift index 281a7a046..89cbabe42 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.swift +++ b/Riot/Modules/Room/DataSources/RoomDataSource.swift @@ -19,7 +19,7 @@ import Foundation extension RoomDataSource { // MARK: - Private Constants private enum Constants { - static let emoteMessageSlashCommandPrefix = String(format: "%@ ", kMXKSlashCmdEmote) + static let emoteMessageSlashCommandPrefix = String(format: "%@ ", MXKSlashCommand.emote.cmd) } // MARK: - NSAttributedString Sending diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index 7c014e018..b1a1bc18b 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -39,7 +39,6 @@ #import "MXKEncryptionKeysImportView.h" #import "NSBundle+MatrixKit.h" -#import "MXKSlashCommands.h" #import "MXKSwiftHeader.h" #import "MXKPreviewViewController.h" @@ -361,7 +360,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; { // Retrieve the potential message partially typed during last room display. // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) - inputToolbarView.attributedTextMessage = roomDataSource.partialAttributedTextMessage; + [inputToolbarView setPartialContent:roomDataSource.partialAttributedTextMessage]; } if (!hasAppearedOnce) @@ -1284,8 +1283,14 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; // TODO: display an alert with the cmd usage in case of error or unrecognized cmd. NSString *cmdUsage; + + NSString* kMXKSlashCmdChangeDisplayName = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeDisplayName]; + NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; + NSString* kMXKSlashCmdPartRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandPartRoom]; + NSString* kMXKSlashCmdChangeRoomTopic = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeRoomTopic]; + - if ([cmd isEqualToString:kMXKSlashCmdEmote]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // send message as an emote [self sendTextMessage:string]; @@ -1320,7 +1325,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /nick "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeDisplayName]; } } else if ([string hasPrefix:kMXKSlashCmdJoinRoom]) @@ -1355,7 +1360,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /join "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } } else if ([string hasPrefix:kMXKSlashCmdPartRoom]) @@ -1413,7 +1418,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /part []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandPartRoom]; } } else if ([string hasPrefix:kMXKSlashCmdChangeRoomTopic]) @@ -1445,10 +1450,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /topic "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeRoomTopic]; } } - else if ([string hasPrefix:kMXKSlashCmdDiscardSession]) + else if ([string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandDiscardSession]]) { [roomDataSource.mxSession.crypto discardOutboundGroupSessionForRoomWithRoomId:roomDataSource.roomId onComplete:^{ MXLogDebug(@"[MXKRoomVC] Manually discarded outbound group session"); @@ -1470,7 +1475,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; userId = nil; } - if ([cmd isEqualToString:kMXKSlashCmdInviteUser]) + if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandInviteUser]]) { if (userId) { @@ -1489,10 +1494,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /invite "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandInviteUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdKickUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandKickUser]]) { if (userId) { @@ -1524,10 +1529,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /kick []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandKickUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdBanUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandBanUser]]) { if (userId) { @@ -1559,10 +1564,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /ban []"; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandBanUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdUnbanUser]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandUnbanUser]]) { if (userId) { @@ -1581,10 +1586,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /unban "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandUnbanUser]; } } - else if ([cmd isEqualToString:kMXKSlashCmdSetUserPowerLevel]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandSetUserPowerLevel]]) { // Retrieve power level NSString *powerLevel = nil; @@ -1617,10 +1622,10 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /op "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandSetUserPowerLevel]; } } - else if ([cmd isEqualToString:kMXKSlashCmdResetUserPowerLevel]) + else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandResetUserPowerLevel]]) { if (userId) { @@ -1639,7 +1644,7 @@ static const CGFloat kCellVisibilityMinimumHeight = 8.0; else { // Display cmd usage in text input as placeholder - cmdUsage = @"Usage: /deop "; + cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandResetUserPowerLevel]; } } else diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index f249b863c..e018bfbe8 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -143,6 +143,12 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { navigationRouter.setRootModule(self, popCompletion: nil) } } + + // FRROT its like sleep() again -> hopefully there is a better solution. When directly calling self.navigation router here its still nil and it needs to be called this early because soon afterwards the pollCells get build + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + TimelinePollProvider.shared.navigationRouter = self.navigationRouter + } + } func start(withEventId eventId: String, completion: (() -> Void)?) { diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewModel.swift b/Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewModel.swift index 932f7fddd..01b78d41e 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewModel.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewModel.swift @@ -98,7 +98,7 @@ final class RoomInfoListViewModel: NSObject, RoomInfoListViewModelType { } func isInviteOnlyRoom() -> Bool { - return room.summary.membership == .invite + return room.summary.joinRule == MXRoomJoinRule.invite.identifier } func isPermalinkableRoom() -> Bool { diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoList/Views/RoomInfoBasicView.swift b/Riot/Modules/Room/RoomInfo/RoomInfoList/Views/RoomInfoBasicView.swift index ca6c1c85c..16af8c4d5 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoList/Views/RoomInfoBasicView.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoList/Views/RoomInfoBasicView.swift @@ -78,19 +78,11 @@ class RoomInfoBasicView: UIView { func configure(withViewData viewData: RoomInfoBasicViewData) { let avatarImage = AvatarGenerator.generateAvatar(forMatrixItem: viewData.roomId, withDisplayName: viewData.roomDisplayName) - if BWIBuildSettings.shared.bwiUseCustomPersonalNotesAvatar { - if let session = AppDelegate.theDelegate().mxSessions.first as? MXSession { - let service = PersonalNotesDefaultService(mxSession: session) - if let personalNotesRoomId = service.personalNotesRoomId(), personalNotesRoomId == viewData.roomId { - avatarImageView.image = UIImage(named: service.avatarImageUrl()) - } - } - } - - if avatarImageView.image == nil { - if let avatarUrl = viewData.avatarUrl { + // bwi: update room avatar + if let avatarUrl = viewData.avatarUrl { + if !avatarUrl.isEmpty { avatarImageView.enableInMemoryCache = true - + avatarImageView.setImageURI(avatarUrl, withType: nil, andImageOrientation: .up, @@ -101,6 +93,17 @@ class RoomInfoBasicView: UIView { } else { avatarImageView.image = avatarImage } + } else { + avatarImageView.image = avatarImage + } + + if BWIBuildSettings.shared.bwiUseCustomPersonalNotesAvatar { + if let session = AppDelegate.theDelegate().mxSessions.first as? MXSession { + let service = PersonalNotesDefaultService(mxSession: session) + if let personalNotesRoomId = service.personalNotesRoomId(), personalNotesRoomId == viewData.roomId { + avatarImageView.image = UIImage(named: service.avatarImageUrl()) + } + } } badgeImageView.image = viewData.encryptionImage diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index b0c482917..1244e838c 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -36,6 +36,7 @@ @class VoiceBroadcastService; @class ComposerLinkActionBridgePresenter; @class PerformanceProfile; +@class BWIAnalyticsHelper; NS_ASSUME_NONNULL_BEGIN @@ -62,7 +63,7 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; // The preview header @property (weak, nonatomic, nullable) IBOutlet UIView *previewHeaderContainer; @property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *previewHeaderContainerHeightConstraint; -@property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *userSuggestionContainerHeightConstraint; +@property (weak, nonatomic, nullable) IBOutlet NSLayoutConstraint *completionSuggestionContainerHeightConstraint; // The jump to last unread banner @property (weak, nonatomic, nullable) IBOutlet UIView *jumpToLastUnreadBannerContainer; diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index e3861e0db..88741b98d 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -100,7 +100,7 @@ static CGSize kThreadListBarButtonItemImageSize; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, CompletionSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate, RoomInputToolbarViewDelegate, ComposerCreateActionListBridgePresenterDelegate> { // The preview header @@ -226,8 +226,8 @@ static CGSize kThreadListBarButtonItemImageSize; @property (nonatomic, strong) ShareManager *shareManager; @property (nonatomic, strong) EventMenuBuilder *eventMenuBuilder; -@property (nonatomic, strong) UserSuggestionCoordinatorBridge *userSuggestionCoordinator; -@property (nonatomic, weak) IBOutlet UIView *userSuggestionContainerView; +@property (nonatomic, strong) CompletionSuggestionCoordinatorBridge *completionSuggestionCoordinator; +@property (nonatomic, weak) IBOutlet UIView *completionSuggestionContainerView; @property (nonatomic, readwrite) RoomDisplayConfiguration *displayConfiguration; @@ -431,7 +431,7 @@ static CGSize kThreadListBarButtonItemImageSize; [self setupActions]; - [self setupUserSuggestionViewIfNeeded]; + [self setupCompletionSuggestionViewIfNeeded]; [self.topBannersStackView vc_removeAllSubviews]; } @@ -713,7 +713,7 @@ static CGSize kThreadListBarButtonItemImageSize; { // Retrieve the potential message partially typed during last room display. // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) - self.inputToolbarView.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage; + [self.inputToolbarView setPartialContent:self.roomDataSource.partialAttributedTextMessage]; } [self setMaximisedToolbarIsHiddenIfNeeded: NO]; @@ -1108,12 +1108,14 @@ static CGSize kThreadListBarButtonItemImageSize; [VoiceMessageMediaServiceProvider.sharedProvider setCurrentRoomSummary:dataSource.room.summary]; _voiceMessageController.roomId = dataSource.roomId; - _userSuggestionCoordinator = [[UserSuggestionCoordinatorBridge alloc] initWithMediaManager:self.roomDataSource.mxSession.mediaManager + _completionSuggestionCoordinator = [[CompletionSuggestionCoordinatorBridge alloc] initWithMediaManager:self.roomDataSource.mxSession.mediaManager room:dataSource.room userID:self.roomDataSource.mxSession.myUserId]; - _userSuggestionCoordinator.delegate = self; + _completionSuggestionCoordinator.delegate = self; - [self setupUserSuggestionViewIfNeeded]; + [self setupCompletionSuggestionViewIfNeeded]; + + [self updateRoomInputToolbarViewClassIfNeeded]; [self updateTopBanners]; } @@ -1214,6 +1216,12 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)updateRoomInputToolbarViewClassIfNeeded { Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; + + // If RTE is enabled, delay the toolbar setup until `completionSuggestionCoordinator` is ready. + if (roomInputToolbarViewClass == WysiwygInputToolbarView.class && _completionSuggestionCoordinator == nil) + { + return; + } BOOL shouldDismissContextualMenu = NO; @@ -1301,6 +1309,8 @@ static CGSize kThreadListBarButtonItemImageSize; - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string { // Override the default behavior for `/join` command in order to open automatically the joined room + + NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; if ([string hasPrefix:kMXKSlashCmdJoinRoom]) { @@ -1337,7 +1347,7 @@ static CGSize kThreadListBarButtonItemImageSize; else { // Display cmd usage in text input as placeholder - self.inputToolbarView.placeholder = @"Usage: /join "; + self.inputToolbarView.placeholder = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } return YES; } @@ -2770,13 +2780,13 @@ static CGSize kThreadListBarButtonItemImageSize; } } -- (void)setupUserSuggestionViewIfNeeded +- (void)setupCompletionSuggestionViewIfNeeded { if(!self.isViewLoaded) { return; } - UIViewController *suggestionsViewController = self.userSuggestionCoordinator.toPresentable; + UIViewController *suggestionsViewController = self.completionSuggestionCoordinator.toPresentable; if (!suggestionsViewController) { @@ -2786,12 +2796,12 @@ static CGSize kThreadListBarButtonItemImageSize; [suggestionsViewController.view setTranslatesAutoresizingMaskIntoConstraints:NO]; [self addChildViewController:suggestionsViewController]; - [self.userSuggestionContainerView addSubview:suggestionsViewController.view]; + [self.completionSuggestionContainerView addSubview:suggestionsViewController.view]; - [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.userSuggestionContainerView.topAnchor], - [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.userSuggestionContainerView.leadingAnchor], - [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.userSuggestionContainerView.trailingAnchor], - [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.userSuggestionContainerView.bottomAnchor],]]; + [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.topAnchor], + [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.leadingAnchor], + [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.trailingAnchor], + [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.bottomAnchor],]]; [suggestionsViewController didMoveToParentViewController:self]; } @@ -4332,7 +4342,9 @@ static CGSize kThreadListBarButtonItemImageSize; } } - if (BWIBuildSettings.shared.messageDetailsAllowPermalink && !(self.roomDataSource.room.isDirect || self.roomDataSource.room.isPersonalNotesRoom || self.roomDataSource.room.summary.membership == MXMembershipInvite)) + if (BWIBuildSettings.shared.messageDetailsAllowPermalink && !(self.roomDataSource.room.isDirect || self.roomDataSource.room.isPersonalNotesRoom || + // bwi #4390: remove permalink action if room is private + [self.roomDataSource.room.summary.joinRule isEqualToString: kMXRoomJoinRuleInvite])) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypePermalink action:[UIAlertAction actionWithTitle:[BWIL10n roomEventActionPermalink] @@ -5202,7 +5214,22 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView *)toolbarView { - [self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage]; + [self.completionSuggestionCoordinator processTextMessage:toolbarView.textMessage]; +} + +- (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern +{ + [self.completionSuggestionCoordinator processSuggestionPattern:suggestionPattern]; +} + +- (CompletionSuggestionViewModelContextWrapper *)completionSuggestionContext +{ + return [self.completionSuggestionCoordinator sharedContext]; +} + +- (MXMediaManager *)mediaManager +{ + return self.roomDataSource.mxSession.mediaManager; } - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView @@ -5228,6 +5255,27 @@ static CGSize kThreadListBarButtonItemImageSize; }]; } +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText +{ + // Create before sending the message in case of a discussion (direct chat) + MXWeakify(self); + [self createDiscussionIfNeeded:^(BOOL readyToSend) { + MXStrongifyAndReturnIfNil(self); + + if (readyToSend) { + if (![self sendAsIRCStyleCommandIfPossible:commandText]) + { + // Display an error for unknown command + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil + message:[VectorL10n roomCommandErrorUnknownCommand] + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; + } + } + }]; +} + - (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView { NSMutableArray *actionItems = [NSMutableArray new]; @@ -5277,7 +5325,7 @@ static CGSize kThreadListBarButtonItemImageSize; if (readyToSend) { BOOL isMessageAHandledCommand = NO; // "/me" command is supported with Pills in RoomDataSource. - if (![attributedTextMessage.string hasPrefix:kMXKSlashCmdEmote]) + if (![attributedTextMessage.string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // Other commands currently work with identifiers (e.g. ban, invite, op, etc). NSString *message; @@ -5302,6 +5350,11 @@ static CGSize kThreadListBarButtonItemImageSize; }]; } +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView shouldStorePartialContent:(NSAttributedString *)partialAttributedTextMessage +{ + self.roomDataSource.partialAttributedTextMessage = partialAttributedTextMessage; +} + #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion @@ -6154,7 +6207,7 @@ static CGSize kThreadListBarButtonItemImageSize; if (self.saveProgressTextInput) { // Restore the potential message partially typed before jump to last unread messages. - self.inputToolbarView.attributedTextMessage = roomDataSource.partialAttributedTextMessage; + [self.inputToolbarView setPartialContent:roomDataSource.partialAttributedTextMessage]; } }; @@ -7543,23 +7596,47 @@ static CGSize kThreadListBarButtonItemImageSize; return; } + NSMutableArray *rowsToReload = [[NSMutableArray alloc] init]; + // Get the current hightlighted event because we will need to reload it + NSString *currentHiglightedEventId = self.customizedRoomDataSource.highlightedEventId; + if (currentHiglightedEventId) + { + NSInteger currentHiglightedRow = [self.roomDataSource indexOfCellDataWithEventId:currentHiglightedEventId]; + if (currentHiglightedRow != NSNotFound) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:currentHiglightedRow inSection:0]; + if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) + { + [rowsToReload addObject:indexPath]; + } + } + } + self.customizedRoomDataSource.highlightedEventId = eventId; + // Add the new highligted event to the list of rows to reload NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; - if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) + BOOL indexPathIsVisible = [[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]; + if (indexPathIsVisible) { - [self.bubblesTableView reloadRowsAtIndexPaths:@[indexPath] + [rowsToReload addObject:indexPath]; + } + + // Reload rows + if (rowsToReload.count > 0) + { + [self.bubblesTableView reloadRowsAtIndexPaths:rowsToReload withRowAnimation:UITableViewRowAnimationNone]; - [self.bubblesTableView scrollToRowAtIndexPath:indexPath - atScrollPosition:UITableViewScrollPositionMiddle - animated:YES]; } - else if ([self.bubblesTableView vc_hasIndexPath:indexPath]) + + // Scroll to the newly highlighted row + if (indexPathIsVisible || [self.bubblesTableView vc_hasIndexPath:indexPath]) { [self.bubblesTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionMiddle animated:YES]; } + if (completion) { completion(); @@ -8111,6 +8188,7 @@ static CGSize kThreadListBarButtonItemImageSize; { [self.roomDataSource sendVoiceMessage:url additionalContentParams:nil mimeType:nil duration:duration samples:samples success:^(NSString *eventId) { MXLogDebug(@"Success with event id %@", eventId); + [self trackVoiceMessage: duration]; completion(YES); } failure:^(NSError *error) { MXLogError(@"Failed sending voice message"); @@ -8137,24 +8215,29 @@ static CGSize kThreadListBarButtonItemImageSize; [[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId]; } -#pragma mark - Bwi Measurements - - (void) finishTextMessageProfil:(PerformanceProfile*)profile { [profile stopMeasurement]; if( [profile isLogable] ) { [self.roomDataSource.room members:^(MXRoomMembers *roomMembers) { NSUInteger noOfUsers = roomMembers.joinedMembers.count; - NSUInteger noOfDevices = 0; - for (MXRoomMember* member in roomMembers.joinedMembers) { - noOfDevices += [self.mainSession.crypto devicesForUser:member.userId].count; - } - [profile log2AnalyticsWithUsers:noOfUsers devices:noOfDevices]; + [BWIAnalyticsHelper getRoomDeviceCountWithRoom:self.roomDataSource.room completion:^(NSInteger deviceCount) { + [profile log2AnalyticsWithUsers:noOfUsers devices:deviceCount]; + }]; } failure:^(NSError *error) { }]; } } +// Bwi #4795: voice message +- (void) trackVoiceMessage:(NSInteger)duration { + [BWIAnalyticsHelper getRoomDeviceCountWithRoom:self.roomDataSource.room completion:^(NSInteger deviceCount) { + NSString *deviceCountString = [BWIAnalyticsHelper dimensionForDeviceCount: deviceCount]; + NSNumber *durationInSeconds = [NSNumber numberWithInteger:(duration / 1000)]; + [BWIAnalytics.sharedTracker trackEventWithDimensionWithCategory:@"Feature" action:@"SendVoiceMessage" dimension:deviceCountString value:durationInSeconds name:@"send_voice_message_default"]; + }]; +} + #pragma mark - BWI Emoji History - (void) bwiAddedEmoji:(NSString*)emoji { @@ -8164,7 +8247,7 @@ static CGSize kThreadListBarButtonItemImageSize; #pragma mark - UserSuggestionCoordinatorBridgeDelegate -- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator +- (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didRequestMentionForMember:(MXRoomMember *)member textTrigger:(NSString *)textTrigger { @@ -8172,16 +8255,32 @@ static CGSize kThreadListBarButtonItemImageSize; [self mention:member]; } -- (void)userSuggestionCoordinatorBridgeDidRequestMentionForRoom:(UserSuggestionCoordinatorBridge *)coordinator +- (void)completionSuggestionCoordinatorBridgeDidRequestMentionForRoom:(CompletionSuggestionCoordinatorBridge *)coordinator textTrigger:(NSString *)textTrigger { [self removeTriggerTextFromComposer:textTrigger]; - [self.inputToolbarView pasteText:[UserSuggestionID.room stringByAppendingString:@" "]]; + [self.inputToolbarView pasteText:[CompletionSuggestionUserID.room stringByAppendingString:@" "]]; +} + +- (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator + didRequestCommand:(NSString *)command + textTrigger:(NSString *)textTrigger +{ + [self removeTriggerTextFromComposer:textTrigger]; + [self setCommand:command]; } - (void)removeTriggerTextFromComposer:(NSString *)textTrigger { RoomInputToolbarView *toolbar = (RoomInputToolbarView *)self.inputToolbarView; + Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; + + // RTE handles removing the text trigger by itself. + if (roomInputToolbarViewClass == WysiwygInputToolbarView.class && RiotSettings.shared.enableWysiwygTextFormatting) + { + return; + } + if (toolbar && textTrigger.length) { NSMutableAttributedString *attributedTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:toolbar.attributedTextMessage]; [[attributedTextMessage mutableString] replaceOccurrencesOfString:textTrigger @@ -8192,11 +8291,11 @@ static CGSize kThreadListBarButtonItemImageSize; } } -- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator didUpdateViewHeight:(CGFloat)height +- (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didUpdateViewHeight:(CGFloat)height { - if (self.userSuggestionContainerHeightConstraint.constant != height) + if (self.completionSuggestionContainerHeightConstraint.constant != height) { - self.userSuggestionContainerHeightConstraint.constant = height; + self.completionSuggestionContainerHeightConstraint.constant = height; [self.view layoutIfNeeded]; } diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index ef1c3e828..8c52a9619 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -14,46 +14,68 @@ // limitations under the License. // +import HTMLParser import UIKit import WysiwygComposer extension RoomViewController { // MARK: - Override open override func mention(_ roomMember: MXRoomMember) { - guard let inputToolbar = inputToolbar else { - return - } - - let newAttributedString = NSMutableAttributedString(attributedString: inputToolbar.attributedTextMessage) - - if inputToolbar.attributedTextMessage.length > 0 { - if #available(iOS 15.0, *) { - newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, - isHighlighted: false, - font: inputToolbar.textDefaultFont)) - } else { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) - } - newAttributedString.appendString(" ") - } else if roomMember.userId == self.mainSession.myUser.userId { - newAttributedString.appendString("/me ") + if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled { + wysiwygInputToolbar.mention(roomMember) + wysiwygInputToolbar.becomeFirstResponder() } else { - if #available(iOS 15.0, *) { - newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, - isHighlighted: false, - font: inputToolbar.textDefaultFont)) - } else { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) - } - newAttributedString.appendString(": ") - } + guard let attributedText = inputToolbarView.attributedTextMessage else { return } + let newAttributedString = NSMutableAttributedString(attributedString: attributedText) - inputToolbar.attributedTextMessage = newAttributedString - inputToolbar.becomeFirstResponder() + if attributedText.length > 0 { + if #available(iOS 15.0, *) { + newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, + isHighlighted: false, + font: inputToolbarView.defaultFont)) + } else { + newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + } + newAttributedString.appendString(" ") + } else if roomMember.userId == self.mainSession.myUser.userId { + newAttributedString.appendString("/me ") + newAttributedString.addAttribute(.font, + value: inputToolbarView.defaultFont, + range: .init(location: 0, length: newAttributedString.length)) + } else { + if #available(iOS 15.0, *) { + newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, + isHighlighted: false, + font: inputToolbarView.defaultFont)) + } else { + newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + } + newAttributedString.appendString(": ") + } + + inputToolbarView.attributedTextMessage = newAttributedString + inputToolbarView.becomeFirstResponder() + } + } + + @objc func setCommand(_ command: String) { + if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled { + wysiwygInputToolbar.command(command) + wysiwygInputToolbar.becomeFirstResponder() + } else { + guard let attributedText = inputToolbarView.attributedTextMessage else { return } + + let newAttributedString = NSMutableAttributedString(attributedString: attributedText) + newAttributedString.append(NSAttributedString(string: "\(command) ", + attributes: [.font: inputToolbarView.defaultFont])) + + inputToolbarView.attributedTextMessage = newAttributedString + inputToolbarView.becomeFirstResponder() + } } - /// Send the formatted text message and its raw counterpat to the room + /// Send the formatted text message and its raw counterpart to the room /// /// - Parameter rawTextMsg: the raw text message /// - Parameter htmlMsg: the html text message @@ -85,7 +107,7 @@ extension RoomViewController { "event_id": eventModified.eventId ]) }) - } else if !self.send(asIRCStyleCommandIfPossible: rawTextMsg) { + } else { roomDataSource.sendFormattedTextMessage(rawTextMsg, html: htmlMsg) { response in switch response { case .success: @@ -369,6 +391,48 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate { } } +// MARK: - PermalinkReplacer +extension RoomViewController: PermalinkReplacer { + public func replacementForLink(_ url: String, text: String) -> NSAttributedString? { + guard #available(iOS 15.0, *), + let url = URL(string: url), + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { + return nil + } + + return PillsFormatter.mentionPill(withUrl: url, + andLabel: text, + session: session, + eventFormatter: eventFormatter, + roomState: roomState) + } + + public func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString { + guard #available(iOS 15.0, *), + let roomDataSource, + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { + return attributedString + } + return PillsFormatter.insertPills(in: attributedString, + withSession: session, + eventFormatter: eventFormatter, + roomState: roomState, + font: inputToolbarView.defaultFont) + } + + public func restoreMarkdown(in attributedString: NSAttributedString) -> String { + if #available(iOS 15.0, *) { + return PillsFormatter.stringByReplacingPills(in: attributedString, mode: .markdown) + } else { + return attributedString.string + } + } +} + // MARK: - VoiceBroadcast extension RoomViewController { @objc func stopUncompletedVoiceBroadcastIfNeeded() { diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index f33d661bd..cdb656508 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -1,9 +1,8 @@ - + - - + @@ -13,6 +12,8 @@ + + @@ -32,13 +33,12 @@ - - + @@ -47,20 +47,20 @@ - + - + - + - + @@ -236,11 +236,6 @@ - - - - - diff --git a/Riot/Modules/Room/Settings/RoomSettingsViewController.m b/Riot/Modules/Room/Settings/RoomSettingsViewController.m index baccdff0c..e096ee2e9 100644 --- a/Riot/Modules/Room/Settings/RoomSettingsViewController.m +++ b/Riot/Modules/Room/Settings/RoomSettingsViewController.m @@ -132,6 +132,8 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti { // The updated user data NSMutableDictionary *updatedItemsDict; + // bwi #4743: remove room avatar + BOOL shouldRemoveRoomAvatarImage; // The current table items UITextField* nameTextField; @@ -1342,7 +1344,7 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti [self dismissFirstResponder]; // Check whether some changes have been done - if (updatedItemsDict.count) + if (updatedItemsDict.count|| shouldRemoveRoomAvatarImage) { [self promptUserToSaveChanges]; } @@ -1408,7 +1410,7 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti - (IBAction)onSave:(id)sender { - if (updatedItemsDict.count) + if (updatedItemsDict.count || shouldRemoveRoomAvatarImage) { [self startActivityIndicator]; @@ -1417,6 +1419,43 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti // check if there is some updates related to room state if (mxRoomState) { + // bwi #4743: remove room avatar + if (shouldRemoveRoomAvatarImage) { + shouldRemoveRoomAvatarImage = false; + [updatedItemsDict removeObjectForKey:kRoomSettingsAvatarKey]; + // delete room avatar + pendingOperation = [mxRoom sendStateEventOfType:kMXEventTypeStringRoomAvatar content:@{} stateKey:@"" success:^(NSString *eventId) { + if (weakSelf) + { + typeof(self) self = weakSelf; + + self->pendingOperation = nil; + + [self onSave:nil]; + } + } failure:^(NSError *error) { + MXLogDebug(@"[RoomSettingsViewController] reset image failed"); + + if (weakSelf) + { + typeof(self) self = weakSelf; + + self->pendingOperation = nil; + + dispatch_async(dispatch_get_main_queue(), ^{ + + NSString* message = error.localizedDescription; + if (!message.length) + { + message = [VectorL10n roomDetailsFailToUpdateAvatar]; + } + [self onSaveFailed:message withKeys:@[kRoomSettingsAvatarKey]]; + + }); + } + }]; + } + if (updatedItemsDict[kRoomSettingsAvatarKey]) { // Retrieve the current picture and make sure its orientation is up @@ -2226,6 +2265,11 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti { roomPhotoCell.mxkImageView.image = (UIImage*) updatedItemsDict[kRoomSettingsAvatarKey]; } + else if (shouldRemoveRoomAvatarImage) { + // bwi #4743: remove room avatar + NSString *roomName = mxRoom.summary.displayName; + roomPhotoCell.mxkImageView.image = [AvatarGenerator generateAvatarForMatrixItem:mxRoom.roomId withDisplayName:roomName]; + } else { // bwi if the room is a personal notes room use a local image @@ -3254,6 +3298,8 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti if (!mxRoom.isDirect || BWIBuildSettings.shared.showUnrelatedRoomSettingsForDirectMessages) { SingleImagePickerPresenter *singleImagePickerPresenter = [[SingleImagePickerPresenter alloc] initWithSession:self.mainSession]; singleImagePickerPresenter.delegate = self; + // bwi #4743: remove room avatar + singleImagePickerPresenter.allowsRemoveImage = (updatedItemsDict[kRoomSettingsAvatarKey] != nil || (mxRoom.summary.avatar != nil && ![mxRoom.summary.avatar isEqual: @""])); UIView *sourceView; @@ -3679,6 +3725,12 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti // This method should never be called here because room settings should not show the remove image option. But nevertheless we do a nice cleanup also for this delegate call [presenter dismissWithAnimated:YES completion:nil]; self.imagePickerPresenter = nil; + + // bwi #4743: the room avatar should be removable + [updatedItemsDict removeObjectForKey:kRoomSettingsAvatarKey]; + shouldRemoveRoomAvatarImage = true; + [self getNavigationItem].rightBarButtonItem.enabled = YES; + [self refreshRoomSettings]; } #pragma mark - TableViewSectionsDelegate diff --git a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift index f490950df..2d66b1eb5 100644 --- a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift +++ b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCell.swift @@ -165,6 +165,11 @@ class RoomCreationIntroCell: MXKRoomBubbleTableViewCell { roomCellContentView.didTapAddParticipants = { [weak self] in self?.notifyDelegate(with: RoomCreationIntroCell.tapOnAddParticipants) } + + self.accessibilityElements = [roomCellContentView.roomAvatarView as Any, + roomCellContentView.titleLabel as Any, + roomCellContentView.informationLabel as Any, + roomCellContentView.addParticipantsContainerView as Any] } diff --git a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift index 8b3ebb708..92ed4cf40 100644 --- a/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift +++ b/Riot/Modules/Room/TimelineCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift @@ -70,8 +70,10 @@ final class RoomCreationIntroCellContentView: UIView, NibLoadable, Themable { self.addParticipantsButton.layer.masksToBounds = true self.addParticipantsButton.addTarget(self, action: #selector(socialButtonAction(_:)), for: .touchUpInside) + self.addParticipantsButton.accessibilityLabel = VectorL10n.roomIntroCellAddParticipantsAction self.addParticipantsLabel.text = VectorL10n.roomIntroCellAddParticipantsAction + self.addParticipantsLabel.isAccessibilityElement = false self.roomAvatarView.showCameraBadgeOnFallbackImage = true } @@ -161,10 +163,17 @@ final class RoomCreationIntroCellContentView: UIView, NibLoadable, Themable { if let topic = topic, topic.isEmpty == false { attributedString.append(NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithTopicSentence2(topic), attributes: informationTextDefaultAttributes)) } else { - let secondSentencePart1 = NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithoutTopicSentence2Part1, attributes: [.foregroundColor: self.theme.tintColor]) - let secondSentencePart2 = NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithoutTopicSentence2Part2, attributes: informationTextDefaultAttributes) - attributedString.append(secondSentencePart1) - attributedString.append(secondSentencePart2) + if BWIBuildSettings.shared.useNewBumColors { + let secondSentencePart1 = NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithoutTopicSentence2Part1, attributes: [.foregroundColor: self.theme.tintColor, .font: self.theme.fonts.bodySB]) // bwi: 4769 + let secondSentencePart2 = NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithoutTopicSentence2Part2, attributes: informationTextDefaultAttributes) + attributedString.append(secondSentencePart1) + attributedString.append(secondSentencePart2) + } else { + let secondSentencePart1 = NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithoutTopicSentence2Part1, attributes: [.foregroundColor: self.theme.tintColor]) + let secondSentencePart2 = NSAttributedString(string: VectorL10n.roomIntroCellInformationRoomWithoutTopicSentence2Part2, attributes: informationTextDefaultAttributes) + attributedString.append(secondSentencePart1) + attributedString.append(secondSentencePart2) + } } return attributedString @@ -182,9 +191,7 @@ final class RoomCreationIntroCellContentView: UIView, NibLoadable, Themable { attributedString.append(firstSentencePart2) attributedString.append(firstSentencePart3) - if isDirect { - attributedString.append(NSAttributedString(string: VectorL10n.roomIntroCellInformationDmSentence2, attributes: informationTextDefaultAttributes)) - } else { + if !isDirect { attributedString.append(NSAttributedString(string: VectorL10n.roomIntroCellInformationMultipleDmSentence2, attributes: informationTextDefaultAttributes)) } diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift index de15e91d3..f3f00f12f 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/TextMessage/Outgoing/TextMessageOutgoingWithoutSenderInfoBubbleCell.swift @@ -35,6 +35,15 @@ class TextMessageOutgoingWithoutSenderInfoBubbleCell: TextMessageBaseBubbleCell, self.textMessageContentView?.bubbleBackgroundView?.backgroundColor = theme.roomCellOutgoingBubbleBackgroundColor } + override func render(_ cellData: MXKCellData!) { + // This cell displays an outgoing message without any sender information. + // However, we need to set the following properties to our cellData, otherwise, to make room for the timestamp, a whitespace could be added when calculating the position of the components. + // If we don't, the component frame calculation will not work for this cell. + (cellData as? RoomBubbleCellData)?.shouldHideSenderName = false + (cellData as? RoomBubbleCellData)?.shouldHideSenderInformation = false + super.render(cellData) + } + // MARK: - Private private func setupBubbleConstraints() { diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift index 47d981c86..eabd68c9e 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift @@ -23,6 +23,7 @@ class RoomInputToolbarTextView: UITextView { private var heightConstraint: NSLayoutConstraint! + private var pillViews = [UIView]() weak var toolbarDelegate: RoomInputToolbarTextViewDelegate? @@ -51,12 +52,18 @@ class RoomInputToolbarTextView: UITextView { } override var text: String! { + willSet { + flushPills() + } didSet { updateUI() } } override var attributedText: NSAttributedString! { + willSet { + flushPills() + } didSet { updateUI() } @@ -162,3 +169,17 @@ class RoomInputToolbarTextView: UITextView { delegate.onTouchUp(inside: delegate.rightInputToolbarButton) } } + +extension RoomInputToolbarTextView: PillViewFlusher { + func registerPillView(_ pillView: UIView) { + pillViews.append(pillView) + } + + private func flushPills() { + for view in pillViews { + view.alpha = 0.0 + view.removeFromSuperview() + } + pillViews.removeAll() + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index af84b462d..5bbdeaa51 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -21,6 +21,8 @@ @class RoomActionsBar; @class RoomInputToolbarView; @class LinkActionWrapper; +@class SuggestionPatternWrapper; +@class CompletionSuggestionViewModelContextWrapper; /** Destination of the message in the composer @@ -59,7 +61,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) @param toolbarView the room input toolbar view */ -- (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView*)toolbarView; +- (void)roomInputToolbarViewDidChangeTextMessage:(MXKRoomInputToolbarView*)toolbarView; /** Inform the delegate that the action menu was opened. @@ -80,6 +82,12 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didSendLinkAction: (LinkActionWrapper *)linkAction; +- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; + +- (CompletionSuggestionViewModelContextWrapper *)completionSuggestionContext; + +- (MXMediaManager *)mediaManager; + @end /** @@ -128,8 +136,6 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) */ @property (nonatomic, weak, readonly) UIButton *attachMediaButton; -@property (nonatomic, readonly, nonnull) UIFont *textDefaultFont; - /** Adds a voice message toolbar view to be displayed inside this input toolbar */ diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index c2826e3b9..960a18d82 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -70,6 +70,8 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; _sendMode = RoomInputToolbarViewSendModeSend; self.inputContextViewHeightConstraint.constant = 0; + self.inputContextLabel.isAccessibilityElement = NO; + self.inputContextButton.isAccessibilityElement = NO; [self.rightInputToolbarButton setTitle:nil forState:UIControlStateNormal]; [self.rightInputToolbarButton setTitle:nil forState:UIControlStateHighlighted]; @@ -157,7 +159,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; { NSMutableAttributedString *mutableTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedTextMessage]; [mutableTextMessage addAttributes:@{ NSForegroundColorAttributeName: ThemeService.shared.theme.textPrimaryColor, - NSFontAttributeName: self.textDefaultFont } + NSFontAttributeName: self.defaultFont } range:NSMakeRange(0, mutableTextMessage.length)]; attributedTextMessage = mutableTextMessage; } @@ -184,7 +186,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; return self.textView.text; } -- (UIFont *)textDefaultFont +- (UIFont *)defaultFont { if (self.textView.font) { @@ -255,6 +257,10 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; break; } + // Hide the context items from VoiceOver when the context view is "hidden". + self.inputContextLabel.isAccessibilityElement = self.inputContextViewHeightConstraint.constant > 0; + self.inputContextButton.isAccessibilityElement = self.inputContextViewHeightConstraint.constant > 0; + [self.rightInputToolbarButton setImage:buttonImage forState:UIControlStateNormal]; if (self.maxHeight && updatedHeight > self.maxHeight) @@ -480,11 +486,22 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; [self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor], [self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor], [self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]]; + + // The voice message toolbar is taller than the input toolbar so the record button is read + // out before the other subviews. Fix this by manually adding the elements in the right order. + self.accessibilityElements = @[self.attachMediaButton, + self.actionsBar, + self.inputContextLabel, + self.inputContextButton, + self.textView, + self.rightInputToolbarButton, + self.voiceMessageToolbarView]; } else { [self.voiceMessageToolbarView removeFromSuperview]; _voiceMessageToolbarView = nil; + self.accessibilityElements = nil; } } @end diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index db6cc8193..edd951fd6 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -72,6 +72,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } // MARK: Public + + override var delegate: MXKRoomInputToolbarViewDelegate! { + didSet { + setupComposerIfNeeded() + } + } override var placeholder: String! { get { @@ -85,6 +91,29 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override var isFocused: Bool { viewModel.isFocused } + + override var attributedTextMessage: NSAttributedString? { + // Note: this is only interactive in plain text mode. If RTE is enabled, + // APIs from the composer view model should be used. + get { + guard !self.textFormattingEnabled else { + MXLog.failure("[WysiwygInputToolbarView] Trying to get attributedTextMessage in RTE mode") + return nil + } + return self.wysiwygViewModel.textView.attributedText + } + set { + guard !self.textFormattingEnabled else { + MXLog.failure("[WysiwygInputToolbarView] Trying to set attributedTextMessage in RTE mode") + return + } + self.wysiwygViewModel.textView.attributedText = newValue + } + } + + override var defaultFont: UIFont { + return UIFont.preferredFont(forTextStyle: .body) + } var isMaximised: Bool { wysiwygViewModel.maximised @@ -120,93 +149,15 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? { return (delegate as? RoomInputToolbarViewDelegate) ?? nil } + + private var permalinkReplacer: PermalinkReplacer? { + return (delegate as? PermalinkReplacer) + } override func awakeFromNib() { super.awakeFromNib() - viewModel = ComposerViewModel( - initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, - isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false))) - - viewModel.callback = { [weak self] result in - self?.handleViewModelResult(result) - } - wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting - - inputAccessoryViewForKeyboard = UIView(frame: .zero) - - let composer = Composer( - viewModel: viewModel.context, - wysiwygViewModel: wysiwygViewModel, - resizeAnimationDuration: Double(kResizeComposerAnimationDuration), - sendMessageAction: { [weak self] content in - guard let self = self else { return } - self.sendWysiwygMessage(content: content) - }, showSendMediaActions: { [weak self] in - guard let self = self else { return } - self.showSendMediaActions() - }).introspectTextView { [weak self] textView in - guard let self = self else { return } - textView.inputAccessoryView = self.inputAccessoryViewForKeyboard - } - - hostingViewController = VectorHostingController(rootView: composer) - hostingViewController.publishHeightChanges = true - let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height - let subView: UIView = hostingViewController.view - self.addSubview(subView) - - self.translatesAutoresizingMaskIntoConstraints = false - subView.translatesAutoresizingMaskIntoConstraints = false - heightConstraint = subView.heightAnchor.constraint(equalToConstant: height) - NSLayoutConstraint.activate([ - heightConstraint, - subView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) - ]) - - cancellables = [ - hostingViewController.heightPublisher - .removeDuplicates() - .sink(receiveValue: { [weak self] idealHeight in - guard let self = self else { return } - self.updateToolbarHeight(wysiwygHeight: idealHeight) - }), - // Required to update the view constraints after minimise/maximise is tapped - wysiwygViewModel.$idealHeight - .removeDuplicates() - .sink { [weak hostingViewController] _ in - hostingViewController?.view.setNeedsLayout() - }, - - wysiwygViewModel.$maximised - .dropFirst() - .removeDuplicates() - .sink { [weak self] value in - guard let self = self else { return } - self.toolbarViewDelegate?.didChangeMaximisedState(value) - self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0 - if !value { - self.voiceMessageBottomConstraint?.constant = 2 - } - } - ] - - update(theme: ThemeService.shared().theme) - registerThemeServiceDidChangeThemeNotification() - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) + + setupComposerIfNeeded() } override func customizeRendering() { @@ -217,6 +168,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override func dismissKeyboard() { self.viewModel.dismissKeyboard() } + + @discardableResult + override func becomeFirstResponder() -> Bool { + self.wysiwygViewModel.textView.becomeFirstResponder() + } override func dismissValidationView(_ validationView: MXKImageView!) { super.dismissValidationView(validationView) @@ -224,6 +180,16 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp showKeyboard() } } + + override func setPartialContent(_ attributedTextMessage: NSAttributedString) { + let content: String + if #available(iOS 15.0, *) { + content = PillsFormatter.stringByReplacingPills(in: attributedTextMessage, mode: .markdown) + } else { + content = attributedTextMessage.string + } + self.wysiwygViewModel.setMarkdownContent(content) + } func showKeyboard() { self.viewModel.showKeyboard() @@ -239,8 +205,142 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } wysiwygViewModel.applyLinkOperation(linkOperation) } + + func mention(_ member: MXRoomMember) { + self.wysiwygViewModel.setMention(url: MXTools.permalinkToUser(withUserId: member.userId), + name: member.displayname, + mentionType: .user) + } + + func command(_ command: String) { + self.wysiwygViewModel.setCommand(name: command) + } // MARK: - Private + + private func setupComposerIfNeeded() { + guard hostingViewController == nil, + let toolbarViewDelegate, + let permalinkReplacer else { return } + + viewModel = ComposerViewModel( + initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, + isLandscapePhone: isLandscapePhone, + bindings: ComposerBindings(focused: false))) + + viewModel.callback = { [weak self] result in + self?.handleViewModelResult(result) + } + wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting + wysiwygViewModel.permalinkReplacer = permalinkReplacer + + inputAccessoryViewForKeyboard = UIView(frame: .zero) + + let composer = Composer( + viewModel: viewModel.context, + wysiwygViewModel: wysiwygViewModel, + completionSuggestionSharedContext: toolbarViewDelegate.completionSuggestionContext().context, + resizeAnimationDuration: Double(kResizeComposerAnimationDuration), + sendMessageAction: { [weak self] content in + guard let self = self else { return } + self.sendWysiwygMessage(content: content) + }, showSendMediaActions: { [weak self] in + guard let self = self else { return } + self.showSendMediaActions() + }) + .introspectTextView { [weak self] textView in + guard let self = self else { return } + textView.inputAccessoryView = self.inputAccessoryViewForKeyboard + } + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: toolbarViewDelegate.mediaManager()))) + + hostingViewController = VectorHostingController(rootView: composer) + hostingViewController.publishHeightChanges = true + let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height + let subView: UIView = hostingViewController.view + self.addSubview(subView) + + self.translatesAutoresizingMaskIntoConstraints = false + subView.translatesAutoresizingMaskIntoConstraints = false + heightConstraint = subView.heightAnchor.constraint(equalToConstant: height) + NSLayoutConstraint.activate([ + heightConstraint, + subView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + + cancellables = [ + hostingViewController.heightPublisher + .removeDuplicates() + .sink(receiveValue: { [weak self] idealHeight in + guard let self = self else { return } + self.updateToolbarHeight(wysiwygHeight: idealHeight) + }), + // Required to update the view constraints after minimise/maximise is tapped + wysiwygViewModel.$idealHeight + .removeDuplicates() + .sink { [weak hostingViewController] _ in + hostingViewController?.view.setNeedsLayout() + }, + + wysiwygViewModel.$maximised + .dropFirst() + .removeDuplicates() + .sink { [weak self] value in + guard let self = self else { return } + self.toolbarViewDelegate?.didChangeMaximisedState(value) + self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0 + if !value { + self.voiceMessageBottomConstraint?.constant = 2 + } + }, + + wysiwygViewModel.$plainTextContent + .removeDuplicates() + .dropFirst() + .sink { [weak self] attributed in + // Note: filter out `plainTextMode` being off, as switching to RTE will trigger this + // publisher with empty content. This avoids saving the partial text message + // or trying to compute suggestion from this empty content. + guard let self, self.wysiwygViewModel.plainTextMode else { return } + self.textMessage = attributed.string + self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self) + self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed) + }, + + wysiwygViewModel.$attributedContent + .removeDuplicates(by: { + $0.text == $1.text + }) + .dropFirst() + .sink { [weak self] _ in + // Note: filter out `plainTextMode` being on, as switching to plain text mode will trigger this + // publisher with empty content. This avoids saving the partial text message + // or trying to compute suggestion from this empty content. + guard let self, !self.wysiwygViewModel.plainTextMode else { return } + let markdown = self.wysiwygViewModel.content.markdown + let attributed = NSAttributedString(string: markdown, attributes: [.font: self.defaultFont]) + self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed) + } + ] + + update(theme: ThemeService.shared().theme) + registerThemeServiceDidChangeThemeNotification() + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) + } @objc private func keyboardWillShow(_ notification: Notification) { if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { @@ -273,7 +373,30 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } private func sendWysiwygMessage(content: WysiwygComposerContent) { - delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + if content.markdown.prefix(while: { $0 == "/" }).count == 1 { + let commandText: String + if content.markdown.hasPrefix(MXKSlashCommand.emote.cmd) { + // `/me` command works with markdown content + commandText = content.markdown + } else if #available(iOS 15.0, *) { + // Other commands should see pills replaced by matrix identifiers + commandText = PillsFormatter.stringByReplacingPills(in: self.wysiwygViewModel.textView.attributedText, mode: .identifier) + } else { + // Without Pills support, just use the raw text for command + commandText = self.wysiwygViewModel.textView.text + } + + // Fix potential command failures due to trailing characters + // or NBSP that are not properly handled by the command interpreter + let sanitizedCommand = commandText + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: String.nbsp, with: " ") + + delegate?.roomInputToolbarView?(self, sendCommand: sanitizedCommand) + } else { + delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + } + if isMaximised { minimise() } @@ -291,6 +414,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp setVoiceMessageToolbarIsHidden(!isEmpty) case let .linkTapped(linkAction): toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction)) + case let .suggestion(pattern): + toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern)) } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift index 8e31a229c..2cc989d99 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift @@ -206,11 +206,12 @@ class VoiceMessageAttachmentCacheManager { } private func convertFileAtPath(_ path: String?, numberOfSamples: Int, identifier: String, semaphore: DispatchSemaphore) { - guard let filePath = path else { + guard let path else { return } - - let fileExtension = filePath.hasSuffix(".mp4") ? "mp4" : "m4a" + + let filePath = URL(fileURLWithPath: path) + let fileExtension = filePath.hasSupportedAudioExtension ? filePath.pathExtension : "m4a" let newURL = temporaryFilesFolderURL.appendingPathComponent(identifier).appendingPathExtension(fileExtension) let conversionCompletion: (Result) -> Void = { result in @@ -252,7 +253,7 @@ class VoiceMessageAttachmentCacheManager { if FileManager.default.fileExists(atPath: newURL.path) { conversionCompletion(Result.success(())) } else { - VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL, completion: conversionCompletion) + VoiceMessageAudioConverter.convertToMPEG4AACIfNeeded(sourceURL: filePath, destinationURL: newURL, completion: conversionCompletion) } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift index 996e33b4a..608f14234 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift @@ -39,10 +39,10 @@ struct VoiceMessageAudioConverter { } } - static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) { + static func convertToMPEG4AACIfNeeded(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) { DispatchQueue.global(qos: .userInitiated).async { do { - if sourceURL.pathExtension == "mp4" { + if sourceURL.hasSupportedAudioExtension { try FileManager.default.copyItem(atPath: sourceURL.path, toPath: destinationURL.path) } else { try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL) @@ -86,3 +86,11 @@ struct VoiceMessageAudioConverter { } } } + +extension URL { + /// Returns true if the URL has a supported audio extension + var hasSupportedAudioExtension: Bool { + let supportedExtensions = ["mp3", "mp4", "m4a", "wav", "aac"] + return supportedExtensions.contains(pathExtension.lowercased()) + } +} diff --git a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCell.swift b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCell.swift index 77f689688..e185a1429 100644 --- a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCell.swift +++ b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCell.swift @@ -118,7 +118,7 @@ extension DirectoryRoomTableViewCell: Themable { joinButton.layer.borderColor = theme.textSecondaryColor.cgColor } else { joinButton.backgroundColor = theme.tintColor - joinButton.tintColor = .white + joinButton.tintColor = BWIBuildSettings.shared.useNewBumColors ? theme.backgroundColor : .white // bwi: 4769 joinButton.layer.borderWidth = 0.0 joinButton.layer.borderColor = nil } diff --git a/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift b/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift index 0c6a959fa..593f10139 100644 --- a/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift +++ b/Riot/Modules/Secrets/Recover/SecretsRecoveryCoordinator.swift @@ -122,11 +122,12 @@ final class SecretsRecoveryCoordinator: SecretsRecoveryCoordinatorType { private func showSecureBackupSetup(checkKeyBackup: Bool) { let coordinator = SecureBackupSetupCoordinator(session: self.session, checkKeyBackup: checkKeyBackup, navigationRouter: self.navigationRouter, cancellable: self.cancellable) coordinator.delegate = self - coordinator.start() - - self.navigationRouter.push(coordinator.toPresentable(), animated: true, popCompletion: { [weak self] in + // Fix: calling coordinator.start() will update the navigationRouter without a popCompletion + coordinator.start(popCompletion: { [weak self] in self?.remove(childCoordinator: coordinator) }) + // Fix: do not push the presentable from the coordinator to the navigation router as this has already been done by coordinator.start(). + // Also, coordinator.toPresentable() returns a navigation controller, which cannot be pushed into a navigation router. self.add(childCoordinator: coordinator) } } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift b/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift index 402dab935..aa03a01ad 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetCoordinator.swift @@ -94,11 +94,11 @@ extension SecretsResetCoordinator: SecretsResetViewModelCoordinatorDelegate { extension SecretsResetCoordinator: ReauthenticationCoordinatorDelegate { func reauthenticationCoordinatorDidComplete(_ coordinator: ReauthenticationCoordinatorType, withAuthenticationParameters authenticationParameters: [String: Any]?) { - self.secretsResetViewModel.process(viewAction: .authenticationInfoEntered(authenticationParameters ?? [:])) } func reauthenticationCoordinatorDidCancel(_ coordinator: ReauthenticationCoordinatorType) { + self.secretsResetViewModel.process(viewAction: .authenticationCancelled) self.remove(childCoordinator: coordinator) } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift index aa135b5fe..5b960342a 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewAction.swift @@ -22,6 +22,7 @@ import Foundation enum SecretsResetViewAction { case loadData case reset + case authenticationCancelled case authenticationInfoEntered(_ authInfo: [String: Any]) case cancel } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift index 3559ac368..855cf7861 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewController.swift @@ -137,6 +137,8 @@ final class SecretsResetViewController: UIViewController { self.renderLoading() case .resetDone: self.renderLoaded() + case .resetCancelled: + self.renderCancelled() case .error(let error): self.render(error: error) } @@ -150,6 +152,10 @@ final class SecretsResetViewController: UIViewController { self.activityPresenter.removeCurrentActivityIndicator(animated: true) } + private func renderCancelled() { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + } + private func render(error: Error) { self.activityPresenter.removeCurrentActivityIndicator(animated: true) self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift index 2e8e7604c..62b0c686f 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift @@ -49,6 +49,8 @@ final class SecretsResetViewModel: SecretsResetViewModelType { break case .reset: self.askAuthentication() + case .authenticationCancelled: + self.authenticationCancelled() case .authenticationInfoEntered(let authParameters): self.resetSecrets(with: authParameters) case .cancel: @@ -68,7 +70,6 @@ final class SecretsResetViewModel: SecretsResetViewModelType { } MXLog.debug("[SecretsResetViewModel] resetSecrets") - self.update(viewState: .resetting) crossSigning.setup(withAuthParams: authParameters, success: { [weak self] in guard let self = self else { return @@ -96,7 +97,13 @@ final class SecretsResetViewModel: SecretsResetViewModelType { } private func askAuthentication() { + self.update(viewState: .resetting) + let setupCrossSigningRequest = self.crossSigningService.setupCrossSigningRequest() self.coordinatorDelegate?.secretsResetViewModel(self, needsToAuthenticateWith: setupCrossSigningRequest) } + + private func authenticationCancelled() { + self.update(viewState: .resetCancelled) + } } diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift index b7cb0acb8..128f90b19 100644 --- a/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift +++ b/Riot/Modules/Secrets/Reset/SecretsResetViewState.swift @@ -22,5 +22,6 @@ import Foundation enum SecretsResetViewState { case resetting case resetDone + case resetCancelled case error(Error) } diff --git a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift index 1060d79ea..d58444206 100644 --- a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift +++ b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift @@ -74,12 +74,16 @@ final class SecureBackupSetupCoordinator: SecureBackupSetupCoordinatorType { // MARK: - Public methods func start() { + start(popCompletion: nil) + } + + func start(popCompletion: (() -> Void)?) { let rootViewController = self.createIntro() if self.navigationRouter.modules.isEmpty == false { - self.navigationRouter.push(rootViewController, animated: true, popCompletion: nil) + self.navigationRouter.push(rootViewController, animated: true, popCompletion: popCompletion) } else { - self.navigationRouter.setRootModule(rootViewController) + self.navigationRouter.setRootModule(rootViewController, popCompletion: popCompletion) } } diff --git a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard index 774f4b22e..ab5ef6482 100644 --- a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard +++ b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.storyboard @@ -1,109 +1,109 @@ - - + + - + - + - - - + + + - - - + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + - + + - diff --git a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.swift b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.swift index e9e24480d..fe3ef3c6c 100644 --- a/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.swift +++ b/Riot/Modules/SetPinCode/EnterPinCode/EnterPinCodeViewController.swift @@ -84,11 +84,10 @@ final class EnterPinCodeViewController: UIViewController { UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") } - if BWIBuildSettings.shared.showBUMLottieAnimation { - inactiveLogoImageView.isHidden = true - } // BWI: accessibility description + /* bwi: 4782 digitButtonReset.vc_setupAccessibilityTraitsButton(withTitle: BWIL10n.pinProtectionResetButtonAccessibilityLabel, hint: BWIL10n.pinProtectionResetButtonAccessibilityHint, isEnabled: true) + */ } override func viewWillAppear(_ animated: Bool) { @@ -190,8 +189,9 @@ final class EnterPinCodeViewController: UIViewController { } // show BuM Logo instead of Element logo - if BWIBuildSettings.shared.bwiEnableBuMUI { + if BWIBuildSettings.shared.bwiEnableBuMUI || BWIBuildSettings.shared.bwiLoginFlowLayout { logoImageView.image = Asset.Images.launchScreenLogo.image + inactiveLogoImageView.image = Asset.Images.launchScreenLogo.image } } diff --git a/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift b/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift index c381b76eb..6c4e69d14 100644 --- a/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift +++ b/Riot/Modules/SetPinCode/SetPinCoordinatorBridgePresenter.swift @@ -91,6 +91,10 @@ final class SetPinCoordinatorBridgePresenter: NSObject { } func presentWithMainAppWindow(_ window: UIWindow) { + // Prevents the VoiceOver reading accessible content when the PIN screen is on top + // Calling `makeKeyAndVisible` in `dismissWithMainAppWindow(_:)` restores the visibility state. + window.isHidden = true + let pinCoordinatorWindow = UIWindow(frame: window.bounds) let setPinCoordinator = SetPinCoordinator(session: self.session, viewMode: self.viewMode, pinCodePreferences: .shared) diff --git a/Riot/Modules/Settings/SettingsViewController.h b/Riot/Modules/Settings/SettingsViewController.h index 3e99593fc..5b5f87b27 100644 --- a/Riot/Modules/Settings/SettingsViewController.h +++ b/Riot/Modules/Settings/SettingsViewController.h @@ -16,6 +16,14 @@ #import "MatrixKit.h" +FOUNDATION_EXPORT NSString *const BWISettingsErrorDomain; + +typedef enum : NSUInteger +{ + BWISettingsOfflineLogoutErrorCode +} +BWISettingsErrorCode; + @interface SettingsViewController : MXKTableViewController + (instancetype)instantiate; diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 37fe44088..7780f60a1 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -47,6 +47,8 @@ NSString* const kSettingsViewControllerPhoneBookCountryCellId = @"kSettingsViewControllerPhoneBookCountryCellId"; +NSString *const BWISettingsErrorDomain = @"BWISettingsErrorDomain"; + typedef NS_ENUM(NSUInteger, SECTION_TAG) { SECTION_TAG_SIGN_OUT = 0, @@ -208,6 +210,7 @@ typedef NS_ENUM(NSUInteger, ABOUT) ABOUT_TERM_CONDITIONS_INDEX, ABOUT_ACCEPTABLE_USE_INDEX, ABOUT_PRIVACY_INDEX, + ABOUT_IMPRINT_INDEX, ABOUT_THIRD_PARTY_INDEX, ABOUT_SUPPORT_INDEX, ABOUT_SHOW_NSFW_ROOMS_INDEX, @@ -216,7 +219,8 @@ typedef NS_ENUM(NSUInteger, ABOUT) ABOUT_MARK_ALL_AS_READ_INDEX, ABOUT_CLEAR_CACHE_INDEX, ABOUT_REPORT_BUG_INDEX, - ABOUT_NETIQUETTE_INDEX + ABOUT_NETIQUETTE_INDEX, + ABOUT_ACCESSIBILITY_DECLARATION_INDEX, }; typedef NS_ENUM(NSUInteger, LABS_ENABLE) @@ -726,7 +730,15 @@ ChangePasswordCoordinatorBridgePresenterDelegate> { [sectionAbout addRowWithTag:ABOUT_PRIVACY_INDEX]; } + // bwi 4772 - show accessibility declaration + if (BWIBuildSettings.shared.bwiShowAccessibilityDeclaration) { + [sectionAbout addRowWithTag:ABOUT_ACCESSIBILITY_DECLARATION_INDEX]; + } [sectionAbout addRowWithTag:ABOUT_THIRD_PARTY_INDEX]; + // bwi #4682 - Show Imprint + if ([self.mainSession.homeserverWellknown imprintURL]) { + [sectionAbout addRowWithTag:ABOUT_IMPRINT_INDEX]; + } sectionAbout.headerTitle = VectorL10n.settingsAbout; [sectionAbout addRowWithTag:ABOUT_MARK_ALL_AS_READ_INDEX]; @@ -749,31 +761,34 @@ ChangePasswordCoordinatorBridgePresenterDelegate> if (BWIBuildSettings.shared.settingsScreenShowLabSettings) { Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS]; + if (!BWIBuildSettings.shared.enableNewSessionManagerByDefault) { + [sectionLabs addRowWithTag:LABS_ENABLE_NEW_SESSION_MANAGER]; + } /* bwi: disabled for our apps - if ([CryptoSDKFeature.shared canManuallyEnableForUserId:self.mainSession.myUserId]) - { + if ([CryptoSDKFeature.shared canManuallyEnableForUserId:self.mainSession.myUserId]) + { [sectionLabs addRowWithTag:LABS_ENABLE_CRYPTO_SDK]; - } + } + + [sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX]; + [sectionLabs addRowWithTag:LABS_ENABLE_THREADS_INDEX]; + [sectionLabs addRowWithTag:LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS]; + [sectionLabs addRowWithTag:LABS_ENABLE_NEW_CLIENT_INFO_FEATURE]; + [sectionLabs addRowWithTag:LABS_ENABLE_LIVE_LOCATION_SHARING]; + */ - [sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX]; - [sectionLabs addRowWithTag:LABS_ENABLE_THREADS_INDEX]; - [sectionLabs addRowWithTag:LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS]; - if (BWIBuildSettings.shared.locationSharingEnabled) - { - [sectionLabs addRowWithTag:LABS_ENABLE_LIVE_LOCATION_SHARING]; + if (BWIBuildSettings.shared.enableLabFeatureWYSIWYG) { + if (@available(iOS 15.0, *)) + { + [sectionLabs addRowWithTag:LABS_ENABLE_WYSIWYG_COMPOSER]; + } } - */ - [sectionLabs addRowWithTag:LABS_ENABLE_NEW_SESSION_MANAGER]; - /* bwi: disabled for our apps - [sectionLabs addRowWithTag:LABS_ENABLE_NEW_CLIENT_INFO_FEATURE]; - */ - if (@available(iOS 15.0, *)) + + // bwi: disabled for our apps + if (BWIBuildSettings.shared.enableLabFeatureVoiceBroadcasts) { - [sectionLabs addRowWithTag:LABS_ENABLE_WYSIWYG_COMPOSER]; + [sectionLabs addRowWithTag:LABS_ENABLE_VOICE_BROADCAST]; } - /* bwi: disabled for our apps - [sectionLabs addRowWithTag:LABS_ENABLE_VOICE_BROADCAST]; - */ sectionLabs.headerTitle = [VectorL10n settingsLabs]; if (sectionLabs.hasAnyRows) { @@ -2488,9 +2503,15 @@ ChangePasswordCoordinatorBridgePresenterDelegate> theme = @"auto"; } + // bwi: 4744 (map previous set black theme to dark) + if([theme isEqualToString:@"black"]) { + theme = @"dark"; + } + theme = [NSString stringWithFormat:@"settings_ui_theme_%@", theme]; + NSString *i18nTheme = NSLocalizedStringFromTable(theme, @"Vector", nil); - + cell.textLabel.textColor = ThemeService.shared.theme.textPrimaryColor; cell.textLabel.text = [VectorL10n settingsUiTheme]; @@ -2690,6 +2711,16 @@ ChangePasswordCoordinatorBridgePresenterDelegate> cell = thirdPartyCell; } + else if (row == ABOUT_IMPRINT_INDEX) + { + MXKTableViewCell *imprintCell = [self getDefaultTableViewCell:tableView]; + + imprintCell.textLabel.text = [BWIL10n settingsImprint]; + + [imprintCell vc_setAccessoryDisclosureIndicatorWithCurrentTheme]; + + cell = imprintCell; + } else if (row == ABOUT_SHOW_NSFW_ROOMS_INDEX) { MXKTableViewCellWithLabelAndSwitch* labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; @@ -2843,6 +2874,16 @@ ChangePasswordCoordinatorBridgePresenterDelegate> cell = privacyPolicyCell; } + else if (row == ABOUT_ACCESSIBILITY_DECLARATION_INDEX) + { + MXKTableViewCell *accessibilityDeclarationCell = [self getDefaultTableViewCell:tableView]; + + accessibilityDeclarationCell.textLabel.text = [BWIL10n bwiAccessibilityDeclarationButtonTitle]; + + [accessibilityDeclarationCell vc_setAccessoryDisclosureIndicatorWithCurrentTheme]; + + cell = accessibilityDeclarationCell; + } else if (row == ABOUT_THIRD_PARTY_INDEX) { MXKTableViewCell *thirdPartyCell = [self getDefaultTableViewCell:tableView]; @@ -3302,21 +3343,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> } else if (row == ABOUT_COPYRIGHT_INDEX) { - WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BWIBuildSettings.shared.applicationCopyrightUrlString]; - - webViewViewController.title = [BWIL10n settingsCopyright]; - [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; - - [self pushViewController:webViewViewController]; + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:BWIBuildSettings.shared.applicationCopyrightUrlString] options:@{} completionHandler:nil]; } else if (row == ABOUT_ACCEPTABLE_USE_INDEX) { - WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BuildSettings.applicationAcceptableUsePolicyUrlString]; - - webViewViewController.title = [VectorL10n settingsAcceptableUse]; - [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; - - [self pushViewController:webViewViewController]; + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:BuildSettings.applicationAcceptableUsePolicyUrlString] options:@{} completionHandler:nil]; } else if (row == ABOUT_SUPPORT_INDEX) { @@ -3331,12 +3362,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> } else if (row == ABOUT_PRIVACY_INDEX) { - WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BWIBuildSettings.shared.applicationPrivacyPolicyUrlString]; - - webViewViewController.title = [VectorL10n settingsPrivacyPolicy]; - [webViewViewController vc_setLargeTitleDisplayMode:UINavigationItemLargeTitleDisplayModeNever]; - - [self pushViewController:webViewViewController]; + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:BWIBuildSettings.shared.applicationPrivacyPolicyUrlString] options:@{} completionHandler:nil]; + } + else if (row == ABOUT_ACCESSIBILITY_DECLARATION_INDEX) + { + [self showAccessibilityDeclaration]; } else if (row == ABOUT_THIRD_PARTY_INDEX) { @@ -3349,6 +3379,10 @@ ChangePasswordCoordinatorBridgePresenterDelegate> [self pushViewController:webViewViewController]; } + else if (row == ABOUT_IMPRINT_INDEX) + { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:[self.mainSession.homeserverWellknown imprintURL]] options:@{} completionHandler:nil]; + } } else if (section == SECTION_TAG_USER_SETTINGS) { @@ -3460,13 +3494,24 @@ ChangePasswordCoordinatorBridgePresenterDelegate> - (void)onSignout:(id)sender { - self.signOutButton = (UIButton*)sender; - - SignOutFlowPresenter *flowPresenter = [[SignOutFlowPresenter alloc] initWithSession:self.mainSession presentingViewController:self]; - flowPresenter.delegate = self; - - [flowPresenter startWithSourceView:self.signOutButton]; - self.signOutFlowPresenter = flowPresenter; + // bwi (#3539) Check connectivity before login out. Logout can have strange effects when occuring without network connection + if ([AppDelegate theDelegate].isOffline) + { + NSError *error = [NSError errorWithDomain:BWISettingsErrorDomain + code:BWISettingsOfflineLogoutErrorCode + userInfo:@{ + NSLocalizedDescriptionKey : BWIL10n.bwiErrorLogoutOffline + }]; + [[AppDelegate theDelegate] showErrorAsAlert:error]; + } else { + self.signOutButton = (UIButton*)sender; + + SignOutFlowPresenter *flowPresenter = [[SignOutFlowPresenter alloc] initWithSession:self.mainSession presentingViewController:self]; + flowPresenter.delegate = self; + + [flowPresenter startWithSourceView:self.signOutButton]; + self.signOutFlowPresenter = flowPresenter; + } } - (void)onRemove3PID:(NSIndexPath*)indexPath @@ -4405,7 +4450,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> { __weak typeof(self) weakSelf = self; - __block UIAlertAction *autoAction, *lightAction, *darkAction, *blackAction; + __block UIAlertAction *autoAction, *lightAction, *darkAction/* bwi: 4744 , *blackAction*/; NSString *themePickerMessage; void (^actionBlock)(UIAlertAction *action) = ^(UIAlertAction * action) { @@ -4427,11 +4472,13 @@ ChangePasswordCoordinatorBridgePresenterDelegate> { newTheme = @"dark"; } + /* bwi: 4744 else if (action == blackAction) { newTheme = @"black"; } - + */ + NSString *theme = RiotSettings.shared.userInterfaceTheme; if (newTheme && ![newTheme isEqualToString:theme]) { @@ -4475,9 +4522,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate> darkAction = [UIAlertAction actionWithTitle:[VectorL10n settingsUiThemeDark] style:UIAlertActionStyleDefault handler:actionBlock]; + /* bwi: 4744 blackAction = [UIAlertAction actionWithTitle:[VectorL10n settingsUiThemeBlack] style:UIAlertActionStyleDefault handler:actionBlock]; + */ UIAlertController *themePicker = [UIAlertController alertControllerWithTitle:[VectorL10n settingsUiThemePickerTitle] @@ -4490,8 +4539,10 @@ ChangePasswordCoordinatorBridgePresenterDelegate> } [themePicker addAction:lightAction]; [themePicker addAction:darkAction]; + /* bwi: 4744 [themePicker addAction:blackAction]; - + */ + // Cancel button [themePicker addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel @@ -4550,6 +4601,12 @@ ChangePasswordCoordinatorBridgePresenterDelegate> UIViewController *developerSettingsViewController = [DeveloperSettingsViewController makeViewControllerWithSession:self.mainSession]; [self pushViewController:developerSettingsViewController]; } + +- (void)showAccessibilityDeclaration +{ + UIViewController *accessibilityDeclarationViewController = [AccessibilityDeclarationViewController makeViewController]; + [self pushViewController:accessibilityDeclarationViewController]; +} - (void)showPersonalStateSettings { @@ -4730,6 +4787,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate> || (language == nil && [NSBundle mxk_language])) { [NSBundle mxk_setLanguage:language]; + UIApplication.sharedApplication.accessibilityLanguage = language; // Store user settings NSUserDefaults *sharedUserDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; diff --git a/Riot/Modules/TabBar/MasterTabBarController.m b/Riot/Modules/TabBar/MasterTabBarController.m index 938107f76..3cdd0bfb9 100644 --- a/Riot/Modules/TabBar/MasterTabBarController.m +++ b/Riot/Modules/TabBar/MasterTabBarController.m @@ -1105,10 +1105,8 @@ message:[BWIL10n bwiAnalyticsAlertBody:AppInfo.current.displayName] preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:BWIL10n.bwiAnalyticsAlertInfoButton style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { - WebViewViewController *webViewViewController = [[WebViewViewController alloc] initWithURL:BWIBuildSettings.shared.applicationPrivacyPolicyUrlString]; - webViewViewController.title = [VectorL10n settingsPrivacyPolicy]; - [self.navigationController pushViewController:webViewViewController animated:YES]; + [alert addAction:[UIAlertAction actionWithTitle:BWIL10n.bwiAnalyticsAlertInfoButton style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:BWIBuildSettings.shared.applicationPrivacyPolicyUrlString] options:@{} completionHandler:nil]; }]]; [alert addAction:[UIAlertAction actionWithTitle:BWIL10n.bwiAnalyticsAlertCancelButton style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { BWIAnalytics.sharedTracker.running = NO; diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift index b99eec0c3..be4ed85dc 100644 --- a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift @@ -35,7 +35,7 @@ class ThreadTableViewCell: UITableViewCell { @IBOutlet private weak var rootMessageAvatarView: UserAvatarView! @IBOutlet private weak var rootMessageSenderLabel: UILabel! - @IBOutlet private weak var rootMessageContentLabel: UILabel! + @IBOutlet private weak var rootMessageContentTextView: UITextView! @IBOutlet private weak var lastMessageTimeLabel: UILabel! @IBOutlet private weak var summaryView: ThreadSummaryView! @IBOutlet private weak var notificationStatusView: ThreadNotificationStatusView! @@ -61,7 +61,7 @@ class ThreadTableViewCell: UITableViewCell { if let rootMessageText = model.rootMessageText { updateRootMessageContentAttributes(rootMessageText, color: rootMessageColor) } else { - rootMessageContentLabel.attributedText = nil + rootMessageContentTextView.attributedText = nil } lastMessageTimeLabel.text = model.lastMessageTime if let summaryModel = model.summaryModel { @@ -83,7 +83,7 @@ class ThreadTableViewCell: UITableViewCell { mutable.addAttributes([ .foregroundColor: color ], range: NSRange(location: 0, length: mutable.length)) - rootMessageContentLabel.attributedText = mutable + rootMessageContentTextView.attributedText = mutable } } @@ -97,7 +97,7 @@ extension ThreadTableViewCell: Themable { Self.usernameColorGenerator.update(theme: theme) updateRootMessageSenderColor() rootMessageAvatarView.backgroundColor = .clear - if let attributedText = rootMessageContentLabel.attributedText { + if let attributedText = rootMessageContentTextView.attributedText { updateRootMessageContentAttributes(attributedText, color: rootMessageColor) } lastMessageTimeLabel.textColor = theme.colors.secondaryContent diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib index f9c881396..3014cd711 100644 --- a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib @@ -1,9 +1,9 @@ - + - + @@ -11,14 +11,14 @@ - + - + @@ -27,7 +27,7 @@ - + @@ -51,13 +51,13 @@ - - + + + @@ -68,20 +68,20 @@ - + - - + - + + @@ -89,7 +89,7 @@ - + @@ -97,6 +97,9 @@ + + + diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index ef7892ecf..d144ac1db 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -18,6 +18,7 @@ #import "RoomBubbleCellData.h" #import "MXKRoomBubbleTableViewCell+Riot.h" #import "UserEncryptionTrustLevel.h" +#import "RoomEncryptionTrustLevel.h" #import "RoomReactionsViewSizer.h" #import "RoomEncryptedDataBubbleCell.h" #import "LegacyAppDelegate.h" diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 321f8d23e..a16a2857e 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -107,8 +107,8 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; @"event_id": event.eventId ?: @"unknown" }); string = [[NSAttributedString alloc] initWithString:[VectorL10n noticeErrorUnformattableEvent] attributes:@{ - NSForegroundColorAttributeName: self.sendingTextColor, - NSFontAttributeName: [self encryptedMessagesTextFont] + NSFontAttributeName: [self encryptedMessagesTextFont], + NSForegroundColorAttributeName: [self encryptingTextColor] }]; } } @@ -581,8 +581,13 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent { // Force the default text color for the last message (cancel highlighted message color) NSMutableAttributedString *lastEventDescription = [[NSMutableAttributedString alloc] initWithAttributedString:summary.lastMessage.attributedText]; - [lastEventDescription addAttribute:NSForegroundColorAttributeName value:ThemeService.shared.theme.textSecondaryColor - range:NSMakeRange(0, lastEventDescription.length)]; + NSRange range = NSMakeRange(0, lastEventDescription.length); + [lastEventDescription addAttribute:NSForegroundColorAttributeName + value:ThemeService.shared.theme.colors.secondaryContent + range:range]; + [lastEventDescription addThemeColorNameAttribute:@"secondaryContent" range:range]; + [lastEventDescription addThemeIdentifierAttribute]; + summary.lastMessage.attributedText = lastEventDescription; } @@ -678,9 +683,11 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent NSAttributedString *attachmentString = nil; UIColor *textColor; + NSString *colorIdentifier; if (isStoppedVoiceBroadcast) { - textColor = ThemeService.shared.theme.textSecondaryColor; + textColor = ThemeService.shared.theme.colors.secondaryContent; + colorIdentifier = @"secondaryContent"; NSString *senderDisplayName; if ([stateEvent.stateKey isEqualToString:session.myUser.userId]) { @@ -696,6 +703,7 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent else { textColor = ThemeService.shared.theme.colors.alert; + colorIdentifier = @"alert"; UIImage *liveImage = AssetImages.voiceBroadcastLive.image; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; @@ -725,6 +733,12 @@ withVoiceBroadcastInfoStateEvent:lastVoiceBroadcastInfoEvent } [lastMessage addAttribute:NSForegroundColorAttributeName value:textColor range:NSMakeRange(0, lastMessage.length)]; + if (colorIdentifier) + { + [lastMessage addThemeColorNameAttribute:colorIdentifier range:NSMakeRange(0, lastMessage.length)]; + [lastMessage addThemeIdentifierAttribute]; + } + summary.lastMessage.attributedText = lastMessage; return YES; diff --git a/Riot/Utils/ThemeColorResolver.swift b/Riot/Utils/ThemeColorResolver.swift new file mode 100644 index 000000000..c0010c97d --- /dev/null +++ b/Riot/Utils/ThemeColorResolver.swift @@ -0,0 +1,48 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Utility struct to get a theme color by its name +struct ThemeColorResolver { + private static var theme: Theme? + private static var colorsTable: [String: UIColor] = [:] + private static let queue = DispatchQueue(label: "io.element.ThemeColorResolver.queue", qos: .userInteractive) + + private static func setTheme(theme: Theme) { + queue.sync { + guard self.theme?.identifier != theme.identifier else { + return + } + self.theme = theme + colorsTable = [:] + let mirror = Mirror(reflecting: theme.colors) + for child in mirror.children { + if let colorName = child.label { + colorsTable[colorName] = child.value as? UIColor + } + } + } + } + + /// Finds a color by its name in the current theme colors + /// - Parameter name: color name + /// - Returns: the corresponding color or nil + static func getColorByName(_ name: String) -> UIColor? { + setTheme(theme: ThemeService.shared().theme) + return colorsTable[name] + } +} diff --git a/Riot/target-bum-beta.yml b/Riot/target-bum-beta.yml index 1351d3fa0..3a39d7c17 100644 --- a/Riot/target-bum-beta.yml +++ b/Riot/target-bum-beta.yml @@ -80,6 +80,7 @@ targets: excludes: - "AppIdentifiers.xcconfig" - "BuM" + - "BuM-Open" - "*.sh" - path: . excludes: diff --git a/Riot/target-bum-open.yml b/Riot/target-bum-open.yml new file mode 100644 index 000000000..60bbc951b --- /dev/null +++ b/Riot/target-bum-open.yml @@ -0,0 +1,107 @@ +name: BuM-Open + +schemes: + BuM-Open: + analyze: + config: Debug + archive: + config: Release + build: + targets: + BuM-Open: + - running + - testing + - profiling + - analyzing + - archiving + profile: + config: Release + run: + config: Debug + disableMainThreadChecker: true + test: + config: Debug + disableMainThreadChecker: true + gatherCoverageData: true + language: "de" + region: "DE" + environmentVariables: + username: + defaultpin: + defaultpassphrase: + defaultpassword: + targets: + - RiotTests + +targets: + BuM-Open: + type: application + platform: iOS + + dependencies: + - target: RiotNSE + - target: DesignKit + - target: CommonKit + - package: AnalyticsEvents + - package: Mapbox + - package: OrderedCollections + - package: SwiftOGG + - package: Lottie + - package: WysiwygComposer + - package: DeviceKit + + configFiles: + Debug: Debug.xcconfig + Release: Release.xcconfig + + preBuildScripts: + - name: ⚠️ SwiftLint + runOnlyWhenInstalling: false + shell: /bin/sh + script: "${PODS_ROOT}/SwiftLint/swiftlint\n" + - name: 🛠 SwiftGen + runOnlyWhenInstalling: false + shell: /bin/sh + script: "${PODS_ROOT}/SwiftGen/bin/swiftgen config run --config Tools/SwiftGen/swiftgen-config.yml\n" + + sources: + - path: ../RiotSwiftUI/Modules + # Riot will provide it's own LocaleProviderType so exclude. + # Riot will provide it's own LocaleProviderType so exclude. + excludes: + - "Common/Locale/LocaleProvider.swift" + - "**/Test/**" + - path: ../Tools + excludes: + - "Logs" + - "Release" + - "Templates/*.sh" + - path: ../Config + excludes: + - "BuM-Beta" + - "BuM" + - "AppIdentifiers.xcconfig" + - "*.sh" + - path: . + excludes: + - "Modules/Room/EmojiPicker/Data/EmojiMart/EmojiJSONStore.swift" + - "Modules/Analytics/Test/Unit/BWIAnalyticsTests.swift" + - "**/*.strings" # Exclude all strings files + - path: ../bwi + excludes: + - "Tests" + - "ServerURLs/serverurls.json" + - path: ../RiotShareExtension/Shared + - path: Modules/MatrixKit + excludes: + - "**/*.md" # excludes all files with the .md extension + # Add separately localizable files + # Once a language has enough translations (>80%), it must be declared here + - path: Assets/en.lproj/InfoPlist.strings + - path: Assets/en.lproj/Localizable.strings + - path: Assets/en.lproj/Vector.strings + - path: Assets/en.lproj/Bwi.strings + - path: Assets/de.lproj/InfoPlist.strings + - path: Assets/de.lproj/Localizable.strings + - path: Assets/de.lproj/Vector.strings + - path: Assets/de.lproj/Bwi.strings diff --git a/Riot/target-messenger.yml b/Riot/target-messenger.yml index 8b53a33c5..654c4a78c 100644 --- a/Riot/target-messenger.yml +++ b/Riot/target-messenger.yml @@ -79,6 +79,7 @@ targets: - path: ../Config excludes: - "BuM-Beta" + - "BuM-Open" - "*.sh" - path: . excludes: diff --git a/Riot/target.yml b/Riot/target.yml index 48f4a6ebf..cfbe335a3 100644 --- a/Riot/target.yml +++ b/Riot/target.yml @@ -85,6 +85,7 @@ targets: - "beta" - "bwi" - "BuM-Beta" + - "BuM-Open" - "*.sh" - path: . excludes: diff --git a/RiotShareExtension/Shared/ForwardingShareItemSender.swift b/RiotShareExtension/Shared/ForwardingShareItemSender.swift index 3909812db..e87fa8c3f 100644 --- a/RiotShareExtension/Shared/ForwardingShareItemSender.swift +++ b/RiotShareExtension/Shared/ForwardingShareItemSender.swift @@ -53,10 +53,10 @@ class ForwardingShareItemSender: NSObject, ShareItemSenderProtocol { var localEcho: MXEvent? room.sendMessage(withContent: event.content, threadId: nil, localEcho: &localEcho) { result in switch result { + case .success(_): + self.trackForwardMessage(room: room) case .failure(let innerError): errors.append(innerError) - default: - break } dispatchGroup.leave() @@ -72,4 +72,12 @@ class ForwardingShareItemSender: NSObject, ShareItemSenderProtocol { success() } } + + func trackForwardMessage(room: MXRoom) { + BWIAnalyticsHelper.getRoomDeviceCount(room: room) { deviceCount in + let deviceCountString = BWIAnalyticsHelper.dimensionForDeviceCount(deviceCount) + let messageType = BWIAnalyticsHelper.getForwardingType(event: self.event) + BWIAnalytics.sharedTracker.trackEventWithDimension(category: "Feature", action: "ForwardMessage", dimension: deviceCountString, value: nil, name: messageType) + } + } } diff --git a/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h b/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h index 9a7cf3af1..618849c4d 100644 --- a/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h +++ b/RiotShareExtension/SupportingFiles/RiotShareExtension-Bridging-Header.h @@ -6,6 +6,8 @@ #import "AvatarGenerator.h" #import "BuildInfo.h" #import "ShareItemSender.h" +#import "UserEncryptionTrustLevel.h" +#import "RoomEncryptionTrustLevel.h" // MatrixKit imports #import "MatrixKit-Bridging-Header.h" diff --git a/RiotShareExtension/target.yml b/RiotShareExtension/target.yml index cc5c6853c..242a7e373 100644 --- a/RiotShareExtension/target.yml +++ b/RiotShareExtension/target.yml @@ -94,3 +94,4 @@ targets: - "**/*.md" # excludes all files with the .md extension - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK + - path: ../Riot/Modules/Encryption/EncryptionTrustLevel.swift diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index e59aa0189..34d7adb90 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift @@ -86,6 +86,10 @@ class HomeserverAddress: NSObject { /// - Ensure the address contains a scheme, otherwise make it `https`. /// - Remove any trailing slashes. static func sanitized(_ address: String) -> String { + guard !address.isEmpty else { + // prevent prefixing an empty string with "https:" + return address + } var address = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if !address.contains("://") { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index dac56608d..356bd5fd3 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -35,6 +35,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible { case qrLogin /// bwi: register info case register + /// bwi #4772: accessibility declaration + case accessibilityDeclaration /// A string representation of the result, ignoring any associated values that could leak PII. var description: String { @@ -55,6 +57,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible { return "qrLogin" case .register: return "register" + case .accessibilityDeclaration: + return "accessibilityDeclaration" } } } @@ -118,6 +122,8 @@ enum AuthenticationLoginViewAction { case qrLogin /// bwi: register info case register + /// bwi #4772: accessibility declaration + case accessibilityDeclaration } enum AuthenticationLoginErrorType: Hashable { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index 64ee83363..07879621c 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -54,6 +54,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica Task { await callback?(.qrLogin) } case .register: Task { await callback?(.register) } + case .accessibilityDeclaration: + Task { await callback?(.accessibilityDeclaration) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index f71f6c0c9..df7f688f4 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -140,6 +140,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { case .register: // bwi: show info alert self.authenticationLoginViewModel.displayInfoAlert(.register) + case .accessibilityDeclaration: + self.showAccessibilityDeclaration() } } } @@ -325,6 +327,13 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { } } + /// bwi #4772 show accessibility declaration + @MainActor private func showAccessibilityDeclaration() { + MXLog.debug("[AuthenticationLoginCoordinator] showAccessibilityDeclaration") + let accessibilityDeclarationViewController = AccessibilityDeclarationViewController.makeViewController() + navigationRouter.push(accessibilityDeclarationViewController, animated: true, popCompletion: nil) + } + /// Updates the view model to reflect any changes made to the homeserver. @MainActor private func updateViewModel() { let homeserver = authenticationService.state.homeserver diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index 662c26383..60e1b2d35 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -93,6 +93,13 @@ struct AuthenticationLoginScreen: View { .readableFrame() .padding(.horizontal, 16) } + + if BWIBuildSettings.shared.bumLoginFlowLayout && BWIBuildSettings.shared.bwiShowAccessibilityDeclaration { + accessibilityDeclaration + .frame(alignment: .bottom) + .padding(.bottom, 10) + } + if BWIBuildSettings.shared.bumLoginFlowLayout { dataPrivacyForm .frame(alignment: .bottom) @@ -305,6 +312,19 @@ struct AuthenticationLoginScreen: View { return BWIBuildSettings.shared.bwiLoginFlowLayout ? BWIL10n.authUserIdPlaceholder : BWIL10n.authenticationLoginUsername } } + + // bwi: Accessibility declaration + var accessibilityDeclaration: some View { + Button(action: { + viewModel.send(viewAction: .accessibilityDeclaration) + }, label: { + Text(BWIL10n.bwiAccessibilityDeclarationButtonTitle) + .font(theme.fonts.footnote) + .foregroundColor(.blue) + .underline() + }) + .padding([.horizontal], 20) + } } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index f4ebbc004..b2f161cbf 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -57,7 +57,13 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { self.parameters = parameters let homeserver = parameters.authenticationService.state.homeserver - let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.displayableAddress, + let homeserverAddress: String + if BuildSettings.forceHomeserverSelection, homeserver.addressFromUser == nil { + homeserverAddress = "" + } else { + homeserverAddress = homeserver.displayableAddress + } + let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserverAddress, flow: parameters.authenticationService.state.flow, hasModalPresentation: parameters.hasModalPresentation) let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context) diff --git a/RiotSwiftUI/Modules/Authentication/Terms/View/AuthenticationTermsToggleStyle.swift b/RiotSwiftUI/Modules/Authentication/Terms/View/AuthenticationTermsToggleStyle.swift index f5e94ce84..491cd630e 100644 --- a/RiotSwiftUI/Modules/Authentication/Terms/View/AuthenticationTermsToggleStyle.swift +++ b/RiotSwiftUI/Modules/Authentication/Terms/View/AuthenticationTermsToggleStyle.swift @@ -24,7 +24,7 @@ struct AuthenticationTermsToggleStyle: ToggleStyle { Button { configuration.isOn.toggle() } label: { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle") .font(.title3.weight(.regular)) - .foregroundColor(theme.colors.accent) + .foregroundColor(BWIBuildSettings.shared.useNewBumColors ? Color(ThemeService.shared().theme.tintColor) : theme.colors.accent) // bwi: 4769 } .buttonStyle(.plain) } diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 91bf25a51..0afe12c02 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -51,7 +51,7 @@ enum MockAppScreens { MockStaticLocationViewingScreenState.self, MockLocationSharingScreenState.self, MockAnalyticsPromptScreenState.self, - MockUserSuggestionScreenState.self, + MockCompletionSuggestionScreenState.self, MockPollEditFormScreenState.self, MockSpaceCreationEmailInvitesScreenState.self, MockSpaceSettingsScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index 6fc4381f4..ef395aa99 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -98,7 +98,12 @@ struct RoundedBorderTextField: View { /// The text field's border color. private var borderColor: Color { if isEditing { - return BWIBuildSettings.shared.bwiEnableBuMUI ? Color(hex: 0x108194) : theme.colors.accent + // bwi: 4769 + if BWIBuildSettings.shared.useNewBumColors { + return Color(ThemeService.shared().theme.tintColor) + } else { + return Color(hex: 0x108194) + } } else if footerText != nil, isError { return theme.colors.alert } else { diff --git a/RiotSwiftUI/Modules/Onboarding/Common/OnboardingIcon.swift b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingIcon.swift index 4993ce002..c974e2f9a 100644 --- a/RiotSwiftUI/Modules/Onboarding/Common/OnboardingIcon.swift +++ b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingIcon.swift @@ -25,7 +25,7 @@ struct OnboardingIconImage: View { Image(image.name) .resizable() .renderingMode(.template) - .foregroundColor(theme.colors.accent) + .foregroundColor(BWIBuildSettings.shared.useNewBumColors ? Color(ThemeService.shared().theme.tintColor) : theme.colors.accent) .frame(width: OnboardingMetrics.iconSize, height: OnboardingMetrics.iconSize) .background(Circle().foregroundColor(.white).padding(2)) .accessibilityHidden(true) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift new file mode 100644 index 000000000..8476834b9 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionModels.swift @@ -0,0 +1,43 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum CompletionSuggestionViewAction { + case selectedItem(CompletionSuggestionViewStateItem) +} + +enum CompletionSuggestionViewModelResult { + case selectedItemWithIdentifier(String) +} + +enum CompletionSuggestionViewStateItem: Identifiable { + case command(name: String, parametersFormat: String, description: String) + case user(id: String, avatar: AvatarInputProtocol?, displayName: String?) + + var id: String { + switch self { + case .command(let name, _, _): + return name + case .user(let id, _, _): + return id + } + } +} + +struct CompletionSuggestionViewState: BindableState { + var items: [CompletionSuggestionViewStateItem] +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift new file mode 100644 index 000000000..5bdd72088 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionScreenState.swift @@ -0,0 +1,95 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +enum MockCompletionSuggestionScreenState: MockScreenState, CaseIterable { + case multipleResults + + private static var members: [RoomMembersProviderMember]! + + var screenType: Any.Type { + CompletionSuggestionList.self + } + + var screenView: ([Any], AnyView) { + let service = CompletionSuggestionService(roomMemberProvider: self, commandProvider: self) + let listViewModel = CompletionSuggestionViewModel(completionSuggestionService: service) + + let viewModel = CompletionSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in + service.processTextMessage(textMessage) + } + + return ( + [service, listViewModel], + AnyView(CompletionSuggestionListWithInput(viewModel: viewModel) + .environmentObject(AvatarViewModel.withMockedServices())) + ) + } +} + +extension MockCompletionSuggestionScreenState: RoomMembersProviderProtocol { + var canMentionRoom: Bool { false } + + func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { + if Self.members == nil { + Self.members = generateUsersWithCount(10) + } + + members(Self.members) + } + + private func generateUsersWithCount(_ count: UInt) -> [RoomMembersProviderMember] { + (0.. Void) { + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action", + requiresAdminPowerLevel: false) + ]) + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift new file mode 100644 index 000000000..53d2c6975 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModel.swift @@ -0,0 +1,85 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +typealias CompletionSuggestionViewModelType = StateStoreViewModel + +class CompletionSuggestionViewModel: CompletionSuggestionViewModelType, CompletionSuggestionViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let completionSuggestionService: CompletionSuggestionServiceProtocol + + // MARK: Public + + var sharedContext: CompletionSuggestionViewModelType.Context { + context + } + + var completion: ((CompletionSuggestionViewModelResult) -> Void)? + + // MARK: - Setup + + init(completionSuggestionService: CompletionSuggestionServiceProtocol) { + self.completionSuggestionService = completionSuggestionService + + let items = completionSuggestionService.items.value.map { suggestionItem in + switch suggestionItem { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + + super.init(initialViewState: CompletionSuggestionViewState(items: items)) + + completionSuggestionService.items.sink { [weak self] items in + self?.state.items = items.map { item in + switch item { + case .command(let completionSuggestionCommandItem): + return CompletionSuggestionViewStateItem.command( + name: completionSuggestionCommandItem.name, + parametersFormat: completionSuggestionCommandItem.parametersFormat, + description: completionSuggestionCommandItem.description + ) + case .user(let completionSuggestionUserItem): + return CompletionSuggestionViewStateItem.user(id: completionSuggestionUserItem.userId, + avatar: completionSuggestionUserItem, + displayName: completionSuggestionUserItem.displayName) + } + } + }.store(in: &cancellables) + } + + // MARK: - Public + + override func process(viewAction: CompletionSuggestionViewAction) { + switch viewAction { + case .selectedItem(let item): + completion?(.selectedItemWithIdentifier(item.id)) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift similarity index 52% rename from RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift index d4e984f88..d7c51909f 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/CompletionSuggestionViewModelProtocol.swift @@ -16,20 +16,10 @@ import Foundation -enum UserSuggestionViewAction { - case selectedItem(UserSuggestionViewStateItem) -} - -enum UserSuggestionViewModelResult { - case selectedItemWithIdentifier(String) -} - -struct UserSuggestionViewStateItem: Identifiable { - let id: String - let avatar: AvatarInputProtocol? - let displayName: String? -} - -struct UserSuggestionViewState: BindableState { - var items: [UserSuggestionViewStateItem] +protocol CompletionSuggestionViewModelProtocol { + /// Defines a shared context providing the ability to use a single `CompletionSuggestionViewModel` for multiple + /// `CompletionSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` + /// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload the data. + var sharedContext: CompletionSuggestionViewModelType.Context { get } + var completion: ((CompletionSuggestionViewModelResult) -> Void)? { get set } } diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift new file mode 100644 index 000000000..4196da77a --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinator.swift @@ -0,0 +1,272 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import SwiftUI +import UIKit +import WysiwygComposer + +protocol CompletionSuggestionCoordinatorDelegate: AnyObject { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) +} + +struct CompletionSuggestionCoordinatorParameters { + let mediaManager: MXMediaManager + let room: MXRoom + let userID: String +} + +/// Wrapper around `CompletionSuggestionViewModelType.Context` to pass it through obj-c. +final class CompletionSuggestionViewModelContextWrapper: NSObject { + let context: CompletionSuggestionViewModelType.Context + + init(context: CompletionSuggestionViewModelType.Context) { + self.context = context + } +} + +final class CompletionSuggestionCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: CompletionSuggestionCoordinatorParameters + + private var completionSuggestionHostingController: UIHostingController + private var completionSuggestionService: CompletionSuggestionServiceProtocol + private var completionSuggestionViewModel: CompletionSuggestionViewModelProtocol + private var roomMemberProvider: CompletionSuggestionCoordinatorRoomMemberProvider + private var commandProvider: CompletionSuggestionCoordinatorCommandProvider + + private var cancellables = Set() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: (() -> Void)? + + weak var delegate: CompletionSuggestionCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: CompletionSuggestionCoordinatorParameters) { + self.parameters = parameters + + roomMemberProvider = CompletionSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) + commandProvider = CompletionSuggestionCoordinatorCommandProvider(room: parameters.room, userID: parameters.userID) + completionSuggestionService = CompletionSuggestionService(roomMemberProvider: roomMemberProvider, commandProvider: commandProvider) + + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) + + completionSuggestionViewModel = viewModel + completionSuggestionHostingController = VectorHostingController(rootView: view) + + completionSuggestionViewModel.completion = { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .selectedItemWithIdentifier(let identifier): + if identifier == CompletionSuggestionUserID.room { + self.delegate?.completionSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.completionSuggestionService.currentTextTrigger) + return + } + + if let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.completionSuggestionService.currentTextTrigger) + } else if let command = self.commandProvider.commands.filter({ $0.cmd == identifier }).first { + self.delegate?.completionSuggestionCoordinator(self, didRequestCommand: command.cmd, textTrigger: self.completionSuggestionService.currentTextTrigger) + } + } + } + + completionSuggestionService.items.sink { [weak self] _ in + guard let self = self else { return } + self.delegate?.completionSuggestionCoordinator(self, didUpdateViewHeight: self.calculateViewHeight()) + }.store(in: &cancellables) + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionService.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + completionSuggestionService.processSuggestionPattern(suggestionPattern) + } + + // MARK: - Public + + func start() { } + + func toPresentable() -> UIViewController { + completionSuggestionHostingController + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + CompletionSuggestionViewModelContextWrapper(context: completionSuggestionViewModel.sharedContext) + } + + // MARK: - Private + + private func calculateViewHeight() -> CGFloat { + let viewModel = CompletionSuggestionViewModel(completionSuggestionService: completionSuggestionService) + let view = CompletionSuggestionList(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) + + let controller = VectorHostingController(rootView: view) + guard let view = controller.view else { + return 0 + } + view.isHidden = true + + toPresentable().view.addSubview(view) + controller.didMove(toParent: toPresentable()) + + view.setNeedsLayout() + view.layoutIfNeeded() + + let result = view.intrinsicContentSize.height + + controller.didMove(toParent: nil) + view.removeFromSuperview() + + return result + } +} + +private class CompletionSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { + private let room: MXRoom + private let userID: String + + var roomMembers: [MXRoomMember] = [] + var canMentionRoom = false + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + /// Gets the power levels for the room to update suggestions accordingly. + func updateWithPowerLevels() { + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + let mentionRoomPowerLevel = powerLevels.minimumPowerLevel(forNotifications: kMXRoomPowerLevelNotificationsRoomKey, + defaultPower: kMXRoomPowerLevelNotificationsRoomDefault) + self.canMentionRoom = userPowerLevel >= mentionRoomPowerLevel + } + } + + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { + room.members { [weak self] roomMembers in + guard let self = self, let joinedMembers = roomMembers?.joinedMembers else { + return + } + self.roomMembers = joinedMembers + members(self.roomMembersToProviderMembers(joinedMembers)) + } lazyLoadedMembers: { [weak self] lazyRoomMembers in + guard let self = self, let joinedMembers = lazyRoomMembers?.joinedMembers else { + return + } + self.roomMembers = joinedMembers + members(self.roomMembersToProviderMembers(joinedMembers)) + } failure: { error in + MXLog.error("[CompletionSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) + } + } + + private func roomMembersToProviderMembers(_ roomMembers: [MXRoomMember]) -> [RoomMembersProviderMember] { + roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } + } +} + +private class CompletionSuggestionCoordinatorCommandProvider: CommandsProviderProtocol { + private let room: MXRoom + private let userID: String + + var commands = MXKSlashCommand.allCases + var isRoomAdmin = false + + init(room: MXRoom, userID: String) { + self.room = room + self.userID = userID + updateWithPowerLevels() + } + + func updateWithPowerLevels() { + room.state { [weak self] state in + guard let self, let powerLevels = state?.powerLevels else { return } + + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) + self.isRoomAdmin = RoomPowerLevel(rawValue: userPowerLevel) == .admin + } + } + + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands(self.commands.map { CommandsProviderCommand(name: $0.cmd, parametersFormat: $0.parametersFormat, description: $0.description, requiresAdminPowerLevel: $0.requiresAdminPowerLevel) }) + } +} + +private extension MXKSlashCommand { + var description: String { + switch self { + case .changeDisplayName: + return VectorL10n.roomCommandChangeDisplayNameDescription + case .emote: + return VectorL10n.roomCommandEmoteDescription + case .joinRoom: + return VectorL10n.roomCommandJoinRoomDescription + case .partRoom: + return VectorL10n.roomCommandPartRoomDescription + case .inviteUser: + return VectorL10n.roomCommandInviteUserDescription + case .kickUser: + return VectorL10n.roomCommandKickUserDescription + case .banUser: + return VectorL10n.roomCommandBanUserDescription + case .unbanUser: + return VectorL10n.roomCommandUnbanUserDescription + case .setUserPowerLevel: + return VectorL10n.roomCommandSetUserPowerLevelDescription + case .resetUserPowerLevel: + return VectorL10n.roomCommandResetUserPowerLevelDescription + case .changeRoomTopic: + return VectorL10n.roomCommandChangeRoomTopicDescription + case .discardSession: + return VectorL10n.roomCommandDiscardSessionDescription + } + } + + // Note: for now only filter out `/op` and `/deop` (same as Element-Web), + // but we could use power level for ban/invite/etc to filter further. + var requiresAdminPowerLevel: Bool { + switch self { + case .setUserPowerLevel, .resetUserPowerLevel: + return true + default: + return false + } + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift new file mode 100644 index 000000000..83a9ed94c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Coordinator/CompletionSuggestionCoordinatorBridge.swift @@ -0,0 +1,79 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@objc +protocol CompletionSuggestionCoordinatorBridgeDelegate: AnyObject { + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) + func completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinatorBridge, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didRequestCommand command: String, textTrigger: String?) + func completionSuggestionCoordinatorBridge(_ coordinator: CompletionSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) +} + +@objcMembers +final class CompletionSuggestionCoordinatorBridge: NSObject { + private var _completionSuggestionCoordinator: Any? + fileprivate var completionSuggestionCoordinator: CompletionSuggestionCoordinator { + _completionSuggestionCoordinator as! CompletionSuggestionCoordinator + } + + weak var delegate: CompletionSuggestionCoordinatorBridgeDelegate? + + init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { + let parameters = CompletionSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) + let completionSuggestionCoordinator = CompletionSuggestionCoordinator(parameters: parameters) + _completionSuggestionCoordinator = completionSuggestionCoordinator + + super.init() + + completionSuggestionCoordinator.delegate = self + } + + func processTextMessage(_ textMessage: String) { + completionSuggestionCoordinator.processTextMessage(textMessage) + } + + func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { + completionSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) + } + + func toPresentable() -> UIViewController? { + completionSuggestionCoordinator.toPresentable() + } + + func sharedContext() -> CompletionSuggestionViewModelContextWrapper { + completionSuggestionCoordinator.sharedContext() + } +} + +extension CompletionSuggestionCoordinatorBridge: CompletionSuggestionCoordinatorDelegate { + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) + } + + func completionSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: CompletionSuggestionCoordinator, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didRequestCommand command: String, textTrigger: String?) { + delegate?.completionSuggestionCoordinatorBridge(self, didRequestCommand: command, textTrigger: textTrigger) + } + + func completionSuggestionCoordinator(_ coordinator: CompletionSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { + delegate?.completionSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift new file mode 100644 index 000000000..5ded36c2c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionService.swift @@ -0,0 +1,231 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import WysiwygComposer + +struct RoomMembersProviderMember { + var userId: String + var displayName: String + var avatarUrl: String +} + +struct CommandsProviderCommand { + let name: String + let parametersFormat: String + let description: String + let requiresAdminPowerLevel: Bool +} + +class CompletionSuggestionUserID: NSObject { + /// A special case added for suggesting `@room` mentions. + @objc static let room = "@room" +} + +protocol RoomMembersProviderProtocol { + var canMentionRoom: Bool { get } + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) +} + +protocol CommandsProviderProtocol { + var isRoomAdmin: Bool { get } + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) +} + +struct CompletionSuggestionServiceUserItem: CompletionSuggestionUserItemProtocol { + let userId: String + let displayName: String? + let avatarUrl: String? +} + +struct CompletionSuggestionServiceCommandItem: CompletionSuggestionCommandItemProtocol { + let name: String + let parametersFormat: String + let description: String +} + +class CompletionSuggestionService: CompletionSuggestionServiceProtocol { + // MARK: - Properties + + // MARK: Private + + private let roomMemberProvider: RoomMembersProviderProtocol + private let commandProvider: CommandsProviderProtocol + + private var suggestionItems: [CompletionSuggestionItem] = [] + private let currentTextTriggerSubject = CurrentValueSubject(nil) + private var cancellables = Set() + + // MARK: Public + + var items = CurrentValueSubject<[CompletionSuggestionItem], Never>([]) + + var currentTextTrigger: String? { + currentTextTriggerSubject.value?.asString() + } + + // MARK: - Setup + + init(roomMemberProvider: RoomMembersProviderProtocol, + commandProvider: CommandsProviderProtocol, + shouldDebounce: Bool = true) { + self.roomMemberProvider = roomMemberProvider + self.commandProvider = commandProvider + + if shouldDebounce { + currentTextTriggerSubject + .debounce(for: 0.5, scheduler: RunLoop.main) + .removeDuplicates() + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } + .store(in: &cancellables) + } else { + currentTextTriggerSubject + .sink { [weak self] in self?.fetchAndFilterSuggestionsForTextTrigger($0) } + .store(in: &cancellables) + } + } + + // MARK: - CompletionSuggestionServiceProtocol + + func processTextMessage(_ textMessage: String?) { + guard let textMessage = textMessage, + let textTrigger = textMessage.currentTextTrigger + else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + currentTextTriggerSubject.send(textTrigger) + } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + guard let suggestionPattern else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + switch suggestionPattern.key { + case .at: + currentTextTriggerSubject.send(TextTrigger(key: .at, text: suggestionPattern.text)) + case .hash: + // No room suggestion support yet + items.send([]) + currentTextTriggerSubject.send(nil) + case .slash: + currentTextTriggerSubject.send(TextTrigger(key: .slash, text: suggestionPattern.text)) + } + } + + // MARK: - Private + + private func fetchAndFilterSuggestionsForTextTrigger(_ textTrigger: TextTrigger?) { + guard let textTrigger else { return } + + switch textTrigger.key { + case .at: + roomMemberProvider.fetchMembers { [weak self] members in + guard let self = self else { + return + } + + self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in + CompletionSuggestionItem.user(value: CompletionSuggestionServiceUserItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl)) + } + + self.items.send(self.suggestionItems.filter { item in + guard case let .user(completionSuggestionUserItem) = item else { return false } + + let containedInUsername = completionSuggestionUserItem.userId.lowercased().contains(textTrigger.text.lowercased()) + let containedInDisplayName = (completionSuggestionUserItem.displayName ?? "").lowercased().contains(textTrigger.text.lowercased()) + + return (containedInUsername || containedInDisplayName) + }) + } + case .slash: + commandProvider.fetchCommands { [weak self] commands in + guard let self else { return } + + self.suggestionItems = commands.filtered(isRoomAdmin: self.commandProvider.isRoomAdmin).map { command in + CompletionSuggestionItem.command(value: CompletionSuggestionServiceCommandItem( + name: command.name, + parametersFormat: command.parametersFormat, + description: command.description + )) + } + + if textTrigger.text.isEmpty { + // A single `/` will display all available commands. + self.items.send(self.suggestionItems) + } else { + self.items.send(self.suggestionItems.filter { item in + guard case let .command(commandSuggestion) = item else { return false } + + return commandSuggestion.name.lowercased().contains(textTrigger.text.lowercased()) + }) + } + } + } + } +} + +extension Array where Element == RoomMembersProviderMember { + /// Returns the array with an additional member that represents an `@room` mention. + func withRoom(_ canMentionRoom: Bool) -> Self { + guard canMentionRoom else { return self } + return self + [RoomMembersProviderMember(userId: CompletionSuggestionUserID.room, displayName: "Everyone", avatarUrl: "")] + } +} + +extension Array where Element == CommandsProviderCommand { + func filtered(isRoomAdmin: Bool) -> Self { + guard !isRoomAdmin else { return self } + return filter { !$0.requiresAdminPowerLevel } + } +} + +private enum SuggestionKey: Character { + case at = "@" + case slash = "/" +} + +private struct TextTrigger: Equatable { + let key: SuggestionKey + let text: String + + func asString() -> String { + String(key.rawValue) + text + } +} + +private extension String { + // Returns current completion suggestion for a text message, if any. + var currentTextTrigger: TextTrigger? { + let components = components(separatedBy: .whitespaces) + guard var lastComponent = components.last, + lastComponent.count > 0, + let suggestionKey = SuggestionKey(rawValue: lastComponent.removeFirst()), + // If a second character exists and is the same as the key it shouldn't trigger. + lastComponent.first != suggestionKey.rawValue, + // Slash commands should be displayed only if there is a single component + !(suggestionKey == .slash && components.count > 1) + else { return nil } + + return TextTrigger(key: suggestionKey, text: lastComponent) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift similarity index 59% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift index 81edb0df9..3930c59d1 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Service/CompletionSuggestionServiceProtocol.swift @@ -16,24 +16,37 @@ import Combine import Foundation +import WysiwygComposer -protocol UserSuggestionItemProtocol: Avatarable { +protocol CompletionSuggestionUserItemProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } } -protocol UserSuggestionServiceProtocol { - var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never> { get } +protocol CompletionSuggestionCommandItemProtocol { + var name: String { get } + var parametersFormat: String { get } + var description: String { get } +} + +enum CompletionSuggestionItem { + case command(value: CompletionSuggestionCommandItemProtocol) + case user(value: CompletionSuggestionUserItemProtocol) +} + +protocol CompletionSuggestionServiceProtocol { + var items: CurrentValueSubject<[CompletionSuggestionItem], Never> { get } var currentTextTrigger: String? { get } func processTextMessage(_ textMessage: String?) + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) } // MARK: Avatarable -extension UserSuggestionItemProtocol { +extension CompletionSuggestionUserItemProtocol { var mxContentUri: String? { avatarUrl } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift similarity index 79% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift index f44744a9c..5ec9d4b9b 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/UI/CompletionSuggestionUITests.swift @@ -17,9 +17,9 @@ import RiotSwiftUI import XCTest -class UserSuggestionUITests: MockScreenTestCase { - func testUserSuggestionScreen() throws { - app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title) +class CompletionSuggestionUITests: MockScreenTestCase { + func testCompletionSuggestionScreen() throws { + app.goToScreenWithIdentifier(MockCompletionSuggestionScreenState.multipleResults.title) let firstButton = app.buttons["displayNameText-userIdText"].firstMatch XCTAssert(firstButton.waitForExistence(timeout: 10)) diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift new file mode 100644 index 000000000..90542868d --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/Test/Unit/CompletionSuggestionServiceTests.swift @@ -0,0 +1,266 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import XCTest + +@testable import RiotSwiftUI + +class CompletionSuggestionServiceTests: XCTestCase { + var service: CompletionSuggestionService! + var canMentionRoom = false + var isRoomAdmin = false + + override func setUp() { + service = CompletionSuggestionService(roomMemberProvider: self, + commandProvider: self, + shouldDebounce: false) + canMentionRoom = false + isRoomAdmin = false + } + + // MARK: - User suggestions + + func testAlice() { + service.processTextMessage("@Al") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@al") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@ice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@Alice") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + + service.processTextMessage("@alice:matrix.org") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + } + + func testBob() { + service.processTextMessage("@ob") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + + service.processTextMessage("@ob:") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + + service.processTextMessage("@b:matrix") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Bob") + } + + func testBoth() { + service.processTextMessage("@:matrix") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") + + service.processTextMessage("@.org") + XCTAssertEqual(service.items.value.first?.asUser?.displayName, "Alice") + XCTAssertEqual(service.items.value.last?.asUser?.displayName, "Bob") + } + + func testEmptyResult() { + service.processTextMessage("Lorem ipsum idolor") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@@") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("alice@matrix.org") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testStuff() { + service.processTextMessage("@@") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testWhitespaces() { + service.processTextMessage("") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("\n") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" \n ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage("@A ") + XCTAssertTrue(service.items.value.isEmpty) + + service.processTextMessage(" @A ") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testRoomWithoutPower() { + // Given a user without the power to mention a room. + canMentionRoom = false + + // Given a user without the power to mention a room. + service.processTextMessage("@ro") + + // Then the completion for a room mention should not be shown. + XCTAssertTrue(service.items.value.isEmpty) + } + + func testRoomWithPower() { + // Given a user with the power to mention a room. + canMentionRoom = true + + // Given a user with the power to mention a room. + service.processTextMessage("@ro") + + // Then the completion for a room mention should be shown. + XCTAssertEqual(service.items.value.first?.asUser?.userId, CompletionSuggestionUserID.room) + } + + // MARK: - Command suggestions + + func testJoin() { + service.processTextMessage("/jo") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/joi") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/join") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + + service.processTextMessage("/oin") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/join") + } + + func testInvite() { + service.processTextMessage("/inv") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/invite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + + service.processTextMessage("/vite") + XCTAssertEqual(service.items.value.first?.asCommand?.name, "/invite") + } + + func testMultipleResults() { + service.processTextMessage("/in") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/invite", "/join"] + ) + } + + func testDoubleSlashDontTrigger() { + service.processTextMessage("//") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testNonLeadingSlashCommandDontTrigger() { + service.processTextMessage("test /joi") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreNotAvailable() { + isRoomAdmin = false + + service.processTextMessage("/op") + XCTAssertTrue(service.items.value.isEmpty) + } + + func testAdminCommandsAreAvailable() { + isRoomAdmin = true + + service.processTextMessage("/op") + XCTAssertEqual(service.items.value.compactMap { $0.asCommand?.name }, ["/op", "/deop"]) + } + + func testDisplayAllCommandsAsStandardUser() { + isRoomAdmin = false + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/me"] + ) + } + + func testDisplayAllCommandsAsAdmin() { + isRoomAdmin = true + + service.processTextMessage("/") + XCTAssertEqual( + service.items.value.compactMap { $0.asCommand?.name }, + ["/ban", "/invite", "/join", "/op", "/deop", "/me"] + ) + } +} + +extension CompletionSuggestionServiceTests: RoomMembersProviderProtocol { + func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { + let users = [("Alice", "@alice:matrix.org"), + ("Bob", "@bob:matrix.org")] + + members(users.map { user in + RoomMembersProviderMember(userId: user.1, displayName: user.0, avatarUrl: "") + }) + } +} + +extension CompletionSuggestionServiceTests: CommandsProviderProtocol { + func fetchCommands(_ commands: @escaping ([CommandsProviderCommand]) -> Void) { + commands([ + CommandsProviderCommand(name: "/ban", + parametersFormat: " []", + description: "Bans user with given id", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/invite", + parametersFormat: "", + description: "Invites user with given id to current room", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/join", + parametersFormat: "", + description: "Joins room with given address", + requiresAdminPowerLevel: false), + CommandsProviderCommand(name: "/op", + parametersFormat: " ", + description: "Define the power level of a user", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/deop", + parametersFormat: "", + description: "Deops user with given id", + requiresAdminPowerLevel: true), + CommandsProviderCommand(name: "/me", + parametersFormat: "", + description: "Displays action", + requiresAdminPowerLevel: false) + ]) + } +} + +extension CompletionSuggestionItem { + var asUser: CompletionSuggestionUserItemProtocol? { + if case let .user(value) = self { return value } else { return nil } + } + + var asCommand: CompletionSuggestionCommandItemProtocol? { + if case let .command(value) = self { return value } else { return nil } + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift new file mode 100644 index 000000000..cf8e34e02 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionList.swift @@ -0,0 +1,137 @@ +// +// 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 CompletionSuggestionList: View { + private enum Constants { + static let topPadding: CGFloat = 8.0 + static let listItemPadding: CGFloat = 4.0 + static let lineSpacing: CGFloat = 10.0 + static let maxHeight: CGFloat = 300.0 + static let maxVisibleRows = 4 + + /* + As of iOS 16.0, SwiftUI's List uses `UICollectionView` instead + of `UITableView` internally, this value is an adjustment to apply + to the list items in order to be as close as possible as the + `UITableView` display. + */ + @available(iOS 16.0, *) + static let collectionViewPaddingCorrection: CGFloat = -5.0 + } + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + @State private var prototypeListItemFrame: CGRect = .zero + + // MARK: Public + + @ObservedObject var viewModel: CompletionSuggestionViewModel.Context + var showBackgroundShadow = true + + var body: some View { + if viewModel.viewState.items.isEmpty { + EmptyView() + } else { + ZStack { + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user(id: "Prototype", avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), displayName: "Prototype")) + .background(ViewFrameReader(frame: $prototypeListItemFrame)) + .hidden() + if showBackgroundShadow { + BackgroundView { + list() + } + } else { + list() + } + } + } + } + + private func contentHeightForRowCount(_ count: Int) -> CGFloat { + (prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding + } + + private func list() -> some View { + List(viewModel.viewState.items) { item in + Button { + viewModel.send(viewAction: .selectedItem(item)) + } label: { + CompletionSuggestionListItem(content: item) + .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) + } + } + .listStyle(PlainListStyle()) + .frame(height: min(Constants.maxHeight, + min(contentHeightForRowCount(Constants.maxVisibleRows), + contentHeightForRowCount(viewModel.viewState.items.count)))) + .id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues. + } + + private struct ListItemPaddingModifier: ViewModifier { + private let isFirst: Bool + + init(isFirst: Bool) { + self.isFirst = isFirst + } + + func body(content: Content) -> some View { + var topPadding: CGFloat = isFirst ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding + var bottomPadding: CGFloat = Constants.listItemPadding + if #available(iOS 16.0, *) { + topPadding += Constants.collectionViewPaddingCorrection + bottomPadding += Constants.collectionViewPaddingCorrection + } + + return content + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + } + } +} + +private struct BackgroundView: View { + var content: () -> Content + + @Environment(\.theme) private var theme: ThemeSwiftUI + private let shadowRadius: CGFloat = 20.0 + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + content() + .background(theme.colors.background) + .clipShape(RoundedCornerShape(radius: shadowRadius, corners: [.topLeft, .topRight])) + .shadow(color: .black.opacity(0.20), radius: 20.0, x: 0.0, y: 3.0) + .mask(Rectangle().padding(.init(top: -(shadowRadius * 2), leading: 0.0, bottom: 0.0, trailing: 0.0))) + .edgesIgnoringSafeArea(.all) + } +} + +// MARK: - Previews + +struct CompletionSuggestion_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift new file mode 100644 index 000000000..4a1616189 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListItem.swift @@ -0,0 +1,84 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct CompletionSuggestionListItem: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let content: CompletionSuggestionViewStateItem + + var body: some View { + HStack { + switch content { + case .command(let name, let parametersFormat, let description): + VStack(alignment: .leading) { + HStack { + Text(name) + .font(theme.fonts.body.bold()) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "nameText") + .lineLimit(1) + Text(parametersFormat) + .font(theme.fonts.body.italic()) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "parametersFormatText") + .lineLimit(1) + } + Text(description) + .font(theme.fonts.body) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "descriptionText") + } + case .user(let userId, let avatar, let displayName): + if let avatar = avatar { + AvatarImage(avatarData: avatar, size: .medium) + } + VStack(alignment: .leading) { + Text(displayName ?? "") + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "displayNameText") + .lineLimit(1) + Text(userId) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.tertiaryContent) + .accessibility(identifier: "userIdText") + .lineLimit(1) + } + } + } + } +} + +// MARK: - Previews + +struct CompletionSuggestionHeader_Previews: PreviewProvider { + static var previews: some View { + CompletionSuggestionListItem(content: CompletionSuggestionViewStateItem.user( + id: "@alice:matrix.org", + avatar: MockAvatarInput.example, + displayName: "Alice" + )) + .environmentObject(AvatarViewModel.withMockedServices()) + } +} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift similarity index 70% rename from RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift rename to RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift index 176be8ec4..223b4fbc6 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/CompletionSuggestion/View/CompletionSuggestionListWithInput.swift @@ -16,31 +16,31 @@ import SwiftUI -struct UserSuggestionListWithInputViewModel { - let listViewModel: UserSuggestionViewModel +struct CompletionSuggestionListWithInputViewModel { + let listViewModel: CompletionSuggestionViewModel let callback: (String) -> Void } -struct UserSuggestionListWithInput: View { +struct CompletionSuggestionListWithInput: View { // MARK: - Properties // MARK: Private // MARK: Public - var viewModel: UserSuggestionListWithInputViewModel + var viewModel: CompletionSuggestionListWithInputViewModel @State private var inputText = "" var body: some View { VStack(spacing: 0.0) { - UserSuggestionList(viewModel: viewModel.listViewModel.context) - TextField("Search for user", text: $inputText) + CompletionSuggestionList(viewModel: viewModel.listViewModel.context) + TextField("Search for user/command", text: $inputText) .background(Color.white) .onChange(of: inputText, perform: viewModel.callback) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding([.leading, .trailing]) .onAppear { - inputText = "@-" // Make the list show all available mock results + inputText = "@-" // Make the list show all available user mock results } } } @@ -48,8 +48,8 @@ struct UserSuggestionListWithInput: View { // MARK: - Previews -struct UserSuggestionListWithInput_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer +struct CompletionSuggestionListWithInput_Previews: PreviewProvider { + static let stateRenderer = MockCompletionSuggestionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup() } diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift index 6bdc5ebc5..335ff3196 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift @@ -33,7 +33,7 @@ enum MockComposerLinkActionScreenState: MockScreenState, CaseIterable { case .create: viewModel = .init(from: .create) case .edit: - viewModel = .init(from: .edit(link: "https://element.io")) + viewModel = .init(from: .edit(url: "https://element.io")) } return ( [viewModel], diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift index fdf92cab5..0c3ba03e2 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift @@ -41,6 +41,7 @@ extension ComposerLinkActionViewState { switch linkAction { case .createWithText, .create: return VectorL10n.wysiwygComposerLinkActionCreateTitle case .edit: return VectorL10n.wysiwygComposerLinkActionEditTitle + case .disabled: return "" } } @@ -64,6 +65,7 @@ extension ComposerLinkActionViewState { case .createWithText: return bindings.text.isEmpty case .create: return false case .edit: return !bindings.hasEditedUrl + case .disabled: return false } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift index 2407eccc4..3fbb8d564 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift @@ -54,7 +54,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase { func testEditDefaultState() { let link = "element.io" - setUp(with: .edit(link: link)) + setUp(with: .edit(url: link)) XCTAssertEqual(context.viewState.bindings.text, "") XCTAssertEqual(context.viewState.bindings.linkUrl, link) XCTAssertTrue(context.viewState.isSaveButtonDisabled) @@ -83,7 +83,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase { } func testRemoveAction() { - setUp(with: .edit(link: "element.io")) + setUp(with: .edit(url: "element.io")) var result: ComposerLinkActionViewModelResult! viewModel.callback = { value in result = value @@ -119,7 +119,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase { } func testSaveActionForEdit() { - setUp(with: .edit(link: "element.io")) + setUp(with: .edit(url: "element.io")) var result: ComposerLinkActionViewModelResult! viewModel.callback = { value in result = value diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift index 9683ac621..d16dd7212 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift @@ -36,7 +36,7 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos switch linkAction { case let .edit(link): initialViewState = .init( - linkAction: .edit(link: link), + linkAction: .edit(url: link), bindings: .init( text: "", linkUrl: link @@ -46,6 +46,9 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos initialViewState = .init(linkAction: .createWithText, bindings: simpleBindings) case .create: initialViewState = .init(linkAction: .create, bindings: simpleBindings) + case .disabled: + // Note: Unreachable + initialViewState = .init(linkAction: .disabled, bindings: simpleBindings) } super.init(initialViewState: initialViewState) @@ -74,6 +77,8 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos .setLink(urlString: state.bindings.linkUrl) ) ) + case .disabled: + break } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 35a628d02..79322b78a 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,12 +29,22 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel + let completionSuggestionViewModel = MockCompletionSuggestionViewModel(initialViewState: CompletionSuggestionViewState(items: [])) let bindings = ComposerBindings(focused: false) switch self { - case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) + case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", + sendMode: .reply, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) } let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360) @@ -57,6 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, + completionSuggestionSharedContext: completionSuggestionViewModel.context, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -70,3 +81,5 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { ) } } + +private final class MockCompletionSuggestionViewModel: CompletionSuggestionViewModelType { } diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 98d7febf6..33d73ef4a 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -229,12 +229,14 @@ enum ComposerViewAction: Equatable { case contentDidChange(isEmpty: Bool) case linkTapped(linkAction: LinkAction) case storeSelection(selection: NSRange) + case suggestion(pattern: SuggestionPattern?) } enum ComposerViewModelResult: Equatable { case cancel case contentDidChange(isEmpty: Bool) case linkTapped(LinkAction: LinkAction) + case suggestion(pattern: SuggestionPattern?) } final class LinkActionWrapper: NSObject { @@ -245,3 +247,21 @@ final class LinkActionWrapper: NSObject { super.init() } } + +final class SuggestionPatternWrapper: NSObject { + let suggestionPattern: SuggestionPattern? + + init(_ suggestionPattern: SuggestionPattern?) { + self.suggestionPattern = suggestionPattern + super.init() + } +} + +final class CompletionSuggestionViewModelWrapper: NSObject { + let completionSuggestionViewModel: CompletionSuggestionViewModel + + init(_ completionSuggestionViewModel: CompletionSuggestionViewModel) { + self.completionSuggestionViewModel = completionSuggestionViewModel + super.init() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift index e4d5b595d..c68cd7783 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift @@ -98,7 +98,7 @@ final class ComposerViewModelTests: XCTestCase { XCTAssertEqual(result, .linkTapped(LinkAction: .createWithText)) context.send(viewAction: .linkTapped(linkAction: .create)) XCTAssertEqual(result, .linkTapped(LinkAction: .create)) - context.send(viewAction: .linkTapped(linkAction: .edit(link: "https://element.io"))) - XCTAssertEqual(result, .linkTapped(LinkAction: .edit(link: "https://element.io"))) + context.send(viewAction: .linkTapped(linkAction: .edit(url: "https://element.io"))) + XCTAssertEqual(result, .linkTapped(LinkAction: .edit(url: "https://element.io"))) } } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index e121d6075..30d88ca3d 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,6 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel + private let completionSuggestionSharedContext: CompletionSuggestionViewModelType.Context private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -31,15 +32,42 @@ struct Composer: View { @Environment(\.theme) private var theme: ThemeSwiftUI @State private var isActionButtonShowing = false - + private let horizontalPadding: CGFloat = 12 private let borderHeight: CGFloat = 40 - private var verticalPadding: CGFloat { + private let standardVerticalPadding: CGFloat = 8.0 + private let contextBannerHeight: CGFloat = 14.5 + + /// Spacing applied within the VStack holding the context banner and the composer text view. + private let verticalComponentSpacing: CGFloat = 12.0 + /// Padding for the main composer text view. Always applied on bottom. + /// Applied on top only if no context banner is present. + private var composerVerticalPadding: CGFloat { (borderHeight - wysiwygViewModel.minHeight) / 2 } - - private var topPadding: CGFloat { - viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding + + /// Computes the top padding to apply on the composer text view depending on context. + private var composerTopPadding: CGFloat { + viewModel.viewState.shouldDisplayContext ? 0 : composerVerticalPadding + } + + /// Computes the additional height required to display the context banner. + /// Returns 0.0 if the banner is not displayed. + /// Note: height of the actual banner + its added standard top padding + VStack spacing + private var additionalHeightForContextBanner: CGFloat { + viewModel.viewState.shouldDisplayContext ? contextBannerHeight + standardVerticalPadding + verticalComponentSpacing : 0 + } + + /// Computes the total height of the composer (excluding the RTE formatting bar). + /// This height includes the text view, as well as the context banner + /// and user suggestion list when displayed. + private var composerHeight: CGFloat { + wysiwygViewModel.idealHeight + + composerTopPadding + + composerVerticalPadding + // Extra padding added on top of the VStack containing the composer + + standardVerticalPadding + + additionalHeightForContextBanner } private var cornerRadius: CGFloat { @@ -84,7 +112,7 @@ struct Composer: View { private var composerContainer: some View { let rect = RoundedRectangle(cornerRadius: cornerRadius) - return VStack(spacing: 12) { + return VStack(spacing: verticalComponentSpacing) { if viewModel.viewState.shouldDisplayContext { HStack { if let imageName = viewModel.viewState.contextImageName { @@ -106,7 +134,8 @@ struct Composer: View { } .accessibilityIdentifier("cancelButton") } - .padding(.top, 8) + .frame(height: contextBannerHeight) + .padding(.top, standardVerticalPadding) .padding(.horizontal, horizontalPadding) } HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) { @@ -116,7 +145,6 @@ struct Composer: View { ) .tintColor(theme.colors.accent) .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent) - .frame(height: wysiwygViewModel.idealHeight) .onAppear { if wysiwygViewModel.isContentEmpty { wysiwygViewModel.setup() @@ -137,13 +165,13 @@ struct Composer: View { } } .padding(.horizontal, horizontalPadding) - .padding(.top, topPadding) - .padding(.bottom, verticalPadding) + .padding(.top, composerTopPadding) + .padding(.bottom, composerVerticalPadding) } .clipShape(rect) .overlay(rect.stroke(borderColor, lineWidth: 1)) .animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight) - .padding(.top, 8) + .padding(.top, standardVerticalPadding) .onTapGesture { if viewModel.focused { viewModel.focused = true @@ -195,11 +223,13 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, + completionSuggestionSharedContext: CompletionSuggestionViewModelType.Context, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { self.viewModel = viewModel self.wysiwygViewModel = wysiwygViewModel + self.completionSuggestionSharedContext = completionSuggestionSharedContext self.resizeAnimationDuration = resizeAnimationDuration self.sendMessageAction = sendMessageAction self.showSendMediaActions = showSendMediaActions @@ -213,17 +243,23 @@ struct Composer: View { .frame(width: 36, height: 5) .padding(.top, 10) } - HStack(alignment: .bottom, spacing: 0) { - if !viewModel.viewState.textFormattingEnabled { - sendMediaButton - .padding(.bottom, 1) + VStack { + HStack(alignment: .bottom, spacing: 0) { + if !viewModel.viewState.textFormattingEnabled { + sendMediaButton + .padding(.bottom, 1) + } + composerContainer + if !viewModel.viewState.textFormattingEnabled { + sendButton + .padding(.bottom, 1) + } } - composerContainer - if !viewModel.viewState.textFormattingEnabled { - sendButton - .padding(.bottom, 1) + if wysiwygViewModel.maximised { + CompletionSuggestionList(viewModel: completionSuggestionSharedContext, showBackgroundShadow: false) } } + .frame(height: composerHeight) if viewModel.viewState.textFormattingEnabled { HStack(alignment: .center, spacing: 0) { sendMediaButton @@ -248,6 +284,9 @@ struct Composer: View { wysiwygViewModel.maximised = false } } + .onChange(of: wysiwygViewModel.suggestionPattern) { newValue in + sendMentionPattern(pattern: newValue) + } } private func storeCurrentSelection() { @@ -258,6 +297,10 @@ struct Composer: View { let linkAction = wysiwygViewModel.getLinkAction() viewModel.send(viewAction: .linkTapped(linkAction: linkAction)) } + + private func sendMentionPattern(pattern: SuggestionPattern?) { + viewModel.send(viewAction: .suggestion(pattern: pattern)) + } } private extension WysiwygComposerViewModel { diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift index a78018f60..6448b9de3 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift @@ -90,6 +90,8 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol callback?(.linkTapped(LinkAction: linkAction)) case let .storeSelection(selection): selectionToRestore = selection + case let .suggestion(pattern: pattern): + callback?(.suggestion(pattern: pattern)) } } diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/View/FormPickerItem.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/View/FormPickerItem.swift index f194c352a..0d90a2d0e 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/View/FormPickerItem.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/View/FormPickerItem.swift @@ -36,12 +36,14 @@ struct FormPickerItem: View { if let subtitle = subtitle, !subtitle.isEmpty && BWIBuildSettings.shared.notificationSettingsLikeAndroidAndWeb { VStack(alignment: .leading, spacing: 4) { Text(title) + .foregroundColor(Color(ThemeService.shared().theme.tintColor)) Text(subtitle) .font(.footnote) .foregroundColor(.secondary) } } else { Text(title) + .foregroundColor(Color(ThemeService.shared().theme.tintColor)) } Spacer() if selected { diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift index dcda5b5ab..c3f44b40a 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift @@ -42,14 +42,14 @@ final class PollEditFormCoordinator: Coordinator, Presentable { init(parameters: PollEditFormCoordinatorParameters) { self.parameters = parameters - + var viewModel: PollEditFormViewModel if let startEvent = parameters.pollStartEvent, let pollContent = MXEventContentPollStart(fromJSON: startEvent.content) { viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .editing, pollDetails: EditFormPollDetails(type: Self.pollKindKeyToDetailsType(pollContent.kind), question: pollContent.question, - answerOptions: pollContent.answerOptions.map(\.text)))) + answerOptions: pollContent.answerOptions.map(\.text), showParticipants: pollContent.showParticipants))) } else { viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default)) @@ -79,6 +79,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable { guard let self = self else { return } self.pollEditFormViewModel.stopLoading() + self.log2Analytics(details: details, room: self.parameters.room) self.completion?() } failure: { [weak self] error in guard let self = self else { return } @@ -134,6 +135,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable { return MXEventContentPollStart(question: details.question, kind: Self.pollDetailsTypeToKindKey(details.type), + showParticipants: details.showParticipants, maxSelections: NSNumber(value: details.maxSelections), answerOptions: options) } @@ -153,4 +155,28 @@ final class PollEditFormCoordinator: Coordinator, Presentable { return mapping[key] ?? EditFormPollType.disclosed } + + + // MARK: Bwi tracking + private func log2Analytics(details: EditFormPollDetails, room: MXRoom) { + BWIAnalyticsHelper.getRoomDeviceCount(room: room) { deviceCount in + var eventName: String + switch details.type { + case .undisclosed: + if details.showParticipants { + eventName = "undisclosed_show_participants" + } else { + eventName = "undisclosed" + } + case .disclosed: + if details.showParticipants { + eventName = "disclosed_show_participants" + } else { + eventName = "disclosed" + } + } + + BWIAnalytics.sharedTracker.trackEventWithDimension(category: "Feature", action: "SendPoll", dimension: BWIAnalyticsHelper.dimensionForDeviceCount(deviceCount), value: NSNumber(value: details.answerOptions.count), name: eventName) + } + } } diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift index 9e2171bf7..f11393b26 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift @@ -27,9 +27,10 @@ struct EditFormPollDetails { let question: String let answerOptions: [String] let maxSelections: UInt = 1 + let showParticipants: Bool static var `default`: EditFormPollDetails { - EditFormPollDetails(type: .disclosed, question: "", answerOptions: ["", ""]) + EditFormPollDetails(type: .disclosed, question: "", answerOptions: ["", ""], showParticipants: false) } } @@ -96,6 +97,7 @@ struct PollEditFormViewStateBindings { var question: PollEditFormQuestion var answerOptions: [PollEditFormAnswerOption] var type: EditFormPollType + var showParticipants: Bool var alertInfo: PollEditFormErrorAlertInfo? } diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift index 43ce0dc72..a4848e57c 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift @@ -50,7 +50,8 @@ class PollEditFormViewModel: PollEditFormViewModelType, PollEditFormViewModelPro bindings: PollEditFormViewStateBindings( question: PollEditFormQuestion(text: parameters.pollDetails.question, maxLength: Constants.maxQuestionLength), answerOptions: parameters.pollDetails.answerOptions.map { PollEditFormAnswerOption(text: $0, maxLength: Constants.maxAnswerOptionLength) }, - type: parameters.pollDetails.type + type: parameters.pollDetails.type, + showParticipants: parameters.pollDetails.showParticipants ) ) @@ -100,11 +101,13 @@ class PollEditFormViewModel: PollEditFormViewModelType, PollEditFormViewModelPro // MARK: - Private private func buildPollDetails() -> EditFormPollDetails { + EditFormPollDetails(type: state.bindings.type, question: state.bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines), answerOptions: state.bindings.answerOptions.compactMap { answerOption in let text = answerOption.text.trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text - }) + }, + showParticipants: state.bindings.showParticipants) } } diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift b/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift index dd6fb6252..8a3c8a190 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift @@ -34,6 +34,11 @@ struct PollEditForm: View { VStack(alignment: .leading, spacing: 32.0) { PollEditFormTypePicker(selectedType: $viewModel.type) + // bwi (#4483) Adds a boolean shoparticpants to the view models, the event and the view + if BWIBuildSettings.shared.bwiPollShowParticipantsToggle { + PollEditFormParticipationToggle(showParticipants: $viewModel.showParticipants) + } + VStack(alignment: .leading, spacing: 16.0) { Text(VectorL10n.pollEditFormPollQuestionOrTopic) .font(theme.fonts.title3SB) @@ -64,12 +69,16 @@ struct PollEditForm: View { } } - Button(VectorL10n.pollEditFormAddOption) { + // bwi: 4769 + Button { withAnimation(.easeInOut(duration: 0.2)) { viewModel.send(viewAction: .addAnswerOption) } + } label: { + Text(VectorL10n.pollEditFormAddOption) + .foregroundColor(Color(ThemeService.shared().theme.tintColor)) + .font(theme.fonts.bodySB) } - .foregroundColor(Color(ThemeService.shared().theme.tintColor)) .disabled(!viewModel.viewState.addAnswerOptionButtonEnabled) Spacer() @@ -84,6 +93,7 @@ struct PollEditForm: View { } .padding(.vertical, 24.0) .padding(.horizontal, 16.0) + .background(Color(ThemeService.shared().theme.backgroundColor)) // bwi: 4769 .activityIndicator(show: viewModel.viewState.showLoadingIndicator) .alert(item: $viewModel.alertInfo) { info in Alert(title: Text(info.title), diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditFormParticipationToggle.swift b/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditFormParticipationToggle.swift new file mode 100644 index 000000000..a0462b35c --- /dev/null +++ b/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditFormParticipationToggle.swift @@ -0,0 +1,35 @@ +// +/* + * Copyright (c) 2022 BWI GmbH + * + * 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 PollEditFormParticipationToggle: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + @Binding var showParticipants: Bool + + var body: some View { + Toggle(BWIL10n.pollEditFormParticipantToggle, isOn: $showParticipants) + .accessibilityIdentifier("PollEditFormParticipationToggle") + } +} + +struct PollEditFormParticipationToggle_Previews: PreviewProvider { + static var previews: some View { + PollEditFormParticipationToggle(showParticipants: .constant(true)) + } +} diff --git a/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift b/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift index 09a8fb3c7..26eaf6485 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/PollHistoryDetail/MockPollHistoryDetailScreenState.swift @@ -29,9 +29,9 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable { } var poll: TimelinePollDetails { - let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false), - TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true), - TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)] + let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false, voters:[]), + TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true, voters:[]), + TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false, voters:[])] let poll = TimelinePollDetails(id: "id", question: "Question", @@ -40,6 +40,7 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable { startDate: .init(timeIntervalSinceReferenceDate: 0), totalAnswerCount: 20, type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed, + showParticipants: false, eventType: self == .closedPollEnded ? .ended : .started, maxAllowedSelections: 1, hasBeenEdited: false, @@ -48,7 +49,7 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable { } var screenView: ([Any], AnyView) { - let timelineViewModel = TimelinePollViewModel(timelinePollDetails: poll) + let timelineViewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(poll)) let viewModel = PollHistoryDetailViewModel(poll: poll) return ([viewModel], AnyView(PollHistoryDetail(viewModel: viewModel.context, contentPoll: TimelinePollView(viewModel: timelineViewModel.context)))) diff --git a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift index 7f6d8c5f6..c4471844e 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/Service/MatrixSDK/PollHistoryService.swift @@ -209,13 +209,13 @@ extension PollHistoryService: PollAggregatorDelegate { func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { } func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { - guard let context = pollAggregationContexts[aggregator.poll.id], context.published == false else { + guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published == false else { return } context.published = true - let newPoll: TimelinePollDetails = .init(poll: aggregator.poll, represent: .started) + let newPoll: TimelinePollDetails = .init(poll: poll, represent: .started) if context.isLivePoll { livePollsSubject.send(newPoll) @@ -225,9 +225,9 @@ extension PollHistoryService: PollAggregatorDelegate { } func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) { - guard let context = pollAggregationContexts[aggregator.poll.id], context.published else { + guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published else { return } - updatesSubject.send(.init(poll: aggregator.poll, represent: .started)) + updatesSubject.send(.init(poll: poll, represent: .started)) } } diff --git a/RiotSwiftUI/Modules/Room/PollHistory/Service/Mock/MockPollHistoryService.swift b/RiotSwiftUI/Modules/Room/PollHistory/Service/Mock/MockPollHistoryService.swift index c98f4e136..e97a8d3f7 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/Service/Mock/MockPollHistoryService.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/Service/Mock/MockPollHistoryService.swift @@ -57,6 +57,7 @@ private extension MockPollHistoryService { startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24), totalAnswerCount: 30, type: .disclosed, + showParticipants: false, eventType: .started, maxAllowedSelections: 1, hasBeenEdited: false, @@ -69,11 +70,12 @@ private extension MockPollHistoryService { .map { index in TimelinePollDetails(id: "p\(index)", question: "Do you like the active poll number \(index)?", - answerOptions: [.init(id: "id", text: "Yes, of course!", count: 20, winner: true, selected: true)], + answerOptions: [.init(id: "id", text: "Yes, of course!", count: 20, winner: true, selected: true, voters: [])], closed: true, startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24), totalAnswerCount: 30, type: .disclosed, + showParticipants: false, eventType: .started, maxAllowedSelections: 1, hasBeenEdited: false, diff --git a/RiotSwiftUI/Modules/Room/PollHistory/View/PollListItem.swift b/RiotSwiftUI/Modules/Room/PollHistory/View/PollListItem.swift index 6ee1b0ddf..96b0278ed 100644 --- a/RiotSwiftUI/Modules/Room/PollHistory/View/PollListItem.swift +++ b/RiotSwiftUI/Modules/Room/PollHistory/View/PollListItem.swift @@ -85,11 +85,12 @@ struct PollListItem_Previews: PreviewProvider { Group { let pollData1 = TimelinePollDetails(id: UUID().uuidString, question: "Do you like polls?", - answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)], + answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true, voters:[])], closed: true, startDate: .init(), totalAnswerCount: 30, type: .disclosed, + showParticipants: false, eventType: .started, maxAllowedSelections: 1, hasBeenEdited: false, @@ -97,11 +98,12 @@ struct PollListItem_Previews: PreviewProvider { let pollData2 = TimelinePollDetails(id: UUID().uuidString, question: "Do you like polls?", - answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)], + answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true, voters:[])], closed: false, startDate: .init(), totalAnswerCount: 30, type: .disclosed, + showParticipants: false, eventType: .started, maxAllowedSelections: 1, hasBeenEdited: false, @@ -110,13 +112,14 @@ struct PollListItem_Previews: PreviewProvider { let pollData3 = TimelinePollDetails(id: UUID().uuidString, question: "Do you like polls?", answerOptions: [ - .init(id: "id1", text: "Yes, of course!", count: 15, winner: true, selected: true), - .init(id: "id2", text: "No, I don't :-(", count: 15, winner: true, selected: true) + .init(id: "id1", text: "Yes, of course!", count: 15, winner: true, selected: true, voters:[]), + .init(id: "id2", text: "No, I don't :-(", count: 15, winner: true, selected: true, voters:[]) ], closed: true, startDate: .init(), totalAnswerCount: 30, type: .disclosed, + showParticipants: false, eventType: .started, maxAllowedSelections: 1, hasBeenEdited: false, diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index 3214fae65..b033661dc 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -29,10 +29,11 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel // MARK: Private + private let navigationRouter: NavigationRouterType? private let parameters: TimelinePollCoordinatorParameters private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>() - private var pollAggregator: PollAggregator + private var pollAggregator: PollAggregator! private(set) var viewModel: TimelinePollViewModelProtocol! private var cancellables = Set() @@ -43,19 +44,22 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel // MARK: - Setup - init(parameters: TimelinePollCoordinatorParameters) throws { + init(parameters: TimelinePollCoordinatorParameters, navigationRouter: NavigationRouterType? = nil) throws { self.parameters = parameters - try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent) - pollAggregator.delegate = self + self.navigationRouter = navigationRouter + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading) + try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent, delegate: self) - viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll)) viewModel.completion = { [weak self] result in guard let self = self else { return } switch result { case .selectedAnswerOptionsWithIdentifiers(let identifiers): self.selectedAnswerIdentifiersSubject.send(identifiers) + case .showParticipants: + self.showParticipantsView() + } } @@ -92,11 +96,11 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } func canEndPoll() -> Bool { - pollAggregator.poll.isClosed == false + pollAggregator.poll?.isClosed == false } func canEditPoll() -> Bool { - pollAggregator.poll.isClosed == false && pollAggregator.poll.totalAnswerCount == 0 + pollAggregator.poll?.isClosed == false && pollAggregator.poll?.totalAnswerCount == 0 } func endPoll() { @@ -105,17 +109,43 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } } + func showParticipantsView() { + if let navigationRouter = navigationRouter, let poll = pollAggregator.poll { + let parameters = PollParticipantDetailsCoordinatorParameters(room: parameters.room, poll: poll) + let coordinator = PollParticipantDetailsCoordinator(parameters: parameters) + + add(childCoordinator: coordinator) + + if navigationRouter.modules.isEmpty == false { + navigationRouter.push(coordinator, animated: true, popCompletion: nil) + } else { + navigationRouter.setRootModule(coordinator, popCompletion: nil) + } + + coordinator.start() + } + } + // MARK: - PollAggregatorDelegate func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) { - viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll)) + if let poll = aggregator.poll { + viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll))) + } } func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { } - func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { } + func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { + guard let poll = aggregator.poll else { + return + } + viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll))) + } - func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { } + func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { + viewModel.updateWithPollDetailsState(.errored) + } // MARK: - Private @@ -134,7 +164,8 @@ extension TimelinePollDetails { text: pollAnswerOption.text, count: pollAnswerOption.count, winner: pollAnswerOption.isWinner, - selected: pollAnswerOption.isCurrentUserSelection) + selected: pollAnswerOption.isCurrentUserSelection, + voters:pollAnswerOption.voters) } self.init(id: poll.id, @@ -144,6 +175,7 @@ extension TimelinePollDetails { startDate: poll.startDate, totalAnswerCount: poll.totalAnswerCount, type: poll.kind.timelinePollType, + showParticipants: poll.showParticipants, eventType: eventType, maxAllowedSelections: poll.maxAllowedSelections, hasBeenEdited: poll.hasBeenEdited, diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift index 0c7233298..43db0c9e6 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift @@ -31,11 +31,15 @@ class TimelinePollProvider: NSObject { } } } + + var navigationRouter: NavigationRouterType? = nil + var coordinatorsForEventIdentifiers = [String: TimelinePollCoordinator]() /// Create or retrieve the poll timeline coordinator for this event and return /// a view to be displayed in the timeline func buildTimelinePollVCForEvent(_ event: MXEvent) -> UIViewController? { + guard let session = session, let room = session.room(withRoomId: event.roomId) else { return nil } @@ -45,7 +49,7 @@ class TimelinePollProvider: NSObject { } let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollEvent: event) - guard let coordinator = try? TimelinePollCoordinator(parameters: parameters) else { + guard let coordinator = try? TimelinePollCoordinator(parameters: parameters, navigationRouter: navigationRouter ) else { return messageViewController(for: event) } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift index a36a7d092..2fd2b032f 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift @@ -41,111 +41,134 @@ class TimelinePollViewModelTests: XCTestCase { hasBeenEdited: false, hasDecryptionError: false) - viewModel = TimelinePollViewModel(timelinePollDetails: timelinePoll) + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(timelinePoll)) context = viewModel.context } func testInitialState() { - XCTAssertEqual(context.viewState.poll.answerOptions.count, 3) - XCTAssertFalse(context.viewState.poll.closed) - XCTAssertEqual(context.viewState.poll.type, .disclosed) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions.count, 3) + XCTAssertEqual(context.viewState.pollState.poll?.closed, false) + XCTAssertEqual(context.viewState.pollState.poll?.type, .disclosed) } func testSingleSelectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testSingleReselectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testMultipleSelectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) } func testMultipleReselectionOnMax1Allowed() { context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) } func testClosedSelection() { - viewModel.state.poll.closed = true + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.closed = true + viewModel.updateWithPollDetailsState(.loaded(poll)) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testSingleSelectionOnMax2Allowed() { - viewModel.state.poll.maxAllowedSelections = 2 + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.maxAllowedSelections = 2 + viewModel.updateWithPollDetailsState(.loaded(poll)) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testSingleReselectionOnMax2Allowed() { - viewModel.state.poll.maxAllowedSelections = 2 + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.maxAllowedSelections = 2 + viewModel.updateWithPollDetailsState(.loaded(poll)) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) } func testMultipleSelectionOnMax2Allowed() { - viewModel.state.poll.maxAllowedSelections = 2 - + guard case var .loaded(poll) = context.viewState.pollState else { + return XCTFail() + } + poll.maxAllowedSelections = 2 + viewModel.updateWithPollDetailsState(.loaded(poll)) + context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) context.send(viewAction: .selectAnswerOptionWithIdentifier("2")) - XCTAssertTrue(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) context.send(viewAction: .selectAnswerOptionWithIdentifier("1")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) context.send(viewAction: .selectAnswerOptionWithIdentifier("2")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[1].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true) context.send(viewAction: .selectAnswerOptionWithIdentifier("3")) - XCTAssertFalse(context.viewState.poll.answerOptions[0].selected) - XCTAssertTrue(context.viewState.poll.answerOptions[1].selected) - XCTAssertFalse(context.viewState.poll.answerOptions[2].selected) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, true) + XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false) + } +} + +private extension TimelinePollDetailsState { + var poll: TimelinePollDetails? { + switch self { + case .loaded(let poll): + return poll + default: + return nil + } } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift index 0ee87c55f..ca31dbae7 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift @@ -21,10 +21,12 @@ typealias TimelinePollViewModelCallback = (TimelinePollViewModelResult) -> Void enum TimelinePollViewAction { case selectAnswerOptionWithIdentifier(String) + case showParticipants } enum TimelinePollViewModelResult { case selectedAnswerOptionsWithIdentifiers([String]) + case showParticipants } enum TimelinePollType { @@ -37,19 +39,27 @@ enum TimelinePollEventType { case ended } +enum TimelinePollDetailsState { + case loading + case loaded(TimelinePollDetails) + case errored +} + struct TimelinePollAnswerOption: Identifiable { var id: String var text: String var count: UInt var winner: Bool var selected: Bool + var voters: [MXEvent] - init(id: String, text: String, count: UInt, winner: Bool, selected: Bool) { + init(id: String, text: String, count: UInt, winner: Bool, selected: Bool, voters: [MXEvent]) { self.id = id self.text = text self.count = count self.winner = winner self.selected = selected + self.voters = voters } } @@ -69,6 +79,7 @@ struct TimelinePollDetails { var startDate: Date var totalAnswerCount: UInt var type: TimelinePollType + var showParticipants: Bool var eventType: TimelinePollEventType var maxAllowedSelections: UInt var hasBeenEdited: Bool @@ -89,12 +100,16 @@ struct TimelinePollDetails { var representsPollEndedEvent: Bool { eventType == .ended } + + var shouldShowShowParticipantsButton: Bool { + return (showParticipants && hasCurrentUserVoted && (type == TimelinePollType.disclosed || closed)) + } } extension TimelinePollDetails: Identifiable { } struct TimelinePollViewState: BindableState { - var poll: TimelinePollDetails + var pollState: TimelinePollDetailsState var bindings: TimelinePollViewStateBindings } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift index 8c70b21e3..9522e9fce 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift @@ -23,15 +23,18 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { case openUndisclosed case closedUndisclosed case closedPollEnded + case loading + case invalidStartEvent + case withAlert var screenType: Any.Type { TimelinePollDetails.self } var screenView: ([Any], AnyView) { - let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false), - TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true), - TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)] + let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false, voters:[]), + TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true, voters:[]), + TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false, voters:[])] let poll = TimelinePollDetails(id: "id", question: "Question", @@ -40,12 +43,26 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { startDate: .init(), totalAnswerCount: 20, type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed, + showParticipants: false, eventType: self == .closedPollEnded ? .ended : .started, maxAllowedSelections: 1, hasBeenEdited: false, hasDecryptionError: false) - let viewModel = TimelinePollViewModel(timelinePollDetails: poll) + let viewModel: TimelinePollViewModel + + switch self { + case .loading: + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading) + case .invalidStartEvent: + viewModel = TimelinePollViewModel(timelinePollDetailsState: .errored) + default: + viewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(poll)) + } + + if self == .withAlert { + viewModel.showAnsweringFailure() + } return ([viewModel], AnyView(TimelinePollView(viewModel: viewModel.context))) } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift index a86862cf4..ea093ac05 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift @@ -30,8 +30,8 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - Setup - init(timelinePollDetails: TimelinePollDetails) { - super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings())) + init(timelinePollDetailsState: TimelinePollDetailsState) { + super.init(initialViewState: TimelinePollViewState(pollState: timelinePollDetailsState, bindings: TimelinePollViewStateBindings())) } // MARK: - Public @@ -40,22 +40,24 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro switch viewAction { // Update local state. An update will be pushed from the coordinator once sent. case .selectAnswerOptionWithIdentifier(let identifier): - guard !state.poll.closed else { + // only if the poll is ready and not closed + guard case let .loaded(poll) = state.pollState, !poll.closed else { return } - - if state.poll.maxAllowedSelections == 1 { + if poll.maxAllowedSelections == 1 { updateSingleSelectPollLocalState(selectedAnswerIdentifier: identifier, callback: completion) } else { updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion) } + case .showParticipants: + completion?(.showParticipants) } } // MARK: - TimelinePollViewModelProtocol - func updateWithPollDetails(_ pollDetails: TimelinePollDetails) { - state.poll = pollDetails + func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState) { + state.pollState = pollDetailsState } func showAnsweringFailure() { @@ -73,33 +75,40 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro // MARK: - Private func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) { - state.poll.answerOptions.updateEach { answerOption in + guard case var .loaded(poll) = state.pollState else { return } + + var pollAnswerOptions = poll.answerOptions + pollAnswerOptions.updateEach { answerOption in if answerOption.selected { answerOption.selected = false answerOption.count = UInt(max(0, Int(answerOption.count) - 1)) - state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1)) + poll.totalAnswerCount = UInt(max(0, Int(poll.totalAnswerCount) - 1)) } if answerOption.id == selectedAnswerIdentifier { answerOption.selected = true answerOption.count += 1 - state.poll.totalAnswerCount += 1 + poll.totalAnswerCount += 1 } } - + poll.answerOptions = pollAnswerOptions + state.pollState = .loaded(poll) informCoordinatorOfSelectionUpdate(state: state, callback: callback) } func updateMultiSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) { - let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true } + guard case .loaded(var poll) = state.pollState else { return } + + let selectedAnswerOptions = poll.answerOptions.filter { $0.selected == true } let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0 - if !isDeselecting, selectedAnswerOptions.count >= state.poll.maxAllowedSelections { + if !isDeselecting, selectedAnswerOptions.count >= poll.maxAllowedSelections { return } - state.poll.answerOptions.updateEach { answerOption in + var pollAnswerOptions = poll.answerOptions + pollAnswerOptions.updateEach { answerOption in if answerOption.id != selectedAnswerIdentifier { return } @@ -107,22 +116,24 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro if answerOption.selected { answerOption.selected = false answerOption.count = UInt(max(0, Int(answerOption.count) - 1)) - state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1)) + poll.totalAnswerCount = UInt(max(0, Int(poll.totalAnswerCount) - 1)) } else { answerOption.selected = true answerOption.count += 1 - state.poll.totalAnswerCount += 1 + poll.totalAnswerCount += 1 } } - + poll.answerOptions = pollAnswerOptions + state.pollState = .loaded(poll) informCoordinatorOfSelectionUpdate(state: state, callback: callback) } func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) { - let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in + guard case .loaded(let poll) = state.pollState else { return } + + let selectedIdentifiers = poll.answerOptions.compactMap { answerOption in answerOption.selected ? answerOption.id : nil } - callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers)) } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift index 492f7f7a3..ade681438 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift @@ -20,7 +20,7 @@ protocol TimelinePollViewModelProtocol { var context: TimelinePollViewModelType.Context { get } var completion: ((TimelinePollViewModelResult) -> Void)? { get set } - func updateWithPollDetails(_ pollDetails: TimelinePollDetails) + func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState) func showAnsweringFailure() func showClosingFailure() } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift index dd468b008..958df2099 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift @@ -161,6 +161,7 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider { startDate: .init(), totalAnswerCount: 100, type: type, + showParticipants: false, eventType: .started, maxAllowedSelections: 1, hasBeenEdited: false, @@ -168,6 +169,6 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider { } static func buildAnswerOption(text: String = "Test", selected: Bool, winner: Bool = false) -> TimelinePollAnswerOption { - TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected) + TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected, voters:[]) } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift index 2109a0e8a..675e59ba5 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift @@ -28,8 +28,23 @@ struct TimelinePollView: View { @ObservedObject var viewModel: TimelinePollViewModel.Context var body: some View { - let poll = viewModel.viewState.poll - + Group { + switch viewModel.viewState.pollState { + case .loading: + TimelinePollMessageView(message: VectorL10n.pollTimelineLoading) + case .loaded(let poll): + pollContent(poll) + case .errored: + TimelinePollMessageView(message: VectorL10n.pollTimelineReplyEndedPoll) + } + } + .alert(item: $viewModel.alertInfo) { info in + info.alert + } + } + + @ViewBuilder + private func pollContent(_ poll: TimelinePollDetails) -> some View { VStack(alignment: .leading, spacing: 16.0) { if poll.representsPollEndedEvent { Text(VectorL10n.pollTimelineEndedText) @@ -40,7 +55,7 @@ struct TimelinePollView: View { Text(poll.question) .font(theme.fonts.bodySB) .foregroundColor(theme.colors.primaryContent) + - Text(editedText) + Text(editedText(poll)) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) @@ -54,21 +69,29 @@ struct TimelinePollView: View { .disabled(poll.closed) .fixedSize(horizontal: false, vertical: true) - Text(totalVotesString) + Text(totalVotesString(poll)) .lineLimit(2) .font(theme.fonts.footnote) .foregroundColor(theme.colors.tertiaryContent) + + if poll.shouldShowShowParticipantsButton { + Button(action: { + viewModel.send(viewAction:.showParticipants) + }) + { + Text(BWIL10n.pollTimelineShowParticipantsButton) + .font(theme.fonts.body) + .bold() + .foregroundColor(theme.colors.accent) + } + } + } .padding([.horizontal, .top], 2.0) .padding([.bottom]) - .alert(item: $viewModel.alertInfo) { info in - info.alert - } } - private var totalVotesString: String { - let poll = viewModel.viewState.poll - + private func totalVotesString(_ poll: TimelinePollDetails) -> String { if poll.hasDecryptionError, poll.totalAnswerCount > 0 { return VectorL10n.pollTimelineDecryptionError } @@ -95,8 +118,8 @@ struct TimelinePollView: View { } } - private var editedText: String { - viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : "" + private func editedText(_ poll: TimelinePollDetails) -> String { + poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : "" } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift deleted file mode 100644 index cc3f208c3..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import Foundation -import SwiftUI -import UIKit - -protocol UserSuggestionCoordinatorDelegate: AnyObject { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) -} - -struct UserSuggestionCoordinatorParameters { - let mediaManager: MXMediaManager - let room: MXRoom - let userID: String -} - -final class UserSuggestionCoordinator: Coordinator, Presentable { - // MARK: - Properties - - // MARK: Private - - private let parameters: UserSuggestionCoordinatorParameters - - private var userSuggestionHostingController: UIHostingController - private var userSuggestionService: UserSuggestionServiceProtocol - private var userSuggestionViewModel: UserSuggestionViewModelProtocol - private var roomMemberProvider: UserSuggestionCoordinatorRoomMemberProvider - - private var cancellables = Set() - - // MARK: Public - - // Must be used only internally - var childCoordinators: [Coordinator] = [] - var completion: (() -> Void)? - - weak var delegate: UserSuggestionCoordinatorDelegate? - - // MARK: - Setup - - init(parameters: UserSuggestionCoordinatorParameters) { - self.parameters = parameters - - roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room, userID: parameters.userID) - userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider) - - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - - userSuggestionViewModel = viewModel - userSuggestionHostingController = VectorHostingController(rootView: view) - - userSuggestionViewModel.completion = { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .selectedItemWithIdentifier(let identifier): - if identifier == UserSuggestionID.room { - self.delegate?.userSuggestionCoordinatorDidRequestMentionForRoom(self, textTrigger: self.userSuggestionService.currentTextTrigger) - return - } - - guard let member = self.roomMemberProvider.roomMembers.filter({ $0.userId == identifier }).first else { - return - } - - self.delegate?.userSuggestionCoordinator(self, didRequestMentionForMember: member, textTrigger: self.userSuggestionService.currentTextTrigger) - } - } - - userSuggestionService.items.sink { [weak self] _ in - guard let self = self else { return } - self.delegate?.userSuggestionCoordinator(self, - didUpdateViewHeight: self.calculateViewHeight()) - }.store(in: &cancellables) - } - - func processTextMessage(_ textMessage: String) { - userSuggestionService.processTextMessage(textMessage) - } - - // MARK: - Public - - func start() { } - - func toPresentable() -> UIViewController { - userSuggestionHostingController - } - - // MARK: - Private - - private func calculateViewHeight() -> CGFloat { - let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService) - let view = UserSuggestionList(viewModel: viewModel.context) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager))) - - let controller = VectorHostingController(rootView: view) - guard let view = controller.view else { - return 0 - } - view.isHidden = true - - toPresentable().view.addSubview(view) - controller.didMove(toParent: toPresentable()) - - view.setNeedsLayout() - view.layoutIfNeeded() - - let result = view.intrinsicContentSize.height - - controller.didMove(toParent: nil) - view.removeFromSuperview() - - return result - } -} - -private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol { - private let room: MXRoom - private let userID: String - - var roomMembers: [MXRoomMember] = [] - var canMentionRoom = false - - init(room: MXRoom, userID: String) { - self.room = room - self.userID = userID - updateWithPowerLevels() - } - - /// Gets the power levels for the room to update suggestions accordingly. - func updateWithPowerLevels() { - room.state { [weak self] state in - guard let self, let powerLevels = state?.powerLevels else { return } - let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.userID) - let mentionRoomPowerLevel = powerLevels.minimumPowerLevel(forNotifications: kMXRoomPowerLevelNotificationsRoomKey, - defaultPower: kMXRoomPowerLevelNotificationsRoomDefault) - self.canMentionRoom = userPowerLevel >= mentionRoomPowerLevel - } - } - - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { - room.members { [weak self] roomMembers in - guard let self = self, let joinedMembers = roomMembers?.joinedMembers else { - return - } - self.roomMembers = joinedMembers - members(self.roomMembersToProviderMembers(joinedMembers)) - } lazyLoadedMembers: { [weak self] lazyRoomMembers in - guard let self = self, let joinedMembers = lazyRoomMembers?.joinedMembers else { - return - } - self.roomMembers = joinedMembers - members(self.roomMembersToProviderMembers(joinedMembers)) - } failure: { error in - MXLog.error("[UserSuggestionCoordinatorRoomMemberProvider] Failed loading room", context: error) - } - } - - private func roomMembersToProviderMembers(_ roomMembers: [MXRoomMember]) -> [RoomMembersProviderMember] { - roomMembers.map { RoomMembersProviderMember(userId: $0.userId, displayName: $0.displayname ?? "", avatarUrl: $0.avatarUrl ?? "") } - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift deleted file mode 100644 index ea8163106..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -@objc -protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject { - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) - func userSuggestionCoordinatorBridgeDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinatorBridge, textTrigger: String?) - func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge, didUpdateViewHeight height: CGFloat) -} - -@objcMembers -final class UserSuggestionCoordinatorBridge: NSObject { - private var _userSuggestionCoordinator: Any? - fileprivate var userSuggestionCoordinator: UserSuggestionCoordinator { - _userSuggestionCoordinator as! UserSuggestionCoordinator - } - - weak var delegate: UserSuggestionCoordinatorBridgeDelegate? - - init(mediaManager: MXMediaManager, room: MXRoom, userID: String) { - let parameters = UserSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room, userID: userID) - let userSuggestionCoordinator = UserSuggestionCoordinator(parameters: parameters) - _userSuggestionCoordinator = userSuggestionCoordinator - - super.init() - - userSuggestionCoordinator.delegate = self - } - - func processTextMessage(_ textMessage: String) { - userSuggestionCoordinator.processTextMessage(textMessage) - } - - func toPresentable() -> UIViewController? { - userSuggestionCoordinator.toPresentable() - } -} - -extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridge(self, didRequestMentionForMember: member, textTrigger: textTrigger) - } - - func userSuggestionCoordinatorDidRequestMentionForRoom(_ coordinator: UserSuggestionCoordinator, textTrigger: String?) { - delegate?.userSuggestionCoordinatorBridgeDidRequestMentionForRoom(self, textTrigger: textTrigger) - } - - func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didUpdateViewHeight height: CGFloat) { - delegate?.userSuggestionCoordinatorBridge(self, didUpdateViewHeight: height) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift deleted file mode 100644 index 2bd8a4569..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import Foundation - -struct RoomMembersProviderMember { - var userId: String - var displayName: String - var avatarUrl: String -} - -class UserSuggestionID: NSObject { - /// A special case added for suggesting `@room` mentions. - @objc static let room = "@room" -} - -protocol RoomMembersProviderProtocol { - var canMentionRoom: Bool { get } - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) -} - -struct UserSuggestionServiceItem: UserSuggestionItemProtocol { - let userId: String - let displayName: String? - let avatarUrl: String? -} - -class UserSuggestionService: UserSuggestionServiceProtocol { - // MARK: - Properties - - // MARK: Private - - private let roomMemberProvider: RoomMembersProviderProtocol - - private var suggestionItems: [UserSuggestionItemProtocol] = [] - private let currentTextTriggerSubject = CurrentValueSubject(nil) - private var cancellables = Set() - - // MARK: Public - - var items = CurrentValueSubject<[UserSuggestionItemProtocol], Never>([]) - - var currentTextTrigger: String? { - currentTextTriggerSubject.value - } - - // MARK: - Setup - - init(roomMemberProvider: RoomMembersProviderProtocol, shouldDebounce: Bool = true) { - self.roomMemberProvider = roomMemberProvider - - if shouldDebounce { - currentTextTriggerSubject - .debounce(for: 0.5, scheduler: RunLoop.main) - .removeDuplicates() - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } - .store(in: &cancellables) - } else { - currentTextTriggerSubject - .sink { [weak self] in self?.fetchAndFilterMembersForTextTrigger($0) } - .store(in: &cancellables) - } - } - - // MARK: - UserSuggestionServiceProtocol - - func processTextMessage(_ textMessage: String?) { - guard let textMessage = textMessage, - textMessage.count > 0, - let lastComponent = textMessage.components(separatedBy: .whitespaces).last, - lastComponent.prefix(while: { $0 == "@" }).count == 1 // Partial username should start with one and only one "@" character - else { - items.send([]) - currentTextTriggerSubject.send(nil) - return - } - - currentTextTriggerSubject.send(lastComponent) - } - - // MARK: - Private - - private func fetchAndFilterMembersForTextTrigger(_ textTrigger: String?) { - guard var partialName = textTrigger else { - return - } - - partialName.removeFirst() // remove the '@' prefix - - roomMemberProvider.fetchMembers { [weak self] members in - guard let self = self else { - return - } - - self.suggestionItems = members.withRoom(self.roomMemberProvider.canMentionRoom).map { member in - UserSuggestionServiceItem(userId: member.userId, displayName: member.displayName, avatarUrl: member.avatarUrl) - } - - self.items.send(self.suggestionItems.filter { userSuggestion in - let containedInUsername = userSuggestion.userId.lowercased().contains(partialName.lowercased()) - let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().contains(partialName.lowercased()) - - return (containedInUsername || containedInDisplayName) - }) - } - } -} - -extension Array where Element == RoomMembersProviderMember { - /// Returns the array with an additional member that represents an `@room` mention. - func withRoom(_ canMentionRoom: Bool) -> Self { - guard canMentionRoom else { return self } - return self + [RoomMembersProviderMember(userId: UserSuggestionID.room, displayName: "Everyone", avatarUrl: "")] - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift deleted file mode 100644 index 7ae0bfa39..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/Unit/UserSuggestionServiceTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import XCTest - -@testable import RiotSwiftUI - -class UserSuggestionServiceTests: XCTestCase { - var service: UserSuggestionService! - var canMentionRoom = false - - override func setUp() { - service = UserSuggestionService(roomMemberProvider: self, shouldDebounce: false) - canMentionRoom = false - } - - func testAlice() { - service.processTextMessage("@Al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@al") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@ice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@Alice") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - - service.processTextMessage("@alice:matrix.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - } - - func testBob() { - service.processTextMessage("@ob") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - - service.processTextMessage("@ob:") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - - service.processTextMessage("@b:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Bob") - } - - func testBoth() { - service.processTextMessage("@:matrix") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") - - service.processTextMessage("@.org") - XCTAssertEqual(service.items.value.first?.displayName, "Alice") - XCTAssertEqual(service.items.value.last?.displayName, "Bob") - } - - func testEmptyResult() { - service.processTextMessage("Lorem ipsum idolor") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@@") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("alice@matrix.org") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testStuff() { - service.processTextMessage("@@") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testWhitespaces() { - service.processTextMessage("") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("\n") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" \n ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage("@A ") - XCTAssertTrue(service.items.value.isEmpty) - - service.processTextMessage(" @A ") - XCTAssertTrue(service.items.value.isEmpty) - } - - func testRoomWithoutPower() { - // Given a user without the power to mention a room. - canMentionRoom = false - - // Given a user without the power to mention a room. - service.processTextMessage("@ro") - - // Then the completion for a room mention should not be shown. - XCTAssertTrue(service.items.value.isEmpty) - } - - func testRoomWithPower() { - // Given a user without the power to mention a room. - canMentionRoom = true - - // Given a user without the power to mention a room. - service.processTextMessage("@ro") - - // Then the completion for a room mention should be shown. - XCTAssertEqual(service.items.value.first?.userId, UserSuggestionID.room) - } -} - -extension UserSuggestionServiceTests: RoomMembersProviderProtocol { - func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void) { - let users = [("Alice", "@alice:matrix.org"), - ("Bob", "@bob:matrix.org")] - - members(users.map { user in - RoomMembersProviderMember(userId: user.1, displayName: user.0, avatarUrl: "") - }) - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift deleted file mode 100644 index 0a9395fa5..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import SwiftUI - -enum MockUserSuggestionScreenState: MockScreenState, CaseIterable { - case multipleResults - - private static var members: [RoomMembersProviderMember]! - - var screenType: Any.Type { - UserSuggestionList.self - } - - var screenView: ([Any], AnyView) { - let service = UserSuggestionService(roomMemberProvider: self) - let listViewModel = UserSuggestionViewModel(userSuggestionService: service) - - let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in - service.processTextMessage(textMessage) - } - - return ( - [service, listViewModel], - AnyView(UserSuggestionListWithInput(viewModel: viewModel) - .environmentObject(AvatarViewModel.withMockedServices())) - ) - } -} - -extension MockUserSuggestionScreenState: RoomMembersProviderProtocol { - var canMentionRoom: Bool { false } - - func fetchMembers(_ members: ([RoomMembersProviderMember]) -> Void) { - if Self.members == nil { - Self.members = generateUsersWithCount(10) - } - - members(Self.members) - } - - private func generateUsersWithCount(_ count: UInt) -> [RoomMembersProviderMember] { - (0.. - -class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewModelProtocol { - // MARK: - Properties - - // MARK: Private - - private let userSuggestionService: UserSuggestionServiceProtocol - - // MARK: Public - - var completion: ((UserSuggestionViewModelResult) -> Void)? - - // MARK: - Setup - - init(userSuggestionService: UserSuggestionServiceProtocol) { - self.userSuggestionService = userSuggestionService - - let items = userSuggestionService.items.value.map { suggestionItem in - UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.displayName) - } - - super.init(initialViewState: UserSuggestionViewState(items: items)) - - userSuggestionService.items.sink { [weak self] items in - self?.state.items = items.map { item in - UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName) - } - }.store(in: &cancellables) - } - - // MARK: - Public - - override func process(viewAction: UserSuggestionViewAction) { - switch viewAction { - case .selectedItem(let item): - completion?(.selectedItemWithIdentifier(item.id)) - } - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift deleted file mode 100644 index 859b0b414..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -struct UserSuggestionList: View { - private enum Constants { - static let topPadding: CGFloat = 8.0 - static let listItemPadding: CGFloat = 4.0 - static let lineSpacing: CGFloat = 10.0 - static let maxHeight: CGFloat = 300.0 - static let maxVisibleRows = 4 - } - - // MARK: - Properties - - // MARK: Private - - @Environment(\.theme) private var theme: ThemeSwiftUI - @State private var prototypeListItemFrame: CGRect = .zero - - // MARK: Public - - @ObservedObject var viewModel: UserSuggestionViewModel.Context - - var body: some View { - if viewModel.viewState.items.isEmpty { - EmptyView() - } else { - ZStack { - UserSuggestionListItem(avatar: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Prototype"), - displayName: "Prototype", - userId: "Prototype") - .background(ViewFrameReader(frame: $prototypeListItemFrame)) - .hidden() - BackgroundView { - List(viewModel.viewState.items) { item in - Button { - viewModel.send(viewAction: .selectedItem(item)) - } label: { - UserSuggestionListItem( - avatar: item.avatar, - displayName: item.displayName, - userId: item.id - ) - .padding(.bottom, Constants.listItemPadding) - .padding(.top, viewModel.viewState.items.first?.id == item.id ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding) - } - } - .listStyle(PlainListStyle()) - .frame(height: min(Constants.maxHeight, - min(contentHeightForRowCount(Constants.maxVisibleRows), - contentHeightForRowCount(viewModel.viewState.items.count)))) - .id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues. - } - } - } - } - - private func contentHeightForRowCount(_ count: Int) -> CGFloat { - (prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding - } -} - -private struct BackgroundView: View { - var content: () -> Content - - @Environment(\.theme) private var theme: ThemeSwiftUI - private let shadowRadius: CGFloat = 20.0 - - init(@ViewBuilder content: @escaping () -> Content) { - self.content = content - } - - var body: some View { - content() - .background(theme.colors.background) - .clipShape(RoundedCornerShape(radius: shadowRadius, corners: [.topLeft, .topRight])) - .shadow(color: .black.opacity(0.20), radius: 20.0, x: 0.0, y: 3.0) - .mask(Rectangle().padding(.init(top: -(shadowRadius * 2), leading: 0.0, bottom: 0.0, trailing: 0.0))) - .edgesIgnoringSafeArea(.all) - } -} - -// MARK: - Previews - -struct UserSuggestion_Previews: PreviewProvider { - static let stateRenderer = MockUserSuggestionScreenState.stateRenderer - static var previews: some View { - stateRenderer.screenGroup() - } -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift deleted file mode 100644 index 862e7573d..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListItem.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -struct UserSuggestionListItem: View { - // MARK: - Properties - - // MARK: Private - - @Environment(\.theme) private var theme: ThemeSwiftUI - - // MARK: Public - - let avatar: AvatarInputProtocol? - let displayName: String? - let userId: String - - var body: some View { - HStack { - if let avatar = avatar { - AvatarImage(avatarData: avatar, size: .medium) - } - VStack(alignment: .leading) { - Text(displayName ?? "") - .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - .accessibility(identifier: "displayNameText") - .lineLimit(1) - Text(userId) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.tertiaryContent) - .accessibility(identifier: "userIdText") - .lineLimit(1) - } - } - } -} - -// MARK: - Previews - -struct UserSuggestionHeader_Previews: PreviewProvider { - static var previews: some View { - UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org") - .environmentObject(AvatarViewModel.withMockedServices()) - } -} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift index c4851e779..eb9530f36 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift @@ -146,7 +146,14 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } func resumeRecordingVoiceBroadcast() { - try? audioEngine.start() + do { + // If we paused the recording because of an error, playing a sound mostly changed the category so we need to set it back to .playAndRecord + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) + try audioEngine.start() + } catch { + MXLog.error("[VoiceBroadcastRecorderService] failed to resume recording", context: error) + return + } startTimer() voiceBroadcastService?.resumeVoiceBroadcast(success: { [weak self] _ in @@ -179,12 +186,19 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } func pauseOnErrorRecordingVoiceBroadcast() { + guard audioEngine.isRunning else { + return + } + audioEngine.pause() UIApplication.shared.isIdleTimerDisabled = false invalidateTimer() // Update state - self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .error) + serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .error) + + // Play a sound + playSound(soundName: "vberror") } // MARK: - Private @@ -389,4 +403,21 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } } } + + private func playSound(soundName: String, delay: TimeInterval = 1.0) { + if let audioFileUrl = audioURLWithName(soundName: soundName) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + MXLog.debug("[VoiceBroadcastRecorderService] Playing sound: \(audioFileUrl.absoluteString)") + MXKSoundPlayer.sharedInstance().playSound(at: audioFileUrl, repeat: false, vibrate: false, routeToBuiltInReceiver: false) + } + } + } + + private func audioURLWithName(soundName: String) -> URL? { + if let path = Bundle.main.path(forResource: soundName, ofType: "mp3") { + return URL(fileURLWithPath: path) + } else { + return Bundle.mxk_audioURLFromMXKAssetsBundle(withName: soundName) + } + } } diff --git a/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift b/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift new file mode 100644 index 000000000..f038ba5e2 --- /dev/null +++ b/RiotTests/Modules/Encryption/EncryptionTrustLevelTests.swift @@ -0,0 +1,177 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import Element +@testable import MatrixSDK + +class EncryptionTrustLevelTests: XCTestCase { + + var encryption: EncryptionTrustLevel! + override func setUp() { + encryption = EncryptionTrustLevel() + } + + // MARK: - Helpers + + func makeCrossSigning(isVerified: Bool) -> MXCrossSigningInfo { + return .init( + userIdentity: .init( + identity: .other( + userId: "Bob", + masterKey: "MSK", + selfSigningKey: "SSK" + ), + isVerified: isVerified + ) + ) + } + + func makeProgress(trusted: Int, total: Int) -> Progress { + let progress = Progress(totalUnitCount: Int64(total)) + progress.completedUnitCount = Int64(trusted) + return progress + } + + // MARK: - Users + + func test_userTrustLevel_whenCrossSigningDisabled() { + let devicesToTrustLevel: [(Progress, UserEncryptionTrustLevel)] = [ + (makeProgress(trusted: 0, total: 0), .notVerified), + (makeProgress(trusted: 0, total: 2), .notVerified), + (makeProgress(trusted: 1, total: 2), .warning), + (makeProgress(trusted: 3, total: 4), .warning), + (makeProgress(trusted: 5, total: 5), .trusted), + (makeProgress(trusted: 10, total: 5), .trusted) + ] + + for (devices, expected) in devicesToTrustLevel { + let trustLevel = encryption.userTrustLevel( + crossSigning: nil, + trustedDevicesProgress: devices + ) + XCTAssertEqual(trustLevel, expected, "\(devices.completedUnitCount) trusted device(s) out of \(devices.totalUnitCount)") + } + } + + func test_userTrustLevel_whenCrossSigningNotVerified() { + let devicesToTrustLevel: [(Progress, UserEncryptionTrustLevel)] = [ + (makeProgress(trusted: 0, total: 0), .notVerified), + (makeProgress(trusted: 0, total: 2), .notVerified), + (makeProgress(trusted: 1, total: 2), .notVerified), + (makeProgress(trusted: 3, total: 4), .notVerified), + (makeProgress(trusted: 5, total: 5), .notVerified), + (makeProgress(trusted: 10, total: 5), .notVerified) + ] + + for (devices, expected) in devicesToTrustLevel { + let trustLevel = encryption.userTrustLevel( + crossSigning: makeCrossSigning(isVerified: false), + trustedDevicesProgress: devices + ) + XCTAssertEqual(trustLevel, expected, "\(devices.completedUnitCount) trusted device(s) out of \(devices.totalUnitCount)") + } + } + + func test_userTrustLevel_whenCrossSigningVerified() { + let devicesToTrustLevel: [(Progress, UserEncryptionTrustLevel)] = [ + (makeProgress(trusted: 0, total: 0), .trusted), + (makeProgress(trusted: 0, total: 2), .warning), + (makeProgress(trusted: 1, total: 2), .warning), + (makeProgress(trusted: 3, total: 4), .warning), + (makeProgress(trusted: 5, total: 5), .trusted), + (makeProgress(trusted: 10, total: 5), .trusted) + ] + + for (devices, expected) in devicesToTrustLevel { + let trustLevel = encryption.userTrustLevel( + crossSigning: makeCrossSigning(isVerified: true), + trustedDevicesProgress: devices + ) + XCTAssertEqual(trustLevel, expected, "\(devices.completedUnitCount) trusted device(s) out of \(devices.totalUnitCount)") + } + } + + // MARK: - Rooms + + func test_roomTrustLevel() { + let usersDevicesToTrustLevel: [(Progress, Progress, RoomEncryptionTrustLevel)] = [ + // No users verified + (makeProgress(trusted: 0, total: 0), makeProgress(trusted: 0, total: 0), .normal), + + // Only some users verified + (makeProgress(trusted: 0, total: 1), makeProgress(trusted: 0, total: 1), .normal), + (makeProgress(trusted: 3, total: 4), makeProgress(trusted: 5, total: 5), .normal), + (makeProgress(trusted: 3, total: 4), makeProgress(trusted: 5, total: 5), .normal), + + // All users verified + (makeProgress(trusted: 2, total: 2), makeProgress(trusted: 0, total: 0), .trusted), + (makeProgress(trusted: 3, total: 3), makeProgress(trusted: 0, total: 1), .warning), + (makeProgress(trusted: 3, total: 3), makeProgress(trusted: 3, total: 4), .warning), + (makeProgress(trusted: 4, total: 4), makeProgress(trusted: 5, total: 5), .trusted), + (makeProgress(trusted: 10, total: 4), makeProgress(trusted: 10, total: 5), .trusted), + ] + + for (users, devices, expected) in usersDevicesToTrustLevel { + let trustLevel = encryption.roomTrustLevel( + summary: MXUsersTrustLevelSummary( + trustedUsersProgress: users, + andTrustedDevicesProgress: devices + ) + ) + XCTAssertEqual(trustLevel, expected, "\(users.completedUnitCount)/\(users.totalUnitCount) trusted users(s), \(devices.completedUnitCount)/\(devices.totalUnitCount) trusted device(s)") + } + } +} + +extension UserEncryptionTrustLevel: CustomStringConvertible { + public var description: String { + switch self { + case .trusted: + return "trusted" + case .warning: + return "warning" + case .notVerified: + return "notVerified" + case .noCrossSigning: + return "noCrossSigning" + case .none: + return "none" + case .unknown: + return "unknown" + @unknown default: + return "unknown" + } + } +} + +extension RoomEncryptionTrustLevel: CustomStringConvertible { + public var description: String { + switch self { + case .trusted: + return "trusted" + case .warning: + return "warning" + case .normal: + return "normal" + case .unknown: + return "unknown" + @unknown default: + return "unknown" + } + } +} diff --git a/RiotTests/PillsFormatterTests.swift b/RiotTests/PillsFormatterTests.swift index 573fd234c..a52011776 100644 --- a/RiotTests/PillsFormatterTests.swift +++ b/RiotTests/PillsFormatterTests.swift @@ -29,12 +29,14 @@ private enum Inputs { static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org") static let alicePermalink = "https://matrix.to/#/@alice:matrix.org" static let mentionToAlice = NSAttributedString(string: aliceDisplayname, attributes: [.link: URL(string: alicePermalink)!]) - static let markdownLinkToAlice = "[Alice](\(alicePermalink))" + static let markdownLinkToAlice = "[\(aliceDisplayname)](\(alicePermalink))" static let bobUserId = "@bob:matrix.org" static let bobDisplayname = "Bob" static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ" static let bobMember = FakeMXRoomMember(displayname: bobDisplayname, avatarUrl: bobAvatarUrl, userId: bobUserId) + static let bobPermalink = "https://matrix.to/#/@bob:matrix.org" + static let markdownLinkToBob = "[\(bobDisplayname)](\(bobPermalink))" static let anotherUserId = "@another.user:matrix.org" static let anotherUserPermalink = "https://matrix.to/#/@another.user:matrix.org" @@ -310,7 +312,7 @@ class PillsFormatterTests: XCTestCase { case .room(let userId): XCTAssertEqual(userId, Inputs.roomId) switch pillTextAttachmentData.items.first { - case .asset(let assetName, let parameters): + case .asset(let assetName, _): XCTAssertEqual(assetName, "link_icon") default: XCTFail("First pill item should be the asset") @@ -436,7 +438,7 @@ class PillsFormatterTests: XCTestCase { XCTAssertEqual(roomId, Inputs.anotherRoomId) XCTAssertEqual(messageId, Inputs.messageEventId) switch pillTextAttachmentData.items.first { - case .asset(let name, let parameters): + case .asset(let name, _): XCTAssertEqual(name, "link_icon") default: XCTFail("First pill item should be the asset") @@ -445,6 +447,79 @@ class PillsFormatterTests: XCTestCase { XCTFail("Pill should be of type .message") } } + + func testInsertPillInMarkdownString() { + let message = "Hello \(Inputs.markdownLinkToBob)" + let messageWithPills = insertPillsInMarkdownString(message) + XCTAssertTrue(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment + XCTAssertEqual(pillTextAttachment?.data?.displayText, Inputs.bobDisplayname) + } + + func testInsertMultiplePillsInMarkdownString() { + let message = "Hello \(Inputs.markdownLinkToBob) and \(Inputs.markdownLinkToAlice)" + let messageWithPills = insertPillsInMarkdownString(message) + let bobPillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment + XCTAssertEqual(bobPillTextAttachment?.data?.displayText, Inputs.bobDisplayname) + + let alicePillTextAttachment = messageWithPills.attribute(.attachment, at: 12, effectiveRange: nil) as? PillTextAttachment + XCTAssertEqual(alicePillTextAttachment?.data?.displayText, Inputs.aliceDisplayname) + // No self highlight + XCTAssert(alicePillTextAttachment?.data?.isHighlighted == false) + } + + func testMarkdownLinkToUnknownUserIsNotPillified() { + let message = "Hello [Unknown user](https://matrix.to/#/@unknown:matrix.org)" + let messageWithPills = insertPillsInMarkdownString(message) + XCTAssertFalse(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment) + } + + func testMarkdownSingleLinkDetection() { + let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice)") + let expected = [ + PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!, + label: Inputs.aliceDisplayname, + range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count)) + ] + + XCTAssertEqual( + PillsFormatter.markdownLinks(in: message), + expected + ) + } + + func testMarkdownMultipleLinksDetection() { + let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice) and \(Inputs.markdownLinkToBob)") + let expected = [ + PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!, + label: Inputs.aliceDisplayname, + range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count)), + PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.bobPermalink)!, + label: Inputs.bobDisplayname, + range: NSRange(location: 6 + Inputs.markdownLinkToAlice.count + 5, + length: Inputs.markdownLinkToBob.count)) + ] + + XCTAssertEqual( + PillsFormatter.markdownLinks(in: message), + expected + ) + } + + func testBrokenMarkdownLinkIsNotDetected() { + let brokenMarkdownMessages = [ + NSAttributedString(string: "Hello [Alice](https://matrix.to/#/@alice:matrix.org"), + NSAttributedString(string: "Hello [Alice]https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello [Alice(https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello Alice](https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello [Alice]](https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello (https://matrix.to/#/@alice:matrix.org)"), + ] + + for message in brokenMarkdownMessages { + XCTAssertTrue(PillsFormatter.markdownLinks(in: message).isEmpty) + } + } } @available(iOS 15.0, *) @@ -604,6 +679,15 @@ private extension PillsFormatterTests { return messageWithPills } + private func insertPillsInMarkdownString(_ markdownString: String) -> NSAttributedString { + let message = NSAttributedString(string: markdownString) + let session = FakeMXSession(myUserId: Inputs.aliceUserId) + return PillsFormatter.insertPills(in: message, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers()), + font: UIFont.systemFont(ofSize: 15.0)) + } } // MARK: - Mock objects diff --git a/RiotTests/SessionCreatorTests.swift b/RiotTests/SessionCreatorTests.swift index dbc28c728..688313756 100644 --- a/RiotTests/SessionCreatorTests.swift +++ b/RiotTests/SessionCreatorTests.swift @@ -25,8 +25,9 @@ class SessionCreatorTests: XCTestCase { let mockIS = "mock_identity_server" let credentials = MXCredentials(homeServer: "mock_home_server", - userId: "mock_user_id", + userId: "@mock_user_id:localhost", accessToken: "mock_access_token") + credentials.deviceId = "mock_device_id" let client = MXRestClient(credentials: credentials) client.identityServer = mockIS let session = await sessionCreator.createSession(credentials: credentials, client: client, removeOtherAccounts: false) diff --git a/bwi/AccessibilityDeclaration/AccessibilityDeclarationView.swift b/bwi/AccessibilityDeclaration/AccessibilityDeclarationView.swift new file mode 100644 index 000000000..0dd71f2d7 --- /dev/null +++ b/bwi/AccessibilityDeclaration/AccessibilityDeclarationView.swift @@ -0,0 +1,46 @@ +// +/* + * Copyright (c) 2023 BWI GmbH + * + * 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 + +@objcMembers class AccessibilityDeclarationViewController: NSObject { + + @available(iOS 14.0, *) + class func makeViewController() -> UIViewController { + var accessibilityDeclarationFilePath: URL? = nil + if !BWIBuildSettings.shared.accessibilityDeclarationFileDe.isEmpty && Bundle.main.preferredLocalizations[0].elementsEqual("de") { + accessibilityDeclarationFilePath = Bundle.main.url(forResource: BWIBuildSettings.shared.accessibilityDeclarationFileDe, withExtension: "md") + } else if !BWIBuildSettings.shared.accessibilityDeclarationFileEn.isEmpty { + accessibilityDeclarationFilePath = Bundle.main.url(forResource: BWIBuildSettings.shared.accessibilityDeclarationFileEn, withExtension: "md") + } + + if let url = accessibilityDeclarationFilePath { + guard let string = try? String(contentsOf: url) else { + return UIHostingController(rootView: EmptyView()) + } + let vc = UIHostingController(rootView: MarkDownView(markdownString: string)) + vc.title = BWIL10n.bwiAccessibilityDeclarationButtonTitle + vc.view.backgroundColor = ThemeService.shared().theme.backgroundColor + vc.navigationItem.largeTitleDisplayMode = .never + return vc + } else { + return UIHostingController(rootView: EmptyView()) + } + } +} + + diff --git a/bwi/MarkDown/MarkDownView.swift b/bwi/MarkDown/MarkDownView.swift new file mode 100644 index 000000000..bb3cf0820 --- /dev/null +++ b/bwi/MarkDown/MarkDownView.swift @@ -0,0 +1,62 @@ +// +/* + * Copyright (c) 2023 BWI GmbH + * + * 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 Down + +struct MarkDownView: View { + var markdownString: String + @State var labelHeight: CGFloat = .zero + + var body: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + UIMarkDownWrapper(markDownString: markdownString, height: $labelHeight) + .frame(width: geometry.size.width - 20) + .frame(minHeight: labelHeight) + .padding(10) + } + .background(Color(ThemeService.shared().theme.backgroundColor)) + } + } +} + +struct UIMarkDownWrapper: UIViewRepresentable { + var markDownString: String + @Binding var height: CGFloat + + func makeUIView(context: Context) -> UILabel { + let label = UILabel(frame: .zero) + label.numberOfLines = 0 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + } + + func updateUIView(_ uiView: UILabel, context: Context) { + let down = Down(markdownString: markDownString) + + guard let attributedString = try? down.toAttributedString(stylesheet: "* {font-family: sans-serif; font-size: 16pt; } ") else { return } + let mutableString = NSMutableAttributedString(attributedString: attributedString) + mutableString.addAttributes([.foregroundColor: ThemeService.shared().theme.textPrimaryColor], range: NSRange(location: 0, length: attributedString.length)) + + uiView.attributedText = mutableString + DispatchQueue.main.async { + height = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height + } + } + +} diff --git a/bwi/MatomoAnalytics/BWIAnalytics.swift b/bwi/MatomoAnalytics/BWIAnalytics.swift index 5b82d975e..a4b43efd6 100644 --- a/bwi/MatomoAnalytics/BWIAnalytics.swift +++ b/bwi/MatomoAnalytics/BWIAnalytics.swift @@ -167,6 +167,12 @@ import MatomoTracker } } + func trackEvent(category: String, action: String, name: String, number: NSNumber?) { + if fastRunning { + matomo?.track(eventWithCategory: category, action: action, name: name, number: number, url: nil) + } + } + func trackBwiDuration(_ duration: TimeInterval, _ category: String, _ name: String) { if fastRunning { matomo?.track(eventWithCategory: "Metrics", action: category, name: name, number: NSNumber(value: duration), url:nil) @@ -190,7 +196,18 @@ import MatomoTracker // bwi: Analytics use custom config if let dimensionIndex = analyticsConfig.selectedSendMessageDimensionIndex() { matomo?.setDimension(dimension, forIndex: dimensionIndex) - matomo?.track(eventWithCategory: "Performance", action: "SendMessage", name: nil, number: NSNumber(value: value), url:nil) + matomo?.track(eventWithCategory: "Performance", action: "SendMessage", name: "send_message_default", number: NSNumber(value: value), url:nil) + matomo?.remove(dimensionAtIndex: dimensionIndex) + } + } + } + + func trackEventWithDimension(category: String, action: String, dimension: String, value: NSNumber?, name: String?) { + if fastRunning { + // bwi: Analytics use custom config + if let dimensionIndex = analyticsConfig.selectedSendMessageDimensionIndex() { + matomo?.setDimension(dimension, forIndex: dimensionIndex) + matomo?.track(eventWithCategory: category, action: action, name: name, number: value, url:nil) // name optional unwrap? matomo?.remove(dimensionAtIndex: dimensionIndex) } } diff --git a/bwi/MatomoAnalytics/BWIAnalyticsHelper.swift b/bwi/MatomoAnalytics/BWIAnalyticsHelper.swift new file mode 100644 index 000000000..62f4f7f96 --- /dev/null +++ b/bwi/MatomoAnalytics/BWIAnalyticsHelper.swift @@ -0,0 +1,94 @@ +// +/* + * Copyright (c) 2023 BWI GmbH + * + * 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 +@objc class BWIAnalyticsHelper: NSObject { + + @objc static func getRoomDeviceCount(room: MXRoom, completion: @escaping(Int) -> ()) { + room.members { roomMembers in + var noOfDevices = 0 + for member in roomMembers?.joinedMembers ?? [MXRoomMember]() { + noOfDevices += room.mxSession.crypto.devices(forUser: member.userId).count + } + completion(noOfDevices) + } lazyLoadedMembers: { _ in + completion(0) + } failure: { error in + MXLog.error("[RoomAnalyticsHelper] Failed loading room", context: error) + completion(0) + } + } + + @objc static func getForwardingType(event: MXEvent) -> String? { + guard let messageType: MXMessageType = event.messageType else { + return nil + } + switch messageType { + case .text: + return "text" + case .image: + return "image" + case .video: + return "video" + case .audio: + if (event.content["org.matrix.msc2516.voice"] != nil) || (event.content["org.matrix.msc3245.voice"] != nil) { + return "voice_message" + } else { + return "audio" + } + case .emote: + return nil + case .notice: + return nil + case .location: + return "location" + case .file: + return "file" + case .custom(_): + return nil + } + } + + // MARK: custom dimensions + + @objc static func dimensionForDeviceCount(_ deviceCount: Int) -> String { + if deviceCount <= 0 { + return "Undefiniert" + } + if deviceCount <= 10 { + return "1 bis 10" + } + if deviceCount <= 100 { + return "11 bis 100" + } + if deviceCount <= 200 { + return "101 bis 200" + } + if deviceCount <= 500 { + return "201 bis 500" + } + if deviceCount <= 1000 { + return "501 bis 1000" + } + if deviceCount <= 2500 { + return "1001 bis 2500" + } else { + return "mehr als 2500" + } + } +} diff --git a/bwi/PerformanceProfiles/PerformanceProfile.swift b/bwi/PerformanceProfiles/PerformanceProfile.swift index b48aefe52..98b9b6d86 100644 --- a/bwi/PerformanceProfiles/PerformanceProfile.swift +++ b/bwi/PerformanceProfiles/PerformanceProfile.swift @@ -53,33 +53,7 @@ import Foundation func log2Analytics(users: Int, devices: Int) { if isLogable() { - BWIAnalytics.sharedTracker.trackSlowMessage(dimension: dimensionForDeviceCount(devices), value: Int(timeInterval*1000)) - } - } - - func dimensionForDeviceCount(_ deviceCount: Int) -> String { - if deviceCount <= 0 { - return "Undefiniert" - } - if deviceCount <= 10 { - return "1 bis 10" - } - if deviceCount <= 100 { - return "11 bis 100" - } - if deviceCount <= 200 { - return "101 bis 200" - } - if deviceCount <= 500 { - return "201 bis 500" - } - if deviceCount <= 1000 { - return "501 bis 1000" - } - if deviceCount <= 2500 { - return "1001 bis 2500" - } else { - return "mehr als 2500" + BWIAnalytics.sharedTracker.trackSlowMessage(dimension: BWIAnalyticsHelper.dimensionForDeviceCount(devices), value: Int(timeInterval*1000)) } } } diff --git a/bwi/PollParticipantDetails/PollParticipantDetailsCoordinator.swift b/bwi/PollParticipantDetails/PollParticipantDetailsCoordinator.swift new file mode 100644 index 000000000..efe8aa1d6 --- /dev/null +++ b/bwi/PollParticipantDetails/PollParticipantDetailsCoordinator.swift @@ -0,0 +1,68 @@ +// +/* + * Copyright (c) 2022 BWI GmbH + * + * 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 UIKit + +struct PollParticipantDetailsCoordinatorParameters { + let room: MXRoom + let poll: PollProtocol +} + +final class PollParticipantDetailsCoordinator: Coordinator, Presentable { + + // MARK: Private + + private let parameters: PollParticipantDetailsCoordinatorParameters + private let pollParticipantDetailsHostingController: UIViewController + private var pollParticipantDetailsViewModel: PollParticipantDetailsViewModelProtocol + + // MARK: Public + + var childCoordinators: [Coordinator] = [] + + var completion: (() -> Void)? + + // MARK: - Setup + + init(parameters: PollParticipantDetailsCoordinatorParameters) { + self.parameters = parameters + + var viewModel: PollParticipantDetailsViewModel + + viewModel = PollParticipantDetailsViewModel.init(parameters: PollParticipantDetailsViewModelParameters(poll: parameters.poll, room: parameters.room)) + + let view = PollParticipantDetailsView(viewModel: viewModel.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.room.mxSession.mediaManager))) + + pollParticipantDetailsViewModel = viewModel + pollParticipantDetailsHostingController = VectorHostingController(rootView: view) + } + + // MARK: - Public + + func start() { + + } + + // MARK: - Presentable + + func toPresentable() -> UIViewController { + pollParticipantDetailsHostingController + } +} diff --git a/bwi/PollParticipantDetails/PollParticipantDetailsModels.swift b/bwi/PollParticipantDetails/PollParticipantDetailsModels.swift new file mode 100644 index 000000000..2a8f11599 --- /dev/null +++ b/bwi/PollParticipantDetails/PollParticipantDetailsModels.swift @@ -0,0 +1,96 @@ +// +/* + * Copyright (c) 2022 BWI GmbH + * + * 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 PollParticipantDetailsViewState: BindableState { + var answers: [PollParticipantAnswer] = [] + var poll: PollParticipantPoll +} + +enum PollParticipantDetailsMode { + case someParticipants + case allParticipants +} + +enum PollParticipantDetailsViewAction { + case openAllParticipants(index: Int) + case closeAllParticipants(index: Int) +} + +struct PollParticipantVoter: Identifiable, BindableState { + var id: String { + displayName + } + + var displayName: String + var userAvatarData: AvatarInputProtocol + var formattedVotingTime: String + + static func buildPollParticipantVoter( event: MXEvent, room: MXRoom) -> PollParticipantVoter? { + if let user = room.mxSession.user(withUserId: event.sender) { + let avatarData = AvatarInput(mxContentUri: user.avatarUrl, matrixItemId: event.sender, displayName: user.displayname) + + let votingTime = Date(timeIntervalSince1970: TimeInterval(event.originServerTs / 1000)) + let dateFormatter = DateFormatter() + dateFormatter.timeZone = TimeZone.current + dateFormatter.calendar = Calendar.current + dateFormatter.dateFormat = "dd. MMMM, yyyy HH:mm" + let strDate = dateFormatter.string(from: votingTime) + + return PollParticipantVoter(displayName: user.displayname, userAvatarData: avatarData, formattedVotingTime: strDate) + } else { + return nil + } + } +} + +struct PollParticipantAnswer: Identifiable, BindableState { + var id: String { + originalId + } + + var name: String + var votes: Int + var visibleVotes: Int + var votesText: String + var originalId: String + var voters: [PollParticipantVoter] + var expanded: Bool = false + + static func buildPollParticipantAnswer( answerOption: PollAnswerOptionProtocol, parameters: PollParticipantDetailsViewModelParameters) -> PollParticipantAnswer { + var voters: [PollParticipantVoter] = [] + for participantEvent in answerOption.voters { + if let voter = PollParticipantVoter.buildPollParticipantVoter(event: participantEvent, room: parameters.room) { + voters.append(voter) + } + } + + let votesText = answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)) + return PollParticipantAnswer.init( name: answerOption.text, + votes: Int(answerOption.count), + visibleVotes: min(Int(answerOption.count), BWIBuildSettings.shared.bwiPollVisibleVotes), + votesText: votesText, + originalId: answerOption.id, + voters: voters) + } +} + +struct PollParticipantPoll : BindableState { + var name: String + let voterRows: Int = 2 +} diff --git a/bwi/PollParticipantDetails/PollParticipantDetailsView.swift b/bwi/PollParticipantDetails/PollParticipantDetailsView.swift new file mode 100644 index 000000000..7b9bb6c4a --- /dev/null +++ b/bwi/PollParticipantDetails/PollParticipantDetailsView.swift @@ -0,0 +1,122 @@ +// +/* + * Copyright (c) 2022 BWI GmbH + * + * 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 PollParticipantDetailsView: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: PollParticipantDetailsViewModel.Context + + var body: some View { + NavigationView { + VStack { + PollParticipantPollHeaderView(poll: viewModel.viewState.poll) + .padding(.top, 10) + + List { + ForEach(Array(viewModel.viewState.answers.enumerated()), id: \.offset) { index, answer in + SwiftUI.Section(header: PollParticipantSectionHeaderView(answer: answer)) { + if answer.votes > 0 { + VStack { + ForEach(answer.voters.prefix(upTo: answer.visibleVotes)) { voter in + PollParticipantVoterView(voter: voter) + } + + if answer.votes > answer.visibleVotes && !answer.expanded { + Button(action: { onExpandButton(index: index) }) { + Text(BWIL10n.pollParticipantDetailsShowMore(Int(answer.votes-answer.visibleVotes))) + } + } + } + } + } + } + } + .listStyle(.grouped) + } + } + .accentColor(theme.colors.accent) + .navigationViewStyle(StackNavigationViewStyle()) + .navigationTitle(BWIL10n.pollParticipantDetailsTitle) + .navigationBarTitleDisplayMode(.inline) + } + + private func onExpandButton(index: Int) { + viewModel.send(viewAction: .openAllParticipants(index: index)) + } +} + +struct PollParticipantVoterView: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + var voter: PollParticipantVoter + + var body: some View { + HStack(alignment: .center, spacing: 10) { + AvatarImage(avatarData: voter.userAvatarData, size: .medium) + .border() + + VStack(alignment: .leading, spacing: 3) { + Text(voter.displayName) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + Text(voter.formattedVotingTime) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + } + } + } +} + +struct PollParticipantPollHeaderView: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + var poll: PollParticipantPoll + + var body: some View { + Text(poll.name) + .font(theme.fonts.title3) + .foregroundColor(theme.colors.primaryContent) + } +} + +struct PollParticipantSectionHeaderView: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + var answer: PollParticipantAnswer + + var body: some View { + HStack(alignment: .center) { + Text(answer.name) + .font(theme.fonts.headline) + .foregroundColor(theme.colors.primaryContent) + Spacer() + Text(answer.votesText) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .accessibilityIdentifier("PollAnswerOptionCount") + } + } +} diff --git a/bwi/PollParticipantDetails/PollParticipantDetailsViewModel.swift b/bwi/PollParticipantDetails/PollParticipantDetailsViewModel.swift new file mode 100644 index 000000000..e9b56e62f --- /dev/null +++ b/bwi/PollParticipantDetails/PollParticipantDetailsViewModel.swift @@ -0,0 +1,66 @@ +// +/* + * Copyright (c) 2022 BWI GmbH + * + * 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 Combine +import SwiftUI + +struct PollParticipantDetailsViewModelParameters { + let poll: PollProtocol + let room: MXRoom +} + +typealias PollParticipantDetailsViewModelType = StateStoreViewModel + +class PollParticipantDetailsViewModel: PollParticipantDetailsViewModelType, PollParticipantDetailsViewModelProtocol { + + init(parameters: PollParticipantDetailsViewModelParameters) { + var state = PollParticipantDetailsViewState(poll: PollParticipantPoll(name: parameters.poll.text)) + + var answers: [PollParticipantAnswer] = [] + + let room = parameters.room + + for answerOption in parameters.poll.answerOptions { + + let answer = PollParticipantAnswer.buildPollParticipantAnswer(answerOption: answerOption, parameters: parameters) + answers.append(answer) + } + answers.sort { + $0.votes > $1.votes + } + + state.answers = answers + + super.init(initialViewState: state) + } + + // MARK: - Public + + override func process(viewAction: PollParticipantDetailsViewAction) { + switch viewAction { + + case .openAllParticipants(index: let index): + state.answers[index].expanded = true + state.answers[index].visibleVotes = state.answers[index].votes + case .closeAllParticipants(index: let index): + state.answers[index].expanded = false + state.answers[index].visibleVotes = min(state.answers[index].votes, state.poll.voterRows) + } + } +} diff --git a/bwi/PollParticipantDetails/PollParticipantDetailsViewModelProtocol.swift b/bwi/PollParticipantDetails/PollParticipantDetailsViewModelProtocol.swift new file mode 100644 index 000000000..4511d979b --- /dev/null +++ b/bwi/PollParticipantDetails/PollParticipantDetailsViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +/* + * Copyright (c) 2022 BWI GmbH + * + * 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 PollParticipantDetailsViewModelProtocol { + +} diff --git a/bwi/QRCode/MyQRCodeView.swift b/bwi/QRCode/MyQRCodeView.swift index f2588af49..5c32bcd9c 100644 --- a/bwi/QRCode/MyQRCodeView.swift +++ b/bwi/QRCode/MyQRCodeView.swift @@ -96,7 +96,7 @@ struct MyQRCodeView: View { var footer: some View { Text(BWIL10n.showMyQrScreenMessage(AppInfo.current.displayName)) .font(.system(size: 15)) - .foregroundColor(Color(ThemeService.shared().theme.colors.primaryContent)) + .foregroundColor(BWIBuildSettings.shared.useNewBumColors ? Color(ThemeService.shared().theme.tintColor) : Color(ThemeService.shared().theme.colors.primaryContent)) // bwi: 4769 .multilineTextAlignment(.center) } diff --git a/bwi/QRCode/PermalinkQRCodeScanner.swift b/bwi/QRCode/PermalinkQRCodeScanner.swift index df3945181..ef99bf4f9 100644 --- a/bwi/QRCode/PermalinkQRCodeScanner.swift +++ b/bwi/QRCode/PermalinkQRCodeScanner.swift @@ -71,7 +71,7 @@ struct PermalinkQRCodeScanner: View { if !BWIBuildSettings.shared.clientPermalinkBaseUrl.isEmpty && qrCode.hasPrefix(BWIBuildSettings.shared.clientPermalinkBaseUrl) { presentationMode.wrappedValue.dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - if let url = URL(string: qrCode) { + if let url = NSURLComponents(string: qrCode)?.url { AppDelegate.theDelegate().handleUniversalLinkURL(url) } } diff --git a/bwi/Wellknown/Wellknown+Bwi.swift b/bwi/Wellknown/Wellknown+Bwi.swift index 7040fc475..ae290aada 100644 --- a/bwi/Wellknown/Wellknown+Bwi.swift +++ b/bwi/Wellknown/Wellknown+Bwi.swift @@ -98,4 +98,18 @@ public extension MXWellKnown { return nil } } + + @objc func imprintURL() -> String? { + do { + guard let bwiDict = self.jsonDictionary()["de.bwi"] as? [String : Any] else { + return nil + } + + let bwi = try WellknownBWI(dict: bwiDict) + return bwi.imprintURL + } + catch { + return nil + } + } } diff --git a/bwi/Wellknown/WellknownBWI.swift b/bwi/Wellknown/WellknownBWI.swift index 6cfc4bc92..6b166a20a 100644 --- a/bwi/Wellknown/WellknownBWI.swift +++ b/bwi/Wellknown/WellknownBWI.swift @@ -19,6 +19,7 @@ import Foundation struct WellknownBWI { let dataPrivacyURL: String? + let imprintURL: String? init(dict: [String: Any]) throws { let jsonData = try JSONSerialization.data(withJSONObject: dict, options: []) @@ -30,5 +31,6 @@ struct WellknownBWI { extension WellknownBWI: Decodable { enum CodingKeys: String, CodingKey { case dataPrivacyURL = "data_privacy_url" + case imprintURL = "imprint_url" } } diff --git a/project.yml b/project.yml index 9d63a33aa..76fbbb359 100644 --- a/project.yml +++ b/project.yml @@ -30,6 +30,7 @@ include: - path: Riot/target.yml - path: Riot/target-messenger.yml - path: Riot/target-bum-beta.yml + - path: Riot/target-bum-open.yml - path: RiotTests/target.yml - path: RiotShareExtension/target.yml - path: SiriIntents/target.yml @@ -62,7 +63,7 @@ packages: maxVersion: 3.5.0 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 1.1.1 + version: 2.1.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0