diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml
index 0d6b6689a..738454280 100644
--- a/.github/workflows/triage-move-labelled.yml
+++ b/.github/workflows/triage-move-labelled.yml
@@ -44,7 +44,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 +208,81 @@ 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-Composer-WYSIWYG')
+ 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 }}
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/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/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/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/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 226d5fd1d..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 %@";
@@ -2505,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";
diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings
index dc87963f7..fdb8d22d9 100644
--- a/Riot/Assets/hu.lproj/Vector.strings
+++ b/Riot/Assets/hu.lproj/Vector.strings
@@ -2625,3 +2625,4 @@
"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). Jelenleg a hang közvetítést csak a szoba idővonalán jelezzük, egyenlőre nem lehet hangot sugározni vagy belehallgatni a közvetítésbe";
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 850b19a65..922174427 100644
--- a/Riot/Generated/Images.swift
+++ b/Riot/Generated/Images.swift
@@ -337,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 f538c1a5e..6c82fd266 100644
--- a/Riot/Generated/Strings.swift
+++ b/Riot/Generated/Strings.swift
@@ -7539,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")
}
@@ -9063,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")
@@ -9219,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.m b/Riot/Modules/Application/LegacyAppDelegate.m
index 040e243f5..65650817c 100644
--- a/Riot/Modules/Application/LegacyAppDelegate.m
+++ b/Riot/Modules/Application/LegacyAppDelegate.m
@@ -610,6 +610,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
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/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/Room/CellData/RoomBubbleCellData.h b/Riot/Modules/Room/CellData/RoomBubbleCellData.h
index cc76d4880..94f7346aa 100644
--- a/Riot/Modules/Room/CellData/RoomBubbleCellData.h
+++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.h
@@ -37,7 +37,9 @@ typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag)
RoomBubbleCellDataTagPoll,
RoomBubbleCellDataTagLocation,
RoomBubbleCellDataTagLiveLocation,
- RoomBubbleCellDataTagVoiceBroadcast
+ RoomBubbleCellDataTagVoiceBroadcastRecord,
+ RoomBubbleCellDataTagVoiceBroadcastPlayback,
+ RoomBubbleCellDataTagVoiceBroadcastNoDisplay
};
/**
diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m
index 301b87328..adcd6692e 100644
--- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m
+++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m
@@ -183,13 +183,43 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO;
}
}
- else if ([event.type isEqualToString:VoiceBroadcastSettings.eventType])
+ else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType])
{
- self.tag = RoomBubbleCellDataTagVoiceBroadcast;
+ VoiceBroadcastInfo *voiceBroadcastInfo = [VoiceBroadcastInfo modelFromJSON: event.content];
+ if ([VoiceBroadcastInfo isStartedFor:voiceBroadcastInfo.state])
+ {
+ // This state event corresponds to the beginning of a voice broadcast
+ // Check whether this is a local live broadcast to display it with the recorder view or not
+ // Note: Because of race condition, the voiceBroadcastService may be running without id here (the sync response may be received before
+ // the success of the event sending), in that case, we will display a recorder view by default to let the user be able to stop a potential record.
+ if ([event.sender isEqualToString: self.mxSession.myUserId] &&
+ [voiceBroadcastInfo.deviceId isEqualToString:self.mxSession.myDeviceId] &&
+ self.mxSession.voiceBroadcastService != nil &&
+ ([event.eventId isEqualToString: self.mxSession.voiceBroadcastService.voiceBroadcastInfoEventId] ||
+ self.mxSession.voiceBroadcastService.voiceBroadcastInfoEventId == nil))
+ {
+ self.tag = RoomBubbleCellDataTagVoiceBroadcastRecord;
+ }
+ else
+ {
+ self.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback;
+ }
+ }
+ else
+ {
+ self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay;
+
+ if ([VoiceBroadcastInfo isStoppedFor:voiceBroadcastInfo.state])
+ {
+ // This state event corresponds to the end of a voice broadcast
+ // Force the tag of the potential cellData which corresponds to the started event to switch the display from recorder to listener
+ id bubbleData = [roomDataSource cellDataOfEventWithEventId:voiceBroadcastInfo.eventId];
+ bubbleData.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback;
+ }
+ }
self.collapsable = NO;
self.collapsed = NO;
- 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/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..4782f6fde 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)];
@@ -8007,6 +8039,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..1b39e9822 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;
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..49f496dbd 100644
--- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift
+++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift
@@ -29,8 +29,6 @@ import CoreGraphics
// The toolbar for editing with rich text
class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInputToolbarViewProtocol {
-
-
// MARK: - Properties
// MARK: Private
@@ -41,6 +39,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
private var viewModel: ComposerViewModelProtocol = ComposerViewModel(initialViewState: ComposerViewState())
// MARK: Public
+ var isEncryptionEnabled = false {
+ didSet {
+ updatePlaceholderText()
+ }
+ }
/// The current html content of the composer
var htmlContent: String {
@@ -69,6 +72,16 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
}
set {
viewModel.sendMode = ComposerSendMode(from: newValue)
+ updatePlaceholderText()
+ }
+ }
+
+ override var placeholder: String! {
+ get {
+ viewModel.placeholder
+ }
+ set {
+ viewModel.placeholder = newValue
}
}
@@ -86,13 +99,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
super.awakeFromNib()
viewModel.callback = { [weak self] result in
- guard let self = self else { return }
- switch result {
- case .cancel:
- self.toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self)
- }
+ self?.handleViewModelResult(result)
}
+
inputAccessoryViewForKeyboard = UIView(frame: .zero)
+
let composer = Composer(viewModel: viewModel.context,
wysiwygViewModel: wysiwygViewModel,
sendMessageAction: { [weak self] content in
@@ -150,11 +161,17 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
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)
+ }
+ }
+
private func registerThemeServiceDidChangeThemeNotification() {
NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
}
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/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/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/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/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/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/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/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..0bcfd6ffd 100644
--- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
@@ -111,6 +111,7 @@ struct Composer: View {
didUpdateText: wysiwygViewModel.didUpdateText
)
.tintColor(theme.colors.accent)
+ .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent)
.frame(height: wysiwygViewModel.idealHeight)
.padding(.horizontal, horizontalPadding)
.onAppear {
diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift
index dcb1ec6fe..65fd747b6 100644
--- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift
+++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift
@@ -45,6 +45,15 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol
}
}
+ var placeholder: String? {
+ get {
+ state.placeholder
+ }
+ set {
+ state.placeholder = newValue
+ }
+ }
+
// MARK: - Public
override func process(viewAction: ComposerViewAction) {
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/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/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/changelog.d/5326.bugfix b/changelog.d/5326.bugfix
new file mode 100644
index 000000000..8eaa35254
--- /dev/null
+++ b/changelog.d/5326.bugfix
@@ -0,0 +1 @@
+Timeline: Fix layout for SwiftUI content views.
diff --git a/changelog.d/6847.bugfix b/changelog.d/6847.bugfix
new file mode 100644
index 000000000..3e8dcd7a1
--- /dev/null
+++ b/changelog.d/6847.bugfix
@@ -0,0 +1 @@
+Updates the avatar image loading logics.
diff --git a/changelog.d/6849.bugfix b/changelog.d/6849.bugfix
new file mode 100644
index 000000000..2d54bf805
--- /dev/null
+++ b/changelog.d/6849.bugfix
@@ -0,0 +1 @@
+Fixes input text view height when containing multiple lines of text.
diff --git a/changelog.d/6935.change b/changelog.d/6935.change
new file mode 100644
index 000000000..43807527e
--- /dev/null
+++ b/changelog.d/6935.change
@@ -0,0 +1 @@
+Added a responsive placeholder text to the Rich Text Composer
diff --git a/changelog.d/6949.bugfix b/changelog.d/6949.bugfix
new file mode 100644
index 000000000..2737193db
--- /dev/null
+++ b/changelog.d/6949.bugfix
@@ -0,0 +1 @@
+Fixed the placeholder flickering in the input toolbar when there is an height change.
\ No newline at end of file
diff --git a/changelog.d/pr-6870.feature b/changelog.d/pr-6870.feature
new file mode 100644
index 000000000..2a4ba4edc
--- /dev/null
+++ b/changelog.d/pr-6870.feature
@@ -0,0 +1 @@
+Changed the info in the background audio message player.
diff --git a/changelog.d/pr-6936.change b/changelog.d/pr-6936.change
new file mode 100644
index 000000000..d1e649c9f
--- /dev/null
+++ b/changelog.d/pr-6936.change
@@ -0,0 +1 @@
+Improves external links interaction UX.
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