diff --git a/.github/workflows/ci-ui-tests.yml b/.github/workflows/ci-ui-tests.yml
index 37a103035..39c90d509 100644
--- a/.github/workflows/ci-ui-tests.yml
+++ b/.github/workflows/ci-ui-tests.yml
@@ -1,9 +1,6 @@
name: UI Tests CI
on:
- push:
- branches: [ develop ]
-
pull_request:
workflow_dispatch:
diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml
index 0d6b6689a..632e8b538 100644
--- a/.github/workflows/triage-move-labelled.yml
+++ b/.github/workflows/triage-move-labelled.yml
@@ -17,7 +17,8 @@ jobs:
contains(github.event.issue.labels.*.name, 'Z-IA') ||
contains(github.event.issue.labels.*.name, 'A-Themes-Custom') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
- contains(github.event.issue.labels.*.name, 'A-Tags')
+ contains(github.event.issue.labels.*.name, 'A-Tags') ||
+ contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor')
steps:
- uses: actions/github-script@v5
with:
@@ -44,7 +45,13 @@ jobs:
name: P1 X-Needs-Design to Design project board
runs-on: ubuntu-latest
if: >
- contains(github.event.issue.labels.*.name, 'X-Needs-Design')
+ contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
+ (contains(github.event.issue.labels.*.name, 'S-Critical') &&
+ (contains(github.event.issue.labels.*.name, 'O-Frequent') ||
+ contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
+ (contains(github.event.issue.labels.*.name, 'S-Major') &&
+ contains(github.event.issue.labels.*.name, 'O-Frequent')) ||
+ contains(github.event.issue.labels.*.name, 'A11y'))
steps:
- uses: octokit/graphql-action@v2.x
id: add_to_project
@@ -202,3 +209,105 @@ jobs:
env:
PROJECT_ID: "PN_kwDOAM0swc4AArk0"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ ps_features1:
+ name: Add labelled issues to PS features team 1
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'A-Polls') ||
+ contains(github.event.issue.labels.*.name, 'A-Location-Sharing') ||
+ (contains(github.event.issue.labels.*.name, 'A-Voice-Messages') &&
+ !contains(github.event.issue.labels.*.name, 'A-Broadcast')) ||
+ (contains(github.event.issue.labels.*.name, 'A-Session-Mgmt') &&
+ contains(github.event.issue.labels.*.name, 'A-User-Settings'))
+ steps:
+ - uses: octokit/graphql-action@v2.x
+ id: add_to_project
+ with:
+ headers: '{"GraphQL-Features": "projects_next_graphql"}'
+ query: |
+ mutation add_to_project($projectid:ID!,$contentid:ID!) {
+ addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
+ item {
+ id
+ }
+ }
+ }
+ projectid: ${{ env.PROJECT_ID }}
+ contentid: ${{ github.event.issue.node_id }}
+ env:
+ PROJECT_ID: "PVT_kwDOAM0swc4AHJKF"
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ ps_features2:
+ name: Add labelled issues to PS features team 2
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'A-DM-Start') ||
+ contains(github.event.issue.labels.*.name, 'A-Broadcast')
+ steps:
+ - uses: octokit/graphql-action@v2.x
+ id: add_to_project
+ with:
+ headers: '{"GraphQL-Features": "projects_next_graphql"}'
+ query: |
+ mutation add_to_project($projectid:ID!,$contentid:ID!) {
+ addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
+ item {
+ id
+ }
+ }
+ }
+ projectid: ${{ env.PROJECT_ID }}
+ contentid: ${{ github.event.issue.node_id }}
+ env:
+ PROJECT_ID: "PVT_kwDOAM0swc4AHJKd"
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ ps_features3:
+ name: Add labelled issues to PS features team 3
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor')
+ steps:
+ - uses: octokit/graphql-action@v2.x
+ id: add_to_project
+ with:
+ headers: '{"GraphQL-Features": "projects_next_graphql"}'
+ query: |
+ mutation add_to_project($projectid:ID!,$contentid:ID!) {
+ addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
+ item {
+ id
+ }
+ }
+ }
+ projectid: ${{ env.PROJECT_ID }}
+ contentid: ${{ github.event.issue.node_id }}
+ env:
+ PROJECT_ID: "PVT_kwDOAM0swc4AHJKW"
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ voip:
+ name: Add labelled issues to VoIP project board
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'Team: VoIP')
+ steps:
+ - uses: octokit/graphql-action@v2.x
+ id: add_to_project
+ with:
+ headers: '{"GraphQL-Features": "projects_next_graphql"}'
+ query: |
+ mutation add_to_project($projectid:ID!,$contentid:ID!) {
+ addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
+ item {
+ id
+ }
+ }
+ }
+ projectid: ${{ env.PROJECT_ID }}
+ contentid: ${{ github.event.issue.node_id }}
+ env:
+ PROJECT_ID: "PVT_kwDOAM0swc4ABMIk"
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/CHANGES.md b/CHANGES.md
index 5aa8502c9..8f8e82c1e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,40 @@
+## Changes in 1.9.10 (2022-11-01)
+
+✨ Features
+
+- Changed the info in the background audio message player. ([#6870](https://github.com/vector-im/element-ios/pull/6870))
+- Added voice message support to the Rich Text Composer ([#6941](https://github.com/vector-im/element-ios/issues/6941))
+
+🙌 Improvements
+
+- Improves external links interaction UX. ([#6936](https://github.com/vector-im/element-ios/pull/6936))
+- Verification: Deprecate legacy device-to-device verification ([#6937](https://github.com/vector-im/element-ios/pull/6937))
+- Crypto: Define MXCrypto and MXCrossSigning as protocols ([#6943](https://github.com/vector-im/element-ios/pull/6943))
+- Hide the old session list when the new device manager is enabled. ([#6999](https://github.com/vector-im/element-ios/pull/6999))
+- Upgrade MatrixSDK version ([v0.24.2](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.24.2)).
+- Added a responsive placeholder text to the Rich Text Composer ([#6935](https://github.com/vector-im/element-ios/issues/6935))
+- Added the maximise/minimise toggle button to the Rich Text Composer ([#6954](https://github.com/vector-im/element-ios/issues/6954))
+
+🐛 Bugfixes
+
+- Timeline: Fix layout for SwiftUI content views. ([#5326](https://github.com/vector-im/element-ios/issues/5326))
+- Updates the avatar image loading logics. ([#6847](https://github.com/vector-im/element-ios/issues/6847))
+- Fixes input text view height when containing multiple lines of text. ([#6849](https://github.com/vector-im/element-ios/issues/6849))
+- Fixed the placeholder flickering in the input toolbar when there is an height change. ([#6949](https://github.com/vector-im/element-ios/issues/6949))
+
+🧱 Build
+
+- Add Z-Labs tag for rich text editor and update to the new label naming. ([#6996](https://github.com/vector-im/element-ios/pull/6996))
+
+🚧 In development 🚧
+
+- Device Manager: Multi-session selection. ([#6928](https://github.com/vector-im/element-ios/issues/6928))
+
+Others
+
+- Updated templates readme file. ([#6925](https://github.com/vector-im/element-ios/issues/6925))
+
+
## Changes in 1.9.9 (2022-10-18)
✨ Features
diff --git a/Config/AppConfiguration.swift b/Config/AppConfiguration.swift
index 7f9e29b5f..70b1d78d5 100644
--- a/Config/AppConfiguration.swift
+++ b/Config/AppConfiguration.swift
@@ -33,7 +33,7 @@ class AppConfiguration: CommonConfiguration {
// Get additional events (modular widget, voice broadcast...)
MXKAppSettings.standard()?.addSupportedEventTypes([kWidgetMatrixEventTypeString,
kWidgetModularEventTypeString,
- VoiceBroadcastSettings.eventType])
+ VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType])
// Hide undecryptable messages that were sent while the user was not in the room
MXKAppSettings.standard()?.hidePreJoinedUndecryptableEvents = true
diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig
index ec3d81ec8..f891de397 100644
--- a/Config/AppVersion.xcconfig
+++ b/Config/AppVersion.xcconfig
@@ -15,5 +15,5 @@
//
// Version
-MARKETING_VERSION = 1.9.9
-CURRENT_PROJECT_VERSION = 1.9.9
+MARKETING_VERSION = 1.9.10
+CURRENT_PROJECT_VERSION = 1.9.10
diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift
index df349eaee..aa0de1873 100644
--- a/Config/BuildSettings.swift
+++ b/Config/BuildSettings.swift
@@ -234,6 +234,8 @@ final class BuildSettings: NSObject {
static let allowInviteExernalUsers: Bool = true
+ static let allowBackgroundAudioMessagePlayback: Bool = true
+
// MARK: - Side Menu
static let enableSideMenu: Bool = true && !newAppLayoutEnabled
static let sideMenuShowInviteFriends: Bool = true
@@ -406,7 +408,7 @@ final class BuildSettings: NSObject {
static let locationSharingEnabled = true
// MARK: - Voice Broadcast
- static let voiceBroadcastChunkLength: Int = 600
+ static let voiceBroadcastChunkLength: Int = 120
static let voiceBroadcastMaxLength: UInt64 = 144000
// MARK: - MXKAppSettings
diff --git a/Config/CommonConfiguration.swift b/Config/CommonConfiguration.swift
index a89427c3a..fee3796ff 100644
--- a/Config/CommonConfiguration.swift
+++ b/Config/CommonConfiguration.swift
@@ -172,7 +172,7 @@ class CommonConfiguration: NSObject, Configurable {
func setupSettingsWhenLoaded(for matrixSession: MXSession) {
// Do not warn for unknown devices. We have cross-signing now
- matrixSession.crypto?.warnOnUnknowDevices = false
+ (matrixSession.crypto as? MXLegacyCrypto)?.warnOnUnknowDevices = false
}
}
diff --git a/Podfile b/Podfile
index c0144e7ad..d89c32f61 100644
--- a/Podfile
+++ b/Podfile
@@ -16,7 +16,7 @@ use_frameworks!
# - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI
#
# Warning: our internal tooling depends on the name of this variable name, so be sure not to change it
-$matrixSDKVersion = '= 0.24.1'
+$matrixSDKVersion = '= 0.24.2'
# $matrixSDKVersion = :local
# $matrixSDKVersion = { :branch => 'develop'}
# $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } }
@@ -154,5 +154,14 @@ post_install do |installer|
config.build_settings['WARNING_CFLAGS'] ||= ['$(inherited)','-Wno-nullability-completeness']
config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['$(inherited)', '-Xcc', '-Wno-nullability-completeness']
end
+
+ # Fix Xcode 14 resource bundle signing issues
+ # https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1259231655
+ if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle"
+ target.build_configurations.each do |config|
+ config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
+ end
+ end
+
end
end
diff --git a/Podfile.lock b/Podfile.lock
index 0a680b6d5..b3673f8ce 100644
--- a/Podfile.lock
+++ b/Podfile.lock
@@ -243,4 +243,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 82fb79d0a6b074f77950ec73304a749eb0329d12
-COCOAPODS: 1.11.2
+COCOAPODS: 1.11.3
diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 482bfb1c9..ef28187e4 100644
--- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -23,7 +23,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift",
"state" : {
- "revision" : "11dad16e3e589dba423f6cc5707e9df8aace89b0"
+ "revision" : "d5ef7054fb43924d5b92d5d627347ca2bc333717"
}
},
{
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/Contents.json
new file mode 100644
index 000000000..132fb8937
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "user_session_list_item_not_selected.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/user_session_list_item_not_selected.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/user_session_list_item_not_selected.svg
new file mode 100644
index 000000000..7b73d0c6e
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_not_selected.imageset/user_session_list_item_not_selected.svg
@@ -0,0 +1,3 @@
+
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/Contents.json
new file mode 100644
index 000000000..7c5fd8698
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "user_session_list_item_selected.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/user_session_list_item_selected.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/user_session_list_item_selected.svg
new file mode 100644
index 000000000..13680d43a
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_selected.imageset/user_session_list_item_selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json
index ead86edbb..04b38da3e 100644
--- a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json
+++ b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Contents.json
@@ -1,17 +1,17 @@
{
"images" : [
{
- "filename" : "action_voice_message.png",
+ "filename" : "Microphone icon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "action_voice_message@2x.png",
+ "filename" : "Microphone icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
- "filename" : "action_voice_message@3x.png",
+ "filename" : "Microphone icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon.png
new file mode 100644
index 000000000..8a6b3eb14
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@2x.png
new file mode 100644
index 000000000..5b404b74c
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@2x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@3x.png
new file mode 100644
index 000000000..520e22e94
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/Microphone icon@3x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message.png
deleted file mode 100644
index b969cb3aa..000000000
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message.png and /dev/null differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@2x.png
deleted file mode 100644
index 32c6236a6..000000000
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@2x.png and /dev/null differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@3x.png
deleted file mode 100644
index e8cc54c29..000000000
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_default.imageset/action_voice_message@3x.png and /dev/null differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json
index 900874ca1..bc412b2cf 100644
--- a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json
+++ b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Contents.json
@@ -1,17 +1,17 @@
{
"images" : [
{
- "filename" : "voice_message_record_button_recording.png",
+ "filename" : "Microphone asset.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "voice_message_record_button_recording@2x.png",
+ "filename" : "Microphone asset@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
- "filename" : "voice_message_record_button_recording@3x.png",
+ "filename" : "Microphone asset@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset.png
new file mode 100644
index 000000000..ffeb00aaf
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@2x.png
new file mode 100644
index 000000000..8582e2d23
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@2x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@3x.png
new file mode 100644
index 000000000..e48d9a36b
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/Microphone asset@3x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png
deleted file mode 100644
index 5972e1272..000000000
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png and /dev/null differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png
deleted file mode 100644
index 802268ba0..000000000
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png and /dev/null differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png
deleted file mode 100644
index b1def35e1..000000000
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png and /dev/null differ
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/Contents.json
new file mode 100644
index 000000000..fa6650d1c
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "voice_broadcast_live.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/voice_broadcast_live.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/voice_broadcast_live.svg
new file mode 100644
index 000000000..fd78cfc25
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_live.imageset/voice_broadcast_live.svg
@@ -0,0 +1,7 @@
+
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/Contents.json
new file mode 100644
index 000000000..4f275b2b0
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "voice_broadcast_pause.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/voice_broadcast_pause.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/voice_broadcast_pause.svg
new file mode 100644
index 000000000..babd78716
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_pause.imageset/voice_broadcast_pause.svg
@@ -0,0 +1,5 @@
+
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/Contents.json
new file mode 100644
index 000000000..6302334b3
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "voice_broadcast_play.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/voice_broadcast_play.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/voice_broadcast_play.svg
new file mode 100644
index 000000000..65849ae58
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_play.imageset/voice_broadcast_play.svg
@@ -0,0 +1,4 @@
+
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/Contents.json
new file mode 100644
index 000000000..48ffc5e34
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "voice_broadcast_record.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/voice_broadcast_record.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/voice_broadcast_record.svg
new file mode 100644
index 000000000..4ca9bd42c
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record.imageset/voice_broadcast_record.svg
@@ -0,0 +1,6 @@
+
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/Contents.json
new file mode 100644
index 000000000..157748565
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "voice_broadcast_record_pause.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/voice_broadcast_record_pause.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/voice_broadcast_record_pause.svg
new file mode 100644
index 000000000..ba12bc64c
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_record_pause.imageset/voice_broadcast_record_pause.svg
@@ -0,0 +1,5 @@
+
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/Contents.json
new file mode 100644
index 000000000..8431bfd58
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "voice_broadcast_stop.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/voice_broadcast_stop.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/voice_broadcast_stop.svg
new file mode 100644
index 000000000..1fed1640b
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_stop.imageset/voice_broadcast_stop.svg
@@ -0,0 +1,6 @@
+
diff --git a/Riot/Assets/de.lproj/Vector.strings b/Riot/Assets/de.lproj/Vector.strings
index 6fc34bb32..5e7c7dee1 100644
--- a/Riot/Assets/de.lproj/Vector.strings
+++ b/Riot/Assets/de.lproj/Vector.strings
@@ -153,15 +153,15 @@
"room_two_users_are_typing" = "%@ und %@ tippen…";
"room_many_users_are_typing" = "%@, %@ und andere tippen…";
"room_message_placeholder" = "Nachricht senden (unverschlüsselt)…";
-"encrypted_room_message_placeholder" = "Verschlüsselte Nachricht…";
-"room_message_short_placeholder" = "Sende eine Nachricht…";
+"encrypted_room_message_placeholder" = "Verschlüsselte Nachricht senden …";
+"room_message_short_placeholder" = "Nachricht senden …";
"room_offline_notification" = "Verbindung zum Server wurde unterbrochen.";
-"room_unsent_messages_notification" = "Nachrichten wurden nicht gesendet.";
-"room_unsent_messages_unknown_devices_notification" = "Nachrichten wurden nicht gesendet, da unbekannte Sitzungen vorhanden waren.";
+"room_unsent_messages_notification" = "Senden der Nachrichten fehlgeschlagen.";
+"room_unsent_messages_unknown_devices_notification" = "Senden der Nachrichten aufgrund unbekannter Sitzungen fehlgeschlagen.";
"room_prompt_resend" = "Alle erneut senden";
-"room_prompt_cancel" = "Alles abbrechen";
+"room_prompt_cancel" = "Alle abbrechen";
"room_resend_unsent_messages" = "Ungesendete Nachrichten erneut senden";
-"room_delete_unsent_messages" = "Lösche ungesendete Nachrichten";
+"room_delete_unsent_messages" = "Nicht gesendete Nachrichten löschen";
"room_event_action_copy" = "Kopieren";
"room_event_action_quote" = "Zitieren";
"room_event_action_more" = "Mehr";
@@ -301,7 +301,7 @@
"room_participants_action_unban" = "Entsperren";
"room_participants_action_set_default_power_level" = "Besondere Berechtigungen entziehen";
"room_participants_action_start_voice_call" = "Starte Sprach-Anruf";
-"room_ongoing_conference_call" = "Laufender Konferenz-Anruf. Trete bei als %@ oder %@.";
+"room_ongoing_conference_call" = "Laufender Konferenzanruf. Tritt als %@ oder %@ bei.";
"room_event_action_redact" = "Entfernen";
"room_warning_about_encryption" = "Ende-zu-Ende-Verschlüsselung ist in Beta und ist evtl. nicht zuverlässig.\n\nMan sollte noch nicht darauf vertrauen, dass die Daten sicher sind.\n\nGeräte werden Nachrichten von vor dem Beitritt des Raumes nicht entschlüsseln können.\n\nVerschlüsselte Nachrichten sind nicht lesbar in Anwendungen, die die Verschlüsselung noch nicht implementiert haben.";
"unknown_devices_alert" = "Dieser Raum enthält unbekannte Sitzungen, die nicht verifiziert wurden.\nDas bedeutet, es gibt keine Garantie, dass sie den angegebenen Benutzern gehört.\nWir empfehlen eine Überprüfung für jedes Gerät, bevor du weitermachst. Du kannst die Nachricht auch ohne Verifizierung erneut senden.";
@@ -411,14 +411,14 @@
"auth_home_server_placeholder" = "URL (z.B. https://matrix.org)";
"auth_identity_server_placeholder" = "URL (z. B. https://vector.im)";
"room_ongoing_conference_call_close" = "Schließen";
-"room_conference_call_no_power" = "Du brauchst die Berechtigung Konferenzgespräche in diesem Raum zu verwalten";
+"room_conference_call_no_power" = "Du bist nicht berechtigt, Konferenzgespräche in diesem Raum zu verwalten";
"settings_labs_create_conference_with_jitsi" = "Erstelle Konferenzgespräche mit Jitsi";
"call_already_displayed" = "Es existiert bereits ein Gespräch.";
"call_jitsi_error" = "Konferenzgespräch konnte nicht betreten werden.";
// Widget
"widget_no_power_to_manage" = "Du brauchst die Berechtigung um Widgets in diesem Raum zu verwalten";
"widget_creation_failure" = "Widget-Erstellung fehlgeschlagen";
-"room_ongoing_conference_call_with_close" = "Laufendes Konferenzgespräch. Trete mit %@ oder %@ bei. %@ es.";
+"room_ongoing_conference_call_with_close" = "Laufendes Konferenzgespräch. Tritt als %@ oder %@ bei. %@ es.";
"settings_ui_theme" = "Thema";
"settings_ui_theme_auto" = "Auto";
"settings_ui_theme_light" = "Hell";
@@ -436,13 +436,13 @@
"call_incoming_voice" = "Eingehender Anruf…";
"call_incoming_video" = "Eingehender Videoanruf…";
// Widget Integration Manager
-"widget_integration_need_to_be_able_to_invite" = "Du musst Benutzer einladen können um das zu tun.";
+"widget_integration_need_to_be_able_to_invite" = "Du musst Benutzer einladen dürfen, um dies zu tun.";
"widget_integration_unable_to_create" = "Erstellen des Widgets nicht möglich.";
"widget_integration_failed_to_send_request" = "Senden der Anfrage fehlgeschlagen.";
"widget_integration_room_not_recognised" = "Dieser Raum wurde nicht erkannt.";
"widget_integration_positive_power_level" = "Berechtigungslevel muss eine positive Zahl sein.";
"widget_integration_must_be_in_room" = "Du bist nicht in diesem Raum.";
-"widget_integration_no_permission_in_room" = "Du hast keine Berechtigung dies in diesem Raum zu tun.";
+"widget_integration_no_permission_in_room" = "Du bist nicht berechtigt, dies in diesem Raum zu tun.";
"widget_integration_missing_room_id" = "room_id fehlt in der Anfrage.";
"widget_integration_missing_user_id" = "user_id fehlt in der Anfrage.";
"widget_integration_room_not_visible" = "Raum %@ ist nicht sichtbar.";
@@ -502,7 +502,7 @@
// Group rooms
"group_rooms_filter_rooms" = "Filtere Community-Räume";
"e2e_room_key_request_message_new_device" = "Du hast die neue Sitzung '%@' hinzugefügt, welche Verschlüsselungs-Schlüssel anfordert.";
-"room_do_not_have_permission_to_post" = "Du hast keine Berechtigung Nachrichten in diesem Raum zu senden";
+"room_do_not_have_permission_to_post" = "Du bist nicht berechtigt, Nachrichten in diesem Raum zu senden";
"room_event_action_kick_prompt_reason" = "Grund für das Entfernen des Benutzers";
"room_event_action_ban_prompt_reason" = "Grund für die Verbannung der Person";
"room_action_send_photo_or_video" = "Foto oder Video senden";
@@ -532,8 +532,8 @@
"rerequest_keys_alert_title" = "Anfrage gesendet";
"rerequest_keys_alert_message" = "Bitte %@ auf einem anderen Gerät öffnen, das die Nachricht entschlüsseln kann, damit es die Schlüssel an diese Sitzung senden kann.";
"room_message_reply_to_placeholder" = "Antwort senden (unverschlüsselt)…";
-"encrypted_room_message_reply_to_placeholder" = "Sende eine verschlüsselte Antwort…";
-"room_message_reply_to_short_placeholder" = "Sende eine Antwort…";
+"encrypted_room_message_reply_to_placeholder" = "Verschlüsselte Antwort senden …";
+"room_message_reply_to_short_placeholder" = "Antwort senden …";
"room_replacement_information" = "Dieser Raum wurde ersetzt und ist nicht länger aktiv.";
"room_replacement_link" = "Die Konversation wird hier fortgesetzt.";
"room_predecessor_information" = "Dieser Raum ist die Fortsetzung einer anderen Konversation.";
@@ -743,8 +743,8 @@
"room_action_send_file" = "Datei senden";
"room_message_edits_history_title" = "Bearbeitungsverlauf";
// Widget
-"widget_no_integrations_server_configured" = "Kein Integrationsserver konfiguriert";
-"widget_integrations_server_failed_to_connect" = "Verbindung zum Integrationsserver fehlgeschlagen";
+"widget_no_integrations_server_configured" = "Kein Integrations-Server konfiguriert";
+"widget_integrations_server_failed_to_connect" = "Verbindung zum Integrations-Server fehlgeschlagen";
"device_verification_security_advice" = "Für maximale Sicherheit empfehlen wir, dies persönlich zu tun oder ein anderes vertrauenswürdiges Kommunikationsmittel zu verwenden";
"device_verification_incoming_description_1" = "Überprüfe diese Sitzung, um sie als vertrauenswürdig zu markieren. Sitzungen von Partnern zu vertrauen gibt dir zusätzliche Sicherheit bei der Verwendung von Ende-zu-Ende verschlüsselten Nachrichten.";
"device_verification_incoming_description_2" = "Wenn du diese Sitzung verifizierst, wird sie für dich und für dein Gegenüber als vertrauenswürdig gekennzeichnet.";
@@ -821,7 +821,7 @@
"media_type_accessibility_video" = "Video";
"media_type_accessibility_location" = "Standort";
"media_type_accessibility_file" = "Datei";
-"media_type_accessibility_sticker" = "Aufkleber";
+"media_type_accessibility_sticker" = "Sticker";
"settings_identity_server_settings" = "IDENTITÄTSERVER";
"settings_three_pids_management_information_part1" = "Verwalte hier, mit welchen E-Mail-Adressen oder Telefonnummern du dich anmeldest, oder dein Konto wiederherstellen kannst. Kontrolliere, wer dich finden kann ";
"settings_three_pids_management_information_part3" = ".";
@@ -902,7 +902,7 @@
"room_participants_security_loading" = "Lade…";
"room_participants_security_information_room_not_encrypted" = "Nachrichten in diesem Raum sind nicht Ende-zu-Ende verschlüsselt.";
"settings_security" = "SICHERHEIT";
-"settings_integrations_allow_description" = "Benutze einen Integrationsmanager (%@), um Bots, Bridges, Widgets und Aufkleberpakete zu verwalten.\n\nIntegrationsmanager erhalten Konfigurationsdaten und können Widgets verändern, Raum-Einladungen versenden sowie Berechtigungen in deinem Namen einstellen.";
+"settings_integrations_allow_description" = "Nutze einen Integrationsassistenten (%@), um Bots, Brücken, Widgets und Sticker-Pakete zu verwalten.\n\nIntegrationsassistenten erhalten Konfigurationsdaten und können Widgets verändern, Raumeinladungen versenden sowie Berechtigungen in deinem Namen einstellen.";
"settings_labs_enable_cross_signing" = "Aktiviere Cross-Signing, um deinen Gesprächspartner anstatt dessen Gerät zu verifizieren (in Entwicklung)";
// Security settings
"security_settings_title" = "Sicherheit";
@@ -1301,8 +1301,8 @@
"settings_show_NSFW_public_rooms" = "Öffentliche Räume mit anstößigen Inhalte anzeigen";
"room_open_dialpad" = "Wähltastatur";
"room_place_voice_call" = "Sprachanruf";
-"room_unsent_messages_cancel_message" = "Bist du dir sicher alle nicht gesendete Nachrichten in diesem Raum zu löschen?";
-"room_unsent_messages_cancel_title" = "Lösche nicht gesendete Nachrichten";
+"room_unsent_messages_cancel_message" = "Bist du dir sicher, dass du alle nicht gesendeten Nachrichten in diesem Raum löschen möchtest?";
+"room_unsent_messages_cancel_title" = "Nicht gesendete Nachrichten löschen";
"callbar_return" = "Zurück";
"callbar_only_multiple_paused" = "%@ pausierte Anrufe";
"callbar_only_single_paused" = "Pausierter Anruf";
@@ -1483,7 +1483,7 @@
// Alert explaining what an identity server / integration manager is.
"service_terms_modal_information_title_identity_server" = "Indentitätsserver";
-"service_terms_modal_description_integration_manager" = "Das erlaubt dir Bots, Bridges und Stickerpacks zu verwenden.";
+"service_terms_modal_description_integration_manager" = "Dies wird dir die Verwendung von Bots, Brücken und Sticker-Paketen ermöglichen.";
"service_terms_modal_description_identity_server" = "Dies erlaubt Personen, die deine Telefonnummer oder E-Mail in ihren Kontakten hat, dich zu finden.";
"service_terms_modal_table_header_identity_server" = "NUTZUNGSBEDINGUNGEN IDENTITÄTSSERVER";
"service_terms_modal_table_header_integration_manager" = "NUTZUNGSBEDINGUNGEN INTEGRATIONSMANAGER";
@@ -1506,12 +1506,12 @@
"poll_edit_form_add_option" = "Option hinzufügen";
"poll_edit_form_option_number" = "Option %lu";
"poll_edit_form_question_or_topic" = "Frage oder Thematik";
-"room_event_action_end_poll" = "Umfrage beenden";
-"room_event_action_remove_poll" = "Umfrage entfernen";
+"room_event_action_end_poll" = "Abstimmung beenden";
+"room_event_action_remove_poll" = "Abstimmung entfernen";
// Mark: - Polls
-"poll_edit_form_create_poll" = "Umfrage erstellen";
+"poll_edit_form_create_poll" = "Abstimmung erstellen";
"settings_labs_enabled_polls" = "Umfragen";
"share_extension_send_now" = "Jetzt senden";
"accessibility_button_label" = "Knopf";
@@ -1538,7 +1538,7 @@
"poll_edit_form_poll_question_or_topic" = "Frage oder Thema der Umfrage";
"poll_edit_form_input_placeholder" = "Schreib etwas";
"analytics_prompt_terms_link_upgrade" = "hier";
-"poll_timeline_not_closed_title" = "Fehler beim Beenden der Abstimmung";
+"poll_timeline_not_closed_title" = "Beenden der Abstimmung fehlgeschlagen";
"poll_timeline_vote_not_registered_subtitle" = "Wir konnten deine Stimme leider nicht erfassen. Versuche es bitte erneut";
"poll_timeline_total_final_results" = "Es wurden %lu Stimmen abgegeben";
"poll_timeline_total_final_results_one_vote" = "Es wurde 1 Stimme abgegeben";
@@ -1547,7 +1547,7 @@
"poll_timeline_not_closed_subtitle" = "Versuche es bitte erneut";
"poll_timeline_vote_not_registered_title" = "Stimme nicht erfasst";
"poll_edit_form_post_failure_subtitle" = "Versuche es bitte erneut";
-"poll_edit_form_post_failure_title" = "Fehler beim Senden der Abstimmung";
+"poll_edit_form_post_failure_title" = "Absenden der Abstimmung fehlgeschlagen";
"share_extension_low_quality_video_message" = "Für eine bessere Qualität sende es in %@ oder sende es in niedriger Qualität.";
"share_extension_low_quality_video_title" = "Das Video wird in niedriger Qualität gesendet werden";
"analytics_prompt_stop" = "Teilen beenden";
@@ -1588,11 +1588,11 @@
"onboarding_splash_register_button_title" = "Konto erstellen";
"settings_enable_room_message_bubbles" = "Nachrichtenblasen";
"poll_edit_form_update_failure_subtitle" = "Bitte erneut versuchen";
-"poll_edit_form_poll_type" = "Umfragetyp";
-"poll_edit_form_poll_type_closed_description" = "Ergebnisse werden erst angezeigt, wenn du die Umfrage beendest";
-"poll_edit_form_poll_type_closed" = "Geschlossene Umfrage";
-"poll_edit_form_poll_type_open_description" = "Ergebnisse werden direkt nach Stimmabgabe angezeigt";
-"poll_edit_form_poll_type_open" = "Offene Umfrage";
+"poll_edit_form_poll_type" = "Abstimmungsart";
+"poll_edit_form_poll_type_closed_description" = "Die Ergebnisse werden erst sichtbar, sobald du die Umfrage beendest";
+"poll_edit_form_poll_type_closed" = "Abgeschlossene Abstimmung";
+"poll_edit_form_poll_type_open_description" = "Abstimmende können die Ergebnisse nach Stimmabgabe sehen";
+"poll_edit_form_poll_type_open" = "Laufende Abstimmung";
"poll_edit_form_update_failure_title" = "Aktualisierung der Umfrage fehlgeschlagen";
"threads_empty_tip" = "Hinweis: Tippe auf eine Nachricht und wähle „Thread“ um einen neuen zu starten.";
"threads_empty_info_my" = "Antworte auf einen laufenden Thread oder tippe auf eine Nachricht und wähle „Thread“ um einen neuen zu starten.";
@@ -1766,7 +1766,7 @@
"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ hat den zukünftigen Verlauf für alle Raumteilnehmer ab deren Einladung sichtbar gemacht.";
"notice_crypto_unable_to_decrypt" = "** Entschlüsselung nicht möglich: %@ **";
"notice_crypto_error_unknown_inbound_session_id" = "Die absendende Sitzung hat uns keine Schlüssel für diese Nachricht gesendet.";
-"notice_sticker" = "Aufkleber";
+"notice_sticker" = "Sticker";
"notice_in_reply_to" = "Als Antwort auf";
// room display name
"room_displayname_empty_room" = "Leerer Raum";
@@ -2365,7 +2365,7 @@
"spaces_explore_rooms_room_number" = "%@ Räume";
"spaces_create_space_title" = "Einen Space erstellen";
"spaces_add_space_title" = "Space erstellen";
-"space_invite_not_enough_permission" = "Du hast keine Berechtigung, Personen zu diesem Space einzuladen";
+"space_invite_not_enough_permission" = "Du hast keine Berechtigung, Personen in diesen Space einzuladen";
"room_invite_not_enough_permission" = "Du hast keine Berechtigung, Personen zu diesem Raum einzuladen";
"room_invite_to_room_option_detail" = "Sie werden kein Teil von %@ sein.";
"room_invite_to_room_option_title" = "Nur zu diesem Raum";
@@ -2639,3 +2639,15 @@
// Send Media Actions
"wysiwyg_composer_start_action_media_picker" = "Fotobibliothek";
"settings_labs_enable_wysiwyg_composer" = "Probiere den Rich-Text-Editor aus (bald auch mit Plain-Text-Modus)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Sprachübertragung";
+"voice_broadcast_already_in_progress_message" = "Du zeichnest bereits eine Sprachübertragung auf. Bitte beende die laufende Übertragung, um eine neue zu beginnen.";
+"voice_broadcast_blocked_by_someone_else_message" = "Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest.";
+"voice_broadcast_permission_denied_message" = "Du hast nicht die nötigen Berechtigungen, um eine Sprachübertragung in diesem Raum zu starten. Kontaktiere einen Raumadministrator, um deine Berechtigungen anzupassen.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Sprachübertragung kann nicht gestartet werden";
+"settings_labs_enable_voice_broadcast" = "Sprachübertragung (in aktiver Entwicklung)";
+"voice_broadcast_playback_loading_error" = "Wiedergabe der Sprachübertragung nicht möglich.";
+"deselect_all" = "Alle abwählen";
+"user_other_session_menu_select_sessions" = "Sitzungen auswählen";
+"user_other_session_selected_count" = "%@ ausgewählt";
diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings
index 9136db086..6d9320f4a 100644
--- a/Riot/Assets/en.lproj/Untranslated.strings
+++ b/Riot/Assets/en.lproj/Untranslated.strings
@@ -20,4 +20,3 @@
"image_picker_action_files" = "Choose from files";
"voice_broadcast_in_timeline_title" = "Voice broadcast detected (under active development)";
-"voice_broadcast_in_timeline_body" = "We currently only detect voice broadcast in the room timeline, this is not possible to send or listen an actual voice broadcast";
diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings
index 025415be3..1f04c58a2 100644
--- a/Riot/Assets/en.lproj/Vector.strings
+++ b/Riot/Assets/en.lproj/Vector.strings
@@ -798,7 +798,7 @@ Tap the + to start adding people.";
"settings_labs_enable_new_client_info_feature" = "Record the client name, version, and url to recognise sessions more easily in session manager";
"settings_labs_enable_new_app_layout" = "New Application Layout";
"settings_labs_enable_wysiwyg_composer" = "Try out the rich text editor (plain text mode coming soon)";
-"settings_labs_enable_voice_broadcast" = "Voice broadcast (under active development). We currently only detect voice broadcast in the room timeline, this is not possible to send or listen an actual voice broadcast";
+"settings_labs_enable_voice_broadcast" = "Voice broadcast (under active development)";
"settings_version" = "Version %@";
"settings_olm_version" = "Olm Version %@";
@@ -2189,6 +2189,13 @@ Tap the + to start adding people.";
"voice_message_stop_locked_mode_recording" = "Tap on your recording to stop or listen";
"voice_message_lock_screen_placeholder" = "Voice message";
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Can't start a new voice broadcast";
+"voice_broadcast_permission_denied_message" = "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.";
+"voice_broadcast_blocked_by_someone_else_message" = "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.";
+"voice_broadcast_already_in_progress_message" = "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.";
+"voice_broadcast_playback_loading_error" = "Unable to play this voice broadcast.";
+
// Mark: - Version check
"version_check_banner_title_supported" = "We’re ending support for iOS %@";
@@ -2453,6 +2460,8 @@ To enable access, tap Settings> Location and select Always";
"user_other_session_no_verified_sessions" = "No verified sessions found.";
"user_other_session_no_unverified_sessions" = "No unverified sessions found.";
"user_other_session_clear_filter" = "Clear filter";
+"user_other_session_selected_count" = "%@ selected";
+"user_other_session_menu_select_sessions" = "Select sessions";
// First item is client name and second item is session display name
"user_session_name" = "%@: %@";
@@ -2503,6 +2512,7 @@ To enable access, tap Settings> Location and select Always";
"wysiwyg_composer_start_action_location" = "Location";
"wysiwyg_composer_start_action_camera" = "Camera";
"wysiwyg_composer_start_action_text_formatting" = "Text Formatting";
+"wysiwyg_composer_start_action_voice_broadcast" = "Voice broadcast";
// Formatting Actions
"wysiwyg_composer_format_action_bold" = "Apply bold format";
@@ -2582,6 +2592,7 @@ To enable access, tap Settings> Location and select Always";
"reset_to_default" = "Reset to default";
"resend_message" = "Resend the message";
"select_all" = "Select All";
+"deselect_all" = "Deselect All";
"cancel_upload" = "Cancel Upload";
"cancel_download" = "Cancel Download";
"show_details" = "Show Details";
diff --git a/Riot/Assets/et.lproj/Vector.strings b/Riot/Assets/et.lproj/Vector.strings
index f9bdd08c5..1dd7c1b42 100644
--- a/Riot/Assets/et.lproj/Vector.strings
+++ b/Riot/Assets/et.lproj/Vector.strings
@@ -2506,3 +2506,86 @@
"authentication_qr_login_start_subtitle" = "Kasuta selle seadme kaamerat ja logi sisse teises seadmes kuvatud QR-koodi alusel:";
"authentication_qr_login_start_title" = "Loe QR-koodi";
"authentication_login_with_qr" = "Logi sisse QR-koodi abil";
+"wysiwyg_composer_format_action_strikethrough" = "Kasuta allajoonitud kirja";
+"wysiwyg_composer_format_action_underline" = "Kasuta läbijoonitud kirja";
+"wysiwyg_composer_format_action_italic" = "Kasuta kaldkirja";
+
+// Formatting Actions
+"wysiwyg_composer_format_action_bold" = "Kasuta paksu kirja";
+"wysiwyg_composer_start_action_voice_broadcast" = "Ringhäälingukõne";
+"wysiwyg_composer_start_action_text_formatting" = "Tekstivorming";
+"wysiwyg_composer_start_action_camera" = "Kaamera";
+"wysiwyg_composer_start_action_location" = "Asukoht";
+"wysiwyg_composer_start_action_polls" = "Küsitlused";
+"wysiwyg_composer_start_action_attachments" = "Manused";
+"wysiwyg_composer_start_action_stickers" = "Kleepsud";
+
+
+// Mark: - WYSIWYG Composer
+
+// Send Media Actions
+"wysiwyg_composer_start_action_media_picker" = "Fotode kogu";
+"user_session_details_last_activity" = "Viimati kasutusel";
+"device_type_name_unknown" = "Tundmatu seadmetüüp";
+"device_type_name_mobile" = "Mobiiltelefon";
+"device_type_name_web" = "Veebiliides";
+"device_type_name_desktop" = "Töölauarakendus";
+"user_inactive_session_item_with_date" = "Pole olnud kasutusel üle 90 päeva (%@)";
+"user_inactive_session_item" = "Pole olnud kasutusel üle 90 päeva";
+"user_session_item_details_last_activity" = "Viimati kasutusel %@";
+"user_other_session_clear_filter" = "Eemalda filter";
+"user_other_session_no_unverified_sessions" = "Verifitseerimata sessioone ei leidu.";
+"user_other_session_no_verified_sessions" = "Verifitseeritud sessioone ei leidu.";
+"user_other_session_no_inactive_sessions" = "Ei leidu sessioone, mis pole aktiivses kasutuses.";
+"user_other_session_filter_menu_inactive" = "Pole pidevas kasutuses";
+"user_other_session_filter_menu_unverified" = "Verifitseerimata";
+"user_other_session_filter_menu_verified" = "Verifitseeritud";
+"user_other_session_filter_menu_all" = "Kõik sessioonid";
+"user_other_session_filter" = "Filtreeri";
+"user_other_session_verified_sessions_header_subtitle" = "Parima turvalisuse nimel logi välja neist sessioonidest, mida sa enam ei kasuta või ei tunne ära.";
+"user_other_session_current_session_details" = "Sinu praegune sessioon";
+"user_other_session_unverified_sessions_header_subtitle" = "Turvalise sõnumvahetuse nimel verifitseeri kõik oma sessioonid ning logi neist välja, mida sa enam ei kasuta või ei tunne enam ära.";
+"user_other_session_security_recommendation_title" = "Turvalisusega seotud soovitused";
+"user_other_session_verified_additional_info" = "See sessioon on valmis turvaliseks sõnumivahetuseks.";
+"user_other_session_unverified_additional_info" = "Parima turvalisuse ja töökindluse nimel verifitseeri see sessioon või logi ta võrgust välja.";
+"user_session_verification_unknown_additional_info" = "Selle sessiooni olekut ei saa tuvastada enne kui oled ta verifitseerinud.";
+"user_session_verification_unknown_short" = "Teadmata olek";
+"user_session_verification_unknown" = "Verifitseerimise olek on määratlemata";
+"user_sessions_overview_link_device" = "Seo teise seadmega";
+
+// MARK: User sessions management
+
+// Parameter is the application display name (e.g. "Element")
+"user_sessions_default_session_display_name" = "%@ iOS";
+"voice_broadcast_playback_loading_error" = "Selle ringhäälingukõne esitamine ei õnnestu.";
+"voice_broadcast_already_in_progress_message" = "Sa juba salvestad ringhäälingukõnet. Uue alustamiseks palun lõpeta eelmine salvestus.";
+"voice_broadcast_blocked_by_someone_else_message" = "Keegi juba salvestab ringhäälingukõnet. Uue ringhäälingukõne salvestamiseks palun oota, kuni see teine ringhäälingukõne on lõppenud.";
+"voice_broadcast_permission_denied_message" = "Sul pole piisavalt õigusi selles jututoas ringhäälingukõne algatamiseks. Õiguste lisamiseks palun võta ühendust jututoa haldajaga.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Uue ringhäälingukõne alustamine pole võimalik";
+"sign_out_confirmation_message" = "Kas sa oled kindel et soovid välja logida?";
+
+// MARK: Sign out warning
+
+"sign_out" = "Logi välja";
+"manage_session_rename" = "Muuda sessiooni nime";
+"manage_session_name_info_link" = "Lisateave";
+/* The placeholder will be replaces with manage_session_name_info_link */
+"manage_session_name_info" = "Palun arvesta, et sessioonide nimed on näha ka kõikidele osapooltele, kellega sa suhtled. %@";
+"manage_session_name_hint" = "Sinu enda kirjutatud sessiooninimede alusel on sul oma seadmeid lihtsam ära tunda.";
+"settings_labs_enable_voice_broadcast" = "Ringhäälingukõne (aktiivses arenduses)";
+"settings_labs_enable_wysiwyg_composer" = "Proovi vormindatud teksti alusel töötavat tekstitoimetit (varsti lisandub ka vormindamata teksti režiim)";
+"authentication_qr_login_failure_retry" = "Proovi uuesti";
+"authentication_qr_login_failure_request_timed_out" = "Sidumine ei lõppenud etteantud aja jooksul.";
+"authentication_qr_login_failure_request_denied" = "Teine seade lükkas päringu tagasi.";
+"authentication_qr_login_failure_invalid_qr" = "QR-kood on vigane.";
+"authentication_qr_login_failure_title" = "Seose loomine ei õnenstunud";
+"authentication_qr_login_loading_signed_in" = "Sa oled oma teises seadmes sisse loginud Matrix'i võrku.";
+"authentication_qr_login_loading_waiting_signin" = "Ootame, et teine seade logiks võrku.";
+"authentication_qr_login_loading_connecting_device" = "Loon ühendust seadmega";
+"authentication_qr_login_confirm_alert" = "Palun vaata, et sa kindlasti tead, kust see QR-kood kuvatakse. Sellisel viisil seadmete sidumisel sa annad oma kasutajakontole täiemahulise ligipääsu.";
+"authentication_qr_login_confirm_subtitle" = "Kontrolli, et järgnev kood klapib teises seadmes kuvatava koodiga:";
+"deselect_all" = "Eemalda kõik valikud";
+"user_other_session_menu_select_sessions" = "Vali sessioonid";
+"user_other_session_selected_count" = "%@ valitud";
diff --git a/Riot/Assets/fa.lproj/Vector.strings b/Riot/Assets/fa.lproj/Vector.strings
index 8ae59ee6b..fe51dcd78 100644
--- a/Riot/Assets/fa.lproj/Vector.strings
+++ b/Riot/Assets/fa.lproj/Vector.strings
@@ -1270,3 +1270,23 @@
"microphone_access_not_granted_for_voice_message" = "جهت ارسال پیام صوتی نیاز به دسترسی به میکروفون وجود دارد اما %@ دسترسی استفاده از آن را ندارد";
"e2e_passphrase_too_short" = "کلمه عبور بیش از حد کوتاه است (حداقل میبایست %d کاراکتر باشد)";
"message_reply_to_sender_sent_a_voice_message" = "یک پیام صوتی ارسال کنید.";
+"onboarding_splash_page_1_title" = "صاحب گفتگوهای خود شوید.";
+"onboarding_splash_login_button_title" = "من از قبل حساب کاربری دارم";
+
+// MARK: Onboarding
+"onboarding_splash_register_button_title" = "ساخت حساب کاربری";
+"accessibility_button_label" = "دکمه";
+"saving" = "در حال ذخیره";
+
+// Activities
+"loading" = "در حال بارگزاری";
+"invite_to" = "دعوت به %@";
+"confirm" = "تأیید";
+"edit" = "ویرایش";
+"suggest" = "پیشنهاد";
+"add" = "افزودن";
+"existing" = "خروج";
+"new_word" = "جدید";
+"stop" = "توقف";
+"joining" = "پیوستن";
+"enable" = "فعال";
diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings
index dc87963f7..1f72aab67 100644
--- a/Riot/Assets/hu.lproj/Vector.strings
+++ b/Riot/Assets/hu.lproj/Vector.strings
@@ -2625,3 +2625,15 @@
"authentication_qr_login_start_subtitle" = "Használd a kamerát ezen az eszközön a másik eszközödön megjelenő QR kód beolvasására:";
"authentication_qr_login_start_title" = "QR kód beolvasása";
"authentication_login_with_qr" = "Belépés QR kóddal";
+"settings_labs_enable_voice_broadcast" = "Hang közvetítés (aktív fejlesztés alatt)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Hang közvetítés";
+"voice_broadcast_playback_loading_error" = "A hang közvetítés nem játszható le.";
+"voice_broadcast_already_in_progress_message" = "Egy hang közvetítés már folyamatban van. Először fejezd be a jelenlegi közvetítést egy új indításához.";
+"voice_broadcast_blocked_by_someone_else_message" = "Valaki már elindított egy hang közvetítést. Várd meg a közvetítés végét az új indításához.";
+"voice_broadcast_permission_denied_message" = "Nincs jogosultságod hang közvetítést indítani ebben a szobában. Vedd fel a kapcsolatot a szoba adminisztrátorával a szükséges jogosultság megszerzéséhez.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Az új hang közvetítés nem indítható el";
+"deselect_all" = "Semmit nem jelöl ki";
+"user_other_session_menu_select_sessions" = "Munkamenetek kiválasztása";
+"user_other_session_selected_count" = "%@ kiválasztva";
diff --git a/Riot/Assets/id.lproj/Vector.strings b/Riot/Assets/id.lproj/Vector.strings
index 210d60bb3..be31a8bdb 100644
--- a/Riot/Assets/id.lproj/Vector.strings
+++ b/Riot/Assets/id.lproj/Vector.strings
@@ -2832,3 +2832,15 @@
"manage_session_name_info" = "Harap diketahui bahwa nama sesi juga terlihat ke orang-orang yang Anda berkomunikasi. %@";
"manage_session_name_hint" = "Nama sesi khusus dapat membantu Anda mengenal perangkat Anda dengan lebih mudah.";
"settings_labs_enable_wysiwyg_composer" = "Coba editor teks kaya (mode teks biasa akan datang)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Siaran suara";
+"voice_broadcast_playback_loading_error" = "Tidak dapat memainkan siaran suara ini.";
+"voice_broadcast_already_in_progress_message" = "Anda saat ini merekam sebuah siaran suara. Mohon akhiri siaran suara Anda saat ini untuk memulai yang baru.";
+"voice_broadcast_blocked_by_someone_else_message" = "Ada orang lain yang saat ini merekam sebuah siaran suara. Tunggu siaran suaranya berakhir untuk memulai yang baru.";
+"voice_broadcast_permission_denied_message" = "Anda tidak memiliki izin untuk memulai sebuah siaran suara di ruangan ini. Hubungi sebuah administrator ruangan untuk meningkatkan izin Anda.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Tidak dapat memulai sebuah siaran suara baru";
+"settings_labs_enable_voice_broadcast" = "Siaran suara (dalam pengembangan aktif)";
+"deselect_all" = "Batalkan Semua Pilihan";
+"user_other_session_menu_select_sessions" = "Pilih sesi";
+"user_other_session_selected_count" = "%@ dipilih";
diff --git a/Riot/Assets/it.lproj/Vector.strings b/Riot/Assets/it.lproj/Vector.strings
index 671e2d08b..0637f0fe9 100644
--- a/Riot/Assets/it.lproj/Vector.strings
+++ b/Riot/Assets/it.lproj/Vector.strings
@@ -2605,4 +2605,15 @@
"manage_session_name_info" = "Ricorda che i nomi di sessione sono anche visibili alle persone con cui comunichi. %@";
"manage_session_name_hint" = "I nomi di sessione personalizzati possono aiutarti a riconoscere i tuoi dispositivi più facilmente.";
"settings_labs_enable_wysiwyg_composer" = "Prova l'editor in rich text (il testo semplice è in arrivo)";
-"settings_labs_enable_voice_broadcast" = "Broadcast voce (in sviluppo attivo). Attualmente rileviamo solo il broadcast vocale nella linea temporale della stanza, non è possibile inviare o ascoltare un vero broadcast vocale";
+"settings_labs_enable_voice_broadcast" = "Trasmissione vocale (in sviluppo attivo)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Trasmissione vocale";
+"voice_broadcast_playback_loading_error" = "Impossibile avviare questa trasmissione vocale.";
+"voice_broadcast_already_in_progress_message" = "Stai già registrando una trasmissione vocale. Termina quella in corso per iniziarne una nuova.";
+"voice_broadcast_blocked_by_someone_else_message" = "Qualcun altro sta già registrando una trasmissione vocale. Aspetta che finisca prima di iniziarne una nuova.";
+"voice_broadcast_permission_denied_message" = "Non hai l'autorizzazione necessaria per iniziare un broadcast vocale in questa stanza. Contatta un amministratore della stanza per aggiornare le tue autorizzazioni.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Impossibile iniziare una nuova trasmissione vocale";
+"deselect_all" = "Deseleziona tutti";
+"user_other_session_menu_select_sessions" = "Seleziona sessioni";
+"user_other_session_selected_count" = "%@ selezionate";
diff --git a/Riot/Assets/nl.lproj/Vector.strings b/Riot/Assets/nl.lproj/Vector.strings
index 254398f9d..297ff79b0 100644
--- a/Riot/Assets/nl.lproj/Vector.strings
+++ b/Riot/Assets/nl.lproj/Vector.strings
@@ -2463,7 +2463,7 @@
"room_access_settings_screen_upgrade_alert_note" = "Houd er rekening mee dat bij het upgraden een nieuwe versie van de kamer wordt gemaakt. Alle huidige berichten blijven in deze gearchiveerde ruimte.";
"room_access_settings_screen_upgrade_alert_message_no_param" = "Iedereen in een bovenliggende space kan deze ruimte vinden en er lid van worden. Het is niet nodig om iedereen handmatig uit te nodigen. U kunt dit op elk moment wijzigen in de kamerinstellingen.";
"room_access_settings_screen_upgrade_alert_message" = "Iedereen in %@ kan deze ruimte vinden en er lid van worden - het is niet nodig om iedereen handmatig uit te nodigen. U kunt dit op elk moment wijzigen in de kamerinstellingen.";
-"settings_presence_offline_mode_description" = "Indien ingeschakeld, verschijnt u altijd offline voor andere personen, zelfs wanneer u de applicatie gebruikt.";
+"settings_presence_offline_mode_description" = "Indien ingeschakeld, verschijnt u altijd offline voor andere personen, zelfs wanneer u de toepassing gebruikt.";
"settings_presence_offline_mode" = "Offline modus";
"settings_presence" = "Aanwezigheid";
"threads_discourage_information_2" = "\n\nWilt u toch threads inschakelen?";
@@ -2652,3 +2652,156 @@
// User sessions management
"user_sessions_settings" = "Beheer sessies";
"invite_to" = "Uitnodigen %@";
+"room_event_encryption_info_key_authenticity_not_guaranteed" = "De authenticiteit van dit versleutelde bericht kan niet worden gegarandeerd op dit apparaat.";
+"deselect_all" = "Deselecteer alles";
+"wysiwyg_composer_format_action_strikethrough" = "Onderstrepen formaat toepassen";
+"wysiwyg_composer_format_action_underline" = "Doorstrepen formaat toepassen";
+"wysiwyg_composer_format_action_italic" = "Cursief formaat toepassen";
+
+// Formatting Actions
+"wysiwyg_composer_format_action_bold" = "Vet formaat toepassen";
+"wysiwyg_composer_start_action_voice_broadcast" = "Spraakuitzending";
+"wysiwyg_composer_start_action_text_formatting" = "Tekst opmaak";
+"wysiwyg_composer_start_action_camera" = "Camera";
+"wysiwyg_composer_start_action_location" = "Locatie";
+"wysiwyg_composer_start_action_polls" = "Peilingen";
+"wysiwyg_composer_start_action_attachments" = "Bijlagen";
+"wysiwyg_composer_start_action_stickers" = "Stikkers";
+
+
+// Mark: - WYSIWYG Composer
+
+// Send Media Actions
+"wysiwyg_composer_start_action_media_picker" = "Fotobibliotheek";
+"user_session_overview_session_details_button_title" = "Sessie details";
+"user_session_overview_session_title" = "Sessie";
+"user_session_overview_current_session_title" = "Huidige sessie";
+"user_session_details_application_url" = "URL";
+"user_session_details_application_version" = "Versie";
+"user_session_details_application_name" = "Naam";
+"user_session_details_device_os" = "Besturingssysteem";
+"user_session_details_device_browser" = "Browser";
+"user_session_details_device_model" = "Model";
+"user_session_details_device_ip_location" = "IP locatie";
+"user_session_details_device_ip_address" = "IP adres";
+"user_session_details_last_activity" = "Laatste activiteit";
+"user_session_details_session_section_footer" = "Kopieer alle gegevens door erop te tikken en ingedrukt te houden.";
+"user_session_details_session_id" = "Sessie ID";
+"user_session_details_session_name" = "Sessie naam";
+"user_session_details_device_section_header" = "Apparaat";
+"device_name_unknown" = "Onbekende toepassing";
+"settings_labs_enable_new_app_layout" = "Nieuwe toepassing-indeling";
+"settings_labs_enable_new_client_info_feature" = "Noteer de naam, versie en url van de toepassing om sessies gemakkelijker te herkennen in sessiebeheer";
+"user_session_details_application_section_header" = "Toepassing";
+"user_session_details_session_section_header" = "Sessie";
+"user_session_details_title" = "Toon details";
+"device_type_name_unknown" = "Onbekend";
+"device_type_name_mobile" = "Mobiel";
+"device_type_name_web" = "Web";
+"device_type_name_desktop" = "Desktop";
+"device_name_mobile" = "%@ Mobiel";
+"device_name_web" = "%@ Web";
+"device_name_desktop" = "%@ Desktop";
+"user_inactive_session_item_with_date" = "Meer dan 90 dagen inactief (%@)";
+"user_inactive_session_item" = "90+ dagen inactief";
+"user_session_item_details_last_activity" = "Laatste activiteit %@";
+
+/* %1$@ will be the verification state and %2$@ will be user_session_item_details_verification_unknown or user_other_session_current_session_details */
+"user_session_item_details" = "%1$@ · %2$@";
+// First item is client name and second item is session display name
+"user_session_name" = "%@: %@";
+"user_other_session_menu_select_sessions" = "Selecteer sessies";
+"user_other_session_selected_count" = "%@ geselecteerd";
+"user_other_session_clear_filter" = "Leeg filter";
+"user_other_session_no_unverified_sessions" = "Geen niet geverifieerde sessies gevonden.";
+"user_other_session_no_verified_sessions" = "Geen geverifieerde sessies gevonden.";
+"user_other_session_no_inactive_sessions" = "Geen inactieve sessies gevonden.";
+"user_other_session_filter_menu_inactive" = "Inactief";
+"user_other_session_filter_menu_unverified" = "Niet geverifieerd";
+"user_other_session_filter_menu_verified" = "Geverifieerd";
+"user_other_session_filter_menu_all" = "Alle sessies";
+"user_other_session_filter" = "Filter";
+"user_other_session_verified_sessions_header_subtitle" = "Voor de beste beveiliging logt u uit bij elke sessie die u niet meer herkent of gebruikt.";
+"user_other_session_current_session_details" = "Uw huidige sessie";
+"user_other_session_unverified_sessions_header_subtitle" = "Verifieer uw sessies voor verbeterde beveiligde berichtenuitwisseling of meld u af bij sessies die u niet meer herkent of gebruikt.";
+"user_other_session_security_recommendation_title" = "Beveiligingsaanbeveling";
+"user_session_push_notifications_message" = "Indien ingeschakeld, ontvangt deze sessie pushmeldingen.";
+"user_session_push_notifications" = "Pushmeldingen";
+"user_other_session_verified_additional_info" = "Deze sessie is klaar voor beveiligde berichtenuitwisseling.";
+"user_other_session_unverified_additional_info" = "Verifieer of meld u af bij deze sessie voor de beste beveiliging en betrouwbaarheid.";
+"user_session_verification_unknown_additional_info" = "Verifieer uw huidige sessie om de verificatiestatus van deze sessie weer te geven.";
+"user_session_unverified_additional_info" = "Verifieer uw huidige sessie voor verbeterde beveiligde berichtenuitwisseling.";
+"user_session_verified_additional_info" = "Uw huidige sessie is klaar voor beveiligde berichtenuitwisseling.";
+"user_session_learn_more" = "Meer lezen";
+"user_session_view_details" = "Bekijk details";
+"user_session_verify_action" = "Sessie verifiëren";
+"user_session_verification_unknown_short" = "Onbekend";
+"user_session_unverified_short" = "Niet geverifieerd";
+"user_session_verified_short" = "Geverifieerd";
+"user_session_verification_unknown" = "Onbekende verificatiestatus";
+"user_session_unverified" = "Niet geverifieerde sessie";
+"user_session_verified" = "Geverifieerde sessie";
+"user_sessions_view_all_action" = "Alles bekijken (%d)";
+"user_sessions_overview_link_device" = "Een apparaat koppelen";
+"user_sessions_overview_current_session_section_title" = "Huidige sessie";
+"user_sessions_overview_other_sessions_section_info" = "Voor de beste beveiliging verifieert u uw sessies en meldt u zich af bij elke sessie die u niet meer herkent of gebruikt.";
+"user_sessions_overview_other_sessions_section_title" = "Andere sessies";
+"user_sessions_overview_security_recommendations_inactive_info" = "Overweeg om u af te melden bij oude sessies (90 dagen of ouder) die u niet meer gebruikt.";
+"user_sessions_overview_security_recommendations_inactive_title" = "Inactieve sessies";
+"user_sessions_overview_security_recommendations_unverified_info" = "Verifieer of meld u af bij niet geverifieerde sessies.";
+"user_sessions_overview_security_recommendations_unverified_title" = "Niet geverifieerde sessies";
+"user_sessions_overview_security_recommendations_section_info" = "Verbeter uw accountbeveiliging door deze aanbevelingen op te volgen.";
+"user_sessions_overview_security_recommendations_section_title" = "Beveiligingsaanbevelingen";
+
+// MARK: User sessions management
+
+// Parameter is the application display name (e.g. "Element")
+"user_sessions_default_session_display_name" = "%@ iOS";
+"all_chats_user_menu_accessibility_label" = "Gebruikersmenu";
+"voice_broadcast_playback_loading_error" = "Kan deze spraakuitzending niet afspelen.";
+"voice_broadcast_already_in_progress_message" = "U neemt al een spraakuitzending op. Beëindig uw huidige spraakuitzending om een nieuwe te starten.";
+"voice_broadcast_blocked_by_someone_else_message" = "Iemand anders neemt al een spraakuitzending op. Wacht tot hun spraakuitzending is afgelopen om een nieuwe te starten.";
+"voice_broadcast_permission_denied_message" = "U heeft niet de vereiste rechten om een spraakuitzending in deze kamer te starten. Neem contact op met een kamer beheerder om uw machtigingen te upgraden.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Kan geen nieuwe spraakuitzending starten";
+"sign_out_confirmation_message" = "Weet u zeker dat u zich wilt afmelden?";
+
+// MARK: Sign out warning
+
+"sign_out" = "Afmelden";
+"manage_session_rename" = "Sessie hernoemen";
+"manage_session_name_info_link" = "Lees meer";
+/* The placeholder will be replaces with manage_session_name_info_link */
+"manage_session_name_info" = "Houd er rekening mee dat sessienamen ook zichtbaar zijn voor mensen met wie u communiceert. %@";
+"manage_session_name_hint" = "Met aangepaste sessienamen kunt u uw apparaten gemakkelijker herkennen.";
+"settings_labs_enable_voice_broadcast" = "Voice-uitzending (in actieve ontwikkeling)";
+"settings_labs_enable_wysiwyg_composer" = "Probeer de rich-text-editor (platte tekst-modus komt binnenkort)";
+"settings_labs_enable_new_session_manager" = "Nieuwe sessiemanager";
+"room_first_message_placeholder" = "Stuur uw eerste bericht…";
+"authentication_qr_login_failure_retry" = "Probeer het nog eens";
+"authentication_qr_login_failure_request_timed_out" = "De koppeling is niet binnen de vereiste tijd voltooid.";
+"authentication_qr_login_failure_request_denied" = "Het verzoek is geweigerd op het andere apparaat.";
+"authentication_qr_login_failure_invalid_qr" = "QR-code is ongeldig.";
+"authentication_qr_login_failure_title" = "Koppelen mislukt";
+"authentication_qr_login_loading_signed_in" = "U bent nu aangemeld op uw andere apparaat.";
+"authentication_qr_login_loading_waiting_signin" = "Wachten tot het apparaat zich aanmeldt.";
+"authentication_qr_login_loading_connecting_device" = "Verbinden met apparaat";
+"authentication_qr_login_confirm_alert" = "Zorg ervoor dat u de herkomst van deze code kent. Door apparaten te koppelen, geeft u iemand volledige toegang tot uw account.";
+"authentication_qr_login_confirm_subtitle" = "Controleer of de onderstaande code overeenkomt met uw andere apparaat:";
+"authentication_qr_login_confirm_title" = "Beveiligde verbinding tot stand gebracht";
+"authentication_qr_login_scan_subtitle" = "Positioneer de QR-code in het vierkant hieronder";
+"authentication_qr_login_scan_title" = "Scan QR-code";
+"authentication_qr_login_display_step2" = "Selecteer 'Aanmelden met QR-code'";
+"authentication_qr_login_display_step1" = "Open Element op uw andere apparaat";
+"authentication_qr_login_display_subtitle" = "Scan de onderstaande QR-code met uw apparaat dat is uitgelogd.";
+"authentication_qr_login_display_title" = "Een apparaat koppelen";
+"authentication_qr_login_start_display_qr" = "QR-code weergeven op dit apparaat";
+"authentication_qr_login_start_need_alternative" = "Een alternatieve methode nodig?";
+"authentication_qr_login_start_step4" = "Selecteer 'Toon QR-code op dit apparaat'";
+"authentication_qr_login_start_step3" = "Selecteer 'Een apparaat koppelen'";
+"authentication_qr_login_start_step2" = "Ga naar Instellingen -> Beveiliging en privacy";
+"authentication_qr_login_start_step1" = "Open Element op uw andere apparaat";
+"authentication_qr_login_start_subtitle" = "Gebruik de camera op dit apparaat om de QR-code te scannen die op uw andere apparaat wordt weergegeven:";
+"authentication_qr_login_start_title" = "Scan QR-code";
+"authentication_login_with_qr" = "Log in met QR-code";
diff --git a/Riot/Assets/pl.lproj/Vector.strings b/Riot/Assets/pl.lproj/Vector.strings
index 3b82fe79e..8532c4b4d 100644
--- a/Riot/Assets/pl.lproj/Vector.strings
+++ b/Riot/Assets/pl.lproj/Vector.strings
@@ -751,7 +751,7 @@
"group_participants_invited_section" = "ZAPROSZONY";
"receipt_status_read" = "Odczytano: ";
// Media picker
-"media_picker_title" = "Selektor mediów";
+"media_picker_title" = "Biblioteka mediów";
// Image picker
"image_picker_action_camera" = "Zrób zdjęcie";
"image_picker_action_library" = "Wybierz z biblioteki";
@@ -2569,7 +2569,7 @@
// Mark: - All Chats
-"all_chats_title" = "Wszystkie rozmowy";
+"all_chats_title" = "Rozmowy";
"spaces_subspace_creation_visibility_message" = "Utworzona przestrzeń zostanie dodana do %@.";
"spaces_subspace_creation_visibility_title" = "Jakiego rodzaju podprzestrzeń chcesz utworzyć?";
"spaces_explore_rooms_format" = "Przeglądaj %@";
diff --git a/Riot/Assets/pt_BR.lproj/Vector.strings b/Riot/Assets/pt_BR.lproj/Vector.strings
index 3d6b5ee57..3741d9841 100644
--- a/Riot/Assets/pt_BR.lproj/Vector.strings
+++ b/Riot/Assets/pt_BR.lproj/Vector.strings
@@ -1688,7 +1688,7 @@
"invite_user" = "Convidar Usuária(o) matrix";
"reset_to_default" = "Resettar para default";
"resend_message" = "Reenviar a mensagem";
-"select_all" = "Selecionar Todas";
+"select_all" = "Selecionar Todas(os)";
"cancel_upload" = "Cancelar Upload";
"cancel_download" = "Cancelar Download";
"show_details" = "Mostrar Detalhes";
@@ -2606,3 +2606,15 @@
"manage_session_name_info" = "Por favor esteja ciente que nomes de sessões também são visíveis a pessoas com quem você se comunica. %@";
"manage_session_name_hint" = "Nomes de sessões personalizados podem ajudar você a reconhecer seus dispositivos mais facilmente.";
"settings_labs_enable_wysiwyg_composer" = "Experimente o editor de texto rico (modo de texto puro vindo em breve)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Broadcast de voz";
+"voice_broadcast_playback_loading_error" = "Incapaz de tocar este broadcast de voz.";
+"voice_broadcast_already_in_progress_message" = "Você já está gravando um broadcast de voz. Por favor termine seu broadcast de voz atual para começar um novo.";
+"voice_broadcast_blocked_by_someone_else_message" = "Alguma outra pessoa já está gravando um broadcast de voz. Espere que o broadcast de voz dela termine para começar um novo.";
+"voice_broadcast_permission_denied_message" = "Você não tem as permissões requeridas para começar um broadcast de voz nesta sala. Contacte um(a) administrador(a) da sala para fazer upgrade de suas permissões.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Não dá para começar um novo broadcast de voz";
+"settings_labs_enable_voice_broadcast" = "Broadcast de voz (sob desenvolvimento ativo)";
+"deselect_all" = "Desselecionar Todas(os)";
+"user_other_session_menu_select_sessions" = "Selecionar sessões";
+"user_other_session_selected_count" = "%@ selecionadas";
diff --git a/Riot/Assets/ru.lproj/Vector.strings b/Riot/Assets/ru.lproj/Vector.strings
index 5c3fb89eb..7680e9765 100644
--- a/Riot/Assets/ru.lproj/Vector.strings
+++ b/Riot/Assets/ru.lproj/Vector.strings
@@ -603,7 +603,7 @@
"key_backup_recover_from_passphrase_passphrase_title" = "Ввод";
"key_backup_recover_from_passphrase_passphrase_placeholder" = "Введите секретную фразу";
"key_backup_recover_from_passphrase_recover_action" = "Разблокировать историю";
-"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Не знаете вашу секретную фразу для восстановления? Вы можете ";
+"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Не помните свою мнемоническую фразу? Вы можете ";
"key_backup_recover_from_passphrase_lost_passphrase_action_part2" = "использовать ключ безопасности";
"key_backup_recover_from_passphrase_lost_passphrase_action_part3" = ".";
"key_backup_recover_from_recovery_key_info" = "Используйте ключ безопасности для разблокировки истории безопасных сообщений";
@@ -624,7 +624,7 @@
"key_backup_setup_success_from_recovery_key_recovery_key_title" = "Ключ безопасности";
"key_backup_setup_success_from_recovery_key_make_copy_action" = "Сделать копию";
"key_backup_setup_success_from_recovery_key_made_copy_action" = "Я сделал копию";
-"key_backup_recover_invalid_passphrase_title" = "Неверная секретная фраза для восстановления";
+"key_backup_recover_invalid_passphrase_title" = "Неверная мнемоническая фраза";
"key_backup_recover_invalid_recovery_key_title" = "Несоответствующий ключ безопасности";
"key_backup_setup_banner_title" = "Не теряйте зашифрованные сообщения";
"key_backup_setup_banner_subtitle" = "Начать использовать ключ восстановления";
@@ -641,7 +641,7 @@
"key_backup_setup_intro_setup_action_with_existing_backup" = "Использовать ключ восстановления";
"settings_key_backup_info" = "Зашифрованные сообщения защищены сквозным шифрованием. Только вы и получатель(и) имеют ключи для чтения этих сообщений.";
"settings_key_backup_info_signout_warning" = "Сделайте резервную копию ключей перед выходом, чтобы не потерять их.";
-"key_backup_setup_passphrase_title" = "Защитите резервную копию секретной фразой";
+"key_backup_setup_passphrase_title" = "Защитите резервную копию мнемонической фразой";
"key_backup_setup_passphrase_setup_recovery_key_info" = "Или защитите свою резервную копию с помощью ключа безопасности, сохранив ее в безопасном месте.";
"key_backup_setup_passphrase_setup_recovery_key_action" = "(Расширенный) Настройка с ключом безопасности";
// Success from passphrase
@@ -654,7 +654,7 @@
"sign_out_non_existing_key_backup_sign_out_confirmation_alert_title" = "Зашифрованные сообщения будут утеряны";
"sign_out_non_existing_key_backup_alert_discard_key_backup_action" = "Мне не нужны мои зашифрованные сообщения";
"sign_out_non_existing_key_backup_alert_title" = "Вы потеряете доступ к зашифрованным сообщениям если выйдете сейчас";
-"key_backup_recover_invalid_passphrase" = "Невозможно расшифровать резервную копию с помощью этой секретной фразы: убедитесь, что вы ввели верную секретную фразу для восстановления.";
+"key_backup_recover_invalid_passphrase" = "Невозможно расшифровать резервную копию с помощью этой фразы: убедитесь, что вы ввели верную мнемоническую фразу.";
"key_backup_recover_invalid_recovery_key" = "Невозможно расшифровать резервную копию с помощью этого ключа: убедитесь, что вы ввели верный ключ безопасности.";
"e2e_key_backup_wrong_version_button_settings" = "Настройки";
"key_backup_setup_intro_manual_export_info" = "(Расширенный)";
@@ -986,7 +986,7 @@
"secure_key_backup_setup_intro_info" = "Защитите себя от потери доступа к зашифрованным сообщениям и данным, создав резервную копию ключей шифрования на своём сервере.";
"secure_key_backup_setup_intro_use_security_key_title" = "Используйте ключ безопасности";
"secure_key_backup_setup_intro_use_security_key_info" = "Создайте ключ безопасности для хранения в надежном месте, например в менеджере паролей или сейфе.";
-"secure_key_backup_setup_intro_use_security_passphrase_title" = "Использовать секретную фразу";
+"secure_key_backup_setup_intro_use_security_passphrase_title" = "Использовать мнемоническую фразу";
"secure_key_backup_setup_intro_use_security_passphrase_info" = "Введите секретную фразу, известную только вам, и создайте ключ для резервного копирования.";
"secure_key_backup_setup_existing_backup_error_title" = "Резервная копия сообщений уже существует";
"secure_key_backup_setup_existing_backup_error_info" = "Разблокируйте его для повторного использования в защищенной резервной копии или удалите для создания новой резервной копии сообщений в защищенной резервной копии.";
@@ -1024,7 +1024,7 @@
"device_verification_self_verify_wait_information" = "Подтвердите этот сеанс на одном из других ваших сеансов, предоставив ему доступ к зашифрованным сообщениям.\n\nИспользуйте последнюю версию %@ на других ваших устройствах:";
"device_verification_self_verify_wait_additional_information" = "Это работает с %@ и другими клиентами Matrix с поддержкой кросс-подписи.";
"device_verification_self_verify_wait_recover_secrets_without_passphrase" = "Используйте ключ безопасности";
-"device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Используйте секретную фразу или ключ безопасности";
+"device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Используйте мнемоническую фразу или бумажный ключ";
"device_verification_self_verify_wait_recover_secrets_additional_information" = "Если вы не можете получить доступ к существующему сеансу";
"key_verification_verify_sas_title_emoji" = "Сравните смайлы";
"key_verification_verify_sas_title_number" = "Сравните числа";
@@ -1102,17 +1102,17 @@
"user_verification_session_details_verify_action_current_user" = "Интерактивная проверка";
"user_verification_session_details_verify_action_current_user_manually" = "Ручная проверка с помощью текста";
"user_verification_session_details_verify_action_other_user" = "Подтверждение вручную";
-"secrets_recovery_with_passphrase_title" = "Секретная фраза";
+"secrets_recovery_with_passphrase_title" = "Мнемоническая фраза";
"secrets_recovery_with_passphrase_information_default" = "Получите доступ к своей защищённой истории сообщений и вашей личности с кросс-подписью для проверки других сеансов, введя секретную фразу.";
-"secrets_recovery_with_passphrase_information_verify_device" = "Используйте секретную фразу, чтобы проверить это устройство.";
+"secrets_recovery_with_passphrase_information_verify_device" = "Используйте свою мнемоническую фразу, чтобы заверить эту сессию.";
"secrets_recovery_with_passphrase_passphrase_title" = "Ввод";
-"secrets_recovery_with_passphrase_passphrase_placeholder" = "Введите секретную фразу";
+"secrets_recovery_with_passphrase_passphrase_placeholder" = "Введите мнемоническую фразу";
"secrets_recovery_with_passphrase_recover_action" = "Использовать секретную фразу";
-"secrets_recovery_with_passphrase_lost_passphrase_action_part1" = "Не знаете вашу секретную фразу? Вы можете ";
-"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "использовать ключ безопасности";
+"secrets_recovery_with_passphrase_lost_passphrase_action_part1" = "Не помните свою мнемоническую фразу? Вы можете ";
+"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "использовать бумажный ключ";
"secrets_recovery_with_passphrase_lost_passphrase_action_part3" = ".";
"secrets_recovery_with_passphrase_invalid_passphrase_title" = "Невозможно получить доступ к секретному хранилищу";
-"secrets_recovery_with_passphrase_invalid_passphrase_message" = "Убедитесь, что вы ввели правильную секретную фразу.";
+"secrets_recovery_with_passphrase_invalid_passphrase_message" = "Убедитесь, что вы ввели верную мнемоническую фразу.";
"secrets_recovery_with_key_title" = "Ключ безопасности";
"secrets_recovery_with_key_information_default" = "Получите доступ к своей защищённой истории сообщений и вашей личности с кросс-подписью для проверки других сеансов, введя ключ безопасности.";
"secrets_recovery_with_key_information_verify_device" = "Используйте ключ безопасности, чтобы проверить это устройство.";
@@ -1128,11 +1128,11 @@
"secrets_setup_recovery_key_done_action" = "Готово";
"secrets_setup_recovery_key_storage_alert_title" = "Храните его в безопасности";
"secrets_setup_recovery_key_storage_alert_message" = "✓ Распечатайте и храните в безопасном месте\n✓ Сохраните его на USB-носителе или резервном носителе\n✓ Скопируйте его в свое личное облачное хранилище";
-"secrets_setup_recovery_passphrase_title" = "Задайте секретную фразу";
+"secrets_setup_recovery_passphrase_title" = "Задайте мнемоническую фразу";
"secrets_setup_recovery_passphrase_information" = "Введите секретную фразу, известную только вам, для защиты данных на вашем сервере.";
"secrets_setup_recovery_passphrase_additional_information" = "Не используйте пароль своей учетной записи.";
"secrets_setup_recovery_passphrase_validate_action" = "Готово";
-"secrets_setup_recovery_passphrase_confirm_information" = "Для подтверждения введите вашу секретную фразу ещё раз.";
+"secrets_setup_recovery_passphrase_confirm_information" = "Введите мнемоническую фразу ещё раз, чтобы подтвердить её.";
"secrets_setup_recovery_passphrase_confirm_passphrase_title" = "Подтвердить";
"secrets_setup_recovery_passphrase_confirm_passphrase_placeholder" = "Подтвердить секретную фразу";
"cross_signing_setup_banner_title" = "Настройка шифрования";
@@ -1238,8 +1238,8 @@
// MARK: - Home
"home_empty_view_title" = "Добро пожаловать в %@,\n%@";
-"secrets_setup_recovery_passphrase_summary_information" = "Запомните свою секретную фразу. Её можно использовать для разблокировки ваших зашифрованных сообщений и данных.";
-"secrets_setup_recovery_passphrase_summary_title" = "Сохраните вашу секретную фразу";
+"secrets_setup_recovery_passphrase_summary_information" = "Запомните свою мнемоническую фразу. Её можно использовать для разблокировки ваших зашифрованных сообщений и данных.";
+"secrets_setup_recovery_passphrase_summary_title" = "Сохраните свою мнемоническую фразу";
"favourites_empty_view_information" = "Вы можете добавить в избранное несколькими способами - самый быстрый - просто нажать и удерживать. Нажмите на звёздочку, и они автоматически появятся здесь, и вы их навсегда сохраните.";
// MARK: - Favourites
@@ -1355,7 +1355,7 @@
"space_feature_unavailable_title" = "Пространств ещё нет";
"secrets_recovery_with_key_information_unlock_secure_backup_with_key" = "Введите свой ключ безопасности, чтобы продолжить.";
-"secrets_recovery_with_key_information_unlock_secure_backup_with_phrase" = "Введите секретную фразу, чтобы продолжить.";
+"secrets_recovery_with_key_information_unlock_secure_backup_with_phrase" = "Введите мнемоническую фразу, чтобы продолжить.";
"key_verification_verify_qr_code_scan_code_other_device_action" = "Сканирование с помощью этого устройства";
// Success from secure backup
diff --git a/Riot/Assets/sk.lproj/Vector.strings b/Riot/Assets/sk.lproj/Vector.strings
index 78fe3194e..ba65a69cb 100644
--- a/Riot/Assets/sk.lproj/Vector.strings
+++ b/Riot/Assets/sk.lproj/Vector.strings
@@ -2828,3 +2828,15 @@
"manage_session_name_info" = "Uvedomte si, že názvy relácií sú viditeľné aj pre ľudí, s ktorými komunikujete. %@";
"manage_session_name_hint" = "Vlastné názvy relácií vám pomôžu ľahšie rozpoznať vaše zariadenia.";
"settings_labs_enable_wysiwyg_composer" = "Vyskúšajte rozšírený textový editor (čistý textový režim sa objaví čoskoro)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Hlasové vysielanie";
+"voice_broadcast_already_in_progress_message" = "Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové.";
+"voice_broadcast_blocked_by_someone_else_message" = "Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové.";
+"voice_broadcast_permission_denied_message" = "Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Nie je možné spustiť nové hlasové vysielanie";
+"settings_labs_enable_voice_broadcast" = "Hlasové vysielanie (v štádiu aktívneho vývoja)";
+"voice_broadcast_playback_loading_error" = "Toto hlasové vysielanie nie je možné prehrať.";
+"deselect_all" = "Zrušiť výber všetkých";
+"user_other_session_selected_count" = "%@ vybratých";
+"user_other_session_menu_select_sessions" = "Vyberte relácie";
diff --git a/Riot/Assets/uk.lproj/Vector.strings b/Riot/Assets/uk.lproj/Vector.strings
index 95706cdf6..d1d1d7d7c 100644
--- a/Riot/Assets/uk.lproj/Vector.strings
+++ b/Riot/Assets/uk.lproj/Vector.strings
@@ -2830,3 +2830,15 @@
"manage_session_name_info" = "Зауважте, що назви сеансів також видно людям, з якими ви спілкуєтесь. %@";
"manage_session_name_hint" = "Власні назви сеансів допоможуть вам легше розпізнавати ваші пристрої.";
"settings_labs_enable_wysiwyg_composer" = "Спробуйте розширений текстовий редактор (незабаром з'явиться режим звичайного тексту)";
+"wysiwyg_composer_start_action_voice_broadcast" = "Голосові повідомлення";
+"voice_broadcast_playback_loading_error" = "Неможливо відтворити це голосове повідомлення.";
+"voice_broadcast_already_in_progress_message" = "Ви вже записуєте голосове повідомлення. Завершіть поточну трансляцію, щоб розпочати нову.";
+"voice_broadcast_blocked_by_someone_else_message" = "Хтось інший вже записує голосове повідомлення. Зачекайте, поки закінчиться трансляція, щоб розпочати нову.";
+"voice_broadcast_permission_denied_message" = "Ви не маєте необхідних дозволів для початку трансляції голосового повідомлення в цій кімнаті. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи.";
+
+// Mark: - Voice broadcast
+"voice_broadcast_unauthorized_title" = "Не вдалося розпочати трансляцію нового голосового повідомлення";
+"settings_labs_enable_voice_broadcast" = "Голосові повідомлення (в активній розробці)";
+"deselect_all" = "Скасувати вибір усіх";
+"user_other_session_menu_select_sessions" = "Вибрати сеанси";
+"user_other_session_selected_count" = "Вибрано %@";
diff --git a/Riot/Categories/MXRoom+Riot.m b/Riot/Categories/MXRoom+Riot.m
index 801221597..df47c1674 100644
--- a/Riot/Categories/MXRoom+Riot.m
+++ b/Riot/Categories/MXRoom+Riot.m
@@ -329,7 +329,7 @@
{
if (self.mxSession.crypto)
{
- [self.mxSession.crypto trustLevelSummaryForUserIds:@[userId] onComplete:^(MXUsersTrustLevelSummary *usersTrustLevelSummary) {
+ [self.mxSession.crypto trustLevelSummaryForUserIds:@[userId] forceDownload:NO success:^(MXUsersTrustLevelSummary *usersTrustLevelSummary) {
UserEncryptionTrustLevel userEncryptionTrustLevel;
double trustedDevicesPercentage = usersTrustLevelSummary.trustedDevicesProgress.fractionCompleted;
@@ -341,7 +341,7 @@
else if (trustedDevicesPercentage == 0.0)
{
// Verify if the user has the user has cross-signing enabled
- if ([self.mxSession.crypto crossSigningKeysForUser:userId])
+ if ([self.mxSession.crypto.crossSigning crossSigningKeysForUser:userId])
{
userEncryptionTrustLevel = UserEncryptionTrustLevelNotVerified;
}
@@ -357,6 +357,9 @@
onComplete(userEncryptionTrustLevel);
+ } failure:^(NSError *error) {
+ MXLogErrorDetails(@"[MXRoom+Riot] Error fetching trust level summary", error);
+ onComplete(UserEncryptionTrustLevelUnknown);
}];
}
else
diff --git a/Riot/Categories/UITableViewCell.swift b/Riot/Categories/UITableViewCell.swift
index 86c4b7ee0..6071a11ec 100644
--- a/Riot/Categories/UITableViewCell.swift
+++ b/Riot/Categories/UITableViewCell.swift
@@ -51,5 +51,16 @@ extension UITableViewCell {
@objc func vc_setAccessoryDisclosureIndicatorWithCurrentTheme() {
self.vc_setAccessoryDisclosureIndicator(withTheme: ThemeService.shared().theme)
}
+
+ @objc var vc_parentViewController: UIViewController? {
+ var parent: UIResponder? = self
+ while parent != nil {
+ parent = parent?.next
+ if let viewController = parent as? UIViewController {
+ return viewController
+ }
+ }
+ return nil
+ }
}
diff --git a/Riot/Categories/UITextView.swift b/Riot/Categories/UITextView.swift
index 56b19047a..1c989cc68 100644
--- a/Riot/Categories/UITextView.swift
+++ b/Riot/Categories/UITextView.swift
@@ -22,7 +22,10 @@ extension UITextView {
self.attributedText.enumerateAttribute(
.attachment,
in: NSRange(location: 0, length: self.attributedText.length),
- options: []) { _, range, _ in
+ options: []) { value, range, _ in
+ guard value != nil else {
+ return
+ }
self.layoutManager.invalidateDisplay(forCharacterRange: range)
}
}
diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift
index 3cc10eb2e..922174427 100644
--- a/Riot/Generated/Images.swift
+++ b/Riot/Generated/Images.swift
@@ -127,6 +127,8 @@ internal class Asset: NSObject {
internal static let userOtherSessionsUnverified = ImageAsset(name: "user_other_sessions_unverified")
internal static let userOtherSessionsVerified = ImageAsset(name: "user_other_sessions_verified")
internal static let userSessionListItemInactiveSession = ImageAsset(name: "user_session_list_item_inactive_session")
+ internal static let userSessionListItemNotSelected = ImageAsset(name: "user_session_list_item_not_selected")
+ internal static let userSessionListItemSelected = ImageAsset(name: "user_session_list_item_selected")
internal static let userSessionUnverified = ImageAsset(name: "user_session_unverified")
internal static let userSessionVerificationUnknown = ImageAsset(name: "user_session_verification_unknown")
internal static let userSessionVerified = ImageAsset(name: "user_session_verified")
@@ -335,6 +337,12 @@ internal class Asset: NSObject {
internal static let tabHome = ImageAsset(name: "tab_home")
internal static let tabPeople = ImageAsset(name: "tab_people")
internal static let tabRooms = ImageAsset(name: "tab_rooms")
+ internal static let voiceBroadcastLive = ImageAsset(name: "voice_broadcast_live")
+ internal static let voiceBroadcastPause = ImageAsset(name: "voice_broadcast_pause")
+ internal static let voiceBroadcastPlay = ImageAsset(name: "voice_broadcast_play")
+ internal static let voiceBroadcastRecord = ImageAsset(name: "voice_broadcast_record")
+ internal static let voiceBroadcastRecordPause = ImageAsset(name: "voice_broadcast_record_pause")
+ internal static let voiceBroadcastStop = ImageAsset(name: "voice_broadcast_stop")
internal static let launchScreenLogo = ImageAsset(name: "launch_screen_logo")
}
@objcMembers
diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift
index 7671c5c73..6c82fd266 100644
--- a/Riot/Generated/Strings.swift
+++ b/Riot/Generated/Strings.swift
@@ -1523,6 +1523,10 @@ public class VectorL10n: NSObject {
public static var delete: String {
return VectorL10n.tr("Vector", "delete")
}
+ /// Deselect All
+ public static var deselectAll: String {
+ return VectorL10n.tr("Vector", "deselect_all")
+ }
/// This operation requires additional authentication.\nTo continue, please enter your password.
public static var deviceDetailsDeletePromptMessage: String {
return VectorL10n.tr("Vector", "device_details_delete_prompt_message")
@@ -7535,7 +7539,7 @@ public class VectorL10n: NSObject {
public static var settingsLabsEnableThreads: String {
return VectorL10n.tr("Vector", "settings_labs_enable_threads")
}
- /// Voice broadcast (under active development). We currently only detect voice broadcast in the room timeline, this is not possible to send or listen an actual voice broadcast
+ /// Voice broadcast (under active development)
public static var settingsLabsEnableVoiceBroadcast: String {
return VectorL10n.tr("Vector", "settings_labs_enable_voice_broadcast")
}
@@ -8671,6 +8675,10 @@ public class VectorL10n: NSObject {
public static var userOtherSessionFilterMenuVerified: String {
return VectorL10n.tr("Vector", "user_other_session_filter_menu_verified")
}
+ /// Select sessions
+ public static var userOtherSessionMenuSelectSessions: String {
+ return VectorL10n.tr("Vector", "user_other_session_menu_select_sessions")
+ }
/// No inactive sessions found.
public static var userOtherSessionNoInactiveSessions: String {
return VectorL10n.tr("Vector", "user_other_session_no_inactive_sessions")
@@ -8687,6 +8695,10 @@ public class VectorL10n: NSObject {
public static var userOtherSessionSecurityRecommendationTitle: String {
return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title")
}
+ /// %@ selected
+ public static func userOtherSessionSelectedCount(_ p1: String) -> String {
+ return VectorL10n.tr("Vector", "user_other_session_selected_count", p1)
+ }
/// Verify or sign out from this session for best security and reliability.
public static var userOtherSessionUnverifiedAdditionalInfo: String {
return VectorL10n.tr("Vector", "user_other_session_unverified_additional_info")
@@ -9051,6 +9063,26 @@ public class VectorL10n: NSObject {
public static var voice: String {
return VectorL10n.tr("Vector", "voice")
}
+ /// You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.
+ public static var voiceBroadcastAlreadyInProgressMessage: String {
+ return VectorL10n.tr("Vector", "voice_broadcast_already_in_progress_message")
+ }
+ /// Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.
+ public static var voiceBroadcastBlockedBySomeoneElseMessage: String {
+ return VectorL10n.tr("Vector", "voice_broadcast_blocked_by_someone_else_message")
+ }
+ /// You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.
+ public static var voiceBroadcastPermissionDeniedMessage: String {
+ return VectorL10n.tr("Vector", "voice_broadcast_permission_denied_message")
+ }
+ /// Unable to play this voice broadcast.
+ public static var voiceBroadcastPlaybackLoadingError: String {
+ return VectorL10n.tr("Vector", "voice_broadcast_playback_loading_error")
+ }
+ /// Can't start a new voice broadcast
+ public static var voiceBroadcastUnauthorizedTitle: String {
+ return VectorL10n.tr("Vector", "voice_broadcast_unauthorized_title")
+ }
/// Voice message
public static var voiceMessageLockScreenPlaceholder: String {
return VectorL10n.tr("Vector", "voice_message_lock_screen_placeholder")
@@ -9207,6 +9239,10 @@ public class VectorL10n: NSObject {
public static var wysiwygComposerStartActionTextFormatting: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_text_formatting")
}
+ /// Voice broadcast
+ public static var wysiwygComposerStartActionVoiceBroadcast: String {
+ return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_voice_broadcast")
+ }
/// Yes
public static var yes: String {
return VectorL10n.tr("Vector", "yes")
diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift
index f571ff96d..1f417c770 100644
--- a/Riot/Generated/UntranslatedStrings.swift
+++ b/Riot/Generated/UntranslatedStrings.swift
@@ -14,10 +14,6 @@ public extension VectorL10n {
static var imagePickerActionFiles: String {
return VectorL10n.tr("Untranslated", "image_picker_action_files")
}
- /// We currently only detect voice broadcast in the room timeline, this is not possible to send or listen an actual voice broadcast
- static var voiceBroadcastInTimelineBody: String {
- return VectorL10n.tr("Untranslated", "voice_broadcast_in_timeline_body")
- }
/// Voice broadcast detected (under active development)
static var voiceBroadcastInTimelineTitle: String {
return VectorL10n.tr("Untranslated", "voice_broadcast_in_timeline_title")
diff --git a/Riot/Modules/Application/LegacyAppDelegate.h b/Riot/Modules/Application/LegacyAppDelegate.h
index c84ff4809..dff221ba3 100644
--- a/Riot/Modules/Application/LegacyAppDelegate.h
+++ b/Riot/Modules/Application/LegacyAppDelegate.h
@@ -195,7 +195,9 @@ UINavigationControllerDelegate
- (BOOL)presentIncomingKeyVerificationRequest:(id)incomingKeyVerificationRequest
inSession:(MXSession*)session;
-- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember session:(MXSession*)mxSession;
+- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember
+ session:(MXSession*)mxSession
+ completion:(void (^)(void))completion;
- (BOOL)presentCompleteSecurityForSession:(MXSession*)mxSession;
diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m
index 040e243f5..63d2afe50 100644
--- a/Riot/Modules/Application/LegacyAppDelegate.m
+++ b/Riot/Modules/Application/LegacyAppDelegate.m
@@ -128,6 +128,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
If any the currently displayed key verification dialog
*/
KeyVerificationCoordinatorBridgePresenter *keyVerificationCoordinatorBridgePresenter;
+
+ /**
+ Completion block for the requester of key verification
+ */
+ void (^keyVerificationCompletionBlock)(void);
/**
Currently displayed secure backup setup
@@ -610,6 +615,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
// Analytics: Force to send the pending actions
[[DecryptionFailureTracker sharedInstance] dispatch];
[Analytics.shared forceUpload];
+
+ // Pause Voice Broadcast recording if needed
+ [VoiceBroadcastRecorderProvider.shared pauseRecording];
}
- (void)applicationWillEnterForeground:(UIApplication *)application
@@ -2280,9 +2288,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
// Stay in launching during the first server sync if the store is empty.
isLaunching = (mainSession.rooms.count == 0 && launchAnimationContainerView);
- if (mainSession.crypto.crossSigning && mainSession.crypto.crossSigning.state == MXCrossSigningStateCrossSigningExists)
+ if (mainSession.crypto.crossSigning && mainSession.crypto.crossSigning.state == MXCrossSigningStateCrossSigningExists && [mainSession.crypto isKindOfClass:[MXLegacyCrypto class]])
{
- [mainSession.crypto setOutgoingKeyRequestsEnabled:NO onComplete:nil];
+ [(MXLegacyCrypto *)mainSession.crypto setOutgoingKeyRequestsEnabled:NO onComplete:nil];
}
break;
case MXSessionStateRunning:
@@ -2495,6 +2503,12 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
- (void)checkLocalPrivateKeysInSession:(MXSession*)mxSession
{
+ if (![mxSession.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ return;
+ }
+ MXLegacyCrypto *crypto = (MXLegacyCrypto *)mxSession.crypto;
+
MXRecoveryService *recoveryService = mxSession.crypto.recoveryService;
NSUInteger keysCount = 0;
if ([recoveryService hasSecretWithSecretId:MXSecretId.keyBackup])
@@ -2515,7 +2529,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
{
// We should have 3 of them. If not, request them again as mitigation
MXLogDebug(@"[AppDelegate] checkLocalPrivateKeysInSession: request keys because keysCount = %@", @(keysCount));
- [mxSession.crypto requestAllPrivateKeys];
+ [crypto requestAllPrivateKeys];
}
}
@@ -3475,17 +3489,24 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession called while the app is not active. Ignore it.");
return;
}
+
+ if (![mxSession.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Only legacy crypto allows manually accepting/rejecting key requests");
+ return;
+ }
+ MXLegacyCrypto *crypto = (MXLegacyCrypto *)mxSession.crypto;
MXWeakify(self);
- [mxSession.crypto pendingKeyRequests:^(MXUsersDevicesMap *> *pendingKeyRequests) {
+ [crypto pendingKeyRequests:^(MXUsersDevicesMap *> *pendingKeyRequests) {
MXStrongifyAndReturnIfNil(self);
MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: cross-signing state: %ld, pendingKeyRequests.count: %@. Already displayed: %@",
- mxSession.crypto.crossSigning.state,
+ crypto.crossSigning.state,
@(pendingKeyRequests.count),
self->roomKeyRequestViewController ? @"YES" : @"NO");
- if (!mxSession.crypto.crossSigning || mxSession.crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped)
+ if (!crypto.crossSigning || crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped)
{
if (self->roomKeyRequestViewController)
{
@@ -3515,13 +3536,13 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
// Give the client a chance to refresh the device list
MXWeakify(self);
- [mxSession.crypto downloadKeys:@[userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) {
+ [crypto downloadKeys:@[userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) {
MXStrongifyAndReturnIfNil(self);
MXDeviceInfo *deviceInfo = [usersDevicesInfoMap objectForDevice:deviceId forUser:userId];
if (deviceInfo)
{
- if (!mxSession.crypto.crossSigning || mxSession.crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped)
+ if (!crypto.crossSigning || crypto.crossSigning.state == MXCrossSigningStateNotBootstrapped)
{
BOOL wasNewDevice = (deviceInfo.trustLevel.localVerificationStatus == MXDeviceUnknown);
@@ -3529,7 +3550,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
{
MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: Open dialog for %@", deviceInfo);
- self->roomKeyRequestViewController = [[RoomKeyRequestViewController alloc] initWithDeviceInfo:deviceInfo wasNewDevice:wasNewDevice andMatrixSession:mxSession onComplete:^{
+ self->roomKeyRequestViewController = [[RoomKeyRequestViewController alloc] initWithDeviceInfo:deviceInfo wasNewDevice:wasNewDevice andMatrixSession:mxSession crypto:crypto onComplete:^{
self->roomKeyRequestViewController = nil;
@@ -3543,7 +3564,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
// If the device was new before, it's not any more.
if (wasNewDevice)
{
- [mxSession.crypto setDeviceVerification:MXDeviceUnverified forDevice:deviceId ofUser:userId success:openDialog failure:nil];
+ [crypto setDeviceVerification:MXDeviceUnverified forDevice:deviceId ofUser:userId success:openDialog failure:nil];
}
else
{
@@ -3552,13 +3573,13 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
}
else if (deviceInfo.trustLevel.isVerified)
{
- [mxSession.crypto acceptAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{
+ [crypto acceptAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{
[self checkPendingRoomKeyRequests];
}];
}
else
{
- [mxSession.crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{
+ [crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{
[self checkPendingRoomKeyRequests];
}];
}
@@ -3566,7 +3587,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
else
{
MXLogDebug(@"[AppDelegate] checkPendingRoomKeyRequestsInSession: No details found for device %@:%@", userId, deviceId);
- [mxSession.crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{
+ [crypto ignoreAllPendingKeyRequestsFromUser:userId andDevice:deviceId onComplete:^{
[self checkPendingRoomKeyRequests];
}];
}
@@ -3697,7 +3718,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
return presented;
}
-- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember session:(MXSession*)mxSession
+- (BOOL)presentUserVerificationForRoomMember:(MXRoomMember*)roomMember
+ session:(MXSession*)mxSession
+ completion:(void (^)(void))completion;
{
MXLogDebug(@"[AppDelegate][MXKeyVerification] presentUserVerificationForRoomMember: %@", roomMember);
@@ -3710,6 +3733,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
[keyVerificationCoordinatorBridgePresenter presentFrom:self.presentedViewController roomMember:roomMember animated:YES];
presented = YES;
+
+ keyVerificationCompletionBlock = completion;
}
else
{
@@ -3741,11 +3766,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
- (void)keyVerificationCoordinatorBridgePresenterDelegateDidComplete:(KeyVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter otherUserId:(NSString * _Nonnull)otherUserId otherDeviceId:(NSString * _Nonnull)otherDeviceId
{
- MXCrypto *crypto = coordinatorBridgePresenter.session.crypto;
- if (!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled)
+ id crypto = coordinatorBridgePresenter.session.crypto;
+ if ([crypto isKindOfClass:[MXLegacyCrypto class]] && (!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled))
{
MXLogDebug(@"[AppDelegate][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys");
- [crypto setOutgoingKeyRequestsEnabled:YES onComplete:nil];
+ [(MXLegacyCrypto *)crypto setOutgoingKeyRequestsEnabled:YES onComplete:nil];
}
[self dismissKeyVerificationCoordinatorBridgePresenter];
}
@@ -3762,6 +3787,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
}];
keyVerificationCoordinatorBridgePresenter = nil;
+
+ if (keyVerificationCompletionBlock) {
+ keyVerificationCompletionBlock();
+ }
+ keyVerificationCompletionBlock = nil;
}
#pragma mark - New request
@@ -3981,7 +4011,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
- (void)registerUserDidSignInOnNewDeviceNotificationForSession:(MXSession*)session
{
- MXCrossSigning *crossSigning = session.crypto.crossSigning;
+ id crossSigning = session.crypto.crossSigning;
if (!crossSigning)
{
@@ -4072,7 +4102,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
- (void)registerDidChangeCrossSigningKeysNotificationForSession:(MXSession*)session
{
- MXCrossSigning *crossSigning = session.crypto.crossSigning;
+ id crossSigning = session.crypto.crossSigning;
if (!crossSigning)
{
diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift
index 47ed8bfab..5aa6b3731 100644
--- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift
+++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift
@@ -758,8 +758,8 @@ extension AuthenticationCoordinator: AuthenticationServiceDelegate {
// MARK: - KeyVerificationCoordinatorDelegate
extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate {
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
- if let crypto = session?.crypto,
- !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled {
+ if let crypto = session?.crypto as? MXLegacyCrypto, let backup = crypto.backup,
+ !backup.hasPrivateKeyInCryptoStore || !backup.enabled {
MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
}
@@ -810,5 +810,4 @@ extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate {
func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) {
dismissFallback()
}
-
}
diff --git a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift
index 13b776c4e..e8ca770ab 100644
--- a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift
+++ b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift
@@ -219,8 +219,8 @@ extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate
// MARK: - KeyVerificationCoordinatorDelegate
extension LegacyAuthenticationCoordinator: KeyVerificationCoordinatorDelegate {
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
- if let crypto = session?.crypto,
- !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled {
+ if let crypto = session?.crypto as? MXLegacyCrypto, let backup = crypto.backup,
+ !backup.hasPrivateKeyInCryptoStore || !backup.enabled {
MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
}
diff --git a/Riot/Modules/Authentication/SessionVerificationListener.swift b/Riot/Modules/Authentication/SessionVerificationListener.swift
index 7d5d7f48b..214c76695 100644
--- a/Riot/Modules/Authentication/SessionVerificationListener.swift
+++ b/Riot/Modules/Authentication/SessionVerificationListener.swift
@@ -69,7 +69,7 @@ class SessionVerificationListener {
}
if session.state == .storeDataReady {
- if let crypto = session.crypto, crypto.crossSigning != nil {
+ if let crypto = session.crypto as? MXLegacyCrypto {
// Do not make key share requests while the "Complete security" is not complete.
// If the device is self-verified, the SDK will restore the existing key backup.
// Then, it will re-enable outgoing key share requests
@@ -78,7 +78,8 @@ class SessionVerificationListener {
} else if session.state == .running {
unregisterSessionStateChangeNotification()
- if let crypto = session.crypto, let crossSigning = crypto.crossSigning {
+ if let crypto = session.crypto {
+ let crossSigning = crypto.crossSigning
crossSigning.refreshState { [weak self] stateUpdated in
guard let self = self else { return }
@@ -100,7 +101,7 @@ class SessionVerificationListener {
self.completion?(.authenticationIsComplete)
} failure: { error in
MXLog.error("[SessionVerificationListener] sessionStateDidChange: Bootstrap failed", context: error)
- crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
+ (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
} else {
@@ -110,12 +111,12 @@ class SessionVerificationListener {
self.completion?(.authenticationIsComplete)
} failure: { error in
MXLog.error("[SessionVerificationListener] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.")
- crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
+ (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
}
} else {
- crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
+ (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
case .crossSigningExists:
@@ -124,12 +125,12 @@ class SessionVerificationListener {
default:
MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Nothing to do")
- crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
+ (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
} failure: { [weak self] error in
MXLog.error("[SessionVerificationListener] sessionStateDidChange: Fail to refresh crypto state", context: error)
- crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
+ (crypto as? MXLegacyCrypto)?.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self?.completion?(.authenticationIsComplete)
}
} else {
diff --git a/Riot/Modules/Call/CallViewController.m b/Riot/Modules/Call/CallViewController.m
index 71c52ba72..a8019c06c 100644
--- a/Riot/Modules/Call/CallViewController.m
+++ b/Riot/Modules/Call/CallViewController.m
@@ -373,7 +373,12 @@ CallAudioRouteMenuViewDelegate>
// Acknowledge the existence of all devices
[self startActivityIndicator];
- [self.mainSession.crypto setDevicesKnown:unknownDevices complete:^{
+ if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ MXLogFailure(@"[CallViewController] call: Only legacy crypto supports manual setting of known devices");
+ return;
+ }
+ [(MXLegacyCrypto *)self.mainSession.crypto setDevicesKnown:unknownDevices complete:^{
[self stopActivityIndicator];
diff --git a/Riot/Modules/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/AvatarView.swift
index e222d5af9..a3f46a5aa 100644
--- a/Riot/Modules/Common/Avatar/AvatarView.swift
+++ b/Riot/Modules/Common/Avatar/AvatarView.swift
@@ -106,19 +106,9 @@ class AvatarView: UIView, Themable {
return
}
- let defaultAvatarImage: UIImage?
- var defaultAvatarImageContentMode: UIView.ContentMode = .scaleAspectFill
+ let (defaultAvatarImage, defaultAvatarImageContentMode) = viewData.fallbackImageParameters() ?? (nil, .scaleAspectFill)
+ updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode)
- switch viewData.fallbackImage {
- case .matrixItem(let matrixItemId, let matrixItemDisplayName):
- defaultAvatarImage = AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName)
- case .image(let image, let contentMode):
- defaultAvatarImage = image
- defaultAvatarImageContentMode = contentMode ?? .scaleAspectFill
- case .none:
- defaultAvatarImage = nil
- }
-
if let avatarUrl = viewData.avatarUrl {
avatarImageView.setImageURI(avatarUrl,
withType: nil,
@@ -127,12 +117,9 @@ class AvatarView: UIView, Themable {
with: MXThumbnailingMethodScale,
previewImage: defaultAvatarImage,
mediaManager: viewData.mediaManager)
- avatarImageView.contentMode = .scaleAspectFill
- avatarImageView.imageView?.contentMode = .scaleAspectFill
+ updateAvatarContentMode(contentMode: .scaleAspectFill)
} else {
- avatarImageView.image = defaultAvatarImage
- avatarImageView.contentMode = defaultAvatarImageContentMode
- avatarImageView.imageView?.contentMode = defaultAvatarImageContentMode
+ updateAvatarImageView(image: defaultAvatarImage, contentMode: defaultAvatarImageContentMode)
}
}
@@ -148,6 +135,16 @@ class AvatarView: UIView, Themable {
gestureRecognizer.minimumPressDuration = 0
self.addGestureRecognizer(gestureRecognizer)
}
+
+ private func updateAvatarImageView(image: UIImage?, contentMode: UIView.ContentMode) {
+ avatarImageView?.image = image
+ updateAvatarContentMode(contentMode: contentMode)
+ }
+
+ private func updateAvatarContentMode(contentMode: UIView.ContentMode) {
+ avatarImageView?.contentMode = contentMode
+ avatarImageView?.imageView?.contentMode = contentMode
+ }
// MARK: - Actions
diff --git a/Riot/Modules/Common/Avatar/AvatarViewData.swift b/Riot/Modules/Common/Avatar/AvatarViewData.swift
index ef5cbb89c..88eb47a07 100644
--- a/Riot/Modules/Common/Avatar/AvatarViewData.swift
+++ b/Riot/Modules/Common/Avatar/AvatarViewData.swift
@@ -29,6 +29,21 @@ struct AvatarViewData: AvatarViewDataProtocol {
/// Matrix media handler if exists
var mediaManager: MXMediaManager?
- /// Fallback image used when avatarUrl is nil
- var fallbackImage: AvatarFallbackImage?
+ /// Fallback images used when avatarUrl is nil
+ var fallbackImages: [AvatarFallbackImage]?
+}
+
+extension AvatarViewData {
+ init(matrixItemId: String,
+ displayName: String? = nil,
+ avatarUrl: String? = nil,
+ mediaManager: MXMediaManager? = nil,
+ fallbackImage: AvatarFallbackImage?) {
+
+ self.matrixItemId = matrixItemId
+ self.displayName = displayName
+ self.avatarUrl = avatarUrl
+ self.mediaManager = mediaManager
+ self.fallbackImages = fallbackImage.map { [$0] }
+ }
}
diff --git a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift
index 9b677e581..f3410783b 100644
--- a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift
+++ b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift
@@ -41,6 +41,24 @@ protocol AvatarViewDataProtocol: AvatarProtocol {
/// Matrix media handler
var mediaManager: MXMediaManager? { get }
- /// Fallback image used when avatarUrl is nil
- var fallbackImage: AvatarFallbackImage? { get }
+ /// Fallback images used when avatarUrl is nil
+ var fallbackImages: [AvatarFallbackImage]? { get }
+}
+
+extension AvatarViewDataProtocol {
+ func fallbackImageParameters() -> (UIImage?, UIView.ContentMode)? {
+ fallbackImages?
+ .lazy
+ .map { fallbackImage in
+ switch fallbackImage {
+ case .matrixItem(let matrixItemId, let matrixItemDisplayName):
+ return (AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName), .scaleAspectFill)
+ case .image(let image, let contentMode):
+ return (image, contentMode ?? .scaleAspectFill)
+ }
+ }
+ .first { (image, contentMode) in
+ image != nil
+ }
+ }
}
diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift
index 141c676e2..ef1c2a66d 100644
--- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift
+++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift
@@ -25,8 +25,7 @@ import Combine
class VectorHostingController: UIHostingController {
// MARK: Private
-
- private let forceZeroSafeAreaInsets: Bool
+
private var theme: Theme
private var heightSubject = CurrentValueSubject(0)
@@ -55,11 +54,8 @@ class VectorHostingController: UIHostingController {
}
/// Initializer
/// - Parameter rootView: Root view for the controller.
- /// - Parameter forceZeroSafeAreaInsets: Whether to force-set the hosting view's safe area insets to zero. Useful when the view is used as part of a table view.
- init(rootView: Content,
- forceZeroSafeAreaInsets: Bool = false) where Content: View {
+ init(rootView: Content) where Content: View {
self.theme = ThemeService.shared().theme
- self.forceZeroSafeAreaInsets = forceZeroSafeAreaInsets
super.init(rootView: AnyView(rootView.vectorContent()))
}
@@ -116,22 +112,6 @@ class VectorHostingController: UIHostingController {
heightSubject.send(height)
}
}
-
- override func viewSafeAreaInsetsDidChange() {
- super.viewSafeAreaInsetsDidChange()
-
- guard forceZeroSafeAreaInsets else {
- return
- }
-
- let counterSafeAreaInsets = UIEdgeInsets(top: -view.safeAreaInsets.top,
- left: -view.safeAreaInsets.left,
- bottom: -view.safeAreaInsets.bottom,
- right: -view.safeAreaInsets.right)
- if additionalSafeAreaInsets != counterSafeAreaInsets, counterSafeAreaInsets != .zero {
- additionalSafeAreaInsets = counterSafeAreaInsets
- }
- }
private func registerThemeServiceDidChangeThemeNotification() {
NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
diff --git a/Riot/Modules/CrossSigning/CrossSigningService.swift b/Riot/Modules/CrossSigning/CrossSigningService.swift
index ab40e6369..c4773581b 100644
--- a/Riot/Modules/CrossSigning/CrossSigningService.swift
+++ b/Riot/Modules/CrossSigning/CrossSigningService.swift
@@ -85,7 +85,7 @@ final class CrossSigningService: NSObject {
@discardableResult
func setupCrossSigningWithoutAuthentication(for session: MXSession, success: @escaping (() -> Void), failure: @escaping ((Error) -> Void)) -> MXHTTPOperation? {
- guard let crossSigning = session.crypto.crossSigning else {
+ guard let crossSigning = session.crypto?.crossSigning else {
failure(CrossSigningServiceError.unknown)
return nil
}
diff --git a/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift b/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift
index f545b2e44..2877de09d 100644
--- a/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift
+++ b/Riot/Modules/CrossSigning/Setup/CrossSigningSetupCoordinator.swift
@@ -72,7 +72,7 @@ final class CrossSigningSetupCoordinator: CrossSigningSetupCoordinatorType {
}
private func setupCrossSigning(with authenticationParameters: [String: Any]) {
- guard let crossSigning = self.parameters.session.crypto.crossSigning else {
+ guard let crossSigning = self.parameters.session.crypto?.crossSigning else {
return
}
diff --git a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift
index 932a52333..1070db4e5 100644
--- a/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift
+++ b/Riot/Modules/Home/AllChats/AllChatsCoordinator.swift
@@ -332,7 +332,7 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol {
createAvatarButtonItem(for: viewController)
}
- private func createAvatarButtonItem(for viewController: UIViewController) {
+ private var avatarMenu: UIMenu {
var actions: [UIMenuElement] = []
actions.append(UIAction(title: VectorL10n.allChatsUserMenuSettings, image: UIImage(systemName: "gearshape")) { [weak self] action in
@@ -358,32 +358,30 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol {
}
]))
- let menu = UIMenu(options: .displayInline, children: actions)
-
+ return UIMenu(options: .displayInline, children: actions)
+ }
+
+ private func createAvatarButtonItem(for viewController: UIViewController) {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 36, height: 36))
view.backgroundColor = .clear
- let button: UIButton = UIButton(frame: view.bounds.inset(by: UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7)))
+ let avatarInsets: UIEdgeInsets = .init(top: 7, left: 7, bottom: 7, right: 7)
+ let button: UIButton = .init(frame: view.bounds.inset(by: avatarInsets))
button.setImage(Asset.Images.tabPeople.image, for: .normal)
- button.menu = menu
+ button.menu = avatarMenu
button.showsMenuAsPrimaryAction = true
button.autoresizingMask = [.flexibleHeight, .flexibleWidth]
button.accessibilityLabel = VectorL10n.allChatsUserMenuAccessibilityLabel
view.addSubview(button)
self.avatarMenuButton = button
- let avatarView = UserAvatarView(frame: view.bounds.inset(by: UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7)))
+ let avatarView = UserAvatarView(frame: view.bounds.inset(by: avatarInsets))
avatarView.isUserInteractionEnabled = false
avatarView.update(theme: ThemeService.shared().theme)
avatarView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
view.addSubview(avatarView)
self.avatarMenuView = avatarView
-
- if let avatar = userAvatarViewData(from: currentMatrixSession) {
- avatarView.fill(with: avatar)
- button.setImage(nil, for: .normal)
- }
-
+ updateAvatarButtonItem()
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: view)
}
diff --git a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift
index d7d0a9a31..82404834e 100644
--- a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift
+++ b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinator.swift
@@ -22,7 +22,7 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType {
// MARK: Private
- private let session: MXSession
+ private let keyBackup: MXKeyBackup
private let navigationRouter: NavigationRouterType
private let keyBackupVersion: MXKeyBackupVersion
@@ -34,8 +34,8 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType {
// MARK: - Setup
- init(session: MXSession, keyBackupVersion: MXKeyBackupVersion, navigationRouter: NavigationRouterType? = nil) {
- self.session = session
+ init(keyBackup: MXKeyBackup, keyBackupVersion: MXKeyBackupVersion, navigationRouter: NavigationRouterType? = nil) {
+ self.keyBackup = keyBackup
self.keyBackupVersion = keyBackupVersion
if let navigationRouter = navigationRouter {
@@ -52,7 +52,7 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType {
let rootCoordinator: Coordinator & Presentable
// Check if we have the private key locally
- if self.session.crypto.backup.hasPrivateKeyInCryptoStore {
+ if keyBackup.hasPrivateKeyInCryptoStore {
rootCoordinator = self.createRecoverFromPrivateKeyCoordinator()
} else {
rootCoordinator = self.createRecoverWithUserInteractionCoordinator()
@@ -93,19 +93,19 @@ final class KeyBackupRecoverCoordinator: KeyBackupRecoverCoordinatorType {
}
private func createRecoverFromPrivateKeyCoordinator() -> KeyBackupRecoverFromPrivateKeyCoordinator {
- let coordinator = KeyBackupRecoverFromPrivateKeyCoordinator(keyBackup: self.session.crypto.backup, keyBackupVersion: self.keyBackupVersion)
+ let coordinator = KeyBackupRecoverFromPrivateKeyCoordinator(keyBackup: keyBackup, keyBackupVersion: self.keyBackupVersion)
coordinator.delegate = self
return coordinator
}
private func createRecoverFromPassphraseCoordinator() -> KeyBackupRecoverFromPassphraseCoordinator {
- let coordinator = KeyBackupRecoverFromPassphraseCoordinator(keyBackup: self.session.crypto.backup, keyBackupVersion: self.keyBackupVersion)
+ let coordinator = KeyBackupRecoverFromPassphraseCoordinator(keyBackup: keyBackup, keyBackupVersion: self.keyBackupVersion)
coordinator.delegate = self
return coordinator
}
private func createRecoverFromRecoveryKeyCoordinator() -> KeyBackupRecoverFromRecoveryKeyCoordinator {
- let coordinator = KeyBackupRecoverFromRecoveryKeyCoordinator(keyBackup: self.session.crypto.backup, keyBackupVersion: self.keyBackupVersion)
+ let coordinator = KeyBackupRecoverFromRecoveryKeyCoordinator(keyBackup: keyBackup, keyBackupVersion: self.keyBackupVersion)
coordinator.delegate = self
return coordinator
}
diff --git a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift
index a06e9befd..2d5be4578 100644
--- a/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift
+++ b/Riot/Modules/KeyBackup/Recover/KeyBackupRecoverCoordinatorBridgePresenter.swift
@@ -49,7 +49,12 @@ final class KeyBackupRecoverCoordinatorBridgePresenter: NSObject {
// MARK: - Public
func present(from viewController: UIViewController, animated: Bool) {
- let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(session: self.session, keyBackupVersion: keyBackupVersion)
+ guard let keyBackup = session.crypto?.backup else {
+ MXLog.failure("[KeyBackupRecoverCoordinatorBridgePresenter] Cannot setup backups without backup module")
+ return
+ }
+
+ let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(keyBackup: keyBackup, keyBackupVersion: keyBackupVersion)
keyBackupSetupCoordinator.delegate = self
viewController.present(keyBackupSetupCoordinator.toPresentable(), animated: animated, completion: nil)
keyBackupSetupCoordinator.start()
@@ -58,12 +63,16 @@ final class KeyBackupRecoverCoordinatorBridgePresenter: NSObject {
}
func push(from navigationController: UINavigationController, animated: Bool) {
+ guard let keyBackup = session.crypto?.backup else {
+ MXLog.failure("[KeyBackupRecoverCoordinatorBridgePresenter] Cannot setup backups without backup module")
+ return
+ }
MXLog.debug("[KeyBackupRecoverCoordinatorBridgePresenter] Push complete security from \(navigationController)")
let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController)
- let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(session: self.session, keyBackupVersion: keyBackupVersion, navigationRouter: navigationRouter)
+ let keyBackupSetupCoordinator = KeyBackupRecoverCoordinator(keyBackup: keyBackup, keyBackupVersion: keyBackupVersion, navigationRouter: navigationRouter)
keyBackupSetupCoordinator.delegate = self
keyBackupSetupCoordinator.start() // Will trigger view controller push
diff --git a/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift b/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift
index 03171ebd2..aab964c2f 100644
--- a/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift
+++ b/Riot/Modules/KeyBackup/Setup/KeyBackupSetupCoordinator.swift
@@ -66,7 +66,7 @@ final class KeyBackupSetupCoordinator: KeyBackupSetupCoordinatorType {
private func createSetupIntroViewController() -> KeyBackupSetupIntroViewController {
- let backupState = self.session.crypto.backup?.state ?? MXKeyBackupStateUnknown
+ let backupState = self.session.crypto?.backup?.state ?? MXKeyBackupStateUnknown
let isABackupAlreadyExists: Bool
switch backupState {
@@ -99,7 +99,12 @@ final class KeyBackupSetupCoordinator: KeyBackupSetupCoordinatorType {
}
private func showSetupPassphrase(animated: Bool) {
- let keyBackupSetupPassphraseCoordinator = KeyBackupSetupPassphraseCoordinator(session: self.session)
+ guard let keyBackup = self.session.crypto?.backup else {
+ MXLog.failure("[KeyBackupSetupCoordinator] Cannot setup backups without backup module")
+ return
+ }
+
+ let keyBackupSetupPassphraseCoordinator = KeyBackupSetupPassphraseCoordinator(keyBackup: keyBackup)
keyBackupSetupPassphraseCoordinator.delegate = self
keyBackupSetupPassphraseCoordinator.start()
@@ -130,7 +135,7 @@ final class KeyBackupSetupCoordinator: KeyBackupSetupCoordinatorType {
}
private func createKeyBackupUsingSecureBackup(privateKey: Data, completion: @escaping (Result) -> Void) {
- guard let keyBackup = session.crypto.backup, let recoveryService = session.crypto.recoveryService else {
+ guard let keyBackup = session.crypto?.backup, let recoveryService = session.crypto?.recoveryService else {
return
}
diff --git a/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift b/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift
index ea0d2b549..f9c0342be 100644
--- a/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift
+++ b/Riot/Modules/KeyBackup/Setup/Passphrase/KeyBackupSetupPassphraseCoordinator.swift
@@ -23,7 +23,6 @@ final class KeyBackupSetupPassphraseCoordinator: KeyBackupSetupPassphraseCoordin
// MARK: Private
- private let session: MXSession
private var keyBackupSetupPassphraseViewModel: KeyBackupSetupPassphraseViewModelType
private let keyBackupSetupPassphraseViewController: KeyBackupSetupPassphraseViewController
@@ -35,10 +34,8 @@ final class KeyBackupSetupPassphraseCoordinator: KeyBackupSetupPassphraseCoordin
// MARK: - Setup
- init(session: MXSession) {
- self.session = session
-
- let keyBackupSetupPassphraseViewModel = KeyBackupSetupPassphraseViewModel(keyBackup: self.session.crypto.backup)
+ init(keyBackup: MXKeyBackup) {
+ let keyBackupSetupPassphraseViewModel = KeyBackupSetupPassphraseViewModel(keyBackup: keyBackup)
let keyBackupSetupPassphraseViewController = KeyBackupSetupPassphraseViewController.instantiate(with: keyBackupSetupPassphraseViewModel)
self.keyBackupSetupPassphraseViewModel = keyBackupSetupPassphraseViewModel
self.keyBackupSetupPassphraseViewController = keyBackupSetupPassphraseViewController
diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift
index 51dd86b69..4129c4f47 100644
--- a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift
+++ b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift
@@ -324,12 +324,8 @@ extension KeyVerificationCoordinator: KeyVerificationDataLoadingCoordinatorDeleg
// MARK: - DeviceVerificationStartCoordinatorDelegate
extension KeyVerificationCoordinator: DeviceVerificationStartCoordinatorDelegate {
- func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) {
- self.showVerifyBySAS(transaction: transaction, animated: true)
- }
-
- func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didTransactionCancelled transaction: MXSASTransaction) {
- self.didCancel()
+ func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, otherDidAcceptRequest request: MXKeyVerificationRequest) {
+ self.showVerifyByScanning(keyVerificationRequest: request, animated: true)
}
func deviceVerificationStartCoordinatorDidCancel(_ coordinator: DeviceVerificationStartCoordinatorType) {
diff --git a/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift b/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift
index b2874db8f..7db1624c9 100644
--- a/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift
+++ b/Riot/Modules/KeyVerification/Common/Loading/KeyVerificationDataLoadingViewModel.swift
@@ -19,7 +19,6 @@
import Foundation
enum KeyVerificationDataLoadingViewModelError: Error {
- case unknown
case transactionCancelled
case transactionCancelledByMe(reason: MXTransactionCancelCode)
}
@@ -137,9 +136,7 @@ final class KeyVerificationDataLoadingViewModel: KeyVerificationDataLoadingViewM
return
}
- let finalError = error ?? KeyVerificationDataLoadingViewModelError.unknown
-
- sself.update(viewState: .error(finalError))
+ sself.update(viewState: .error(error))
})
} else {
diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift
index 29c312bfc..b064d4f84 100644
--- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift
+++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift
@@ -92,21 +92,29 @@ final class KeyVerificationSelfVerifyWaitViewModel: KeyVerificationSelfVerifyWai
// be sure that session has completed its first sync
if session.state >= .running {
- // Always send request instead of waiting for an incoming one as per recent EW changes
- MXLog.debug("[KeyVerificationSelfVerifyWaitViewModel] loadData: Send a verification request to all devices instead of waiting")
-
- let keyVerificationService = KeyVerificationService()
- self.verificationManager.requestVerificationByToDevice(withUserId: self.session.myUserId, deviceIds: nil, methods: keyVerificationService.supportedKeyVerificationMethods(), success: { [weak self] (keyVerificationRequest) in
- guard let self = self else {
- return
- }
+ if let existingRequest = verificationManager.pendingRequests.first(where: { $0.isFromMyUser && !$0.isFromMyDevice && $0.state == MXKeyVerificationRequestStatePending }) {
+ MXLog.debug("[KeyVerificationSelfVerifyWaitViewModel] loadData: Accepting an existing self-verification request instead of starting a new one")
- self.keyVerificationRequest = keyVerificationRequest
+ registerTransactionDidStateChangeNotification()
+ acceptKeyVerificationRequest(existingRequest)
+ } else {
- }, failure: { [weak self] error in
- self?.update(viewState: .error(error))
- })
- continueLoadData()
+ // Always send request instead of waiting for an incoming one as per recent EW changes
+ MXLog.debug("[KeyVerificationSelfVerifyWaitViewModel] loadData: Send a verification request to all devices instead of waiting")
+
+ let keyVerificationService = KeyVerificationService()
+ self.verificationManager.requestVerificationByToDevice(withUserId: self.session.myUserId, deviceIds: nil, methods: keyVerificationService.supportedKeyVerificationMethods(), success: { [weak self] (keyVerificationRequest) in
+ guard let self = self else {
+ return
+ }
+
+ self.keyVerificationRequest = keyVerificationRequest
+
+ }, failure: { [weak self] error in
+ self?.update(viewState: .error(error))
+ })
+ continueLoadData()
+ }
} else {
// show loader
self.update(viewState: .secretsRecoveryCheckingAvailability(VectorL10n.deviceVerificationSelfVerifyWaitRecoverSecretsCheckingAvailability))
diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift
index f6ba2bec8..0c806cedd 100644
--- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift
+++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinator.swift
@@ -63,13 +63,9 @@ extension DeviceVerificationStartCoordinator: DeviceVerificationStartViewModelCo
func deviceVerificationStartViewModelDidUseLegacyVerification(_ viewModel: DeviceVerificationStartViewModelType) {
self.delegate?.deviceVerificationStartCoordinatorDidCancel(self)
}
-
- func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) {
- self.delegate?.deviceVerificationStartCoordinator(self, didCompleteWithOutgoingTransaction: transaction)
- }
-
- func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didTransactionCancelled transaction: MXSASTransaction) {
- self.delegate?.deviceVerificationStartCoordinator(self, didTransactionCancelled: transaction)
+
+ func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, otherDidAcceptRequest request: MXKeyVerificationRequest) {
+ self.delegate?.deviceVerificationStartCoordinator(self, otherDidAcceptRequest: request)
}
func deviceVerificationStartViewModelDidCancel(_ viewModel: DeviceVerificationStartViewModelType) {
diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift
index 16a79760c..f26862e90 100644
--- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift
+++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartCoordinatorType.swift
@@ -19,8 +19,7 @@
import Foundation
protocol DeviceVerificationStartCoordinatorDelegate: AnyObject {
- func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction)
- func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, didTransactionCancelled transaction: MXSASTransaction)
+ func deviceVerificationStartCoordinator(_ coordinator: DeviceVerificationStartCoordinatorType, otherDidAcceptRequest request: MXKeyVerificationRequest)
func deviceVerificationStartCoordinatorDidCancel(_ coordinator: DeviceVerificationStartCoordinatorType)
}
diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift
index 4a8d0e66f..8a64cc881 100644
--- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift
+++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift
@@ -29,7 +29,7 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy
private let otherUser: MXUser
private let otherDevice: MXDeviceInfo
- private var transaction: MXSASTransaction!
+ private var request: MXKeyVerificationRequest?
// MARK: Public
@@ -52,12 +52,12 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy
case .beginVerifying:
self.beginVerifying()
case .verifyUsingLegacy:
- self.cancelTransaction()
+ self.cancelRequest()
self.update(viewState: .verifyUsingLegacy(self.session, self.otherDevice))
case .verifiedUsingLegacy:
self.coordinatorDelegate?.deviceVerificationStartViewModelDidUseLegacyVerification(self)
case .cancel:
- self.cancelTransaction()
+ self.cancelRequest()
self.coordinatorDelegate?.deviceVerificationStartViewModelDidCancel(self)
}
}
@@ -67,30 +67,22 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy
private func beginVerifying() {
self.update(viewState: .loading)
- self.verificationManager.beginKeyVerification(withUserId: self.otherUser.userId, andDeviceId: self.otherDevice.deviceId, method: MXKeyVerificationMethodSAS, success: { [weak self] (transaction) in
-
- guard let sself = self else {
- return
- }
- guard let sasTransaction = transaction as? MXSASTransaction, !sasTransaction.isIncoming else {
+ self.verificationManager.requestVerificationByToDevice(withUserId: otherUser.userId, deviceIds: [otherDevice.deviceId], methods: [MXKeyVerificationMethodSAS], success: { [weak self] request in
+ guard let self = self else {
return
}
- sself.transaction = sasTransaction
+ self.request = request
- sself.update(viewState: .loaded)
- sself.registerTransactionDidStateChangeNotification(transaction: sasTransaction)
+ self.update(viewState: .loaded)
+ self.registerKeyVerificationRequestDidChangeNotification(for: request)
}, failure: {[weak self] error in
self?.update(viewState: .error(error))
})
}
- private func cancelTransaction() {
- guard let transaction = self.transaction else {
- return
- }
-
- transaction.cancel(with: MXTransactionCancelCode.user())
+ private func cancelRequest() {
+ request?.cancel(with: MXTransactionCancelCode.user(), success: nil)
}
private func update(viewState: DeviceVerificationStartViewState) {
@@ -98,37 +90,41 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy
}
- // MARK: - MXKeyVerificationTransactionDidChange
+ // MARK: - MXKeyVerificationRequestDidChange
- private func registerTransactionDidStateChangeNotification(transaction: MXSASTransaction) {
- NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: NSNotification.Name.MXKeyVerificationTransactionDidChange, object: transaction)
+ private func registerKeyVerificationRequestDidChangeNotification(for request: MXKeyVerificationRequest) {
+ NotificationCenter.default.addObserver(self, selector: #selector(requestDidStateChange(notification:)), name: .MXKeyVerificationRequestDidChange, object: request)
}
- private func unregisterTransactionDidStateChangeNotification() {
- NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationTransactionDidChange, object: nil)
+ private func unregisterKeyVerificationRequestDidChangeNotification() {
+ NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationRequestDidChange, object: nil)
}
-
- @objc private func transactionDidStateChange(notification: Notification) {
- guard let transaction = notification.object as? MXSASTransaction, !transaction.isIncoming else {
+
+ @objc private func requestDidStateChange(notification: Notification) {
+ guard let request = notification.object as? MXKeyVerificationRequest, request.requestId == self.request?.requestId else {
return
}
- switch transaction.state {
- case MXSASTransactionStateShowSAS:
- self.unregisterTransactionDidStateChangeNotification()
- self.coordinatorDelegate?.deviceVerificationStartViewModel(self, didCompleteWithOutgoingTransaction: transaction)
- case MXSASTransactionStateCancelled:
- guard let reason = transaction.reasonCancelCode else {
+ switch request.state {
+ case MXKeyVerificationRequestStateAccepted, MXKeyVerificationRequestStateReady:
+ self.unregisterKeyVerificationRequestDidChangeNotification()
+ self.coordinatorDelegate?.deviceVerificationStartViewModel(self, otherDidAcceptRequest: request)
+
+ case MXKeyVerificationRequestStateCancelled:
+ guard let reason = request.reasonCancelCode else {
return
}
- self.unregisterTransactionDidStateChangeNotification()
+ self.unregisterKeyVerificationRequestDidChangeNotification()
self.update(viewState: .cancelled(reason))
- case MXSASTransactionStateCancelledByMe:
- guard let reason = transaction.reasonCancelCode else {
+ case MXKeyVerificationRequestStateCancelledByMe:
+ guard let reason = request.reasonCancelCode else {
return
}
- self.unregisterTransactionDidStateChangeNotification()
+ self.unregisterKeyVerificationRequestDidChangeNotification()
self.update(viewState: .cancelledByMe(reason))
+ case MXKeyVerificationRequestStateExpired:
+ self.unregisterKeyVerificationRequestDidChangeNotification()
+ self.update(viewState: .error(UserVerificationStartViewModelError.keyVerificationRequestExpired))
default:
break
}
diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift
index 015e80faf..c4f04b287 100644
--- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift
+++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModelType.swift
@@ -25,8 +25,7 @@ protocol DeviceVerificationStartViewModelViewDelegate: AnyObject {
protocol DeviceVerificationStartViewModelCoordinatorDelegate: AnyObject {
func deviceVerificationStartViewModelDidUseLegacyVerification(_ viewModel: DeviceVerificationStartViewModelType)
- func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction)
- func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, didTransactionCancelled transaction: MXSASTransaction)
+ func deviceVerificationStartViewModel(_ viewModel: DeviceVerificationStartViewModelType, otherDidAcceptRequest request: MXKeyVerificationRequest)
func deviceVerificationStartViewModelDidCancel(_ viewModel: DeviceVerificationStartViewModelType)
}
diff --git a/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift b/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift
index 104da3b32..a46b30555 100644
--- a/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift
+++ b/Riot/Modules/KeyVerification/User/SessionsStatus/UserVerificationSessionsStatusViewModel.swift
@@ -18,10 +18,6 @@
import Foundation
-enum UserVerificationSessionsStatusViewModelError: Error {
- case unknown
-}
-
final class UserVerificationSessionsStatusViewModel: UserVerificationSessionsStatusViewModelType {
// MARK: - Properties
@@ -103,7 +99,7 @@ final class UserVerificationSessionsStatusViewModel: UserVerificationSessionsSta
}
private func getDevicesFromCache(for userId: String) -> [MXDeviceInfo] {
- guard let deviceInfoMap = self.session.crypto.devices(forUser: self.userId) else {
+ guard let deviceInfoMap = self.session.crypto?.devices(forUser: self.userId) else {
return []
}
return Array(deviceInfoMap.values)
@@ -128,9 +124,7 @@ final class UserVerificationSessionsStatusViewModel: UserVerificationSessionsSta
completion(.success(sessionsViewData))
}, failure: { error in
-
- let finalError = error ?? UserVerificationSessionsStatusViewModelError.unknown
- completion(.failure(finalError))
+ completion(.failure(error))
})
return httpOperation
diff --git a/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift b/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift
index 45e8e378f..604253c26 100644
--- a/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift
+++ b/Riot/Modules/KeyVerification/User/UserVerificationCoordinator.swift
@@ -189,6 +189,7 @@ extension UserVerificationCoordinator: KeyVerificationCoordinatorDelegate {
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
dismissPresenter(coordinator: coordinator)
+ delegate?.userVerificationCoordinatorDidComplete(self)
}
func keyVerificationCoordinatorDidCancel(_ coordinator: KeyVerificationCoordinatorType) {
diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m
index 128e9b161..97cc2fb5c 100644
--- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m
+++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m
@@ -952,7 +952,10 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes;
{
// Force a reload of device keys at the next session start.
// This will fix potential UISIs other peoples receive for our messages.
- [mxSession.crypto resetDeviceKeys];
+ if ([mxSession.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ [(MXLegacyCrypto *)mxSession.crypto resetDeviceKeys];
+ }
// Clean other stores
[mxSession.scanManager deleteAllAntivirusScans];
@@ -1743,8 +1746,18 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes;
return;
}
+ if (![mxSession.crypto.crossSigning isKindOfClass:[MXLegacyCrossSigning class]]) {
+ MXLogFailure(@"Device dehydratation is currently only supported by legacy cross signing, add support to all implementations");
+ if (failure)
+ {
+ failure(nil);
+ }
+ return;
+ }
+ MXLegacyCrossSigning *crossSigning = (MXLegacyCrossSigning *)mxSession.crypto.crossSigning;;
+
MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: starting device dehydration");
- [[MXKAccountManager sharedManager].dehydrationService dehydrateDeviceWithMatrixRestClient:mxRestClient crypto:mxSession.crypto dehydrationKey:keyData success:^(NSString *deviceId) {
+ [[MXKAccountManager sharedManager].dehydrationService dehydrateDeviceWithMatrixRestClient:mxRestClient crossSigning:crossSigning dehydrationKey:keyData success:^(NSString *deviceId) {
MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device successfully dehydrated");
if (success)
diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m
index 6d231262c..520209860 100644
--- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m
+++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m
@@ -65,9 +65,12 @@
_event = event;
_displayFix = MXKRoomBubbleComponentDisplayFixNone;
- if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML])
+
+ NSString *format = event.content[@"format"];
+ if ([format isKindOfClass:[NSString class]] && [format isEqualToString:kMXRoomMessageFormatHTML])
{
- if ([((NSString*)event.content[@"formatted_body"]) containsString:@" bubbleData = [roomDataSource cellDataOfEventWithEventId:voiceBroadcastInfo.eventId];
+ bubbleData.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback;
+ }
+ }
self.collapsable = NO;
self.collapsed = NO;
- MXLogDebug(@"VB incoming initWithEvent")
break;
}
@@ -205,7 +235,7 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
}
else if (event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType])
{
- self.tag = RoomBubbleCellDataTagVoiceBroadcast;
+ self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay;
self.collapsable = NO;
self.collapsed = NO;
}
@@ -315,13 +345,11 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
}
break;
- case RoomBubbleCellDataTagVoiceBroadcast:
- if (RiotSettings.shared.enableVoiceBroadcast == YES &&
- [VoiceBroadcastInfo isStartedFor:[VoiceBroadcastInfo modelFromJSON:self.events.lastObject.content].state])
- {
- hasNoDisplay = NO;
- }
-
+ case RoomBubbleCellDataTagVoiceBroadcastRecord:
+ case RoomBubbleCellDataTagVoiceBroadcastPlayback:
+ hasNoDisplay = NO;
+ break;
+ case RoomBubbleCellDataTagVoiceBroadcastNoDisplay:
break;
default:
hasNoDisplay = [super hasNoDisplay];
@@ -1072,7 +1100,9 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
case RoomBubbleCellDataTagLiveLocation:
shouldAddEvent = NO;
break;
- case RoomBubbleCellDataTagVoiceBroadcast:
+ case RoomBubbleCellDataTagVoiceBroadcastRecord:
+ case RoomBubbleCellDataTagVoiceBroadcastPlayback:
+ case RoomBubbleCellDataTagVoiceBroadcastNoDisplay:
shouldAddEvent = NO;
break;
default:
@@ -1143,7 +1173,7 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
{
shouldAddEvent = NO;
}
- } else if ([event.type isEqualToString:VoiceBroadcastSettings.eventType]) {
+ } else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) {
shouldAddEvent = NO;
}
break;
diff --git a/Riot/Modules/Room/MXKRoomViewController.h b/Riot/Modules/Room/MXKRoomViewController.h
index 0ff875fc3..dd3bfd205 100644
--- a/Riot/Modules/Room/MXKRoomViewController.h
+++ b/Riot/Modules/Room/MXKRoomViewController.h
@@ -73,11 +73,6 @@ typedef NS_ENUM(NSUInteger, MXKRoomViewControllerJoinRoomResult) {
*/
MXKAttachment *currentSharedAttachment;
- /**
- The potential text input placeholder is saved when it is replaced temporarily
- */
- NSString *savedInputToolbarPlaceholder;
-
/**
Tell whether the input toolbar required to run an animation indicator.
*/
diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m
index 7e1ba5129..aecfcf665 100644
--- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m
+++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m
@@ -440,7 +440,9 @@
- (void)startUserVerification
{
- [[AppDelegate theDelegate] presentUserVerificationForRoomMember:self.mxRoomMember session:self.mainSession];
+ [[AppDelegate theDelegate] presentUserVerificationForRoomMember:self.mxRoomMember session:self.mainSession completion:^{
+ [self refreshUserEncryptionTrustLevel];
+ }];
}
- (void)presentUserVerification
@@ -1332,6 +1334,7 @@
- (void)keyVerificationCoordinatorBridgePresenterDelegateDidComplete:(KeyVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter otherUserId:(NSString * _Nonnull)otherUserId otherDeviceId:(NSString * _Nonnull)otherDeviceId
{
+ [self refreshUserEncryptionTrustLevel];
[self dismissKeyVerificationCoordinatorBridgePresenter];
}
diff --git a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift
index 744bfa2b6..caab08a86 100644
--- a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift
+++ b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift
@@ -25,7 +25,7 @@ class RoomNotificationSettingsAvatarView: UIView {
func configure(viewData: AvatarViewDataProtocol) {
avatarView.fill(with: viewData)
- switch viewData.fallbackImage {
+ switch viewData.fallbackImages?.first {
case .matrixItem(_, let matrixItemDisplayName):
nameLabel.text = matrixItemDisplayName
default:
diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift
index 3ba9d8793..35caf9084 100644
--- a/Riot/Modules/Room/RoomCoordinator.swift
+++ b/Riot/Modules/Room/RoomCoordinator.swift
@@ -92,7 +92,8 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
self.roomViewController.parentSpaceId = parameters.parentSpaceId
TimelinePollProvider.shared.session = parameters.session
- TimelineVoiceBroadcastProvider.shared.session = parameters.session
+ VoiceBroadcastPlaybackProvider.shared.session = parameters.session
+ VoiceBroadcastRecorderProvider.shared.session = parameters.session
super.init()
}
diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m
index a1c520302..2149ecaa4 100644
--- a/Riot/Modules/Room/RoomViewController.m
+++ b/Riot/Modules/Room/RoomViewController.m
@@ -598,6 +598,7 @@ static CGSize kThreadListBarButtonItemImageSize;
isAppeared = NO;
[VoiceMessageMediaServiceProvider.sharedProvider pauseAllServices];
+ [VoiceBroadcastRecorderProvider.shared pauseRecording];
// Stop the loading indicator even if the session is still in progress
[self stopLoadingUserIndicator];
@@ -1775,15 +1776,20 @@ static CGSize kThreadListBarButtonItemImageSize;
|| self.customizedRoomDataSource.jitsiWidget;
}
+- (BOOL)canSendStateEventWithType:(MXEventTypeString)eventTypeString
+{
+ MXRoomPowerLevels *powerLevels = [self.roomDataSource.roomState powerLevels];
+ NSInteger requiredPower = [powerLevels minimumPowerLevelForSendingEventAsStateEvent:eventTypeString];
+ NSInteger myPower = [powerLevels powerLevelOfUserWithUserID:self.roomDataSource.mxSession.myUserId];
+ return myPower >= requiredPower;
+}
+
/**
Returns a flag for the current user whether it's privileged to add/remove Jitsi widgets to this room.
*/
- (BOOL)canEditJitsiWidget
{
- MXRoomPowerLevels *powerLevels = [self.roomDataSource.roomState powerLevels];
- NSInteger requiredPower = [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kWidgetModularEventTypeString];
- NSInteger myPower = [powerLevels powerLevelOfUserWithUserID:self.roomDataSource.mxSession.myUserId];
- return myPower >= requiredPower;
+ return [self canSendStateEventWithType:kWidgetModularEventTypeString];
}
- (void)registerURLPreviewNotifications
@@ -1993,9 +1999,9 @@ static CGSize kThreadListBarButtonItemImageSize;
[self updateInputToolBarVisibility];
// Check whether the input toolbar is ready before updating it.
- if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:RoomInputToolbarView.class])
+ if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol])
{
- RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView;
+ id roomInputToolbarView = (id) self.inputToolbarView;
// Update encryption decoration if needed
[self updateEncryptionDecorationForRoomInputToolbar:roomInputToolbarView];
@@ -2115,9 +2121,9 @@ static CGSize kThreadListBarButtonItemImageSize;
- (void)updateInputToolbarEncryptionDecoration
{
- if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:RoomInputToolbarView.class])
+ if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol])
{
- RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView;
+ id roomInputToolbarView = (id)self.inputToolbarView;
[self updateEncryptionDecorationForRoomInputToolbar:roomInputToolbarView];
}
}
@@ -2133,7 +2139,7 @@ static CGSize kThreadListBarButtonItemImageSize;
roomTitleView.badgeImageView.image = self.roomEncryptionBadgeImage;
}
-- (void)updateEncryptionDecorationForRoomInputToolbar:(RoomInputToolbarView*)roomInputToolbarView
+- (void)updateEncryptionDecorationForRoomInputToolbar:(id)roomInputToolbarView
{
roomInputToolbarView.isEncryptionEnabled = self.isEncryptionEnabled;
}
@@ -2290,6 +2296,16 @@ static CGSize kThreadListBarButtonItemImageSize;
[self roomInputToolbarViewDidTapFileUpload];
}]];
}
+ if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat)
+ {
+ [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionLive.image andAction:^{
+ MXStrongifyAndReturnIfNil(self);
+ if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) {
+ ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO;
+ }
+ [self roomInputToolbarViewDidTapVoiceBroadcast];
+ }]];
+ }
if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat)
{
[actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionPoll.image andAction:^{
@@ -2320,35 +2336,6 @@ static CGSize kThreadListBarButtonItemImageSize;
[self showCameraControllerAnimated:YES];
}]];
}
- if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat)
- {
- [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionLive.image andAction:^{
- MXStrongifyAndReturnIfNil(self);
- if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) {
- ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO;
- }
-
- // TODO: Init and start voice broadcast
- MXSession* session = self.roomDataSource.mxSession;
- [session getOrCreateVoiceBroadcastServiceFor:self.roomDataSource.room completion:^(VoiceBroadcastService *voiceBroadcastService) {
- if (voiceBroadcastService) {
- if ([VoiceBroadcastInfo isStoppedFor:[voiceBroadcastService getState]]) {
- [session.voiceBroadcastService startVoiceBroadcastWithSuccess:^(NSString * _Nullable success) {
-
- } failure:^(NSError * _Nonnull error) {
-
- }];
- } else {
- [session.voiceBroadcastService stopVoiceBroadcastWithSuccess:^(NSString * _Nullable success) {
-
- } failure:^(NSError * _Nonnull error) {
-
- }];
- }
- }
- }];
- }]];
- }
roomInputView.actionsBar.actionItems = actionItems;
}
@@ -2436,6 +2423,39 @@ static CGSize kThreadListBarButtonItemImageSize;
self.documentPickerPresenter = documentPickerPresenter;
}
+- (void)roomInputToolbarViewDidTapVoiceBroadcast
+{
+ // Check first the room permission
+ if (![self canSendStateEventWithType:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType])
+ {
+ [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastPermissionDeniedMessage]];
+ return;
+ }
+
+ MXSession* session = self.roomDataSource.mxSession;
+ // Check whether the user is not already broadcasting here or in another room
+ if (session.voiceBroadcastService)
+ {
+ [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastAlreadyInProgressMessage]];
+ return;
+ }
+
+ // Request the voice broadcast service to start recording - No service is returned if someone else is already broadcasting in the room
+ [session getOrCreateVoiceBroadcastServiceFor:self.roomDataSource.room completion:^(VoiceBroadcastService *voiceBroadcastService) {
+ if (voiceBroadcastService) {
+ [voiceBroadcastService startVoiceBroadcastWithSuccess:^(NSString * _Nullable success) {
+
+ } failure:^(NSError * _Nonnull error) {
+
+ }];
+ }
+ else
+ {
+ [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastBlockedBySomeoneElseMessage]];
+ }
+ }];
+}
+
/**
Send a video asset via the room input toolbar prompting the user for the conversion preset to use
if the `showMediaCompressionPrompt` setting has been enabled.
@@ -3211,7 +3231,7 @@ static CGSize kThreadListBarButtonItemImageSize;
}
}
}
- else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcast)
+ else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcastPlayback)
{
if (bubbleData.isIncoming)
{
@@ -3244,6 +3264,22 @@ static CGSize kThreadListBarButtonItemImageSize;
}
}
}
+ else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcastRecord)
+ {
+ if (bubbleData.isPaginationFirstBubble)
+ {
+ cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle;
+ }
+ else if (bubbleData.shouldHideSenderInformation)
+ {
+ cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo;
+ }
+ else
+ {
+ cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder;
+ }
+ }
+
else if (roomBubbleCellData.getFirstBubbleComponentWithDisplay.event.isEmote)
{
if (bubbleData.isIncoming)
@@ -4565,6 +4601,9 @@ static CGSize kThreadListBarButtonItemImageSize;
// Do nothing for dummy links
shouldDoAction = NO;
break;
+ case RoomMessageURLTypeHttp:
+ shouldDoAction = YES;
+ break;
default:
{
MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey];
@@ -4590,16 +4629,20 @@ static CGSize kThreadListBarButtonItemImageSize;
break;
case UITextItemInteractionPresentActions:
{
- // Retrieve the tapped event
- MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey];
-
- if (tappedEvent)
- {
- // Long press on link, present room contextual menu.
- [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:NO cell:cell animated:YES];
+ if (roomMessageURLType == RoomMessageURLTypeHttp) {
+ shouldDoAction = YES;
+ } else {
+ // Retrieve the tapped event
+ MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey];
+
+ if (tappedEvent)
+ {
+ // Long press on link, present room contextual menu.
+ [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:NO cell:cell animated:YES];
+ }
+
+ shouldDoAction = NO;
}
-
- shouldDoAction = NO;
}
break;
case UITextItemInteractionPreview:
@@ -4997,27 +5040,12 @@ static CGSize kThreadListBarButtonItemImageSize;
{
if (self.roomInputToolbarContainerHeightConstraint.constant != height)
{
- // Hide temporarily the placeholder to prevent its distortion during height animation
- if (!savedInputToolbarPlaceholder)
- {
- savedInputToolbarPlaceholder = toolbarView.placeholder.length ? toolbarView.placeholder : @"";
- }
- toolbarView.placeholder = nil;
-
[super roomInputToolbarView:toolbarView heightDidChanged:height completion:^(BOOL finished) {
if (completion)
{
completion (finished);
}
-
- // Consider here the saved placeholder only if no new placeholder has been defined during the height animation.
- if (!toolbarView.placeholder)
- {
- // Restore the placeholder if any
- toolbarView.placeholder = self->savedInputToolbarPlaceholder.length ? self->savedInputToolbarPlaceholder : nil;
- }
- self->savedInputToolbarPlaceholder = nil;
}];
}
}
@@ -5070,6 +5098,10 @@ static CGSize kThreadListBarButtonItemImageSize;
{
[actionItems addObject:@(ComposerCreateActionAttachments)];
}
+ if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat)
+ {
+ [actionItems addObject:@(ComposerCreateActionVoiceBroadcast)];
+ }
if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat)
{
[actionItems addObject:@(ComposerCreateActionPolls)];
@@ -6223,7 +6255,13 @@ static CGSize kThreadListBarButtonItemImageSize;
// Acknowledge the existence of all devices
[self startActivityIndicator];
- [self.mainSession.crypto setDevicesKnown:self->unknownDevices complete:^{
+
+ if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ MXLogFailure(@"[RoomVC] eventDidChangeSentState: Only legacy crypto supports manual setting of known devices");
+ return;
+ }
+ [(MXLegacyCrypto *)self.mainSession.crypto setDevicesKnown:self->unknownDevices complete:^{
self->unknownDevices = nil;
[self stopActivityIndicator];
@@ -8007,6 +8045,9 @@ static CGSize kThreadListBarButtonItemImageSize;
case ComposerCreateActionAttachments:
[self roomInputToolbarViewDidTapFileUpload];
break;
+ case ComposerCreateActionVoiceBroadcast:
+ [self roomInputToolbarViewDidTapVoiceBroadcast];
+ break;
case ComposerCreateActionPolls:
[self.delegate roomViewControllerDidRequestPollCreationFormPresentation:self];
break;
diff --git a/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m b/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m
index 13eac3bcc..d5d9f08ed 100644
--- a/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m
+++ b/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m
@@ -237,6 +237,7 @@ static BOOL _disableLongPressGestureOnEvent;
[tapGesture setDelegate:self];
[self.messageTextView addGestureRecognizer:tapGesture];
self.messageTextView.userInteractionEnabled = YES;
+ self.messageTextView.clipsToBounds = NO;
// Recognise and make tappable phone numbers, address, etc.
self.messageTextView.dataDetectorTypes = UIDataDetectorTypeAll;
@@ -805,7 +806,7 @@ static BOOL _disableLongPressGestureOnEvent;
mimetype = bubbleData.attachment.contentInfo[@"mimetype"];
}
- if ([mimetype isEqualToString:@"image/gif"])
+ if ([mimetype isKindOfClass:[NSString class]] && [mimetype isEqualToString:@"image/gif"])
{
if (_isAutoAnimatedGif)
{
diff --git a/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h b/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h
index 640a2e3bc..3348df0e6 100644
--- a/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h
+++ b/Riot/Modules/Room/TimelineCells/RoomTimelineCellIdentifier.h
@@ -178,6 +178,11 @@ typedef NS_ENUM(NSUInteger, RoomTimelineCellIdentifier) {
RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithoutSenderInfo,
RoomTimelineCellIdentifierOutgoingVoiceBroadcastWithPaginationTitle,
+ // - Voice broadcast recorder
+ RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder,
+ RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo,
+ RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle,
+
// - Others
RoomTimelineCellIdentifierEmpty,
RoomTimelineCellIdentifierSelectedSticker,
diff --git a/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift b/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift
index 5aa5f10e5..f33762144 100644
--- a/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift
+++ b/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift
@@ -16,6 +16,7 @@
import UIKit
import MatrixSDK
+import SwiftUI
@objc protocol SizableBaseRoomCellType: BaseRoomCellProtocol {
static func sizingViewHeightHashValue(from bubbleCellData: MXKRoomBubbleCellData) -> Int
@@ -35,6 +36,7 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType {
private static let reactionsViewModelBuilder = RoomReactionsViewModelBuilder()
private static let urlPreviewViewSizer = URLPreviewViewSizer()
+ private var contentVC: UIViewController?
private class var sizingView: SizableBaseRoomCell {
let sizingView: SizableBaseRoomCell
@@ -115,6 +117,10 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType {
sizingView.setNeedsLayout()
sizingView.layoutIfNeeded()
+ if let contentVC = sizingView.contentVC as? UIHostingController {
+ contentVC.view.invalidateIntrinsicContentSize()
+ }
+
let fittingSize = CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
var height = sizingView.systemLayoutSizeFitting(fittingSize).height
@@ -168,4 +174,24 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType {
return height
}
+
+ func addContentViewController(_ controller: UIViewController, on contentView: UIView) {
+ controller.view.invalidateIntrinsicContentSize()
+
+ let parent = vc_parentViewController
+ parent?.addChild(controller)
+ contentView.vc_addSubViewMatchingParent(controller.view)
+ controller.didMove(toParent: parent)
+
+ contentVC = controller
+ }
+
+ override func prepareForReuse() {
+ contentVC?.removeFromParent()
+ contentVC?.view.removeFromSuperview()
+ contentVC?.didMove(toParent: nil)
+ contentVC = nil
+
+ super.prepareForReuse()
+ }
}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m b/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m
index 42bad501d..c747476ee 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m
+++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m
@@ -143,6 +143,13 @@
[tableView registerClass:VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceBroadcastOutgoingWithPaginationTitleBubbleCell.defaultReuseIdentifier];
}
+- (void)registerVoiceBroadcastRecorderCellsForTableView:(UITableView*)tableView
+{
+ // Outgoing
+ [tableView registerClass:VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.defaultReuseIdentifier];
+ [tableView registerClass:VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.defaultReuseIdentifier];
+}
+
#pragma mark - Mapping
- (NSDictionary*)incomingTextMessageCellsMapping
@@ -318,4 +325,14 @@
};
}
+- (NSDictionary*)voiceBroadcastRecorderCellsMapping
+{
+ return @{
+ // Outgoing
+ @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder) : VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.class,
+ @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo) : VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.class,
+ @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle) : VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.class,
+ };
+}
+
@end
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift
index b69abdcd4..993b606c5 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift
+++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/Poll/PollBaseBubbleCell.swift
@@ -36,10 +36,10 @@ class PollBaseBubbleCell: PollPlainCell {
self.setupBubbleBackgroundView()
}
- override func addPollView(_ pollView: UIView, on contentView: UIView) {
- super.addPollView(pollView, on: contentView)
+ override func addContentViewController(_ controller: UIViewController, on contentView: UIView) {
+ super.addContentViewController(controller, on: contentView)
- self.addBubbleBackgroundViewIfNeeded(for: pollView)
+ self.addBubbleBackgroundViewIfNeeded(for: controller.view)
}
// MARK: - Private
diff --git a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastViewModelProtocol.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.swift
similarity index 70%
rename from RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastViewModelProtocol.swift
rename to Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.swift
index 80d44c211..c30badc8e 100644
--- a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastViewModelProtocol.swift
+++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell.swift
@@ -16,9 +16,12 @@
import Foundation
-protocol TimelineVoiceBroadcastViewModelProtocol {
- var context: TimelineVoiceBroadcastViewModelType.Context { get }
- var completion: (() -> Void)? { get set }
+class VoiceBroadcastRecorderOutgoingWithPaginationTitleBubbleCell: VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell {
+
+ override func setupViews() {
+ super.setupViews()
+
+ roomCellContentView?.showPaginationTitle = true
+ }
- func updateWithVoiceBroadcastDetails(_ voiceBroadcastDetails: TimelineVoiceBroadcastDetails)
}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.swift
new file mode 100644
index 000000000..4d56aee96
--- /dev/null
+++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/Outgoing/VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell.swift
@@ -0,0 +1,41 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class VoiceBroadcastRecorderOutgoingWithoutSenderInfoBubbleCell: VoiceBroadcastRecorderBubbleCell, BubbleOutgoingRoomCellProtocol {
+
+ override func setupViews() {
+ super.setupViews()
+
+ roomCellContentView?.showSenderInfo = false
+
+ // TODO: VB update margins attributes
+ let leftMargin: CGFloat = BubbleRoomCellLayoutConstants.outgoingBubbleBackgroundMargins.left + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left
+ let rightMargin: CGFloat = BubbleRoomCellLayoutConstants.outgoingBubbleBackgroundMargins.right + BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right
+
+ roomCellContentView?.innerContentViewTrailingConstraint.constant = rightMargin
+ roomCellContentView?.innerContentViewLeadingConstraint.constant = leftMargin
+
+ self.setupBubbleDecorations()
+ }
+
+ override func update(theme: Theme) {
+ super.update(theme: theme)
+
+ self.bubbleBackgroundColor = theme.roomCellOutgoingBubbleBackgroundColor
+ }
+}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderBubbleCell.swift
new file mode 100644
index 000000000..5b7a92a2f
--- /dev/null
+++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderBubbleCell.swift
@@ -0,0 +1,113 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+class VoiceBroadcastRecorderBubbleCell: VoiceBroadcastRecorderPlainCell {
+
+ // MARK: - Properties
+
+ var bubbleBackgroundColor: UIColor?
+
+ // MARK: - Overrides
+
+ override func render(_ cellData: MXKCellData!) {
+ super.render(cellData)
+
+ self.update(theme: ThemeService.shared().theme)
+ }
+
+ override func setupViews() {
+ super.setupViews()
+
+ self.setupBubbleBackgroundView()
+ }
+
+ override func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) {
+ super.addVoiceBroadcastView(voiceBroadcastView, on: contentView)
+
+ self.addBubbleBackgroundViewIfNeeded(for: voiceBroadcastView)
+ }
+
+ // MARK: - Private
+
+ private func addBubbleBackgroundViewIfNeeded(for voiceBroadcastView: UIView) {
+
+ guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else {
+ return
+ }
+
+ self.addBubbleBackgroundView( messageBubbleBackgroundView, to: voiceBroadcastView)
+ messageBubbleBackgroundView.backgroundColor = self.bubbleBackgroundColor
+ }
+
+ private func addBubbleBackgroundView(_ bubbleBackgroundView: RoomMessageBubbleBackgroundView,
+ to voiceBroadcastView: UIView) {
+
+ // TODO: VB update margins attributes
+ let topMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.top
+ let leftMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.left
+ let rightMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.right
+ let bottomMargin = BubbleRoomCellLayoutConstants.pollBubbleBackgroundInsets.bottom
+
+ let topAnchor = voiceBroadcastView.topAnchor
+ let leadingAnchor = voiceBroadcastView.leadingAnchor
+ let trailingAnchor = voiceBroadcastView.trailingAnchor
+ let bottomAnchor = voiceBroadcastView.bottomAnchor
+
+ NSLayoutConstraint.activate([
+ bubbleBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: -topMargin),
+ bubbleBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -leftMargin),
+ bubbleBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightMargin),
+ bubbleBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomMargin)
+ ])
+ }
+
+ private func setupBubbleBackgroundView() {
+ let bubbleBackgroundView = RoomMessageBubbleBackgroundView()
+ self.roomCellContentView?.insertSubview(bubbleBackgroundView, at: 0)
+ }
+
+ // The extension property MXKRoomBubbleTableViewCell.messageBubbleBackgroundView is not working there even by doing recursion
+ private func getBubbleBackgroundView() -> RoomMessageBubbleBackgroundView? {
+ guard let contentView = self.roomCellContentView else {
+ return nil
+ }
+
+ let foundView = contentView.subviews.first { view in
+ return view is RoomMessageBubbleBackgroundView
+ }
+ return foundView as? RoomMessageBubbleBackgroundView
+ }
+}
+
+// MARK: - TimestampDisplayable
+extension VoiceBroadcastRecorderBubbleCell: TimestampDisplayable {
+
+ func addTimestampView(_ timestampView: UIView) {
+ guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else {
+ return
+ }
+ messageBubbleBackgroundView.addTimestampView(timestampView)
+ }
+
+ func removeTimestampView() {
+ guard let messageBubbleBackgroundView = self.getBubbleBackgroundView() else {
+ return
+ }
+ messageBubbleBackgroundView.removeTimestampView()
+ }
+}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift
index a05f00285..67db62e88 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift
+++ b/Riot/Modules/Room/TimelineCells/Styles/Bubble/Cells/VoiceBroadcast/VoiceBroadcastBubbleCell.swift
@@ -36,10 +36,10 @@ class VoiceBroadcastBubbleCell: VoiceBroadcastPlainCell {
self.setupBubbleBackgroundView()
}
- override func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) {
- super.addVoiceBroadcastView(voiceBroadcastView, on: contentView)
-
- self.addBubbleBackgroundViewIfNeeded(for: voiceBroadcastView)
+ override func addContentViewController(_ controller: UIViewController, on contentView: UIView) {
+ super.addContentViewController(controller, on: contentView)
+
+ self.addBubbleBackgroundViewIfNeeded(for: controller.view)
}
// MARK: - Private
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift
index 345f0de95..70cf4370f 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift
@@ -17,8 +17,7 @@
import Foundation
class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCellReadMarkerDisplayable {
-
- private var pollView: UIView?
+
private var event: MXEvent?
override func render(_ cellData: MXKCellData!) {
@@ -28,12 +27,12 @@ class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCell
let bubbleData = cellData as? RoomBubbleCellData,
let event = bubbleData.events.last,
event.eventType == __MXEventType.pollStart,
- let view = TimelinePollProvider.shared.buildTimelinePollViewForEvent(event) else {
+ let controller = TimelinePollProvider.shared.buildTimelinePollVCForEvent(event) else {
return
}
self.event = event
- self.addPollView(view, on: contentView)
+ self.addContentViewController(controller, on: contentView)
}
override func setupViews() {
@@ -52,13 +51,6 @@ class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCell
delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event])
}
-
- func addPollView(_ pollView: UIView, on contentView: UIView) {
-
- self.pollView?.removeFromSuperview()
- contentView.vc_addSubViewMatchingParent(pollView)
- self.pollView = pollView
- }
}
extension PollPlainCell: RoomCellThreadSummaryDisplayable {}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift
new file mode 100644
index 000000000..a65254be5
--- /dev/null
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift
@@ -0,0 +1,65 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class VoiceBroadcastRecorderPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCellReadMarkerDisplayable {
+
+ private var voiceBroadcastView: UIView?
+ private var event: MXEvent?
+
+ override func render(_ cellData: MXKCellData!) {
+ super.render(cellData)
+
+ guard let contentView = roomCellContentView?.innerContentView,
+ let bubbleData = cellData as? RoomBubbleCellData,
+ let event = bubbleData.events.last,
+ let voiceBroadcastContent = VoiceBroadcastInfo(fromJSON: event.content),
+ voiceBroadcastContent.state == VoiceBroadcastInfo.State.started.rawValue,
+ let view = VoiceBroadcastRecorderProvider.shared.buildVoiceBroadcastRecorderViewForEvent(event, senderDisplayName: bubbleData.senderDisplayName) else {
+ return
+ }
+
+ self.event = event
+ self.addVoiceBroadcastView(view, on: contentView)
+ }
+
+ override func setupViews() {
+ super.setupViews()
+
+ roomCellContentView?.backgroundColor = .clear
+ roomCellContentView?.showSenderInfo = true
+ roomCellContentView?.showPaginationTitle = false
+ }
+
+ // The normal flow for tapping on cell content views doesn't work for bubbles without attributed strings
+ override func onContentViewTap(_ sender: UITapGestureRecognizer) {
+ guard let event = self.event else {
+ return
+ }
+
+ delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event])
+ }
+
+ func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) {
+
+ self.voiceBroadcastView?.removeFromSuperview()
+ contentView.vc_addSubViewMatchingParent(voiceBroadcastView)
+ self.voiceBroadcastView = voiceBroadcastView
+ }
+}
+
+extension VoiceBroadcastRecorderPlainCell: RoomCellThreadSummaryDisplayable {}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithPaginationTitlePlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithPaginationTitlePlainCell.swift
new file mode 100644
index 000000000..4247f306c
--- /dev/null
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithPaginationTitlePlainCell.swift
@@ -0,0 +1,27 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class VoiceBroadcastRecorderWithPaginationTitlePlainCell: VoiceBroadcastRecorderPlainCell {
+
+ override func setupViews() {
+ super.setupViews()
+
+ roomCellContentView?.showPaginationTitle = true
+ }
+
+}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithoutSenderInfoPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithoutSenderInfoPlainCell.swift
new file mode 100644
index 000000000..172b10aee
--- /dev/null
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderWithoutSenderInfoPlainCell.swift
@@ -0,0 +1,27 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class VoiceBroadcastRecorderWithoutSenderInfoPlainCell: VoiceBroadcastRecorderPlainCell {
+
+ override func setupViews() {
+ super.setupViews()
+
+ roomCellContentView?.showSenderInfo = false
+ }
+
+}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift
index 967f4cef8..14c602c4c 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/VoiceBroadcastPlainCell.swift
@@ -18,7 +18,6 @@ import Foundation
class VoiceBroadcastPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCellReadMarkerDisplayable {
- private var voiceBroadcastView: UIView?
private var event: MXEvent?
override func render(_ cellData: MXKCellData!) {
@@ -29,12 +28,12 @@ class VoiceBroadcastPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable
let event = bubbleData.events.last,
let voiceBroadcastContent = VoiceBroadcastInfo(fromJSON: event.content),
voiceBroadcastContent.state == VoiceBroadcastInfo.State.started.rawValue,
- let view = TimelineVoiceBroadcastProvider.shared.buildTimelineVoiceBroadcastViewForEvent(event) else {
+ let controller = VoiceBroadcastPlaybackProvider.shared.buildVoiceBroadcastPlaybackVCForEvent(event, senderDisplayName: bubbleData.senderDisplayName) else {
return
}
self.event = event
- self.addVoiceBroadcastView(view, on: contentView)
+ self.addContentViewController(controller, on: contentView)
}
override func setupViews() {
@@ -53,13 +52,6 @@ class VoiceBroadcastPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable
delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event])
}
-
- func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) {
-
- self.voiceBroadcastView?.removeFromSuperview()
- contentView.vc_addSubViewMatchingParent(voiceBroadcastView)
- self.voiceBroadcastView = voiceBroadcastView
- }
}
extension VoiceBroadcastPlainCell: RoomCellThreadSummaryDisplayable {}
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h
index 9f18a71d9..b1e85a621 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.h
@@ -58,6 +58,8 @@ NS_ASSUME_NONNULL_BEGIN
- (NSDictionary*)voiceBroadcastCellsMapping;
+- (NSDictionary*)voiceBroadcastRecorderCellsMapping;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m
index db11457d7..4813b539d 100644
--- a/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m
+++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m
@@ -115,6 +115,8 @@
[self registerVoiceBroadcastCellsForTableView:tableView];
+ [self registerVoiceBroadcastRecorderCellsForTableView:tableView];
+
[tableView registerClass:RoomEmptyBubbleCell.class forCellReuseIdentifier:RoomEmptyBubbleCell.defaultReuseIdentifier];
[tableView registerClass:RoomSelectedStickerBubbleCell.class forCellReuseIdentifier:RoomSelectedStickerBubbleCell.defaultReuseIdentifier];
@@ -279,6 +281,13 @@
[tableView registerClass:VoiceBroadcastWithPaginationTitlePlainCell.class forCellReuseIdentifier:VoiceBroadcastWithPaginationTitlePlainCell.defaultReuseIdentifier];
}
+- (void)registerVoiceBroadcastRecorderCellsForTableView:(UITableView*)tableView
+{
+ [tableView registerClass:VoiceBroadcastRecorderPlainCell.class forCellReuseIdentifier:VoiceBroadcastRecorderPlainCell.defaultReuseIdentifier];
+ [tableView registerClass:VoiceBroadcastRecorderWithoutSenderInfoPlainCell.class forCellReuseIdentifier:VoiceBroadcastRecorderWithoutSenderInfoPlainCell.defaultReuseIdentifier];
+ [tableView registerClass:VoiceBroadcastRecorderWithPaginationTitlePlainCell.class forCellReuseIdentifier:VoiceBroadcastRecorderWithPaginationTitlePlainCell.defaultReuseIdentifier];
+}
+
#pragma mark Cell class association
- (NSDictionary*)buildCellClasses
@@ -339,6 +348,9 @@
NSDictionary *voiceBroadcastCellsMapping = [self voiceBroadcastCellsMapping];
[cellClasses addEntriesFromDictionary:voiceBroadcastCellsMapping];
+
+ NSDictionary *voiceBroadcastRecorderCellsMapping = [self voiceBroadcastRecorderCellsMapping];
+ [cellClasses addEntriesFromDictionary:voiceBroadcastRecorderCellsMapping];
NSDictionary *othersCells = @{
@(RoomTimelineCellIdentifierEmpty) : RoomEmptyBubbleCell.class,
@@ -576,5 +588,14 @@
};
}
+- (NSDictionary*)voiceBroadcastRecorderCellsMapping
+{
+ return @{
+ // Outoing
+ @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder) : VoiceBroadcastRecorderPlainCell.class,
+ @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo) : VoiceBroadcastRecorderWithoutSenderInfoPlainCell.class,
+ @(RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle) : VoiceBroadcastRecorderWithPaginationTitlePlainCell.class
+ };
+}
@end
diff --git a/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift b/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift
index 1af0a99aa..7f8df4e18 100644
--- a/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift
+++ b/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift
@@ -26,7 +26,7 @@ struct RoomAvatarViewData: AvatarViewDataProtocol {
return roomId
}
- var fallbackImage: AvatarFallbackImage? {
- return .matrixItem(matrixItemId, displayName)
+ var fallbackImages: [AvatarFallbackImage]? {
+ [.matrixItem(matrixItemId, displayName)]
}
}
diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift
index 389e057ea..47d981c86 100644
--- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift
+++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift
@@ -139,7 +139,7 @@ class RoomInputToolbarTextView: UITextView {
}
private func updateUI() {
- var height = sizeThatFits(CGSize(width: bounds.size.width, height: CGFloat.greatestFiniteMagnitude)).height
+ var height = contentSize.height
height = minHeight > 0 ? max(height, minHeight) : height
height = maxHeight > 0 ? min(height, maxHeight) : height
diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h
index 72341ff2a..4bdea353b 100644
--- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h
+++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h
@@ -37,6 +37,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
@property (nonatomic, strong) NSString *eventSenderDisplayName;
@property (nonatomic, assign) RoomInputToolbarViewSendMode sendMode;
+@property (nonatomic, assign) BOOL isEncryptionEnabled;
- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView;
- (CGFloat)toolbarHeight;
@@ -80,7 +81,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
`RoomInputToolbarView` instance is a view used to handle all kinds of available inputs
for a room (message composer, attachments selection...).
*/
-@interface RoomInputToolbarView : MXKRoomInputToolbarView
+@interface RoomInputToolbarView : MXKRoomInputToolbarView
/**
The delegate notified when inputs are ready.
diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m
index cd9195516..9abfde421 100644
--- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m
+++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m
@@ -19,7 +19,6 @@
#import "ThemeService.h"
#import "GeneratedInterface-Swift.h"
-#import "GBDeviceInfo_iOS.h"
static const CGFloat kContextBarHeight = 24;
static const CGFloat kActionMenuAttachButtonSpringVelocity = 7;
@@ -30,7 +29,7 @@ static const NSTimeInterval kActionMenuAttachButtonAnimationDuration = .4;
static const NSTimeInterval kActionMenuContentAlphaAnimationDuration = .2;
static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
-@interface RoomInputToolbarView()
+@interface RoomInputToolbarView()
@property (nonatomic, weak) IBOutlet UIView *mainToolbarView;
@@ -281,69 +280,6 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
}
}
-- (void)updatePlaceholder
-{
- // Consider the default placeholder
-
- NSString *placeholder;
-
- // Check the device screen size before using large placeholder
- BOOL shouldDisplayLargePlaceholder = [GBDeviceInfo deviceInfo].family == GBDeviceFamilyiPad || [GBDeviceInfo deviceInfo].displayInfo.display >= GBDeviceDisplay5p8Inch;
-
- if (!shouldDisplayLargePlaceholder)
- {
- switch (_sendMode)
- {
- case RoomInputToolbarViewSendModeReply:
- placeholder = [VectorL10n roomMessageReplyToShortPlaceholder];
- break;
-
- case RoomInputToolbarViewSendModeCreateDM:
- placeholder = [VectorL10n roomFirstMessagePlaceholder];
- break;
-
- default:
- placeholder = [VectorL10n roomMessageShortPlaceholder];
- break;
- }
- }
- else
- {
- if (_isEncryptionEnabled)
- {
- switch (_sendMode)
- {
- case RoomInputToolbarViewSendModeReply:
- placeholder = [VectorL10n encryptedRoomMessageReplyToPlaceholder];
- break;
-
- default:
- placeholder = [VectorL10n encryptedRoomMessagePlaceholder];
- break;
- }
- }
- else
- {
- switch (_sendMode)
- {
- case RoomInputToolbarViewSendModeReply:
- placeholder = [VectorL10n roomMessageReplyToPlaceholder];
- break;
-
- case RoomInputToolbarViewSendModeCreateDM:
- placeholder = [VectorL10n roomFirstMessagePlaceholder];
- break;
-
- default:
- placeholder = [VectorL10n roomMessagePlaceholder];
- break;
- }
- }
- }
-
- self.placeholder = placeholder;
-}
-
- (void)setPlaceholder:(NSString *)inPlaceholder
{
[super setPlaceholder:inPlaceholder];
diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift
index 6a9de2f30..045fcc9a4 100644
--- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift
+++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift
@@ -16,6 +16,7 @@
import Foundation
import UIKit
+import GBDeviceInfo
extension RoomInputToolbarView {
open override func sendCurrentMessage() {
@@ -28,15 +29,66 @@ extension RoomInputToolbarView {
self.becomeFirstResponder()
temp.removeFromSuperview()
}
-
+
// Send message if any.
if let messageToSend = self.attributedTextMessage, messageToSend.length > 0 {
self.delegate.roomInputToolbarView(self, sendAttributedTextMessage: messageToSend)
}
-
+
// Reset message, disable view animation during the update to prevent placeholder distorsion.
UIView.setAnimationsEnabled(false)
self.attributedTextMessage = nil
UIView.setAnimationsEnabled(true)
}
}
+
+@objc extension RoomInputToolbarView {
+ func updatePlaceholder() {
+ updatePlaceholderText()
+ }
+}
+
+extension RoomInputToolbarViewProtocol where Self: MXKRoomInputToolbarView {
+ func updatePlaceholderText() {
+ // Consider the default placeholder
+
+ let placeholder: String
+
+ // Check the device screen size before using large placeholder
+ let shouldDisplayLargePlaceholder = GBDeviceInfo.deviceInfo().family == .familyiPad || GBDeviceInfo.deviceInfo().displayInfo.display.rawValue >= GBDeviceDisplay.display5p8Inch.rawValue
+
+ if !shouldDisplayLargePlaceholder {
+ switch sendMode {
+ case .reply:
+ placeholder = VectorL10n.roomMessageReplyToShortPlaceholder
+ case .createDM:
+ placeholder = VectorL10n.roomFirstMessagePlaceholder
+
+ default:
+ placeholder = VectorL10n.roomMessageShortPlaceholder
+ }
+ } else {
+ if isEncryptionEnabled {
+ switch sendMode {
+ case .reply:
+ placeholder = VectorL10n.encryptedRoomMessageReplyToPlaceholder
+
+ default:
+ placeholder = VectorL10n.encryptedRoomMessagePlaceholder
+ }
+ } else {
+ switch sendMode {
+ case .reply:
+ placeholder = VectorL10n.roomMessageReplyToPlaceholder
+
+ case .createDM:
+ placeholder = VectorL10n.roomFirstMessagePlaceholder
+ default:
+ placeholder = VectorL10n.roomMessagePlaceholder
+ }
+ }
+ }
+
+ self.placeholder = placeholder
+ }
+}
diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib
index 16118e659..ca3b0f5a6 100644
--- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib
+++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib
@@ -1,9 +1,9 @@
-
+
-
+
@@ -41,7 +41,7 @@
-
+
@@ -69,7 +69,7 @@
-
+
diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift
index 5f2c06cc7..f886276fd 100644
--- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift
+++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift
@@ -29,11 +29,10 @@ import CoreGraphics
// The toolbar for editing with rich text
class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInputToolbarViewProtocol {
-
-
// MARK: - Properties
// MARK: Private
+ private var voiceMessageToolbarView: VoiceMessageToolbarView?
private var cancellables = Set()
private var heightConstraint: NSLayoutConstraint!
private var hostingViewController: VectorHostingController!
@@ -42,6 +41,141 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
// MARK: Public
+ override var placeholder: String! {
+ get {
+ viewModel.placeholder
+ }
+ set {
+ viewModel.placeholder = newValue
+ }
+ }
+
+ // MARK: - Setup
+
+ override class func instantiate() -> MXKRoomInputToolbarView! {
+ return loadFromNib()
+ }
+
+ private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
+ return (delegate as? RoomInputToolbarViewDelegate) ?? nil
+ }
+
+ override func awakeFromNib() {
+ super.awakeFromNib()
+
+ viewModel.callback = { [weak self] result in
+ self?.handleViewModelResult(result)
+ }
+
+ inputAccessoryViewForKeyboard = UIView(frame: .zero)
+
+ let composer = Composer(viewModel: viewModel.context,
+ wysiwygViewModel: wysiwygViewModel,
+ 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()
+ }
+ ]
+
+ update(theme: ThemeService.shared().theme)
+ registerThemeServiceDidChangeThemeNotification()
+ }
+
+ override func customizeRendering() {
+ super.customizeRendering()
+ self.backgroundColor = .clear
+ }
+
+ // MARK: - Private
+
+ private func updateToolbarHeight(wysiwygHeight: CGFloat) {
+ self.heightConstraint.constant = wysiwygHeight
+ toolbarViewDelegate?.roomInputToolbarView?(self, heightDidChanged: wysiwygHeight, completion: nil)
+ }
+
+ private func sendWysiwygMessage(content: WysiwygComposerContent) {
+ delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.plainText)
+ }
+
+ private func showSendMediaActions() {
+ delegate?.roomInputToolbarViewShowSendMediaActions?(self)
+ }
+
+ private func handleViewModelResult(_ result: ComposerViewModelResult) {
+ switch result {
+ case .cancel:
+ self.toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self)
+ case let .contentDidChange(isEmpty):
+ setVoiceMessageToolbarIsHidden(!isEmpty)
+ }
+ }
+
+ private func setVoiceMessageToolbarIsHidden(_ isHidden: Bool) {
+ guard let voiceMessageToolbarView = voiceMessageToolbarView else { return }
+ UIView.transition(
+ with: voiceMessageToolbarView, duration: 0.15,
+ options: .transitionCrossDissolve,
+ animations: {
+ voiceMessageToolbarView.isHidden = isHidden
+ }
+ )
+ }
+
+ private func registerThemeServiceDidChangeThemeNotification() {
+ NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
+ }
+
+ @objc private func themeDidChange() {
+ self.update(theme: ThemeService.shared().theme)
+ }
+
+ private func update(theme: Theme) {
+ hostingViewController.view.backgroundColor = theme.colors.background
+ wysiwygViewModel.textColor = theme.colors.primaryContent
+ }
+
+ // MARK: - HtmlRoomInputToolbarViewProtocol
+ var isEncryptionEnabled = false {
+ didSet {
+ updatePlaceholderText()
+ }
+ }
+
/// The current html content of the composer
var htmlContent: String {
get {
@@ -69,111 +203,30 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
}
set {
viewModel.sendMode = ComposerSendMode(from: newValue)
+ updatePlaceholderText()
}
}
- // MARK: - Setup
-
- override class func instantiate() -> MXKRoomInputToolbarView! {
- return loadFromNib()
- }
-
- private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
- return (delegate as? RoomInputToolbarViewDelegate) ?? nil
- }
-
- override func awakeFromNib() {
- super.awakeFromNib()
-
- viewModel.callback = { [weak self] result in
- guard let self = self else { return }
- switch result {
- case .cancel:
- self.toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self)
- }
- }
- inputAccessoryViewForKeyboard = UIView(frame: .zero)
- let composer = Composer(viewModel: viewModel.context,
- wysiwygViewModel: wysiwygViewModel,
- 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)
-
- hostingViewController.view.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)
- })
- ]
-
- update(theme: ThemeService.shared().theme)
- registerThemeServiceDidChangeThemeNotification()
- }
-
- override func customizeRendering() {
- super.customizeRendering()
- self.backgroundColor = .clear
- }
-
- // MARK: - Private
-
- private func updateToolbarHeight(wysiwygHeight: CGFloat) {
- self.heightConstraint.constant = wysiwygHeight
- toolbarViewDelegate?.roomInputToolbarView?(self, heightDidChanged: wysiwygHeight, completion: nil)
- }
-
- private func sendWysiwygMessage(content: WysiwygComposerContent) {
- delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.plainText)
- }
-
-
- private func showSendMediaActions() {
- delegate?.roomInputToolbarViewShowSendMediaActions?(self)
- }
-
- private func registerThemeServiceDidChangeThemeNotification() {
- NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
- }
-
- @objc private func themeDidChange() {
- self.update(theme: ThemeService.shared().theme)
- }
-
- private func update(theme: Theme) {
- hostingViewController.view.backgroundColor = theme.colors.background
- wysiwygViewModel.textColor = theme.colors.primaryContent
- }
-
- // MARK: - RoomInputToolbarViewProtocol
-
/// Add the voice message toolbar to the composer
/// - Parameter voiceMessageToolbarView: the voice message toolbar UIView
func setVoiceMessageToolbarView(_ voiceMessageToolbarView: UIView!) {
- // TODO embed the voice messages UI
+ if let voiceMessageToolbarView = voiceMessageToolbarView as? VoiceMessageToolbarView {
+ self.voiceMessageToolbarView = voiceMessageToolbarView
+ voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.deactivate(voiceMessageToolbarView.containersTopConstraints)
+ addSubview(voiceMessageToolbarView)
+ NSLayoutConstraint.activate(
+ [
+ hostingViewController.view.topAnchor.constraint(equalTo: voiceMessageToolbarView.topAnchor),
+ hostingViewController.view.leftAnchor.constraint(equalTo: voiceMessageToolbarView.leftAnchor),
+ hostingViewController.view.bottomAnchor.constraint(equalTo: voiceMessageToolbarView.bottomAnchor, constant: 4),
+ hostingViewController.view.rightAnchor.constraint(equalTo: voiceMessageToolbarView.rightAnchor)
+ ]
+ )
+ } else {
+ self.voiceMessageToolbarView?.removeFromSuperview()
+ self.voiceMessageToolbarView = nil
+ }
}
func toolbarHeight() -> CGFloat {
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift
index c7e52e89a..dc46839e3 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift
@@ -208,7 +208,8 @@ class VoiceMessageAttachmentCacheManager {
return
}
- let newURL = temporaryFilesFolderURL.appendingPathComponent(identifier).appendingPathExtension("m4a")
+ let fileExtension = filePath.hasSuffix(".mp4") ? "mp4" : "m4a"
+ let newURL = temporaryFilesFolderURL.appendingPathComponent(identifier).appendingPathExtension(fileExtension)
let conversionCompletion: (Result) -> Void = { result in
self.workQueue.async {
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift
index 7a21edcf6..996e33b4a 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift
@@ -42,7 +42,12 @@ struct VoiceMessageAudioConverter {
static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) {
DispatchQueue.global(qos: .userInitiated).async {
do {
- try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL)
+ if sourceURL.pathExtension == "mp4" {
+ try FileManager.default.copyItem(atPath: sourceURL.path, toPath: destinationURL.path)
+ } else {
+ try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL)
+ }
+
DispatchQueue.main.async {
completion(.success(()))
}
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift
index ebe038c6d..46e06cfed 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift
@@ -35,12 +35,13 @@ enum VoiceMessageAudioPlayerError: Error {
class VoiceMessageAudioPlayer: NSObject {
private var playerItem: AVPlayerItem?
- private var audioPlayer: AVPlayer?
+ private var audioPlayer: AVQueuePlayer?
private var statusObserver: NSKeyValueObservation?
private var playbackBufferEmptyObserver: NSKeyValueObservation?
private var rateObserver: NSKeyValueObservation?
private var playToEndObserver: NSObjectProtocol?
+ private var appBackgroundObserver: NSObjectProtocol?
private let delegateContainer = DelegateContainer()
@@ -63,6 +64,14 @@ class VoiceMessageAudioPlayer: NSObject {
return abs(CMTimeGetSeconds(audioPlayer?.currentTime() ?? .zero))
}
+ var playerItems: [AVPlayerItem] {
+ guard let audioPlayer = audioPlayer else {
+ return []
+ }
+
+ return audioPlayer.items()
+ }
+
private(set) var isStopped = true
deinit {
@@ -84,11 +93,30 @@ class VoiceMessageAudioPlayer: NSObject {
}
playerItem = AVPlayerItem(url: url)
- audioPlayer = AVPlayer(playerItem: playerItem)
+ audioPlayer = AVQueuePlayer(playerItem: playerItem)
addObservers()
}
+ func addContentFromURL(_ url: URL) {
+ let playerItem = AVPlayerItem(url: url)
+ audioPlayer?.insert(playerItem, after: nil)
+
+ // audioPlayerDidFinishPlaying must be called on this last AVPlayerItem
+ NotificationCenter.default.removeObserver(playToEndObserver as Any)
+ playToEndObserver = NotificationCenter.default.addObserver(forName: Notification.Name.AVPlayerItemDidPlayToEndTime, object: playerItem, queue: nil) { [weak self] notification in
+ guard let self = self else { return }
+
+ self.delegateContainer.notifyDelegatesWithBlock { delegate in
+ (delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishPlaying(self)
+ }
+ }
+ }
+
+ func removeAllPlayerItems() {
+ audioPlayer?.removeAllItems()
+ }
+
func unloadContent() {
url = nil
audioPlayer?.replaceCurrentItem(with: nil)
@@ -121,7 +149,7 @@ class VoiceMessageAudioPlayer: NSObject {
audioPlayer?.seek(to: .zero)
}
- func seekToTime(_ time: TimeInterval, completionHandler:@escaping (Bool) -> Void = { _ in }) {
+ func seekToTime(_ time: TimeInterval, completionHandler: @escaping (Bool) -> Void = { _ in }) {
audioPlayer?.seek(to: CMTime(seconds: time, preferredTimescale: 60000), completionHandler: completionHandler)
}
@@ -198,6 +226,15 @@ class VoiceMessageAudioPlayer: NSObject {
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishPlaying(self)
}
}
+
+ appBackgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in
+ guard let self = self, !BuildSettings.allowBackgroundAudioMessagePlayback else { return }
+
+ self.pause()
+ self.delegateContainer.notifyDelegatesWithBlock { delegate in
+ (delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidPausePlaying(self)
+ }
+ }
}
private func removeObservers() {
@@ -205,6 +242,7 @@ class VoiceMessageAudioPlayer: NSObject {
playbackBufferEmptyObserver?.invalidate()
rateObserver?.invalidate()
NotificationCenter.default.removeObserver(playToEndObserver as Any)
+ NotificationCenter.default.removeObserver(appBackgroundObserver as Any)
}
}
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift
index 3037c67d0..54262f828 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift
@@ -183,6 +183,10 @@ import MediaPlayer
}
private func setUpRemoteCommandCenter() {
+ guard BuildSettings.allowBackgroundAudioMessagePlayback else {
+ return
+ }
+
displayLink.isPaused = false
UIApplication.shared.beginReceivingRemoteControlEvents()
@@ -252,14 +256,8 @@ import MediaPlayer
return
}
- let artwork = MPMediaItemArtwork(boundsSize: Constants.roomAvatarImageSize) { [weak self] size in
- return self?.roomAvatar ?? UIImage()
- }
-
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
- nowPlayingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: audioPlayer.displayName ?? VectorL10n.voiceMessageLockScreenPlaceholder,
- MPMediaItemPropertyArtist: currentRoomSummary?.displayname as Any,
- MPMediaItemPropertyArtwork: artwork,
+ nowPlayingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: VectorL10n.voiceMessageLockScreenPlaceholder,
MPMediaItemPropertyPlaybackDuration: audioPlayer.duration as Any,
MPNowPlayingInfoPropertyElapsedPlaybackTime: audioPlayer.currentTime as Any]
}
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift
index a5e634dfd..b78d1df76 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift
@@ -88,6 +88,8 @@ class VoiceMessageToolbarView: PassthroughView, NibLoadable, Themable, UIGesture
@IBOutlet private var toastNotificationContainerView: UIView!
@IBOutlet private var toastNotificationLabel: UILabel!
+ @IBOutlet var containersTopConstraints: [NSLayoutConstraint]!
+
private var playbackView: VoiceMessagePlaybackView!
private var cancelLabelToRecordButtonDistance: CGFloat = 0.0
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
index 52dcce870..a5cb9a5b8 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
@@ -1,16 +1,16 @@
-
+
-
+
-
+
@@ -19,7 +19,7 @@
-
+
@@ -71,7 +71,7 @@
-
+
@@ -267,12 +267,14 @@
+
+
-
+
diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h
index be19d7b71..e9db3a583 100644
--- a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h
+++ b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.h
@@ -39,10 +39,15 @@
@param deviceInfo the device to share keys to.
@param wasNewDevice flag indicating whether this is the first time we meet the device.
@param session the related matrix session.
+ @param crypto the related (legacy) crypto module
@param onComplete a block called when the the dialog is closed.
@return the newly created instance.
*/
-- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo wasNewDevice:(BOOL)wasNewDevice andMatrixSession:(MXSession*)session onComplete:(void (^)(void))onComplete;
+- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo
+ wasNewDevice:(BOOL)wasNewDevice
+ andMatrixSession:(MXSession*)session
+ crypto:(MXLegacyCrypto *)crypto
+ onComplete:(void (^)(void))onComplete;
/**
Show the dialog in a modal way.
diff --git a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m
index 91f62a8d6..6f638bd78 100644
--- a/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m
+++ b/Riot/Modules/RoomKeyRequest/RoomKeyRequestViewController.m
@@ -26,16 +26,24 @@
BOOL wasNewDevice;
}
+
+@property (nonatomic, strong) MXLegacyCrypto *crypto;
+
@end
@implementation RoomKeyRequestViewController
-- (instancetype)initWithDeviceInfo:(MXDeviceInfo *)deviceInfo wasNewDevice:(BOOL)theWasNewDevice andMatrixSession:(MXSession *)session onComplete:(void (^)(void))onCompleteBlock
+- (instancetype)initWithDeviceInfo:(MXDeviceInfo *)deviceInfo
+ wasNewDevice:(BOOL)theWasNewDevice
+ andMatrixSession:(MXSession *)session
+ crypto:(MXLegacyCrypto *)crypto
+ onComplete:(void (^)(void))onCompleteBlock
{
self = [super init];
if (self)
{
_mxSession = session;
+ _crypto = crypto;
_device = deviceInfo;
wasNewDevice = theWasNewDevice;
onComplete = onCompleteBlock;
@@ -90,7 +98,7 @@
self->_alertController = nil;
// Accept the received requests from this device
- [self.mxSession.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{
+ [self.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{
self->onComplete();
}];
@@ -108,7 +116,7 @@
self->_alertController = nil;
// Ignore all pending requests from this device
- [self.mxSession.crypto ignoreAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{
+ [self.crypto ignoreAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{
self->onComplete();
}];
@@ -160,14 +168,14 @@
keyVerificationCoordinatorBridgePresenter = nil;
// Check device new status
- [self.mxSession.crypto downloadKeys:@[self.device.userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) {
+ [self.crypto downloadKeys:@[self.device.userId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) {
MXDeviceInfo *deviceInfo = [usersDevicesInfoMap objectForDevice:self.device.deviceId forUser:self.device.userId];
if (deviceInfo && deviceInfo.trustLevel.localVerificationStatus == MXDeviceVerified)
{
// Accept the received requests from this device
// As the device is now verified, all other key requests will be automatically accepted.
- [self.mxSession.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{
+ [self.crypto acceptAllPendingKeyRequestsFromUser:self.device.userId andDevice:self.device.deviceId onComplete:^{
self->onComplete();
}];
diff --git a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift
index 099102014..27659b9be 100644
--- a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift
+++ b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift
@@ -27,19 +27,7 @@ struct DirectoryRoomTableViewCellVM {
// TODO: Use AvatarView subclass in the cell view
func setAvatar(in avatarImageView: MXKImageView) {
-
- let defaultAvatarImage: UIImage?
- var defaultAvatarImageContentMode: UIView.ContentMode = .scaleAspectFill
-
- switch self.avatarViewData.fallbackImage {
- case .matrixItem(let matrixItemId, let matrixItemDisplayName):
- defaultAvatarImage = AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName)
- case .image(let image, let contentMode):
- defaultAvatarImage = image
- defaultAvatarImageContentMode = contentMode ?? .scaleAspectFill
- case .none:
- defaultAvatarImage = nil
- }
+ let (defaultAvatarImage, defaultAvatarImageContentMode) = avatarViewData.fallbackImageParameters() ?? (nil, .scaleAspectFill)
if let avatarUrl = self.avatarViewData.avatarUrl {
avatarImageView.enableInMemoryCache = true
diff --git a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift
index 05b691f3a..2e8e7604c 100644
--- a/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift
+++ b/Riot/Modules/Secrets/Reset/SecretsResetViewModel.swift
@@ -63,7 +63,7 @@ final class SecretsResetViewModel: SecretsResetViewModelType {
}
private func resetSecrets(with authParameters: [String: Any]) {
- guard let crossSigning = self.session.crypto.crossSigning else {
+ guard let crossSigning = self.session.crypto?.crossSigning else {
return
}
MXLog.debug("[SecretsResetViewModel] resetSecrets")
diff --git a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift
index 3385063ce..53a03e359 100644
--- a/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift
+++ b/Riot/Modules/SecureBackup/Setup/SecureBackupSetupCoordinator.swift
@@ -149,11 +149,11 @@ final class SecureBackupSetupCoordinator: SecureBackupSetupCoordinatorType {
}
private func showKeyBackupRestore() {
- guard let keyBackupVersion = self.keyBackup?.keyBackupVersion else {
+ guard let backup = keyBackup, let keyBackupVersion = backup.keyBackupVersion else {
return
}
- let coordinator = KeyBackupRecoverCoordinator(session: self.session, keyBackupVersion: keyBackupVersion, navigationRouter: self.navigationRouter)
+ let coordinator = KeyBackupRecoverCoordinator(keyBackup: backup, keyBackupVersion: keyBackupVersion, navigationRouter: self.navigationRouter)
self.add(childCoordinator: coordinator)
coordinator.delegate = self
diff --git a/Riot/Modules/Settings/Security/SecurityViewController.m b/Riot/Modules/Settings/Security/SecurityViewController.m
index 2e7993c38..d3c7c6c35 100644
--- a/Riot/Modules/Settings/Security/SecurityViewController.m
+++ b/Riot/Modules/Settings/Security/SecurityViewController.m
@@ -324,7 +324,7 @@ TableViewSectionsDelegate>
// Crypto sessions section
- if (RiotSettings.shared.settingsSecurityScreenShowSessions)
+ if (RiotSettings.shared.settingsSecurityScreenShowSessions && !RiotSettings.shared.enableNewSessionManager)
{
Section *sessionsSection = [Section sectionWithTag:SECTION_CRYPTO_SESSIONS];
@@ -627,7 +627,7 @@ TableViewSectionsDelegate>
- (void)loadCrossSigning
{
- MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning;
+ id crossSigning = self.mainSession.crypto.crossSigning;
[crossSigning refreshStateWithSuccess:^(BOOL stateUpdated) {
if (stateUpdated)
@@ -643,7 +643,7 @@ TableViewSectionsDelegate>
{
NSInteger numberOfRowsInCrossSigningSection;
- MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning;
+ id crossSigning = self.mainSession.crypto.crossSigning;
switch (crossSigning.state)
{
case MXCrossSigningStateNotBootstrapped: // Action: Bootstrap
@@ -661,7 +661,7 @@ TableViewSectionsDelegate>
- (NSAttributedString*)crossSigningInformation
{
- MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning;
+ id crossSigning = self.mainSession.crypto.crossSigning;
NSString *crossSigningInformation;
switch (crossSigning.state)
@@ -708,7 +708,7 @@ TableViewSectionsDelegate>
buttonCell.mxkButton.accessibilityIdentifier = nil;
// And customise it
- MXCrossSigning *crossSigning = self.mainSession.crypto.crossSigning;
+ id crossSigning = self.mainSession.crypto.crossSigning;
switch (crossSigning.state)
{
case MXCrossSigningStateNotBootstrapped: // Action: Bootstrap
diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m
index 915e99e12..54bb95d34 100644
--- a/Riot/Modules/Settings/SettingsViewController.m
+++ b/Riot/Modules/Settings/SettingsViewController.m
@@ -1445,13 +1445,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
NSString *sdkVersionInfo = [NSString stringWithFormat:@"Matrix SDK %@", MatrixSDKVersion];
- NSString *olmVersionInfo = [NSString stringWithFormat:@"OLM %@", [OLMKit versionString]];
-
[footerText appendFormat:@"%@\n", loggedUserInfo];
[footerText appendFormat:@"%@\n", homeserverInfo];
[footerText appendFormat:@"%@\n", appVersionInfo];
[footerText appendFormat:@"%@\n", sdkVersionInfo];
- [footerText appendFormat:@"%@", olmVersionInfo];
+ [footerText appendFormat:@"%@", self.mainSession.crypto.version];
return [footerText copy];
}
diff --git a/Riot/Modules/User/Avatar/UserAvatarViewData.swift b/Riot/Modules/User/Avatar/UserAvatarViewData.swift
index 2f9dea0f0..2dad83e98 100644
--- a/Riot/Modules/User/Avatar/UserAvatarViewData.swift
+++ b/Riot/Modules/User/Avatar/UserAvatarViewData.swift
@@ -26,7 +26,7 @@ struct UserAvatarViewData: AvatarViewDataProtocol {
return userId
}
- var fallbackImage: AvatarFallbackImage? {
- return .matrixItem(matrixItemId, displayName)
+ var fallbackImages: [AvatarFallbackImage]? {
+ [.matrixItem(matrixItemId, displayName), .image(Asset.Images.tabPeople.image, .scaleAspectFill)]
}
}
diff --git a/Riot/Modules/UserDevices/UsersDevicesViewController.m b/Riot/Modules/UserDevices/UsersDevicesViewController.m
index 6e2145c4c..3b5b8c9a8 100644
--- a/Riot/Modules/UserDevices/UsersDevicesViewController.m
+++ b/Riot/Modules/UserDevices/UsersDevicesViewController.m
@@ -274,7 +274,12 @@
{
// Acknowledge the existence of all devices before leaving this screen
[self startActivityIndicator];
- [mxSession.crypto setDevicesKnown:usersDevices complete:^{
+ if (![self.mainSession.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ MXLogFailure(@"[UsersDevicesViewController] onDone: Only legacy crypto supports manual setting of known devices");
+ return;
+ }
+ [(MXLegacyCrypto *)mxSession.crypto setDevicesKnown:usersDevices complete:^{
[self stopActivityIndicator];
[self dismissViewControllerAnimated:YES completion:nil];
diff --git a/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift b/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift
index a20ef0d41..b6ac2af21 100644
--- a/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift
+++ b/Riot/Modules/VoiceBroadcast/MXSession+VoiceBroadcast.swift
@@ -28,4 +28,8 @@ extension MXSession {
@objc public func getOrCreateVoiceBroadcastService(for room: MXRoom, completion: @escaping (VoiceBroadcastService?) -> Void) {
VoiceBroadcastServiceProvider.shared.getOrCreateVoiceBroadcastService(for: room, completion: completion)
}
+
+ @objc public func tearDownVoiceBroadcastService() {
+ VoiceBroadcastServiceProvider.shared.tearDownVoiceBroadcastService()
+ }
}
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift
index 1a10324d4..1de022904 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift
@@ -25,6 +25,8 @@ public protocol VoiceBroadcastAggregatorDelegate: AnyObject {
func voiceBroadcastAggregatorDidStartLoading(_ aggregator: VoiceBroadcastAggregator)
func voiceBroadcastAggregatorDidEndLoading(_ aggregator: VoiceBroadcastAggregator)
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didFailWithError: Error)
+ func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveChunk: VoiceBroadcastChunk)
+ func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State)
func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator)
}
@@ -42,17 +44,20 @@ public class VoiceBroadcastAggregator {
private let voiceBroadcastBuilder: VoiceBroadcastBuilder
private var voiceBroadcastInfoStartEventContent: VoiceBroadcastInfo!
+ private var voiceBroadcastSenderId: String!
private var referenceEventsListener: Any?
private var events: [MXEvent] = []
- public private(set) var voiceBroadcast: VoiceBroadcastProtocol! {
+ public private(set) var voiceBroadcast: VoiceBroadcast! {
didSet {
delegate?.voiceBroadcastAggregatorDidUpdateData(self)
}
}
+ public private(set) var isStarted: Bool = false
+ public private(set) var voiceBroadcastState: VoiceBroadcastInfo.State
public var delegate: VoiceBroadcastAggregatorDelegate?
deinit {
@@ -61,31 +66,34 @@ public class VoiceBroadcastAggregator {
}
}
- public init(session: MXSession, room: MXRoom, voiceBroadcastStartEventId: String) throws {
+ public init(session: MXSession, room: MXRoom, voiceBroadcastStartEventId: String, voiceBroadcastState: VoiceBroadcastInfo.State) throws {
self.session = session
self.room = room
self.voiceBroadcastStartEventId = voiceBroadcastStartEventId
+ self.voiceBroadcastState = voiceBroadcastState
self.voiceBroadcastBuilder = VoiceBroadcastBuilder()
NotificationCenter.default.addObserver(self, selector: #selector(handleRoomDataFlush), name: NSNotification.Name.mxRoomDidFlushData, object: self.room)
-
+
try buildVoiceBroadcastStartContent()
}
private func buildVoiceBroadcastStartContent() throws {
guard let event = session.store.event(withEventId: voiceBroadcastStartEventId, inRoom: room.roomId),
- let eventContent = VoiceBroadcastInfo(fromJSON: event.content)
+ let eventContent = VoiceBroadcastInfo(fromJSON: event.content),
+ let senderId = event.stateKey
else {
throw VoiceBroadcastAggregatorError.invalidVoiceBroadcastStartEvent
}
voiceBroadcastInfoStartEventContent = eventContent
+ voiceBroadcastSenderId = senderId
- voiceBroadcast = voiceBroadcastBuilder.build(voiceBroadcastStartEventContent: eventContent,
- events: events,
- currentUserIdentifier: session.myUserId)
-
- reloadVoiceBroadcastData()
+ voiceBroadcast = voiceBroadcastBuilder.build(mediaManager: session.mediaManager,
+ voiceBroadcastStartEventId: voiceBroadcastStartEventId,
+ voiceBroadcastInvoiceBroadcastStartEventContent: eventContent,
+ events: events,
+ currentUserIdentifier: session.myUserId)
}
@objc private func handleRoomDataFlush(sender: Notification) {
@@ -93,10 +101,30 @@ public class VoiceBroadcastAggregator {
return
}
- reloadVoiceBroadcastData()
+ // TODO: What is the impact on room data flush on voice broadcast audio streaming?
+ MXLog.warning("[VoiceBroadcastAggregator] handleRoomDataFlush is not supported yet")
}
- private func reloadVoiceBroadcastData() {
+ private func updateState() {
+ self.room.state { roomState in
+ guard let event = roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last,
+ event.stateKey == self.voiceBroadcastSenderId,
+ let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: event.content),
+ (event.eventId == self.voiceBroadcastStartEventId || voiceBroadcastInfo.eventId == self.voiceBroadcastStartEventId),
+ let state = VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) else {
+ return
+ }
+
+ self.delegate?.voiceBroadcastAggregator(self, didReceiveState: state)
+ }
+ }
+
+ func start() {
+ if isStarted {
+ return
+ }
+ isStarted = true
+
delegate?.voiceBroadcastAggregatorDidStartLoading(self)
session.aggregations.referenceEvents(forEvent: voiceBroadcastStartEventId, inRoom: room.roomId, from: nil, limit: -1) { [weak self] response in
@@ -106,29 +134,66 @@ public class VoiceBroadcastAggregator {
self.events.removeAll()
- self.events.append(contentsOf: response.chunk)
+ let filteredChunk = response.chunk.filter { event in
+ event.sender == self.voiceBroadcastSenderId &&
+ event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil
+ }
+ self.events.append(contentsOf: filteredChunk)
- let eventTypes = [VoiceBroadcastSettings.eventType, kMXEventTypeStringRoomMessage]
+ let eventTypes = [VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType, kMXEventTypeStringRoomMessage]
self.referenceEventsListener = self.room.listen(toEventsOfTypes: eventTypes) { [weak self] event, direction, state in
- // TODO: check sender id to block fake voice broadcast chunk
- guard let self = self,
- let relatedEventId = event.relatesTo?.eventId,
- relatedEventId == self.voiceBroadcastStartEventId,
- event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil else {
+
+ guard let self = self else {
return
}
- self.events.append(event)
-
- self.voiceBroadcast = self.voiceBroadcastBuilder.build(voiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent,
- events: self.events,
- currentUserIdentifier: self.session.myUserId)
+ if event.eventType == .roomMessage {
+ guard event.sender == self.voiceBroadcastSenderId,
+ let relatedEventId = event.relatesTo?.eventId,
+ relatedEventId == self.voiceBroadcastStartEventId,
+ event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil else {
+ return
+ }
+
+ if let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) {
+ self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk)
+ }
+
+ if !self.events.contains(where: { newEvent in
+ newEvent.eventId == event.eventId
+ }) {
+ self.events.append(event)
+ MXLog.debug("[VoiceBroadcastAggregator] Got a new chunk for broadcast \(relatedEventId). Total: \(self.events.count)")
+
+ self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager,
+ voiceBroadcastStartEventId: self.voiceBroadcastStartEventId,
+ voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent,
+ events: self.events,
+ currentUserIdentifier: self.session.myUserId)
+ }
+ } else {
+ self.updateState()
+ }
} as Any
- self.voiceBroadcast = self.voiceBroadcastBuilder.build(voiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent,
- events: self.events,
- currentUserIdentifier: self.session.myUserId)
+
+ self.events.forEach { event in
+ guard let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) else {
+ return
+ }
+ self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk)
+ }
+
+ self.updateState()
+
+ self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager,
+ voiceBroadcastStartEventId: self.voiceBroadcastStartEventId,
+ voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent,
+ events: self.events,
+ currentUserIdentifier: self.session.myUserId)
+
+ MXLog.debug("[VoiceBroadcastAggregator] Start aggregation with \(self.voiceBroadcast.chunks.count) chunks for broadcast \(self.voiceBroadcastStartEventId)")
self.delegate?.voiceBroadcastAggregatorDidEndLoading(self)
@@ -137,6 +202,8 @@ public class VoiceBroadcastAggregator {
return
}
+ MXLog.error("[VoiceBroadcastAggregator] start failed", context: error)
+ self.isStarted = false
self.delegate?.voiceBroadcastAggregator(self, didFailWithError: error)
}
}
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift
index df2f60907..e27f5258a 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift
@@ -18,12 +18,29 @@ import Foundation
struct VoiceBroadcastBuilder {
- func build(voiceBroadcastStartEventContent: VoiceBroadcastInfo, events: [MXEvent], currentUserIdentifier: String, hasBeenEdited: Bool = false) -> VoiceBroadcastProtocol {
+ func build(mediaManager: MXMediaManager,
+ voiceBroadcastStartEventId: String,
+ voiceBroadcastInvoiceBroadcastStartEventContent: VoiceBroadcastInfo,
+ events: [MXEvent],
+ currentUserIdentifier: String,
+ hasBeenEdited: Bool = false) -> VoiceBroadcast {
- let voiceBroadcast = VoiceBroadcast()
+ var voiceBroadcast = VoiceBroadcast()
- // TODO: set voice broadcast object
+ voiceBroadcast.chunks = Set(events.compactMap { event in
+ buildChunk(event: event, mediaManager: mediaManager, voiceBroadcastStartEventId: voiceBroadcastStartEventId)
+ })
return voiceBroadcast
}
+
+ func buildChunk(event: MXEvent, mediaManager: MXMediaManager, voiceBroadcastStartEventId: String) -> VoiceBroadcastChunk? {
+ guard let attachment = MXKAttachment(event: event, andMediaManager: mediaManager),
+ let chunkInfo = event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] as? [String: UInt],
+ let sequence = chunkInfo[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence] else {
+ return nil
+ }
+
+ return VoiceBroadcastChunk(voiceBroadcastInfoEventId: voiceBroadcastStartEventId, sequence: sequence, attachment: attachment)
+ }
}
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h
index 2b759102e..36b963e47 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h
@@ -22,21 +22,25 @@ NS_ASSUME_NONNULL_BEGIN
@interface VoiceBroadcastInfo : MXJSONModel
+/// The device id from which the broadcast has been started
+@property (nonatomic) NSString *deviceId;
+
/// The voice broadcast state (started - paused - resumed - stopped).
@property (nonatomic) NSString *state;
/// The length of the voice chunks in seconds. Only required on the started state event.
@property (nonatomic) NSInteger chunkLength;
-/// The event id of the started voice broadcast info state event.
+/// The event id of the started voice broadcast info state event.
@property (nonatomic, strong, nullable) NSString* eventId;
/// The event used to build the MXBeaconInfo.
@property (nonatomic, readonly, nullable) MXEvent *originalEvent;
-- (instancetype)initWithState:(NSString *)state
- chunkLength:(NSInteger)chunkLength
- eventId:(NSString *)eventId;
+- (instancetype)initWithDeviceId:(NSString *)deviceId
+ state:(NSString *)state
+ chunkLength:(NSInteger)chunkLength
+ eventId:(NSString *)eventId;
@end
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m
index 14f3c80c3..51a50876c 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m
@@ -19,12 +19,14 @@
@implementation VoiceBroadcastInfo
-- (instancetype)initWithState:(NSString *)state
- chunkLength:(NSInteger)chunkLength
- eventId:(NSString *)eventId
+- (instancetype)initWithDeviceId:(NSString *)deviceId
+ state:(NSString *)state
+ chunkLength:(NSInteger)chunkLength
+ eventId:(NSString *)eventId
{
if (self = [super init])
{
+ _deviceId = deviceId;
_state = state;
_chunkLength = chunkLength;
_eventId = eventId;
@@ -35,9 +37,18 @@
+ (id)modelFromJSON:(NSDictionary *)JSONDictionary
{
+ // Return nil for redacted state event
+ if (!JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyState])
+ {
+ return nil;
+ }
+
NSString *state;
MXJSONModelSetString(state, JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyState]);
+ NSString *deviceId;
+ MXJSONModelSetString(deviceId, JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyDeviceId]);
+
NSInteger chunkLength = BuildSettings.voiceBroadcastChunkLength;
if (JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkLength])
{
@@ -56,13 +67,15 @@
}
}
- return [[VoiceBroadcastInfo alloc] initWithState:state chunkLength:chunkLength eventId:eventId];
+ return [[VoiceBroadcastInfo alloc] initWithDeviceId:deviceId state:state chunkLength:chunkLength eventId:eventId];
}
- (NSDictionary *)JSONDictionary
{
NSMutableDictionary *JSONDictionary = [NSMutableDictionary dictionary];
+ JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyDeviceId] = self.deviceId;
+
JSONDictionary[VoiceBroadcastSettings.voiceBroadcastContentKeyState] = self.state;
if (_eventId) {
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift
index 4b2bfc258..138af9e32 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift
@@ -16,19 +16,12 @@
import Foundation
-public protocol VoiceBroadcastProtocol {
- var chunks: Set { get }
- var isClosed: Bool { get }
- var kind: VoiceBroadcastKind { get }
-}
-
public enum VoiceBroadcastKind {
- case disclosed
- case undisclosed
+ case player
+ case recorder
}
-class VoiceBroadcast: VoiceBroadcastProtocol {
+public struct VoiceBroadcast {
var chunks: Set = []
- var isClosed: Bool = false
- var kind: VoiceBroadcastKind = .disclosed
+ var kind: VoiceBroadcastKind = .player
}
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift
index 01dd4e80e..81cbc51af 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift
@@ -23,7 +23,7 @@ public class VoiceBroadcastService: NSObject {
// MARK: - Properties
- private var voiceBroadcastInfoEventId: String?
+ public private(set) var voiceBroadcastInfoEventId: String?
public let room: MXRoom
public private(set) var state: VoiceBroadcastInfo.State
@@ -98,12 +98,14 @@ public class VoiceBroadcastService: NSObject {
/// - mimeType: (optional) the mime type of the file. Defaults to `audio/ogg`
/// - duration: the length of the voice message in milliseconds
/// - samples: an array of floating point values normalized to [0, 1], boxed within NSNumbers
+ /// - sequence: value of the chunk sequence.
/// - success: A block object called when the operation succeeds. It returns the event id of the event generated on the homeserver
/// - failure: A block object called when the operation fails.
func sendChunkOfVoiceBroadcast(audioFileLocalURL: URL,
mimeType: String?,
duration: UInt,
samples: [Float]?,
+ sequence: UInt,
success: @escaping ((String?) -> Void),
failure: @escaping ((Error?) -> Void)) {
guard let voiceBroadcastInfoEventId = self.voiceBroadcastInfoEventId else {
@@ -115,6 +117,7 @@ public class VoiceBroadcastService: NSObject {
mimeType: mimeType,
duration: duration,
samples: samples,
+ sequence: sequence,
success: success,
failure: failure)
}
@@ -130,6 +133,9 @@ public class VoiceBroadcastService: NSObject {
let stateKey = userId
let voiceBroadcastInfo = VoiceBroadcastInfo()
+
+ voiceBroadcastInfo.deviceId = self.room.mxSession.myDeviceId
+
voiceBroadcastInfo.state = state.rawValue
if state != VoiceBroadcastInfo.State.started {
@@ -148,7 +154,7 @@ public class VoiceBroadcastService: NSObject {
return nil
}
- return self.room.sendStateEvent(.custom(VoiceBroadcastSettings.eventType),
+ return self.room.sendStateEvent(.custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType),
content: stateEventContent, stateKey: stateKey) { [weak self] response in
guard let self = self else { return }
@@ -246,6 +252,7 @@ extension MXRoom {
/// - duration: the length of the voice message in milliseconds
/// - samples: an array of floating point values normalized to [0, 1]
/// - threadId: the id of the thread to send the message. nil by default.
+ /// - sequence: value of the chunk sequence.
/// - success: A closure called when the operation is complete.
/// - failure: A closure called when the operation fails.
/// - Returns: a `MXHTTPOperation` instance.
@@ -255,6 +262,7 @@ extension MXRoom {
duration: UInt,
samples: [Float]?,
threadId: String? = nil,
+ sequence: UInt,
success: @escaping ((String?) -> Void),
failure: @escaping ((Error?) -> Void)) -> MXHTTPOperation? {
let boxedSamples = samples?.compactMap { NSNumber(value: $0) }
@@ -265,9 +273,12 @@ extension MXRoom {
failure(VoiceBroadcastServiceError.unknown)
return nil
}
+
+ let sequenceValue = [VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence: sequence]
return __sendVoiceMessage(localURL,
- additionalContentParams: [kMXEventRelationRelatesToKey: relatesTo],
+ additionalContentParams: [kMXEventRelationRelatesToKey: relatesTo,
+ VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType: sequenceValue],
mimeType: mimeType,
duration: duration,
samples: boxedSamples,
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift
index 9d17da35b..425cc03f4 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift
@@ -19,8 +19,9 @@ import Foundation
/// Voice Broadcast settings.
@objcMembers
final class VoiceBroadcastSettings: NSObject {
- static let eventType = "io.element.voice_broadcast_info"
+ static let voiceBroadcastInfoContentKeyType = "io.element.voice_broadcast_info"
+ static let voiceBroadcastContentKeyDeviceId = "device_id"
static let voiceBroadcastContentKeyState = "state"
static let voiceBroadcastContentKeyChunkLength = "chunk_length"
static let voiceBroadcastContentKeyChunkType = "io.element.voice_broadcast_chunk"
diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift
index 579ef45d4..e39c838b7 100644
--- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift
+++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift
@@ -66,7 +66,7 @@ class VoiceBroadcastServiceProvider {
/// - completion: Completion block that will return the lastest voice broadcast info state event of the room.
private func getLastVoiceBroadcastInfo(for room: MXRoom, completion: @escaping (MXEvent?) -> Void) {
room.state { roomState in
- completion(roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.eventType))?.last ?? nil)
+ completion(roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last ?? nil)
}
}
diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m
index 85bfbe000..80efe2f99 100644
--- a/Riot/Utils/EventFormatter.m
+++ b/Riot/Utils/EventFormatter.m
@@ -272,7 +272,7 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm";
// Build the attributed string with the right font and color for the events
return [self renderString:displayText forEvent:event];
}
- } else if ([event.type isEqualToString:VoiceBroadcastSettings.eventType]) {
+ } else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) {
MXLogDebug(@"VB incoming build string")
}
}
diff --git a/RiotShareExtension/Shared/ShareManager.m b/RiotShareExtension/Shared/ShareManager.m
index 2233b352d..22d0063be 100644
--- a/RiotShareExtension/Shared/ShareManager.m
+++ b/RiotShareExtension/Shared/ShareManager.m
@@ -102,7 +102,10 @@ static MXSession *fakeSession;
[session setStore:self.fileStore success:^{
MXStrongifyAndReturnIfNil(session);
- session.crypto.warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now
+ if ([session.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ ((MXLegacyCrypto *)session.crypto).warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now
+ }
self.selectedRooms = [NSMutableArray array];
for (NSString *roomIdentifier in roomIdentifiers) {
diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift
index 0dc3f78d5..30059334e 100644
--- a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift
+++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift
@@ -106,7 +106,11 @@ class QRLoginService: NSObject, QRLoginServiceProtocol {
}
func stopScanning(destroy: Bool) {
- zxCapture.delegate = nil
+ if (zxCapture.delegate != nil) {
+ // Setting the zxCapture to nil without checking makes it start
+ // scanning and implicitly requesting camera access
+ zxCapture.delegate = nil
+ }
guard zxCapture.running else {
return
@@ -292,7 +296,7 @@ class QRLoginService: NSObject, QRLoginServiceProtocol {
MXLog.debug("[QRLoginService] Received cross-signing details \(responsePayload)")
if let masterKeyFromVerifyingDevice = responsePayload.masterKey,
- let localMasterKey = session.crypto.crossSigningKeys(forUser: session.myUserId).masterKeys?.keys {
+ let localMasterKey = session.crypto.crossSigning.crossSigningKeys(forUser: session.myUserId)?.masterKeys?.keys {
guard masterKeyFromVerifyingDevice == localMasterKey else {
MXLog.error("[QRLoginService] Received invalid master key from verifying device")
await teardownRendezvous(state: .failed(error: .rendezvousFailed))
@@ -348,6 +352,7 @@ class QRLoginService: NSObject, QRLoginServiceProtocol {
await teardownRendezvous()
}
+ @MainActor
private func teardownRendezvous(state: QRLoginServiceState? = nil) async {
// Stop listening for changes, try deleting the resource
_ = await rendezvousService?.tearDown()
diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift
index 8beba56a5..e2b3ce30e 100644
--- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift
+++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift
@@ -70,6 +70,7 @@ enum MockAppScreens {
MockTemplateRoomChatScreenState.self,
MockSpaceSelectorScreenState.self,
MockComposerScreenState.self,
- MockComposerCreateActionListScreenState.self
+ MockComposerCreateActionListScreenState.self,
+ MockVoiceBroadcastPlaybackScreenState.self
]
}
diff --git a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift
index 91cf8937f..ee618b8f8 100644
--- a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift
+++ b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift
@@ -36,6 +36,7 @@ struct ScreenList: View {
VStack {
TextField("Search", text: $searchQuery)
.textFieldStyle(.roundedBorder)
+ .autocorrectionDisabled()
.padding(.horizontal)
.accessibilityIdentifier("searchQueryTextField")
.onChange(of: searchQuery, perform: search)
diff --git a/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift b/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift
index a2e7dc2b5..f0912c5bc 100644
--- a/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift
+++ b/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift
@@ -20,16 +20,33 @@ import XCTest
extension XCUIApplication {
func goToScreenWithIdentifier(_ identifier: String) {
// Search for the screen identifier
- textFields["searchQueryTextField"].tap()
- typeText(identifier)
-
+ let textField = textFields["searchQueryTextField"]
let button = buttons[identifier]
- let footer = staticTexts["footerText"]
- while !button.isHittable, !footer.isHittable {
- tables.firstMatch.swipeUp()
+ // Sometimes the search gets stuck without showing any results. Try to nudge it along
+ for _ in 0...10 {
+ textField.clearAndTypeText(identifier)
+ if button.exists {
+ break
+ }
}
button.tap()
}
}
+
+private extension XCUIElement {
+ func clearAndTypeText(_ text: String) {
+ guard let stringValue = value as? String else {
+ XCTFail("Tried to clear and type text into a non string value")
+ return
+ }
+
+ tap()
+
+ let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
+
+ typeText(deleteString)
+ typeText(text)
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift
index 0b3a6080f..457cc612a 100644
--- a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Model/ComposerCreateActionListModels.swift
@@ -42,6 +42,8 @@ struct ComposerCreateActionListViewState: BindableState {
case stickers
/// Upload an attachment
case attachments
+ /// Voice broadcast
+ case voiceBroadcast
/// Create a Poll
case polls
/// Add a location
@@ -63,6 +65,8 @@ extension ComposerCreateAction {
return VectorL10n.wysiwygComposerStartActionStickers
case .attachments:
return VectorL10n.wysiwygComposerStartActionAttachments
+ case .voiceBroadcast:
+ return VectorL10n.wysiwygComposerStartActionVoiceBroadcast
case .polls:
return VectorL10n.wysiwygComposerStartActionPolls
case .location:
@@ -80,6 +84,8 @@ extension ComposerCreateAction {
return "stickersAction"
case .attachments:
return "attachmentsAction"
+ case .voiceBroadcast:
+ return "voiceBroadcastAction"
case .polls:
return "pollsAction"
case .location:
@@ -97,6 +103,8 @@ extension ComposerCreateAction {
return Asset.Images.actionSticker.name
case .attachments:
return Asset.Images.actionFile.name
+ case .voiceBroadcast:
+ return Asset.Images.actionLive.name
case .polls:
return Asset.Images.actionPoll.name
case .location:
diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift
index 4aa483785..48d7df054 100644
--- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift
@@ -40,11 +40,13 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
viewModel.callback = { [weak viewModel, weak wysiwygviewModel] result in
guard let viewModel = viewModel else { return }
- if viewModel.sendMode == .edit {
- wysiwygviewModel?.setHtmlContent("")
- }
switch result {
- case .cancel: viewModel.sendMode = .send
+ case .cancel:
+ if viewModel.sendMode == .edit {
+ wysiwygviewModel?.setHtmlContent("")
+ }
+ viewModel.sendMode = .send
+ default: break
}
}
diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift
index 00470aa53..badcd2b20 100644
--- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift
@@ -127,12 +127,14 @@ enum ComposerSendMode: Equatable {
case createDM
}
-enum ComposerViewAction {
+enum ComposerViewAction: Equatable {
case cancel
+ case contentDidChange(isEmpty: Bool)
}
-enum ComposerViewModelResult {
+enum ComposerViewModelResult: Equatable {
case cancel
+ case contentDidChange(isEmpty: Bool)
}
diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift
index 6cba03bed..0f8ad1fdc 100644
--- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift
@@ -19,6 +19,7 @@ import Foundation
struct ComposerViewState: BindableState {
var eventSenderDisplayName: String?
var sendMode: ComposerSendMode = .send
+ var placeholder: String?
}
extension ComposerViewState {
diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift
index 1a37c020b..c80bea819 100644
--- a/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/Test/UI/ComposerUITests.swift
@@ -25,12 +25,24 @@ final class ComposerUITests: MockScreenTestCase {
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
XCTAssertTrue(wysiwygTextView.exists)
let sendButton = app.buttons["sendButton"]
- XCTAssertTrue(sendButton.exists)
- XCTAssertFalse(sendButton.isEnabled)
+ XCTAssertFalse(sendButton.exists)
wysiwygTextView.tap()
wysiwygTextView.typeText("test")
- XCTAssertTrue(sendButton.isEnabled)
+ XCTAssertTrue(sendButton.exists)
XCTAssertFalse(app.buttons["editButton"].exists)
+
+ let maximiseButton = app.buttons["maximiseButton"]
+ let minimiseButton = app.buttons["minimiseButton"]
+ XCTAssertFalse(minimiseButton.exists)
+ XCTAssertTrue(maximiseButton.exists)
+
+ maximiseButton.tap()
+ XCTAssertTrue(minimiseButton.exists)
+ XCTAssertFalse(maximiseButton.exists)
+
+ minimiseButton.tap()
+ XCTAssertFalse(minimiseButton.exists)
+ XCTAssertTrue(maximiseButton.exists)
}
func testReplyMode() throws {
@@ -39,8 +51,7 @@ final class ComposerUITests: MockScreenTestCase {
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
XCTAssertTrue(wysiwygTextView.exists)
let sendButton = app.buttons["sendButton"]
- XCTAssertTrue(sendButton.exists)
- XCTAssertFalse(sendButton.isEnabled)
+ XCTAssertFalse(sendButton.exists)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
@@ -51,13 +62,26 @@ final class ComposerUITests: MockScreenTestCase {
wysiwygTextView.tap()
wysiwygTextView.typeText("test")
- XCTAssertTrue(sendButton.isEnabled)
+ XCTAssertTrue(sendButton.exists)
XCTAssertFalse(app.buttons["editButton"].exists)
cancelButton.tap()
let textViewContent = wysiwygTextView.value as! String
XCTAssertFalse(textViewContent.isEmpty)
XCTAssertFalse(cancelButton.exists)
+
+ let maximiseButton = app.buttons["maximiseButton"]
+ let minimiseButton = app.buttons["minimiseButton"]
+ XCTAssertFalse(minimiseButton.exists)
+ XCTAssertTrue(maximiseButton.exists)
+
+ maximiseButton.tap()
+ XCTAssertTrue(minimiseButton.exists)
+ XCTAssertFalse(maximiseButton.exists)
+
+ minimiseButton.tap()
+ XCTAssertFalse(minimiseButton.exists)
+ XCTAssertTrue(maximiseButton.exists)
}
func testEditMode() throws {
@@ -66,8 +90,7 @@ final class ComposerUITests: MockScreenTestCase {
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
XCTAssertTrue(wysiwygTextView.exists)
let editButton = app.buttons["editButton"]
- XCTAssertTrue(editButton.exists)
- XCTAssertFalse(editButton.isEnabled)
+ XCTAssertFalse(editButton.exists)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
@@ -78,12 +101,25 @@ final class ComposerUITests: MockScreenTestCase {
wysiwygTextView.tap()
wysiwygTextView.typeText("test")
- XCTAssertTrue(editButton.isEnabled)
+ XCTAssertTrue(editButton.exists)
XCTAssertFalse(app.buttons["sendButton"].exists)
cancelButton.tap()
let textViewContent = wysiwygTextView.value as! String
XCTAssertTrue(textViewContent.isEmpty)
XCTAssertFalse(cancelButton.exists)
+
+ let maximiseButton = app.buttons["maximiseButton"]
+ let minimiseButton = app.buttons["minimiseButton"]
+ XCTAssertFalse(minimiseButton.exists)
+ XCTAssertTrue(maximiseButton.exists)
+
+ maximiseButton.tap()
+ XCTAssertTrue(minimiseButton.exists)
+ XCTAssertFalse(maximiseButton.exists)
+
+ minimiseButton.tap()
+ XCTAssertFalse(minimiseButton.exists)
+ XCTAssertTrue(maximiseButton.exists)
}
}
diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift
index f125b638a..5f16cfa42 100644
--- a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift
@@ -63,4 +63,10 @@ final class ComposerViewModelTests: XCTestCase {
context.send(viewAction: .cancel)
XCTAssert(result == .cancel)
}
+
+ func testPlaceholder() {
+ XCTAssert(context.viewState.placeholder == nil)
+ viewModel.placeholder = "Placeholder Test"
+ XCTAssert(context.viewState.placeholder == "Placeholder Test")
+ }
}
diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
index 64069e5c3..624c84638 100644
--- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
@@ -26,7 +26,7 @@ struct Composer: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var focused = false
- @State private var isActionButtonEnabled = false
+ @State private var isActionButtonShowing = false
private let horizontalPadding: CGFloat = 12
private let borderHeight: CGFloat = 40
@@ -51,6 +51,14 @@ struct Composer: View {
viewModel.viewState.sendMode == .edit ? "editButton" : "sendButton"
}
+ private var toggleButtonAcccessibilityIdentifier: String {
+ wysiwygViewModel.maximised ? "minimiseButton" : "maximiseButton"
+ }
+
+ private var toggleButtonImageName: String {
+ wysiwygViewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name
+ }
+
private var borderColor: Color {
focused ? theme.colors.quarterlyContent : theme.colors.quinaryContent
}
@@ -76,8 +84,6 @@ struct Composer: View {
var body: some View {
VStack(spacing: 8) {
let rect = RoundedRectangle(cornerRadius: cornerRadius)
- // TODO: Fix maximise animation bugs before re-enabling
- // ZStack(alignment: .topTrailing) {
VStack(spacing: 12) {
if viewModel.viewState.shouldDisplayContext {
HStack {
@@ -103,35 +109,39 @@ struct Composer: View {
.padding(.top, 8)
.padding(.horizontal, horizontalPadding)
}
- WysiwygComposerView(
- focused: $focused,
- content: wysiwygViewModel.content,
- replaceText: wysiwygViewModel.replaceText,
- select: wysiwygViewModel.select,
- didUpdateText: wysiwygViewModel.didUpdateText
- )
- .tintColor(theme.colors.accent)
- .frame(height: wysiwygViewModel.idealHeight)
- .padding(.horizontal, horizontalPadding)
- .onAppear {
- wysiwygViewModel.setup()
+ HStack(alignment: .top, spacing: 0) {
+ WysiwygComposerView(
+ focused: $focused,
+ content: wysiwygViewModel.content,
+ replaceText: wysiwygViewModel.replaceText,
+ select: wysiwygViewModel.select,
+ didUpdateText: wysiwygViewModel.didUpdateText
+ )
+ .tintColor(theme.colors.accent)
+ .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent)
+ .frame(height: wysiwygViewModel.idealHeight)
+ .onAppear {
+ wysiwygViewModel.setup()
+ }
+ Button {
+ wysiwygViewModel.maximised.toggle()
+ } label: {
+ Image(toggleButtonImageName)
+ .resizable()
+ .foregroundColor(theme.colors.tertiaryContent)
+ .frame(width: 16, height: 16)
+ }
+ .accessibilityIdentifier(toggleButtonAcccessibilityIdentifier)
+ .padding(.leading, 12)
+ .padding(.trailing, 4)
}
- // Button {
- // withAnimation(.easeInOut(duration: 0.25)) {
- // viewModel.maximised.toggle()
- // }
- // } label: {
- // Image(viewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name)
- // .foregroundColor(theme.colors.tertiaryContent)
- // }
- // .padding(.top, 4)
- // .padding(.trailing, 12)
- // }
+ .padding(.horizontal, horizontalPadding)
.padding(.top, topPadding)
.padding(.bottom, verticalPadding)
}
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: 1))
+ .animation(.easeInOut(duration: 0.1), value: wysiwygViewModel.idealHeight)
.padding(.horizontal, horizontalPadding)
.padding(.top, 8)
.onTapGesture {
@@ -147,7 +157,6 @@ struct Composer: View {
.resizable()
.foregroundColor(theme.colors.tertiaryContent)
.frame(width: 14, height: 14)
-
}
.frame(width: 36, height: 36)
.background(Circle().fill(theme.colors.system))
@@ -158,16 +167,6 @@ struct Composer: View {
}
.frame(height: 44)
Spacer()
- // ZStack {
- // TODO: Add support for voice messages
- // Button {
- //
- // } label: {
- // Image(Asset.Images.voiceMessageRecordButtonDefault.name)
- // .foregroundColor(theme.colors.tertiaryContent)
- // }
- // .isHidden(showSendButton)
- // .isHidden(true)
Button {
sendMessageAction(wysiwygViewModel.content)
wysiwygViewModel.clearContent()
@@ -180,18 +179,18 @@ struct Composer: View {
}
.frame(width: 36, height: 36)
.padding(.leading, 8)
- .disabled(!isActionButtonEnabled)
- .opacity(isActionButtonEnabled ? 1 : 0.3)
- .animation(.easeInOut(duration: 0.15), value: isActionButtonEnabled)
+ .isHidden(!isActionButtonShowing)
.accessibilityIdentifier(actionButtonAccessibilityIdentifier)
.accessibilityLabel(VectorL10n.send)
- .onChange(of: wysiwygViewModel.isContentEmpty) { empty in
- isActionButtonEnabled = !empty
+ .onChange(of: wysiwygViewModel.isContentEmpty) { isEmpty in
+ viewModel.send(viewAction: .contentDidChange(isEmpty: isEmpty))
+ withAnimation(.easeInOut(duration: 0.15)) {
+ isActionButtonShowing = !isEmpty
+ }
}
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
- .animation(.none)
}
}
}
diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift
index dcb1ec6fe..1e44ed049 100644
--- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift
@@ -45,12 +45,23 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol
}
}
+ var placeholder: String? {
+ get {
+ state.placeholder
+ }
+ set {
+ state.placeholder = newValue
+ }
+ }
+
// MARK: - Public
override func process(viewAction: ComposerViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
+ case let .contentDidChange(isEmpty):
+ callback?(.contentDidChange(isEmpty: isEmpty))
}
}
}
diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift
index 1448f2d1b..70d943dc7 100644
--- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift
@@ -21,4 +21,5 @@ protocol ComposerViewModelProtocol {
var callback: ((ComposerViewModelResult) -> Void)? { get set }
var sendMode: ComposerSendMode { get set }
var eventSenderDisplayName: String? { get set }
+ var placeholder: String? { get set }
}
diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift
index a587b23d8..1acd907a4 100644
--- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift
+++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift
@@ -84,8 +84,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
func start() { }
func toPresentable() -> UIViewController {
- VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context),
- forceZeroSafeAreaInsets: true)
+ VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context))
}
func canEndPoll() -> Bool {
diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift
index 78b1d8ab7..31fb63849 100644
--- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift
+++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift
@@ -26,13 +26,13 @@ class TimelinePollProvider {
/// Create or retrieve the poll timeline coordinator for this event and return
/// a view to be displayed in the timeline
- func buildTimelinePollViewForEvent(_ event: MXEvent) -> UIView? {
+ func buildTimelinePollVCForEvent(_ event: MXEvent) -> UIViewController? {
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
return nil
}
if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
- return coordinator.toPresentable().view
+ return coordinator.toPresentable()
}
let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event)
@@ -42,7 +42,7 @@ class TimelinePollProvider {
coordinatorsForEventIdentifiers[event.eventId] = coordinator
- return coordinator.toPresentable().view
+ return coordinator.toPresentable()
}
/// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet
diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift
index 5e7eaceef..9b363d367 100644
--- a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift
+++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/UI/TimelinePollUITests.swift
@@ -24,36 +24,45 @@ class TimelinePollUITests: MockScreenTestCase {
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["20 votes cast"].exists)
- XCTAssert(app.buttons["First, 10 votes"].exists)
- XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
+
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%")
+
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
- XCTAssert(app.buttons["Second, 5 votes"].exists)
- XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
+ app.buttons["PollAnswerOption0"].tap()
- XCTAssert(app.buttons["Third, 15 votes"].exists)
- XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "11 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "55%")
- app.buttons["First, 10 votes"].tap()
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "4 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "20%")
- XCTAssert(app.buttons["First, 11 votes"].exists)
- XCTAssertEqual(app.buttons["First, 11 votes"].value as! String, "55%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
- XCTAssert(app.buttons["Second, 4 votes"].exists)
- XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
+ app.buttons["PollAnswerOption2"].tap()
- XCTAssert(app.buttons["Third, 15 votes"].exists)
- XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
- app.buttons["Third, 15 votes"].tap()
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "4 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "20%")
- XCTAssert(app.buttons["First, 10 votes"].exists)
- XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
-
- XCTAssert(app.buttons["Second, 4 votes"].exists)
- XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
-
- XCTAssert(app.buttons["Third, 16 votes"].exists)
- XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "16 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "80%")
}
func testOpenUndisclosedPoll() {
@@ -62,29 +71,29 @@ class TimelinePollUITests: MockScreenTestCase {
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["20 votes cast"].exists)
- XCTAssert(!app.buttons["First, 10 votes"].exists)
- XCTAssert(app.buttons["First"].exists)
- XCTAssertTrue((app.buttons["First"].value as! String).isEmpty)
-
- XCTAssert(!app.buttons["Second, 5 votes"].exists)
- XCTAssert(app.buttons["Second"].exists)
- XCTAssertTrue((app.buttons["Second"].value as! String).isEmpty)
-
- XCTAssert(!app.buttons["Third, 15 votes"].exists)
- XCTAssert(app.buttons["Third"].exists)
- XCTAssertTrue((app.buttons["Third"].value as! String).isEmpty)
-
- app.buttons["First"].tap()
-
- XCTAssert(app.buttons["First"].exists)
- XCTAssert(app.buttons["Second"].exists)
- XCTAssert(app.buttons["Third"].exists)
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssert(!app.staticTexts["PollAnswerOption0Count"].exists)
+ XCTAssert(!app.progressIndicators["PollAnswerOption0Progress"].exists)
- app.buttons["Third"].tap()
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssert(!app.staticTexts["PollAnswerOption1Count"].exists)
+ XCTAssert(!app.progressIndicators["PollAnswerOption1Progress"].exists)
- XCTAssert(app.buttons["First"].exists)
- XCTAssert(app.buttons["Second"].exists)
- XCTAssert(app.buttons["Third"].exists)
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+ XCTAssert(!app.staticTexts["PollAnswerOption2Count"].exists)
+ XCTAssert(!app.progressIndicators["PollAnswerOption2Progress"].exists)
+
+ app.buttons["PollAnswerOption0"].tap()
+
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+
+ app.buttons["PollAnswerOption2"].tap()
+
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
}
func testClosedDisclosedPoll() {
@@ -100,25 +109,31 @@ class TimelinePollUITests: MockScreenTestCase {
private func checkClosedPoll() {
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["Final results based on 20 votes"].exists)
+
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
- XCTAssert(app.buttons["First, 10 votes"].exists)
- XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%")
- XCTAssert(app.buttons["Second, 5 votes"].exists)
- XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
- XCTAssert(app.buttons["Third, 15 votes"].exists)
- XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+ app.buttons["PollAnswerOption0"].tap()
- app.buttons["First, 10 votes"].tap()
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
- XCTAssert(app.buttons["First, 10 votes"].exists)
- XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%")
- XCTAssert(app.buttons["Second, 5 votes"].exists)
- XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
-
- XCTAssert(app.buttons["Third, 15 votes"].exists)
- XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
+ XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
+ XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
}
}
diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift
index aaaba7c37..2ffa68be9 100644
--- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift
+++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift
@@ -41,6 +41,7 @@ struct TimelinePollAnswerOptionButton: View {
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
.accentColor(progressViewAccentColor)
}
+ .accessibilityIdentifier("PollAnswerOption\(optionIndex)")
}
var answerOptionLabel: some View {
@@ -53,6 +54,7 @@ struct TimelinePollAnswerOptionButton: View {
Text(answerOption.text)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
+ .accessibilityIdentifier("PollAnswerOption\(optionIndex)Label")
if poll.closed, answerOption.winner {
Spacer()
@@ -66,11 +68,13 @@ struct TimelinePollAnswerOptionButton: View {
total: Double(poll.totalAnswerCount))
.progressViewStyle(LinearProgressViewStyle())
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
+ .accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress")
if poll.shouldDiscloseResults {
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
.font(theme.fonts.footnote)
.foregroundColor(poll.closed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
+ .accessibilityIdentifier("PollAnswerOption\(optionIndex)Count")
}
}
}
@@ -92,6 +96,10 @@ struct TimelinePollAnswerOptionButton: View {
return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
}
+
+ var optionIndex: Int {
+ poll.answerOptions.firstIndex { $0.id == answerOption.id } ?? Int.max
+ }
}
struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
diff --git a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/Coordinator/TimelineVoiceBroadcastCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/Coordinator/TimelineVoiceBroadcastCoordinator.swift
deleted file mode 100644
index 65c618860..000000000
--- a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/Coordinator/TimelineVoiceBroadcastCoordinator.swift
+++ /dev/null
@@ -1,107 +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 MatrixSDK
-import SwiftUI
-
-struct TimelineVoiceBroadcastCoordinatorParameters {
- let session: MXSession
- let room: MXRoom
- let voiceBroadcastStartEvent: MXEvent
-}
-
-final class TimelineVoiceBroadcastCoordinator: Coordinator, Presentable, VoiceBroadcastAggregatorDelegate {
- // MARK: - Properties
-
- // MARK: Private
-
- private let parameters: TimelineVoiceBroadcastCoordinatorParameters
- private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
-
- private var voiceBroadcastAggregator: VoiceBroadcastAggregator
- private var viewModel: TimelineVoiceBroadcastViewModelProtocol!
- private var cancellables = Set()
-
- // MARK: Public
-
- // Must be used only internally
- var childCoordinators: [Coordinator] = []
-
- // MARK: - Setup
-
- init(parameters: TimelineVoiceBroadcastCoordinatorParameters) throws {
- self.parameters = parameters
-
- try voiceBroadcastAggregator = VoiceBroadcastAggregator(session: parameters.session, room: parameters.room, voiceBroadcastStartEventId: parameters.voiceBroadcastStartEvent.eventId)
- voiceBroadcastAggregator.delegate = self
-
- viewModel = TimelineVoiceBroadcastViewModel(timelineVoiceBroadcastDetails: buildTimelineVoiceBroadcastFrom(voiceBroadcastAggregator.voiceBroadcast))
-
- // TODO: manage voicebroacast chunks
- viewModel.completion = { }
-
- }
-
- // MARK: - Public
-
- func start() { }
-
- func toPresentable() -> UIViewController {
- VectorHostingController(rootView: TimelineVoiceBroadcastView(viewModel: viewModel.context),
- forceZeroSafeAreaInsets: true)
- }
-
- func canEndVoiceBroadcast() -> Bool {
- // TODO: check is voicebroadcast stopped
- return false
- }
-
- func canEditVoiceBroadcast() -> Bool {
- return false
- }
-
- func endVoiceBroadcast() {}
-
- // MARK: - VoiceBroadcastAggregatorDelegate
-
- func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
- viewModel.updateWithVoiceBroadcastDetails(buildTimelineVoiceBroadcastFrom(aggregator.voiceBroadcast))
- }
-
- func voiceBroadcastAggregatorDidStartLoading(_ aggregator: VoiceBroadcastAggregator) { }
-
- func voiceBroadcastAggregatorDidEndLoading(_ aggregator: VoiceBroadcastAggregator) { }
-
- func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didFailWithError: Error) { }
-
- // MARK: - Private
-
- // VoiceBroadcastProtocol is intentionally not available in the SwiftUI target as we don't want
- // to add the SDK as a dependency to it. We need to translate from one to the other on this level.
- func buildTimelineVoiceBroadcastFrom(_ voiceBroadcast: VoiceBroadcastProtocol) -> TimelineVoiceBroadcastDetails {
-
- return TimelineVoiceBroadcastDetails(closed: voiceBroadcast.isClosed,
- type: voiceBroadcastKindToTimelineVoiceBroadcastType(voiceBroadcast.kind))
- }
-
- private func voiceBroadcastKindToTimelineVoiceBroadcastType(_ kind: VoiceBroadcastKind) -> TimelineVoiceBroadcastType {
- let mapping = [VoiceBroadcastKind.disclosed: TimelineVoiceBroadcastType.disclosed,
- VoiceBroadcastKind.undisclosed: TimelineVoiceBroadcastType.undisclosed]
-
- return mapping[kind] ?? .disclosed
- }
-}
diff --git a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/Coordinator/TimelineVoiceBroadcastProvider.swift b/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/Coordinator/TimelineVoiceBroadcastProvider.swift
deleted file mode 100644
index 327da466d..000000000
--- a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/Coordinator/TimelineVoiceBroadcastProvider.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-//
-// Copyright 2022 New Vector Ltd
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-
-class TimelineVoiceBroadcastProvider {
- static let shared = TimelineVoiceBroadcastProvider()
-
- var session: MXSession?
- var coordinatorsForEventIdentifiers = [String: TimelineVoiceBroadcastCoordinator]()
-
- private init() { }
-
- /// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
- /// a view to be displayed in the timeline
- func buildTimelineVoiceBroadcastViewForEvent(_ event: MXEvent) -> UIView? {
- guard let session = session, let room = session.room(withRoomId: event.roomId) else {
- return nil
- }
-
- if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
- return coordinator.toPresentable().view
- }
-
- let parameters = TimelineVoiceBroadcastCoordinatorParameters(session: session, room: room, voiceBroadcastStartEvent: event)
- guard let coordinator = try? TimelineVoiceBroadcastCoordinator(parameters: parameters) else {
- return nil
- }
-
- coordinatorsForEventIdentifiers[event.eventId] = coordinator
-
- return coordinator.toPresentable().view
- }
-
- /// Retrieve the voiceBroadcast timeline coordinator for the given event or nil if it hasn't been created yet
- func timelineVoiceBroadcastCoordinatorForEventIdentifier(_ eventIdentifier: String) -> TimelineVoiceBroadcastCoordinator? {
- coordinatorsForEventIdentifiers[eventIdentifier]
- }
-}
diff --git a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastModels.swift b/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastModels.swift
deleted file mode 100644
index f11cca32b..000000000
--- a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastModels.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-//
-// Copyright 2022 New Vector Ltd
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import SwiftUI
-
-typealias TimelineVoiceBroadcastViewModelCallback = () -> Void
-
-// TODO: add play pause cases
-enum TimelineVoiceBroadcastViewAction { }
-
-enum TimelineVoiceBroadcastType {
- case disclosed
- case undisclosed
-}
-
-struct TimelineVoiceBroadcastDetails {
- var closed: Bool
- var type: TimelineVoiceBroadcastType
-
- init(closed: Bool,
- type: TimelineVoiceBroadcastType) {
- self.closed = closed
- self.type = type
- }
-}
-
-struct TimelineVoiceBroadcastViewState: BindableState {
- var voiceBroadcast: TimelineVoiceBroadcastDetails
- var bindings: TimelineVoiceBroadcastViewStateBindings
-}
-
-struct TimelineVoiceBroadcastViewStateBindings {
- var alertInfo: AlertInfo?
-}
-
-enum TimelineVoiceBroadcastAlertType {
- case failedClosingVoiceBroadcast
-}
-
diff --git a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastViewModel.swift b/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastViewModel.swift
deleted file mode 100644
index dd546cfcc..000000000
--- a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/TimelineVoiceBroadcastViewModel.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-//
-// Copyright 2022 New Vector Ltd
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Combine
-import SwiftUI
-
-typealias TimelineVoiceBroadcastViewModelType = StateStoreViewModel
-
-class TimelineVoiceBroadcastViewModel: TimelineVoiceBroadcastViewModelType, TimelineVoiceBroadcastViewModelProtocol {
- // MARK: - Properties
-
- // MARK: Private
-
- // MARK: Public
-
- var completion: TimelineVoiceBroadcastViewModelCallback?
-
- // MARK: - Setup
-
- init(timelineVoiceBroadcastDetails: TimelineVoiceBroadcastDetails) {
- super.init(initialViewState: TimelineVoiceBroadcastViewState(voiceBroadcast: timelineVoiceBroadcastDetails, bindings: TimelineVoiceBroadcastViewStateBindings()))
- }
-
- // MARK: - Public
-
- override func process(viewAction: TimelineVoiceBroadcastViewAction) {
- // TODO: add some actions as play pause
- }
-
- // MARK: - TimelineVoiceBroadcastViewModelProtocol
-
- func updateWithVoiceBroadcastDetails(_ voiceBroadcastDetails: TimelineVoiceBroadcastDetails) {
- state.voiceBroadcast = voiceBroadcastDetails
- }
-}
diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift
index 23b204083..f44744a9c 100644
--- a/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift
+++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Test/UI/UserSuggestionUITests.swift
@@ -21,10 +21,7 @@ class UserSuggestionUITests: MockScreenTestCase {
func testUserSuggestionScreen() throws {
app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title)
- XCTAssert(app.tables.firstMatch.waitForExistence(timeout: 1))
-
- let firstButton = app.tables.firstMatch.buttons.firstMatch
- _ = firstButton.waitForExistence(timeout: 10)
- XCTAssert(firstButton.identifier == "displayNameText-userIdText")
+ let firstButton = app.buttons["displayNameText-userIdText"].firstMatch
+ XCTAssert(firstButton.waitForExistence(timeout: 10))
}
}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift
new file mode 100644
index 000000000..4184f0d63
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift
@@ -0,0 +1,77 @@
+//
+// Copyright 2021 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Combine
+import MatrixSDK
+import SwiftUI
+
+struct VoiceBroadcastPlaybackCoordinatorParameters {
+ let session: MXSession
+ let room: MXRoom
+ let voiceBroadcastStartEvent: MXEvent
+ let voiceBroadcastState: VoiceBroadcastInfo.State
+ let senderDisplayName: String?
+}
+
+final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
+ // MARK: - Properties
+
+ // MARK: Private
+
+ private let parameters: VoiceBroadcastPlaybackCoordinatorParameters
+
+ private var viewModel: VoiceBroadcastPlaybackViewModelProtocol!
+ private var cancellables = Set()
+
+ // MARK: Public
+
+ // Must be used only internally
+ var childCoordinators: [Coordinator] = []
+
+ // MARK: - Setup
+
+ init(parameters: VoiceBroadcastPlaybackCoordinatorParameters) throws {
+ self.parameters = parameters
+
+ let voiceBroadcastAggregator = try VoiceBroadcastAggregator(session: parameters.session, room: parameters.room, voiceBroadcastStartEventId: parameters.voiceBroadcastStartEvent.eventId, voiceBroadcastState: parameters.voiceBroadcastState)
+
+ let details = VoiceBroadcastPlaybackDetails(senderDisplayName: parameters.senderDisplayName)
+ viewModel = VoiceBroadcastPlaybackViewModel(details: details,
+ mediaServiceProvider: VoiceMessageMediaServiceProvider.sharedProvider,
+ cacheManager: VoiceMessageAttachmentCacheManager.sharedManager,
+ voiceBroadcastAggregator: voiceBroadcastAggregator)
+
+ }
+
+ // MARK: - Public
+
+ func start() { }
+
+ func toPresentable() -> UIViewController {
+ VectorHostingController(rootView: VoiceBroadcastPlaybackView(viewModel: viewModel.context))
+ }
+
+ func canEndVoiceBroadcast() -> Bool {
+ // TODO: VB check is voicebroadcast stopped
+ return false
+ }
+
+ func canEditVoiceBroadcast() -> Bool {
+ return false
+ }
+
+ func endVoiceBroadcast() {}
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift
new file mode 100644
index 000000000..5167a2364
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift
@@ -0,0 +1,73 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class VoiceBroadcastPlaybackProvider {
+ static let shared = VoiceBroadcastPlaybackProvider()
+
+ var session: MXSession?
+ var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]()
+
+ private init() { }
+
+ /// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
+ /// a view to be displayed in the timeline
+ func buildVoiceBroadcastPlaybackVCForEvent(_ event: MXEvent, senderDisplayName: String?) -> UIViewController? {
+ guard let session = session, let room = session.room(withRoomId: event.roomId) else {
+ return nil
+ }
+
+ if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
+ return coordinator.toPresentable()
+ }
+
+ let dispatchGroup = DispatchGroup()
+ dispatchGroup.enter()
+ var voiceBroadcastState = VoiceBroadcastInfo.State.stopped
+
+ room.state { roomState in
+ if let stateEvent = roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last,
+ stateEvent.stateKey == event.stateKey,
+ let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: stateEvent.content),
+ (stateEvent.eventId == event.eventId || voiceBroadcastInfo.eventId == event.eventId),
+ let state = VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) {
+ voiceBroadcastState = state
+ }
+
+ dispatchGroup.leave()
+ }
+
+ let parameters = VoiceBroadcastPlaybackCoordinatorParameters(session: session,
+ room: room,
+ voiceBroadcastStartEvent: event,
+ voiceBroadcastState: voiceBroadcastState,
+ senderDisplayName: senderDisplayName)
+ guard let coordinator = try? VoiceBroadcastPlaybackCoordinator(parameters: parameters) else {
+ return nil
+ }
+
+ coordinatorsForEventIdentifiers[event.eventId] = coordinator
+
+ return coordinator.toPresentable()
+
+ }
+
+ /// Retrieve the voiceBroadcast timeline coordinator for the given event or nil if it hasn't been created yet
+ func voiceBroadcastPlaybackCoordinatorForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastPlaybackCoordinator? {
+ coordinatorsForEventIdentifiers[eventIdentifier]
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift
new file mode 100644
index 000000000..c27da240e
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift
@@ -0,0 +1,334 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Combine
+import SwiftUI
+
+// TODO: VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK
+// We need a VoiceBroadcastPlaybackServiceProtocol and VoiceBroadcastAggregatorProtocol
+import MatrixSDK
+
+class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol {
+
+ // MARK: - Properties
+
+ // MARK: Private
+ private var voiceBroadcastAggregator: VoiceBroadcastAggregator
+ private let mediaServiceProvider: VoiceMessageMediaServiceProvider
+ private let cacheManager: VoiceMessageAttachmentCacheManager
+ private var audioPlayer: VoiceMessageAudioPlayer?
+
+ private var voiceBroadcastChunkQueue: [VoiceBroadcastChunk] = []
+
+ private var isLivePlayback = false
+
+ // MARK: Public
+
+ // MARK: - Setup
+
+ init(details: VoiceBroadcastPlaybackDetails,
+ mediaServiceProvider: VoiceMessageMediaServiceProvider,
+ cacheManager: VoiceMessageAttachmentCacheManager,
+ voiceBroadcastAggregator: VoiceBroadcastAggregator) {
+ self.mediaServiceProvider = mediaServiceProvider
+ self.cacheManager = cacheManager
+ self.voiceBroadcastAggregator = voiceBroadcastAggregator
+
+ let viewState = VoiceBroadcastPlaybackViewState(details: details,
+ broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState),
+ playbackState: .stopped,
+ bindings: VoiceBroadcastPlaybackViewStateBindings())
+ super.init(initialViewState: viewState)
+
+ self.voiceBroadcastAggregator.delegate = self
+ }
+
+ private func release() {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] release")
+ if let audioPlayer = audioPlayer {
+ audioPlayer.deregisterDelegate(self)
+ self.audioPlayer = nil
+ }
+ }
+
+ // MARK: - Public
+
+ override func process(viewAction: VoiceBroadcastPlaybackViewAction) {
+ switch viewAction {
+ case .play:
+ play()
+ case .playLive:
+ playLive()
+ case .pause:
+ pause()
+ }
+ }
+
+
+ // MARK: - Private
+
+ /// Listen voice broadcast
+ private func play() {
+ isLivePlayback = false
+
+ if voiceBroadcastAggregator.isStarted == false {
+ // Start the streaming by fetching broadcast chunks
+ // The audio player will automatically start the playback on incoming chunks
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: Start streaming")
+ state.playbackState = .buffering
+ voiceBroadcastAggregator.start()
+ }
+ else if let audioPlayer = audioPlayer {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: resume")
+ audioPlayer.play()
+ }
+ else {
+ let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: restart from the beginning: \(chunks.count) chunks")
+
+ // Reinject all the chuncks we already have and play them
+ voiceBroadcastChunkQueue.append(contentsOf: chunks)
+ processPendingVoiceBroadcastChunks()
+ }
+ }
+
+ private func playLive() {
+ guard isLivePlayback == false else {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Already playing live")
+ return
+ }
+
+ isLivePlayback = true
+
+ // Flush the current audio player playlist
+ audioPlayer?.removeAllPlayerItems()
+
+ if voiceBroadcastAggregator.isStarted == false {
+ // Start the streaming by fetching broadcast chunks
+ // The audio player will automatically start the playback on incoming chunks
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Start streaming")
+ state.playbackState = .buffering
+ voiceBroadcastAggregator.start()
+ }
+ else {
+ let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: restart from the last chunk: \(chunks.count) chunks")
+
+ // Reinject all the chuncks we already have and play the last one
+ voiceBroadcastChunkQueue.append(contentsOf: chunks)
+ processPendingVoiceBroadcastChunksForLivePlayback()
+ }
+ }
+
+ /// Stop voice broadcast
+ private func pause() {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause")
+
+ isLivePlayback = false
+
+ if let audioPlayer = audioPlayer, audioPlayer.isPlaying {
+ audioPlayer.pause()
+ }
+ }
+
+ private func stopIfVoiceBroadcastOver() {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver")
+
+ // TODO: Check if the broadcast is over before stopping everything
+ // If not, the player should not stopped. The view state must be move to buffering
+ stop()
+ }
+
+ private func stop() {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop")
+
+ isLivePlayback = false
+
+ // Objects will be released on audioPlayerDidStopPlaying
+ audioPlayer?.stop()
+ }
+
+
+ // MARK: - Voice broadcast chunks playback
+
+ /// Start the playback from the beginning or push more chunks to it
+ private func processPendingVoiceBroadcastChunks() {
+ reorderPendingVoiceBroadcastChunks()
+ processNextVoiceBroadcastChunk()
+ }
+
+ /// Start the playback from the last known chunk
+ private func processPendingVoiceBroadcastChunksForLivePlayback() {
+ let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
+ if let lastChunk = chunks.last {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] processPendingVoiceBroadcastChunksForLivePlayback. Use the last chunk: sequence: \(lastChunk.sequence) out of the \(voiceBroadcastChunkQueue.count) chunks")
+ voiceBroadcastChunkQueue = [lastChunk]
+ }
+ processNextVoiceBroadcastChunk()
+ }
+
+ private func reorderPendingVoiceBroadcastChunks() {
+ // Make sure we download and process chunks in the right order
+ voiceBroadcastChunkQueue = reorderVoiceBroadcastChunks(chunks: voiceBroadcastChunkQueue)
+ }
+ private func reorderVoiceBroadcastChunks(chunks: [VoiceBroadcastChunk]) -> [VoiceBroadcastChunk] {
+ chunks.sorted(by: {$0.sequence < $1.sequence})
+ }
+
+ private func processNextVoiceBroadcastChunk() {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: \(voiceBroadcastChunkQueue.count) chunks remaining")
+
+ guard voiceBroadcastChunkQueue.count > 0 else {
+ // We cached all chunks. Nothing more to do
+ return
+ }
+
+ // TODO: Control the download rate to avoid to download all chunk in mass
+ // We could synchronise it with the number of chunks in the player playlist (audioPlayer.playerItems)
+
+ let chunk = voiceBroadcastChunkQueue.removeFirst()
+
+ // numberOfSamples is for the equalizer view we do not support yet
+ cacheManager.loadAttachment(chunk.attachment, numberOfSamples: 1) { [weak self] result in
+ guard let self = self else {
+ return
+ }
+
+ // TODO: Make sure there has no new incoming chunk that should be before this attachment
+ // Be careful that this new chunk is not older than the chunk being played by the audio player. Else
+ // we will get an unexecpted rewind.
+
+ switch result {
+ case .success(let result):
+ guard result.eventIdentifier == chunk.attachment.eventId else {
+ return
+ }
+
+ if let audioPlayer = self.audioPlayer {
+ // Append the chunk to the current playlist
+ audioPlayer.addContentFromURL(result.url)
+
+ // Resume the player. Needed after a pause
+ if audioPlayer.isPlaying == false {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player")
+ audioPlayer.play()
+ }
+ }
+ else {
+ // Init and start the player on the first chunk
+ let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
+ audioPlayer.registerDelegate(self)
+
+ audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName)
+ audioPlayer.play()
+ self.audioPlayer = audioPlayer
+ }
+
+ case .failure (let error):
+ MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error)
+ if self.voiceBroadcastChunkQueue.count == 0 {
+ // No more chunk to try. Go to error
+ self.state.playbackState = .error
+ }
+ }
+
+ self.processNextVoiceBroadcastChunk()
+ }
+ }
+
+ private static func getBroadcastState(from state: VoiceBroadcastInfo.State) -> VoiceBroadcastState {
+ var broadcastState: VoiceBroadcastState
+ switch state {
+ case .started:
+ broadcastState = VoiceBroadcastState.live
+ case .paused:
+ broadcastState = VoiceBroadcastState.paused
+ case .resumed:
+ broadcastState = VoiceBroadcastState.live
+ case .stopped:
+ broadcastState = VoiceBroadcastState.stopped
+ }
+
+ return broadcastState
+ }
+}
+
+// MARK: VoiceBroadcastAggregatorDelegate
+extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate {
+ func voiceBroadcastAggregatorDidStartLoading(_ aggregator: VoiceBroadcastAggregator) {
+ }
+
+ func voiceBroadcastAggregatorDidEndLoading(_ aggregator: VoiceBroadcastAggregator) {
+ }
+
+ func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didFailWithError: Error) {
+ MXLog.error("[VoiceBroadcastPlaybackViewModel] voiceBroadcastAggregator didFailWithError:", context: didFailWithError)
+ }
+
+ func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveChunk: VoiceBroadcastChunk) {
+ voiceBroadcastChunkQueue.append(didReceiveChunk)
+ }
+
+ func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State) {
+ state.broadcastState = VoiceBroadcastPlaybackViewModel.getBroadcastState(from: didReceiveState)
+ }
+
+ func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
+ if isLivePlayback && state.playbackState == .buffering {
+ // We started directly with a live playback but there was no known chuncks at that time
+ // These are the first chunks we get. Start the playback on the latest one
+ processPendingVoiceBroadcastChunksForLivePlayback()
+ }
+ else {
+ processPendingVoiceBroadcastChunks()
+ }
+ }
+}
+
+
+// MARK: - VoiceMessageAudioPlayerDelegate
+extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate {
+ func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) {
+ }
+
+ func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
+ if isLivePlayback {
+ state.playbackState = .playingLive
+ }
+ else {
+ state.playbackState = .playing
+ }
+ }
+
+ func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
+ state.playbackState = .paused
+ }
+
+ func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying")
+ state.playbackState = .stopped
+ release()
+ }
+
+ func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) {
+ state.playbackState = .error
+ }
+
+ func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
+ MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidFinishPlaying: \(audioPlayer.playerItems.count)")
+ stopIfVoiceBroadcastOver()
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/View/TimelineVoiceBroadcastView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackErrorView.swift
similarity index 50%
rename from RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/View/TimelineVoiceBroadcastView.swift
rename to RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackErrorView.swift
index 5235677dc..0ac7822c6 100644
--- a/RiotSwiftUI/Modules/Room/TimelineVoiceBroadcast/View/TimelineVoiceBroadcastView.swift
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackErrorView.swift
@@ -16,7 +16,7 @@
import SwiftUI
-struct TimelineVoiceBroadcastView: View {
+struct VoiceBroadcastPlaybackErrorView: View {
// MARK: - Properties
// MARK: Private
@@ -25,27 +25,27 @@ struct TimelineVoiceBroadcastView: View {
// MARK: Public
- @ObservedObject var viewModel: TimelineVoiceBroadcastViewModel.Context
+ var action: (() -> Void)?
var body: some View {
- let voiceBroadcast = viewModel.viewState.voiceBroadcast
-
- VStack(alignment: .leading, spacing: 16.0) {
- Text(VectorL10n.voiceBroadcastInTimelineTitle)
- .font(theme.fonts.bodySB)
- .foregroundColor(theme.colors.primaryContent)
- Text(VectorL10n.voiceBroadcastInTimelineBody)
- .font(theme.fonts.body)
- .foregroundColor(theme.colors.primaryContent)
- }
- .padding([.horizontal, .top], 2.0)
- .padding([.bottom])
- .alert(item: $viewModel.alertInfo) { info in
- info.alert
+ VStack {
+ VStack {
+ Image(uiImage: Asset.Images.errorIcon.image)
+ .frame(width: 40, height: 40)
+ Text(VectorL10n.voiceBroadcastPlaybackLoadingError)
+ .multilineTextAlignment(.center)
+ .font(theme.fonts.caption1)
+ .foregroundColor(theme.colors.primaryContent)
+ }
+ .padding()
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(theme.colors.system.ignoresSafeArea())
}
}
-// MARK: - Previews
-
-// TODO: Add Voice broadcast preview
+struct VoiceBroadcastPlaybackErrorView_Previews: PreviewProvider {
+ static var previews: some View {
+ VoiceBroadcastPlaybackErrorView()
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift
new file mode 100644
index 000000000..04ade8a77
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift
@@ -0,0 +1,121 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import SwiftUI
+
+// TODO: To remove
+// VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK
+#if canImport(MatrixSDK)
+typealias VoiceBroadcastPlaybackViewModelImpl = VoiceBroadcastPlaybackViewModel
+#else
+typealias VoiceBroadcastPlaybackViewModelImpl = MockVoiceBroadcastPlaybackViewModel
+#endif
+
+struct VoiceBroadcastPlaybackView: View {
+ // MARK: - Properties
+
+ // MARK: Private
+
+ @Environment(\.theme) private var theme: ThemeSwiftUI
+
+ private var backgroundColor: Color {
+ if viewModel.viewState.playbackState == .playingLive {
+ return theme.colors.alert
+ }
+ return theme.colors.quarterlyContent
+ }
+
+ // MARK: Public
+
+ @ObservedObject var viewModel: VoiceBroadcastPlaybackViewModelImpl.Context
+
+ var body: some View {
+ let details = viewModel.viewState.details
+
+ VStack(alignment: .center, spacing: 16.0) {
+
+ HStack {
+ Text(details.senderDisplayName ?? "")
+ //Text(VectorL10n.voiceBroadcastInTimelineTitle)
+ .font(theme.fonts.bodySB)
+ .foregroundColor(theme.colors.primaryContent)
+
+ if viewModel.viewState.broadcastState == .live {
+ Button { viewModel.send(viewAction: .playLive) } label:
+ {
+ HStack {
+ Image(uiImage: Asset.Images.voiceBroadcastLive.image)
+ .renderingMode(.original)
+ Text("Live")
+ .font(theme.fonts.bodySB)
+ .foregroundColor(Color.white)
+ }
+
+ }
+ .padding(5.0)
+ .background(RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(backgroundColor))
+ .accessibilityIdentifier("liveButton")
+ }
+ }
+
+ if viewModel.viewState.playbackState == .error {
+ VoiceBroadcastPlaybackErrorView()
+ } else {
+ ZStack {
+ if viewModel.viewState.playbackState == .playing ||
+ viewModel.viewState.playbackState == .playingLive {
+ Button { viewModel.send(viewAction: .pause) } label: {
+ Image(uiImage: Asset.Images.voiceBroadcastPause.image)
+ .renderingMode(.original)
+ }
+ .accessibilityIdentifier("pauseButton")
+ } else {
+ Button {
+ if viewModel.viewState.broadcastState == .live &&
+ viewModel.viewState.playbackState == .stopped {
+ viewModel.send(viewAction: .playLive)
+ } else {
+ viewModel.send(viewAction: .play)
+ }
+ } label: {
+ Image(uiImage: Asset.Images.voiceBroadcastPlay.image)
+ .renderingMode(.original)
+ }
+ .disabled(viewModel.viewState.playbackState == .buffering)
+ .accessibilityIdentifier("playButton")
+ }
+ }
+ .activityIndicator(show: viewModel.viewState.playbackState == .buffering)
+ }
+
+ }
+ .padding([.horizontal, .top], 2.0)
+ .padding([.bottom])
+ .alert(item: $viewModel.alertInfo) { info in
+ info.alert
+ }
+ }
+}
+
+// MARK: - Previews
+
+struct VoiceBroadcastPlaybackView_Previews: PreviewProvider {
+ static let stateRenderer = MockVoiceBroadcastPlaybackScreenState.stateRenderer
+ static var previews: some View {
+ stateRenderer.screenGroup()
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift
new file mode 100644
index 000000000..09a12b87d
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift
@@ -0,0 +1,62 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import SwiftUI
+
+enum VoiceBroadcastPlaybackViewAction {
+ case play
+ case playLive
+ case pause
+}
+
+enum VoiceBroadcastPlaybackState {
+ case stopped
+ case buffering
+ case playing
+ case playingLive
+ case paused
+ case error
+}
+
+struct VoiceBroadcastPlaybackDetails {
+ let senderDisplayName: String?
+}
+
+enum VoiceBroadcastState {
+ case unknown
+ case stopped
+ case live
+ case paused
+}
+
+struct VoiceBroadcastPlaybackViewState: BindableState {
+ var details: VoiceBroadcastPlaybackDetails
+ var broadcastState: VoiceBroadcastState
+ var playbackState: VoiceBroadcastPlaybackState
+ var bindings: VoiceBroadcastPlaybackViewStateBindings
+}
+
+struct VoiceBroadcastPlaybackViewStateBindings {
+ // TODO: Neeeded?
+ var alertInfo: AlertInfo?
+}
+
+enum VoiceBroadcastPlaybackAlertType {
+ // TODO: What is it?
+ case failedClosingVoiceBroadcast
+}
+
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift
new file mode 100644
index 000000000..72a15185f
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift
@@ -0,0 +1,53 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import SwiftUI
+
+typealias MockVoiceBroadcastPlaybackViewModelType = StateStoreViewModel
+class MockVoiceBroadcastPlaybackViewModel: MockVoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol {
+}
+
+/// Using an enum for the screen allows you define the different state cases with
+/// the relevant associated data for each case.
+enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable {
+ // A case for each state you want to represent
+ // with specific, minimal associated data that will allow you
+ // mock that screen.
+ case animated
+
+ /// The associated screen
+ var screenType: Any.Type {
+ VoiceBroadcastPlaybackView.self
+ }
+
+ /// A list of screen state definitions
+ static var allCases: [MockVoiceBroadcastPlaybackScreenState] {
+ [.animated]
+ }
+
+ /// Generate the view struct for the screen state.
+ var screenView: ([Any], AnyView) {
+
+ let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice")
+ let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, bindings: VoiceBroadcastPlaybackViewStateBindings()))
+
+ return (
+ [false, viewModel],
+ AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context))
+ )
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift
new file mode 100644
index 000000000..1ad8d64c5
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift
@@ -0,0 +1,23 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+typealias VoiceBroadcastPlaybackViewModelType = StateStoreViewModel
+
+protocol VoiceBroadcastPlaybackViewModelProtocol {
+ var context: VoiceBroadcastPlaybackViewModelType.Context { get }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift
new file mode 100644
index 000000000..c13524e13
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift
@@ -0,0 +1,67 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+struct VoiceBroadcastRecorderCoordinatorParameters {
+ let session: MXSession
+ let room: MXRoom
+ let voiceBroadcastStartEvent: MXEvent
+ let senderDisplayName: String?
+}
+
+final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable {
+ // MARK: - Properties
+
+ // MARK: Private
+
+ private let parameters: VoiceBroadcastRecorderCoordinatorParameters
+
+ private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol
+ private var voiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelProtocol
+
+ // MARK: Public
+
+ // Must be used only internally
+ var childCoordinators: [Coordinator] = []
+
+ // MARK: - Setup
+
+ init(parameters: VoiceBroadcastRecorderCoordinatorParameters) {
+ self.parameters = parameters
+
+ voiceBroadcastRecorderService = VoiceBroadcastRecorderService(session: parameters.session, roomId: parameters.room.matrixItemId)
+
+ let details = VoiceBroadcastRecorderDetails(senderDisplayName: parameters.senderDisplayName)
+ let viewModel = VoiceBroadcastRecorderViewModel(details: details,
+ recorderService: voiceBroadcastRecorderService)
+ voiceBroadcastRecorderViewModel = viewModel
+ }
+
+ // MARK: - Public
+
+ func start() { }
+
+ func toPresentable() -> UIViewController {
+ VectorHostingController(rootView: VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context))
+ }
+
+ func pauseRecording() {
+ voiceBroadcastRecorderViewModel.context.send(viewAction: .pause)
+ }
+
+ // MARK: - Private
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift
new file mode 100644
index 000000000..c7bc2b1a0
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift
@@ -0,0 +1,77 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+@objc public class VoiceBroadcastRecorderProvider: NSObject {
+
+ // MARK: - Constants
+ @objc public static let shared = VoiceBroadcastRecorderProvider()
+
+ // MARK: - Properties
+ // MARK: Public
+ var session: MXSession?
+ var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]()
+
+ // MARK: Private
+ private var currentEventIdentifier: String?
+
+ // MARK: - Setup
+ private override init() { }
+
+ // MARK: - Public
+
+ /// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
+ /// a view to be displayed in the timeline
+ func buildVoiceBroadcastRecorderViewForEvent(_ event: MXEvent, senderDisplayName: String?) -> UIView? {
+ guard let session = session,
+ let room = session.room(withRoomId: event.roomId) else {
+ return nil
+ }
+
+ self.currentEventIdentifier = event.eventId
+
+ if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
+ return coordinator.toPresentable().view
+ }
+
+ let parameters = VoiceBroadcastRecorderCoordinatorParameters(session: session,
+ room: room,
+ voiceBroadcastStartEvent: event,
+ senderDisplayName: senderDisplayName)
+ let coordinator = VoiceBroadcastRecorderCoordinator(parameters: parameters)
+
+ coordinatorsForEventIdentifiers[event.eventId] = coordinator
+
+ return coordinator.toPresentable().view
+ }
+
+ /// Pause current voice broadcast recording.
+ @objc public func pauseRecording() {
+ voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecording()
+ }
+
+ // MARK: - Private
+
+ /// Retrieve the voiceBroadcast recorder coordinator for the current event or nil if it hasn't been created yet
+ private func voiceBroadcastRecorderCoordinatorForCurrentEvent() -> VoiceBroadcastRecorderCoordinator? {
+ guard let currentEventIdentifier = currentEventIdentifier else {
+ return nil
+ }
+
+ return coordinatorsForEventIdentifiers[currentEventIdentifier]
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift
new file mode 100644
index 000000000..d75f69830
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift
@@ -0,0 +1,274 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
+
+ // MARK: - Properties
+
+ // MARK: Private
+
+ private let roomId: String
+ private let session: MXSession
+ private var voiceBroadcastService: VoiceBroadcastService? {
+ session.voiceBroadcastService
+ }
+
+ private let audioEngine = AVAudioEngine()
+ private let audioNodeBus = AVAudioNodeBus(0)
+
+ private var chunkFile: AVAudioFile! = nil
+ private var chunkFrames: AVAudioFrameCount = 0
+ private var chunkFileNumber: Int = 1
+
+ // MARK: Public
+
+ weak var serviceDelegate: VoiceBroadcastRecorderServiceDelegate?
+
+ // MARK: - Setup
+
+ init(session: MXSession, roomId: String) {
+ self.session = session
+ self.roomId = roomId
+ }
+
+ // MARK: - VoiceBroadcastRecorderServiceProtocol
+
+ func startRecordingVoiceBroadcast() {
+ let inputNode = audioEngine.inputNode
+
+ let inputFormat = inputNode.inputFormat(forBus: audioNodeBus)
+ MXLog.debug("[VoiceBroadcastRecorderService] Start recording voice broadcast for bus name : \(String(describing: inputNode.name(forInputBus: audioNodeBus)))")
+
+ inputNode.installTap(onBus: audioNodeBus,
+ bufferSize: 512,
+ format: inputFormat) { (buffer, time) -> Void in
+ DispatchQueue.main.async {
+ self.writeBuffer(buffer)
+ }
+ }
+
+ try? audioEngine.start()
+ }
+
+ func stopRecordingVoiceBroadcast() {
+ MXLog.debug("[VoiceBroadcastRecorderService] Stop recording voice broadcast")
+ audioEngine.stop()
+ audioEngine.inputNode.removeTap(onBus: audioNodeBus)
+
+ resetValues()
+
+ voiceBroadcastService?.stopVoiceBroadcast(success: { [weak self] _ in
+ MXLog.debug("[VoiceBroadcastRecorderService] Stopped")
+
+ guard let self = self else { return }
+
+ // Update state
+ self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .stopped)
+
+ // Send current chunk
+ if self.chunkFile != nil {
+ self.sendChunkFile(at: self.chunkFile.url, sequence: self.chunkFileNumber)
+ }
+
+ self.session.tearDownVoiceBroadcastService()
+ }, failure: { error in
+ MXLog.error("[VoiceBroadcastRecorderService] Failed to stop voice broadcast", context: error)
+ })
+ }
+
+ func pauseRecordingVoiceBroadcast() {
+ audioEngine.pause()
+
+ voiceBroadcastService?.pauseVoiceBroadcast(success: { [weak self] _ in
+ guard let self = self else { return }
+
+ // Send current chunk
+ self.sendChunkFile(at: self.chunkFile.url, sequence: self.chunkFileNumber)
+ self.chunkFile = nil
+
+ }, failure: { error in
+ MXLog.error("[VoiceBroadcastRecorderService] Failed to pause voice broadcast", context: error)
+ })
+ }
+
+ func resumeRecordingVoiceBroadcast() {
+ try? audioEngine.start()
+
+ voiceBroadcastService?.resumeVoiceBroadcast(success: { [weak self] _ in
+ guard let self = self else { return }
+
+ // Update state
+ self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .started)
+ }, failure: { error in
+ MXLog.error("[VoiceBroadcastRecorderService] Failed to resume voice broadcast", context: error)
+ })
+ }
+
+ // MARK: - Private
+ /// Reset chunk values.
+ private func resetValues() {
+ chunkFrames = 0
+ chunkFileNumber = 1
+ }
+
+ /// Write audio buffer to chunk file.
+ private func writeBuffer(_ buffer: AVAudioPCMBuffer) {
+ let sampleRate = buffer.format.sampleRate
+
+ if chunkFile == nil {
+ createNewChunkFile(channelsCount: buffer.format.channelCount, sampleRate: sampleRate)
+ }
+ try? chunkFile.write(from: buffer)
+
+ chunkFrames += buffer.frameLength
+
+ if chunkFrames > AVAudioFrameCount(Double(BuildSettings.voiceBroadcastChunkLength) * sampleRate) {
+ sendChunkFile(at: chunkFile.url, sequence: self.chunkFileNumber)
+ // Reset chunkFile
+ chunkFile = nil
+ }
+ }
+
+ /// Create new chunk file with sample rate.
+ private func createNewChunkFile(channelsCount: AVAudioChannelCount, sampleRate: Float64) {
+ guard let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
+ // FIXME: Manage error
+ return
+ }
+ let temporaryFileName = "VoiceBroadcastChunk-\(roomId)-\(chunkFileNumber)"
+ let fileUrl = directory
+ .appendingPathComponent(temporaryFileName)
+ .appendingPathExtension("aac")
+ MXLog.debug("[VoiceBroadcastRecorderService] Create chunk file to \(fileUrl)")
+
+ let settings: [String: Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
+ AVSampleRateKey: sampleRate,
+ AVEncoderBitRateKey: 128000,
+ AVNumberOfChannelsKey: channelsCount,
+ AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
+
+ chunkFile = try? AVAudioFile(forWriting: fileUrl, settings: settings)
+
+ if chunkFile != nil {
+ chunkFileNumber += 1
+ chunkFrames = 0
+ } else {
+ stopRecordingVoiceBroadcast()
+ // FIXME: Manage error ?
+ }
+ }
+
+ /// Send chunk file to the server.
+ private func sendChunkFile(at url: URL, sequence: Int) {
+ guard let voiceBroadcastService = voiceBroadcastService else {
+ // FIXME: Manage error
+ return
+ }
+
+ let dispatchGroup = DispatchGroup()
+ var duration = 0.0
+
+ dispatchGroup.enter()
+ VoiceMessageAudioConverter.mediaDurationAt(url) { result in
+ switch result {
+ case .success:
+ if let someDuration = try? result.get() {
+ duration = someDuration
+ } else {
+ MXLog.error("[VoiceBroadcastRecorderService] Failed to retrieve media duration")
+ }
+ case .failure(let error):
+ MXLog.error("[VoiceBroadcastRecorderService] Failed to get audio duration", context: error)
+ }
+
+ dispatchGroup.leave()
+ }
+
+ convertAACToM4A(at: url) { [weak self] convertedUrl in
+ guard let self = self else { return }
+
+ if let convertedUrl = convertedUrl {
+ dispatchGroup.notify(queue: .main) {
+ self.voiceBroadcastService?.sendChunkOfVoiceBroadcast(audioFileLocalURL: convertedUrl,
+ mimeType: "audio/mp4",
+ duration: UInt(duration * 1000),
+ samples: nil,
+ sequence: UInt(sequence)) { eventId in
+ MXLog.debug("[VoiceBroadcastRecorderService] Send voice broadcast chunk with success.")
+ if eventId != nil {
+ self.deleteRecording(at: url)
+ }
+ } failure: { error in
+ MXLog.error("[VoiceBroadcastRecorderService] Failed to send voice broadcast chunk.", context: error)
+ }
+ }
+ }
+ }
+ }
+
+ /// Delete voice broadcast chunk at URL.
+ private func deleteRecording(at url: URL?) {
+ guard let url = url else {
+ return
+ }
+
+ do {
+ try FileManager.default.removeItem(at: url)
+ } catch {
+ MXLog.error("[VoiceBroadcastRecorderService] Delete chunk file error.", context: error)
+ }
+ }
+
+ /// Convert AAC file into m4a one.
+ private func convertAACToM4A(at url: URL, completion: @escaping (URL?) -> Void) {
+ // FIXME: Manage errors at completion
+ let asset = AVURLAsset(url: url)
+ let updatedPath = url.path.replacingOccurrences(of: ".aac", with: ".m4a")
+ let outputUrl = URL(string: "file://" + updatedPath)
+ MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A updatedPath : \(updatedPath).")
+
+ if FileManager.default.fileExists(atPath: updatedPath) {
+ try? FileManager.default.removeItem(atPath: updatedPath)
+ }
+
+ guard let exportSession = AVAssetExportSession(asset: asset,
+ presetName: AVAssetExportPresetPassthrough) else {
+ completion(nil)
+ return
+ }
+
+ exportSession.outputURL = outputUrl
+ exportSession.outputFileType = AVFileType.m4a
+ let start = CMTimeMakeWithSeconds(0.0, preferredTimescale: 0)
+ let range = CMTimeRangeMake(start: start, duration: asset.duration)
+ exportSession.timeRange = range
+ exportSession.exportAsynchronously() {
+ switch exportSession.status {
+ case .failed:
+ MXLog.error("[VoiceBroadcastRecorderService] convertAACToM4A error", context: exportSession.error)
+ completion(nil)
+ case .completed:
+ MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A success.")
+ completion(outputUrl)
+ default:
+ MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A other cases.")
+ completion(nil)
+ }
+ }
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift
new file mode 100644
index 000000000..7b97eb83a
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift
@@ -0,0 +1,38 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+protocol VoiceBroadcastRecorderServiceDelegate: AnyObject {
+ func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState)
+}
+
+protocol VoiceBroadcastRecorderServiceProtocol {
+ /// Service delegate
+ var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? { get set }
+
+ /// Start voice broadcast recording.
+ func startRecordingVoiceBroadcast()
+
+ /// Stop voice broadcast recording.
+ func stopRecordingVoiceBroadcast()
+
+ /// Pause voice broadcast recording.
+ func pauseRecordingVoiceBroadcast()
+
+ /// Resume voice broadcast recording after paused it.
+ func resumeRecordingVoiceBroadcast()
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift
new file mode 100644
index 000000000..71fb41cc1
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift
@@ -0,0 +1,83 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import SwiftUI
+
+struct VoiceBroadcastRecorderView: View {
+ // MARK: - Properties
+
+ // MARK: Private
+
+ @Environment(\.theme) private var theme: ThemeSwiftUI
+
+ // MARK: Public
+
+ @ObservedObject var viewModel: VoiceBroadcastRecorderViewModel.Context
+
+ var body: some View {
+ let details = viewModel.viewState.details
+
+ VStack(alignment: .leading, spacing: 16.0) {
+ Text(details.senderDisplayName ?? "")
+ .font(theme.fonts.bodySB)
+ .foregroundColor(theme.colors.primaryContent)
+
+ HStack(alignment: .top, spacing: 16.0) {
+ Button {
+ switch viewModel.viewState.recordingState {
+ case .started, .resumed:
+ viewModel.send(viewAction: .pause)
+ case .stopped:
+ viewModel.send(viewAction: .start)
+ case .paused:
+ viewModel.send(viewAction: .resume)
+ }
+ } label: {
+ if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed {
+ Image("voice_broadcast_record_pause")
+ .renderingMode(.original)
+ } else {
+ Image("voice_broadcast_record")
+ .renderingMode(.original)
+ }
+ }
+ .accessibilityIdentifier("recordButton")
+
+ Button {
+ viewModel.send(viewAction: .stop)
+ } label: {
+ Image("voice_broadcast_stop")
+ .renderingMode(.original)
+ }
+ .accessibilityIdentifier("stopButton")
+ .disabled(viewModel.viewState.recordingState == .stopped)
+ .mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0))
+ }
+ }
+ .padding([.horizontal, .top], 2.0)
+ .padding([.bottom])
+ }
+}
+
+
+// MARK: - Previews
+
+struct VoiceBroadcastRecorderView_Previews: PreviewProvider {
+ static let stateRenderer = MockVoiceBroadcastRecorderScreenState.stateRenderer
+ static var previews: some View {
+ stateRenderer.screenGroup()
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift
new file mode 100644
index 000000000..b88021bfe
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift
@@ -0,0 +1,44 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+enum VoiceBroadcastRecorderViewAction {
+ case start
+ case stop
+ case pause
+ case resume
+}
+
+enum VoiceBroadcastRecorderState {
+ case started
+ case stopped
+ case paused
+ case resumed
+}
+
+struct VoiceBroadcastRecorderDetails {
+ let senderDisplayName: String?
+}
+
+struct VoiceBroadcastRecorderViewState: BindableState {
+ var details: VoiceBroadcastRecorderDetails
+ var recordingState: VoiceBroadcastRecorderState
+ var bindings: VoiceBroadcastRecorderViewStateBindings
+}
+
+struct VoiceBroadcastRecorderViewStateBindings {
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift
new file mode 100644
index 000000000..baa9488f4
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift
@@ -0,0 +1,42 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import SwiftUI
+
+typealias MockVoiceBroadcastRecorderViewModelType = StateStoreViewModel
+class MockVoiceBroadcastRecorderViewModel: MockVoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol {
+
+}
+
+/// Using an enum for the screen allows you define the different state cases with
+/// the relevant associated data for each case.
+enum MockVoiceBroadcastRecorderScreenState: MockScreenState, CaseIterable {
+
+ var screenType: Any.Type {
+ VoiceBroadcastRecorderView.self
+ }
+
+ var screenView: ([Any], AnyView) {
+ let details = VoiceBroadcastRecorderDetails(senderDisplayName: "")
+ let viewModel = MockVoiceBroadcastRecorderViewModel(initialViewState: VoiceBroadcastRecorderViewState(details: details, recordingState: .started, bindings: VoiceBroadcastRecorderViewStateBindings()))
+
+ return (
+ [false, viewModel],
+ AnyView(VoiceBroadcastRecorderView(viewModel: viewModel.context))
+ )
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift
new file mode 100644
index 000000000..6e1444162
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift
@@ -0,0 +1,86 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Combine
+import SwiftUI
+
+typealias VoiceBroadcastRecorderViewModelType = StateStoreViewModel
+
+class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol {
+
+ // MARK: - Properties
+
+ // MARK: Private
+
+ private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol
+
+ // MARK: Public
+
+ // MARK: - Setup
+
+ init(details: VoiceBroadcastRecorderDetails,
+ recorderService: VoiceBroadcastRecorderServiceProtocol) {
+ self.voiceBroadcastRecorderService = recorderService
+ super.init(initialViewState: VoiceBroadcastRecorderViewState(details: details,
+ recordingState: .stopped,
+ bindings: VoiceBroadcastRecorderViewStateBindings()))
+
+ self.voiceBroadcastRecorderService.serviceDelegate = self
+ process(viewAction: .start)
+ }
+
+ // MARK: - Public
+
+ override func process(viewAction: VoiceBroadcastRecorderViewAction) {
+ switch viewAction {
+ case .start:
+ start()
+ case .stop:
+ stop()
+ case .pause:
+ pause()
+ case .resume:
+ resume()
+ }
+ }
+
+ // MARK: - Private
+ private func start() {
+ self.state.recordingState = .started
+ voiceBroadcastRecorderService.startRecordingVoiceBroadcast()
+ }
+
+ private func stop() {
+ self.state.recordingState = .stopped
+ voiceBroadcastRecorderService.stopRecordingVoiceBroadcast()
+ }
+
+ private func pause() {
+ self.state.recordingState = .paused
+ voiceBroadcastRecorderService.pauseRecordingVoiceBroadcast()
+ }
+
+ private func resume() {
+ self.state.recordingState = .resumed
+ voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast()
+ }
+}
+
+extension VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderServiceDelegate {
+ func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState) {
+ self.state.recordingState = state
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModelProtocol.swift
new file mode 100644
index 000000000..ab1e74c89
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModelProtocol.swift
@@ -0,0 +1,21 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+protocol VoiceBroadcastRecorderViewModelProtocol {
+ var context: VoiceBroadcastRecorderViewModelType.Context { get }
+}
diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift
index 053d585fd..0e894961d 100644
--- a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift
+++ b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift
@@ -22,7 +22,8 @@ struct DeviceAvatarView: View {
@Environment(\.theme) var theme: ThemeSwiftUI
var viewData: DeviceAvatarViewData
-
+ var isSelected: Bool
+
var avatarSize: CGFloat = 40
var badgeSize: CGFloat = 24
@@ -31,10 +32,12 @@ struct DeviceAvatarView: View {
// Device image
VStack(alignment: .center) {
viewData.deviceType.image
+ .renderingMode(isSelected ? .template : .original)
+ .foregroundColor(isSelected ? theme.colors.background : nil)
}
.padding()
.frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize))
- .background(theme.colors.system)
+ .background(isSelected ? theme.colors.primaryContent : theme.colors.system)
.clipShape(Circle())
// Verification badge
@@ -62,10 +65,10 @@ struct DeviceAvatarViewListPreview: View {
var body: some View {
HStack {
VStack(alignment: .center, spacing: 20) {
- DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, verificationState: .verified))
- DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, verificationState: .unverified))
- DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified))
- DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, verificationState: .unverified))
+ DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, verificationState: .verified), isSelected: false)
+ DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, verificationState: .unverified), isSelected: false)
+ DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified), isSelected: false)
+ DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, verificationState: .unverified), isSelected: false)
}
}
}
diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift
index 172e0d834..864c727d9 100644
--- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift
+++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift
@@ -36,7 +36,7 @@ struct UserSessionCardView: View {
var body: some View {
VStack(alignment: .center, spacing: 12) {
- DeviceAvatarView(viewData: viewData.deviceAvatarViewData)
+ DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: false)
.accessibilityHidden(true)
Text(viewData.sessionName)
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift
index 2fb7d8910..7b9a6d4fb 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift
@@ -207,7 +207,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isCurrent: false)]
}
- private func allSessions() -> [UserSessionInfo] {
+ func allSessions() -> [UserSessionInfo] {
[UserSessionInfo(id: "0",
name: "iOS",
deviceType: .mobile,
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift
index 1273f32d5..45d43f3b3 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift
@@ -56,4 +56,40 @@ class UserOtherSessionsUITests: MockScreenTestCase {
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionVerifiedShort].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle].exists)
}
+
+ func test_whenOtherSessionsMoreMenuButtonSelected_selectSessionsButtonExists() {
+ app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title)
+
+ app.buttons["More"].tap()
+ XCTAssertTrue(app.buttons["Select sessions"].exists)
+ }
+
+ func test_whenOtherSessionsSelectSessionsSelected_navBarContainsCorrectButtons() {
+ app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title)
+
+ app.buttons["More"].tap()
+ app.buttons["Select sessions"].tap()
+ XCTAssertTrue(app.buttons["Select All"].exists)
+ XCTAssertTrue(app.buttons["Cancel"].exists)
+ }
+
+ func test_whenOtherSessionsSelectAllSelected_navBarContainsCorrectButtons() {
+ app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title)
+
+ app.buttons["More"].tap()
+ app.buttons["Select sessions"].tap()
+ app.buttons["Select All"].tap()
+ XCTAssertTrue(app.buttons["Deselect All"].exists)
+ XCTAssertTrue(app.buttons["Cancel"].exists)
+ }
+
+ func test_whenAllOtherSessionsAreSelected_navBarContainsCorrectButtons() {
+ app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title)
+ app.buttons["More"].tap()
+ app.buttons["Select sessions"].tap()
+ for i in 0...MockUserOtherSessionsScreenState.all.allSessions().count - 1 {
+ app.buttons["UserSessionListItem_\(i)"].tap()
+ }
+ XCTAssertTrue(app.buttons["Deselect All"].exists)
+ }
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
index 05a25b5f5..782bdac4f 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
@@ -55,9 +55,13 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive)
let expectedItems = sessionInfos.filter { !$0.isActive }.asViewData()
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .inactive),
+ let bindings = UserOtherSessionsBindings(filter: .inactive, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.sessionItems(header: inactiveSectionHeader, items: expectedItems)])
+ sessionItems: expectedItems,
+ header: inactiveSectionHeader,
+ emptyItemsTitle: VectorL10n.userOtherSessionNoInactiveSessions,
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
@@ -67,9 +71,13 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .all),
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.sessionItems(header: allSectionHeader, items: expectedItems)])
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
@@ -79,9 +87,13 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified)
let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .unverified),
+ let bindings = UserOtherSessionsBindings(filter: .unverified, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.sessionItems(header: unverifiedSectionHeader, items: expectedItems)])
+ sessionItems: expectedItems,
+ header: unverifiedSectionHeader,
+ emptyItemsTitle: VectorL10n.userOtherSessionNoUnverifiedSessions,
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
@@ -91,9 +103,13 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sut = createSUT(sessionInfos: sessionInfos, filter: .verified)
let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .verified),
+ let bindings = UserOtherSessionsBindings(filter: .verified, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.sessionItems(header: verifiedSectionHeader, items: expectedItems)])
+ sessionItems: expectedItems,
+ header: verifiedSectionHeader,
+ emptyItemsTitle: VectorL10n.userOtherSessionNoVerifiedSessions,
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
@@ -101,10 +117,13 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: false),
createUserSessionInfo(sessionId: "session 2", isVerified: false)]
let sut = createSUT(sessionInfos: sessionInfos, filter: .verified)
-
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .verified),
+ let bindings = UserOtherSessionsBindings(filter: .verified, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.emptySessionItems(header: verifiedSectionHeader, title: VectorL10n.userOtherSessionNoVerifiedSessions)])
+ sessionItems: [],
+ header: verifiedSectionHeader,
+ emptyItemsTitle: VectorL10n.userOtherSessionNoVerifiedSessions,
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
@@ -112,10 +131,13 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true),
createUserSessionInfo(sessionId: "session 2", isVerified: true)]
let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified)
-
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .unverified),
+ let bindings = UserOtherSessionsBindings(filter: .unverified, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.emptySessionItems(header: unverifiedSectionHeader, title: VectorL10n.userOtherSessionNoUnverifiedSessions)])
+ sessionItems: [],
+ header: unverifiedSectionHeader,
+ emptyItemsTitle: VectorL10n.userOtherSessionNoUnverifiedSessions,
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
@@ -123,13 +145,134 @@ class UserOtherSessionsViewModelTests: XCTestCase {
let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isActive: true),
createUserSessionInfo(sessionId: "session 2", isActive: true)]
let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive)
-
- let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .inactive),
+ let bindings = UserOtherSessionsBindings(filter: .inactive, isEditModeEnabled: false)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
title: "Title",
- sections: [.emptySessionItems(header: inactiveSectionHeader, title: VectorL10n.userOtherSessionNoInactiveSessions)])
+ sessionItems: [],
+ header: inactiveSectionHeader,
+ emptyItemsTitle: VectorL10n.userOtherSessionNoInactiveSessions,
+ allItemsSelected: false)
XCTAssertEqual(sut.state, expectedState)
}
+ func test_whenEditModeEnabledAndAllItemsSelected_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
+ toggleEditMode(for: sut, value: true)
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1"))
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 2"))
+
+ let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: true) }
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
+ title: VectorL10n.userOtherSessionSelectedCount("2"),
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: true)
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenEditModeEnabledAndItemSelectedAndDeselected_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
+ toggleEditMode(for: sut, value: true)
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1"))
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1"))
+
+ let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: false) }
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
+ title: VectorL10n.userOtherSessionSelectedCount("0"),
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: false)
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenEditModeEnabledAndNotAllItemsSelected_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
+ toggleEditMode(for: sut, value: true)
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 2"))
+
+ let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: $0.id == "session 2") }
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
+ title: VectorL10n.userOtherSessionSelectedCount("1"),
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: false)
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenEditModeEnabledAndAllItemsSelectedByButton_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
+ toggleEditMode(for: sut, value: true)
+ sut.process(viewAction: .toggleAllSelection)
+
+ let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: true) }
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
+ title: VectorL10n.userOtherSessionSelectedCount("2"),
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: true)
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenEditModeEnabledAndAllItemsDeselectedByButton_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
+ toggleEditMode(for: sut, value: true)
+ sut.process(viewAction: .toggleAllSelection)
+ sut.process(viewAction: .toggleAllSelection)
+ let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: false) }
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
+ title: VectorL10n.userOtherSessionSelectedCount("0"),
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: false)
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenEditModeEnabledDisabledAndEnabled_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
+ toggleEditMode(for: sut, value: true)
+ sut.process(viewAction: .editModeWasToggled)
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 1"))
+ sut.process(viewAction: .userOtherSessionSelected(sessionId: "session 2"))
+ toggleEditMode(for: sut, value: false)
+ toggleEditMode(for: sut, value: true)
+ let expectedItems = sessionInfos.map { UserSessionListItemViewDataFactory().create(from: $0, isSelected: false) }
+ let bindings = UserOtherSessionsBindings(filter: .all, isEditModeEnabled: true)
+ let expectedState = UserOtherSessionsViewState(bindings: bindings,
+ title: VectorL10n.userOtherSessionSelectedCount("0"),
+ sessionItems: expectedItems,
+ header: allSectionHeader,
+ emptyItemsTitle: "",
+ allItemsSelected: false)
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ private func toggleEditMode(for model: UserOtherSessionsViewModel, value: Bool) {
+ model.context.isEditModeEnabled = value
+ model.process(viewAction: .editModeWasToggled)
+ }
+
private func createSUT(sessionInfos: [UserSessionInfo],
filter: UserOtherSessionsFilter,
title: String = "Title") -> UserOtherSessionsViewModel {
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift
index c09ff774c..8aefc40b9 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift
@@ -32,25 +32,22 @@ enum UserOtherSessionsViewModelResult: Equatable {
struct UserOtherSessionsViewState: BindableState, Equatable {
var bindings: UserOtherSessionsBindings
- let title: String
- var sections: [UserOtherSessionsSection]
+ var title: String
+ var sessionItems: [UserSessionListItemViewData]
+ var header: UserOtherSessionsHeaderViewData
+ var emptyItemsTitle: String
+ var allItemsSelected: Bool
}
struct UserOtherSessionsBindings: Equatable {
var filter: UserOtherSessionsFilter
-}
-
-enum UserOtherSessionsSection: Hashable, Identifiable {
- var id: Self {
- self
- }
-
- case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData])
- case emptySessionItems(header: UserOtherSessionsHeaderViewData, title: String)
+ var isEditModeEnabled: Bool
}
enum UserOtherSessionsViewAction {
case userOtherSessionSelected(sessionId: String)
case filterWasChanged
case clearFilter
+ case editModeWasToggled
+ case toggleAllSelection
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
index 9bad552d8..b0cac5185 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
@@ -21,15 +21,22 @@ typealias UserOtherSessionsViewModelType = StateStoreViewModel Void)?
private let sessionInfos: [UserSessionInfo]
+ private var selectedSessions: Set = []
+ private let defaultTitle: String
init(sessionInfos: [UserSessionInfo],
filter: UserOtherSessionsFilter,
title: String) {
self.sessionInfos = sessionInfos
- super.init(initialViewState: UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: filter),
+ defaultTitle = title
+ let bindings = UserOtherSessionsBindings(filter: filter, isEditModeEnabled: false)
+ let sessionItems = filter.filterSessionInfos(sessionInfos: sessionInfos, selectedSessions: selectedSessions)
+ super.init(initialViewState: UserOtherSessionsViewState(bindings: bindings,
title: title,
- sections: []))
- updateViewState()
+ sessionItems: sessionItems,
+ header: filter.userOtherSessionsViewHeader,
+ emptyItemsTitle: filter.userOtherSessionsViewEmptyResultsTitle,
+ allItemsSelected: false))
}
// MARK: - Public
@@ -37,56 +44,75 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
override func process(viewAction: UserOtherSessionsViewAction) {
switch viewAction {
case let .userOtherSessionSelected(sessionId: sessionId):
- guard let session = sessionInfos.first(where: { $0.id == sessionId }) else {
- assertionFailure("Session should exist in the array.")
- return
+ if state.bindings.isEditModeEnabled {
+ updateSelectionForSession(sessionId: sessionId)
+ updateViewState()
+ } else {
+ showUserSessionOverview(sessionId: sessionId)
}
- completion?(.showUserSessionOverview(sessionInfo: session))
case .filterWasChanged:
updateViewState()
case .clearFilter:
state.bindings.filter = .all
updateViewState()
+ case .editModeWasToggled:
+ selectedSessions.removeAll()
+ updateViewState()
+ case .toggleAllSelection:
+ toggleAllSelection()
+ updateViewState()
}
}
-
+
// MARK: - Private
- private func updateViewState() {
- let sectionItems = createSectionItems(sessionInfos: sessionInfos, filter: state.bindings.filter)
- let sectionHeader = createHeaderData(filter: state.bindings.filter)
- if sectionItems.isEmpty {
- state.sections = [.emptySessionItems(header: sectionHeader,
- title: noSessionsTitle(filter: state.bindings.filter))]
+ private func showUserSessionOverview(sessionId: String) {
+ guard let session = sessionInfos.first(where: { $0.id == sessionId }) else {
+ assertionFailure("Session should exist in the array.")
+ return
+ }
+ completion?(.showUserSessionOverview(sessionInfo: session))
+ }
+
+ private func updateSelectionForSession(sessionId: String) {
+ if selectedSessions.contains(sessionId) {
+ selectedSessions.remove(sessionId)
} else {
- state.sections = [.sessionItems(header: sectionHeader,
- items: sectionItems)]
+ selectedSessions.insert(sessionId)
}
}
- private func createSectionItems(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter) -> [UserSessionListItemViewData] {
- filterSessions(sessionInfos: sessionInfos, by: filter)
- .map {
- UserSessionListItemViewDataFactory().create(from: $0,
- highlightSessionDetails: filter == .unverified && $0.isCurrent)
+ private func updateViewState() {
+ let currentFilter = state.bindings.filter
+
+ state.sessionItems = currentFilter.filterSessionInfos(sessionInfos: sessionInfos, selectedSessions: selectedSessions)
+ state.header = currentFilter.userOtherSessionsViewHeader
+
+ if state.bindings.isEditModeEnabled {
+ state.title = VectorL10n.userOtherSessionSelectedCount(String(selectedSessions.count))
+ } else {
+ state.title = defaultTitle
+ }
+
+ state.emptyItemsTitle = currentFilter.userOtherSessionsViewEmptyResultsTitle
+
+ state.allItemsSelected = sessionInfos.count == selectedSessions.count
+ }
+
+ private func toggleAllSelection() {
+ if state.allItemsSelected {
+ selectedSessions.removeAll()
+ } else {
+ sessionInfos.forEach { sessionInfo in
+ selectedSessions.insert(sessionInfo.id)
}
- }
-
- private func filterSessions(sessionInfos: [UserSessionInfo], by filter: UserOtherSessionsFilter) -> [UserSessionInfo] {
- switch filter {
- case .all:
- return sessionInfos.filter { !$0.isCurrent }
- case .inactive:
- return sessionInfos.filter { !$0.isActive }
- case .unverified:
- return sessionInfos.filter { $0.verificationState != .verified }
- case .verified:
- return sessionInfos.filter { $0.verificationState == .verified }
}
}
-
- private func createHeaderData(filter: UserOtherSessionsFilter) -> UserOtherSessionsHeaderViewData {
- switch filter {
+}
+
+private extension UserOtherSessionsFilter {
+ var userOtherSessionsViewHeader: UserOtherSessionsHeaderViewData {
+ switch self {
case .all:
return UserOtherSessionsHeaderViewData(title: nil,
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
@@ -106,10 +132,9 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
}
}
- private func noSessionsTitle(filter: UserOtherSessionsFilter) -> String {
- switch filter {
+ var userOtherSessionsViewEmptyResultsTitle: String {
+ switch self {
case .all:
- assertionFailure("The view is not intended to be displayed without any session")
return ""
case .verified:
return VectorL10n.userOtherSessionNoVerifiedSessions
@@ -119,4 +144,26 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
return VectorL10n.userOtherSessionNoInactiveSessions
}
}
+
+ func filterSessionsInfos(_ sessionInfos: [UserSessionInfo]) -> [UserSessionInfo] {
+ switch self {
+ case .all:
+ return sessionInfos.filter { !$0.isCurrent }
+ case .inactive:
+ return sessionInfos.filter { !$0.isActive }
+ case .unverified:
+ return sessionInfos.filter { $0.verificationState != .verified }
+ case .verified:
+ return sessionInfos.filter { $0.verificationState == .verified }
+ }
+ }
+
+ func filterSessionInfos(sessionInfos: [UserSessionInfo], selectedSessions: Set) -> [UserSessionListItemViewData] {
+ filterSessionsInfos(sessionInfos)
+ .map {
+ UserSessionListItemViewDataFactory().create(from: $0,
+ highlightSessionDetails: self == .unverified && $0.isCurrent,
+ isSelected: selectedSessions.contains($0.id))
+ }
+ }
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift
index 9b64b201b..b8f390a05 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift
@@ -23,85 +23,71 @@ struct UserOtherSessions: View {
var body: some View {
ScrollView {
- ForEach(viewModel.viewState.sections) { section in
- switch section {
- case let .sessionItems(header: header, items: items):
- createSessionItemsSection(header: header, items: items)
- case let .emptySessionItems(header: header, title: title):
- createEmptySessionsItemsSection(header: header, title: title)
+ SwiftUI.Section {
+ if viewModel.viewState.sessionItems.isEmpty {
+ noItemsView()
+ } else {
+ itemsView()
}
+ } header: {
+ UserOtherSessionsHeaderView(viewData: viewModel.viewState.header)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 24.0)
}
}
+ .onChange(of: viewModel.isEditModeEnabled) { _ in
+ viewModel.send(viewAction: .editModeWasToggled)
+ }
+ .onChange(of: viewModel.filter) { _ in
+ viewModel.send(viewAction: .filterWasChanged)
+ }
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.navigationTitle(viewModel.viewState.title)
.toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Menu {
- Picker("", selection: $viewModel.filter) {
- ForEach(UserOtherSessionsFilter.allCases) { filter in
- Text(filter.menuLocalizedName).tag(filter)
- }
- }
- .labelsHidden()
- .onChange(of: viewModel.filter) { _ in
- viewModel.send(viewAction: .filterWasChanged)
- }
- } label: {
- Image(viewModel.filter == .all ? Asset.Images.userOtherSessionsFilter.name : Asset.Images.userOtherSessionsFilterSelected.name)
+ UserOtherSessionsToolbar(isEditModeEnabled: $viewModel.isEditModeEnabled,
+ filter: $viewModel.filter,
+ allItemsSelected: viewModel.viewState.allItemsSelected) {
+ viewModel.send(viewAction: .toggleAllSelection)
+ }
+ }
+ .navigationBarBackButtonHidden(viewModel.isEditModeEnabled)
+ .accentColor(theme.colors.accent)
+ }
+
+ private func noItemsView() -> some View {
+ VStack {
+ Text(viewModel.viewState.emptyItemsTitle)
+ .font(theme.fonts.footnote)
+ .foregroundColor(theme.colors.primaryContent)
+ .padding(.bottom, 20)
+ Button {
+ viewModel.send(viewAction: .clearFilter)
+ } label: {
+ VStack(spacing: 0) {
+ SeparatorLine()
+ Text(VectorL10n.userOtherSessionClearFilter)
+ .font(theme.fonts.body)
+ .foregroundColor(theme.colors.accent)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.vertical, 11)
+ SeparatorLine()
}
- .accessibilityLabel(VectorL10n.userOtherSessionFilter)
+ .background(theme.colors.background)
}
}
}
- private func createSessionItemsSection(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData]) -> some View {
- SwiftUI.Section {
- LazyVStack(spacing: 0) {
- ForEach(items) { viewData in
- UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
- viewModel.send(viewAction: .userOtherSessionSelected(sessionId: sessionId))
- })
- }
+ private func itemsView() -> some View {
+ LazyVStack(spacing: 0) {
+ ForEach(viewModel.viewState.sessionItems) { viewData in
+ UserSessionListItem(viewData: viewData,
+ isEditModeEnabled: viewModel.isEditModeEnabled,
+ onBackgroundTap: { sessionId in viewModel.send(viewAction: .userOtherSessionSelected(sessionId: sessionId)) },
+ onBackgroundLongPress: { _ in viewModel.isEditModeEnabled = true })
}
- .background(theme.colors.background)
- } header: {
- headerView(header: header)
}
- }
-
- private func createEmptySessionsItemsSection(header: UserOtherSessionsHeaderViewData, title: String) -> some View {
- SwiftUI.Section {
- VStack {
- Text(title)
- .font(theme.fonts.footnote)
- .foregroundColor(theme.colors.primaryContent)
- .padding(.bottom, 20)
- Button {
- viewModel.send(viewAction: .clearFilter)
- } label: {
- VStack(spacing: 0) {
- SeparatorLine()
- Text(VectorL10n.userOtherSessionClearFilter)
- .font(theme.fonts.body)
- .foregroundColor(theme.colors.accent)
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.vertical, 11)
- SeparatorLine()
- }
- .background(theme.colors.background)
- }
- }
-
- } header: {
- headerView(header: header)
- }
- }
-
- private func headerView(header: UserOtherSessionsHeaderViewData) -> some View {
- UserOtherSessionsHeaderView(viewData: header)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.top, 24.0)
+ .background(theme.colors.background)
}
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift
new file mode 100644
index 000000000..244e1473e
--- /dev/null
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift
@@ -0,0 +1,94 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import SwiftUI
+
+struct UserOtherSessionsToolbar: ToolbarContent {
+ @Environment(\.theme) private var theme
+
+ @Binding var isEditModeEnabled: Bool
+ @Binding var filter: UserOtherSessionsFilter
+ var allItemsSelected: Bool
+ let onToggleSelection: () -> Void
+
+ var body: some ToolbarContent {
+ navigationBarLeading()
+ navigationBarTrailing()
+ }
+
+ private func navigationBarLeading() -> some ToolbarContent {
+ ToolbarItemGroup(placement: .navigationBarLeading) {
+ if isEditModeEnabled {
+ Button(allItemsSelected ? VectorL10n.deselectAll : VectorL10n.selectAll, action: {
+ onToggleSelection()
+ })
+ }
+ }
+ }
+
+ private func navigationBarTrailing() -> some ToolbarContent {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ if isEditModeEnabled {
+ cancelButton()
+ } else {
+ filterMenuButton()
+ .offset(x: 12)
+ optionsMenu()
+ }
+ }
+ }
+
+ private func cancelButton() -> some View {
+ Button(VectorL10n.cancel) {
+ isEditModeEnabled = false
+ }
+ .font(theme.fonts.bodySB)
+ .foregroundColor(theme.colors.accent)
+ }
+
+ private func filterMenuButton() -> some View {
+ Button { } label: {
+ Menu {
+ Picker("", selection: $filter) {
+ ForEach(UserOtherSessionsFilter.allCases) { filter in
+ Text(filter.menuLocalizedName).tag(filter)
+ }
+ }
+ .labelsHidden()
+ } label: {
+ Image(filter == .all ? Asset.Images.userOtherSessionsFilter.name : Asset.Images.userOtherSessionsFilterSelected.name)
+ }
+ .accessibilityLabel(VectorL10n.userOtherSessionFilter)
+ }
+ }
+
+ private func optionsMenu() -> some View {
+ Button { } label: {
+ Menu {
+ Button {
+ isEditModeEnabled = true
+ } label: {
+ Label(VectorL10n.userOtherSessionMenuSelectSessions, systemImage: "checkmark.circle")
+ }
+
+ } label: {
+ Image(systemName: "ellipsis")
+ .padding(.horizontal, 4)
+ .padding(.vertical, 12)
+ }
+ }
+ }
+}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift
index 6eb573fe9..ea2133dd8 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift
@@ -18,7 +18,7 @@ import RiotSwiftUI
import XCTest
class UserSessionDetailsUITests: MockScreenTestCase {
- func test_longPressDetailsCell_CopiesValueToClipboard() throws {
+ func disabled_broken_xcode14_test_longPressDetailsCell_CopiesValueToClipboard() throws {
app.goToScreenWithIdentifier(MockUserSessionDetailsScreenState.allSections.title)
UIPasteboard.general.string = ""
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift
index 84c75b7ea..1028dd3cb 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift
@@ -47,7 +47,7 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState {
guard let deviceInfo = deviceInfo else { return .unknown }
- guard session.crypto?.crossSigning?.canCrossSign == true else {
+ guard session.crypto?.crossSigning.canCrossSign == true else {
return deviceInfo.deviceId == session.myDeviceId ? .unverified : .unknown
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift
index 9c25ad3af..0705c8c54 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift
@@ -25,59 +25,76 @@ struct UserSessionListItem: View {
}
@Environment(\.theme) private var theme: ThemeSwiftUI
-
+
let viewData: UserSessionListItemViewData
+ var isEditModeEnabled = false
+
var onBackgroundTap: ((String) -> Void)?
+ var onBackgroundLongPress: ((String) -> Void)?
var body: some View {
- Button {
- onBackgroundTap?(viewData.sessionId)
- } label: {
- VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) {
- HStack(spacing: LayoutConstants.avatarRightMargin) {
- DeviceAvatarView(viewData: viewData.deviceAvatarViewData)
- VStack(alignment: .leading, spacing: 2) {
- Text(viewData.sessionName)
- .font(theme.fonts.bodySB)
- .foregroundColor(theme.colors.primaryContent)
- .multilineTextAlignment(.leading)
- HStack {
- if let sessionDetailsIcon = viewData.sessionDetailsIcon {
- Image(sessionDetailsIcon)
- .padding(.leading, 2)
- }
- Text(viewData.sessionDetails)
- .font(theme.fonts.caption1)
- .foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent)
+ Button { } label: {
+ ZStack {
+ if viewData.isSelected {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(theme.colors.system)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(4)
+ }
+ VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) {
+ HStack(spacing: LayoutConstants.avatarRightMargin) {
+ if isEditModeEnabled {
+ Image(viewData.isSelected ? Asset.Images.userSessionListItemSelected.name : Asset.Images.userSessionListItemNotSelected.name)
+ }
+ DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: viewData.isSelected)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(viewData.sessionName)
+ .font(theme.fonts.bodySB)
+ .foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.leading)
+ HStack {
+ if let sessionDetailsIcon = viewData.sessionDetailsIcon {
+ Image(sessionDetailsIcon)
+ .padding(.leading, 2)
+ }
+ Text(viewData.sessionDetails)
+ .font(theme.fonts.caption1)
+ .foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent)
+ .multilineTextAlignment(.leading)
+ }
}
}
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, LayoutConstants.horizontalPadding)
+
+ // Separator
+ // Note: Separator leading is matching the text leading, we could use alignment guide in the future
+ SeparatorLine()
+ .padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, LayoutConstants.horizontalPadding)
-
- // Separator
- // Note: Separator leading is matching the text leading, we could use alignment guide in the future
- SeparatorLine()
- .padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
+ .padding(.top, LayoutConstants.verticalPadding)
+ }.onTapGesture {
+ onBackgroundTap?(viewData.sessionId)
+ }
+ .onLongPressGesture {
+ onBackgroundLongPress?(viewData.sessionId)
}
- .padding(.top, LayoutConstants.verticalPadding)
}
.frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityIdentifier("UserSessionListItem_\(viewData.sessionId)")
}
}
struct UserSessionListPreview: View {
let userSessionsOverviewService: UserSessionsOverviewServiceProtocol = MockUserSessionsOverviewService()
+ var isEditModeEnabled = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo)
-
- UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in
-
+ UserSessionListItem(viewData: viewData, isEditModeEnabled: isEditModeEnabled, onBackgroundTap: { _ in
})
}
}
@@ -89,6 +106,8 @@ struct UserSessionListItem_Previews: PreviewProvider {
Group {
UserSessionListPreview().theme(.light).preferredColorScheme(.light)
UserSessionListPreview().theme(.dark).preferredColorScheme(.dark)
+ UserSessionListPreview(isEditModeEnabled: true).theme(.light).preferredColorScheme(.light)
+ UserSessionListPreview(isEditModeEnabled: true).theme(.dark).preferredColorScheme(.dark)
}
}
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift
index 6cddefda2..5122e0895 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift
@@ -16,13 +16,15 @@
import Foundation
+typealias SessionId = String
+
/// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable, Hashable {
var id: String {
sessionId
}
- let sessionId: String
+ let sessionId: SessionId
let sessionName: String
@@ -33,4 +35,6 @@ struct UserSessionListItemViewData: Identifiable, Hashable {
let deviceAvatarViewData: DeviceAvatarViewData
let sessionDetailsIcon: String?
+
+ let isSelected: Bool
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift
index 4fb030de8..5486073a7 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift
@@ -17,7 +17,9 @@
import Foundation
struct UserSessionListItemViewDataFactory {
- func create(from sessionInfo: UserSessionInfo, highlightSessionDetails: Bool = false) -> UserSessionListItemViewData {
+ func create(from sessionInfo: UserSessionInfo,
+ highlightSessionDetails: Bool = false,
+ isSelected: Bool = false) -> UserSessionListItemViewData {
let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType,
sessionDisplayName: sessionInfo.name)
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
@@ -28,7 +30,8 @@ struct UserSessionListItemViewDataFactory {
sessionDetails: sessionDetails,
highlightSessionDetails: highlightSessionDetails,
deviceAvatarViewData: deviceAvatarViewData,
- sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive))
+ sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive),
+ isSelected: isSelected)
}
private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String {
diff --git a/RiotTests/UserSessionsDataProviderTests.swift b/RiotTests/UserSessionsDataProviderTests.swift
index 85459347e..3780dcd65 100644
--- a/RiotTests/UserSessionsDataProviderTests.swift
+++ b/RiotTests/UserSessionsDataProviderTests.swift
@@ -111,9 +111,9 @@ private class MockSession: MXSession {
}
/// A mock `MXCrypto` that can override the `canCrossSign` state.
-private class MockCrypto: MXCrypto {
+private class MockCrypto: MXLegacyCrypto {
let canCrossSign: Bool
- override var crossSigning: MXCrossSigning! { MockCrossSigning(canCrossSign: canCrossSign) }
+ override var crossSigning: MXCrossSigning { MockCrossSigning(canCrossSign: canCrossSign) }
init(canCrossSign: Bool) {
self.canCrossSign = canCrossSign
@@ -123,7 +123,7 @@ private class MockCrypto: MXCrypto {
}
/// A mock `MXCrossSigning` with an overridden `canCrossSign` property.
-private class MockCrossSigning: MXCrossSigning {
+private class MockCrossSigning: MXLegacyCrossSigning {
let canCrossSignMock: Bool
override var canCrossSign: Bool { canCrossSignMock }
diff --git a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m
index 84d9dee63..34ebb66e9 100644
--- a/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m
+++ b/SiriIntents/IntentHandlers/SendMessage/SendMessageIntentHandler.m
@@ -118,7 +118,10 @@
self.selectedRoom = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session];
// Do not warn for unknown devices. We have cross-signing now
- session.crypto.warnOnUnknowDevices = NO;
+ if ([session.crypto isKindOfClass:[MXLegacyCrypto class]])
+ {
+ ((MXLegacyCrypto *)session.crypto).warnOnUnknowDevices = NO;
+ }
MXWeakify(self);
[self.selectedRoom sendTextMessage:intent.content
diff --git a/Tools/Templates/README.md b/Tools/Templates/README.md
index dc1bb15b6..3f83b42c4 100644
--- a/Tools/Templates/README.md
+++ b/Tools/Templates/README.md
@@ -33,6 +33,39 @@ To use it (before it becomes an Xcode template):
- Import created files in the Xcode project
+# SwiftUISimpleScreenTemplate
+This is the boilerplate to create a simple SwiftUI screen including view model, screen coordinator, unit and UI tests.
+
+To create a screen from this template (before it becomes an Xcode template):
+
+- `./createSwiftUISimpleScreen.sh ScreenFolder MyScreenName`
+- Import created files in the Xcode project
+
+This will create `ScreenFolder` within the `RiotSwiftUI/Modules`. Files inside will be named `MyScreenNameXxx`.
+
+
+# SwiftUISingleScreenTempalte
+This is the boilerplate to create a simple SwiftUI screen including view model, screen coordinator, service, unit and UI tests.
+
+To create a screen from this template (before it becomes an Xcode template):
+
+- `./createSwiftUISingleScreen.sh ScreenFolder MyScreenName`
+- Import created files in the Xcode project
+
+This will create `ScreenFolder` within the `RiotSwiftUI/Modules`. Files inside will be named `MyScreenNameXxx`.
+
+
+# SwiftUITwoScreenTemplate
+This is the boilerplate to create two single SwiftUI screens (including view models, screen coordinators, services, unit and UI tests) and a flow coordinator.
+
+To create screens from this template (before it becomes an Xcode template):
+
+- `./createSwiftUITwoScreen.sh TwoScreenFolder MyRootCoordinator FirstScreenName SecondScreenName`
+- Import created files in the Xcode project
+
+This will create `TwoScreenFolder` within the `RiotSwiftUI/Modules`.
+
+
# Usage example
Following commands:
diff --git a/project.yml b/project.yml
index 722cce972..391e91acc 100644
--- a/project.yml
+++ b/project.yml
@@ -53,7 +53,7 @@ packages:
branch: main
WysiwygComposer:
url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift
- revision: 11dad16e3e589dba423f6cc5707e9df8aace89b0
+ revision: d5ef7054fb43924d5b92d5d627347ca2bc333717
DeviceKit:
url: https://github.com/devicekit/DeviceKit
majorVersion: 4.7.0