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