diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index 2c932c52fe..f8d03f08b9 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -4,7 +4,6 @@ on: # Triggers the workflow on any pull request pull_request: - types: [ labeled, synchronized, opened, reopened ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/CHANGES.md b/CHANGES.md index 15c1ca2e63..65b409b528 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,82 @@ +## Changes in 1.9.13 (2022-11-29) + +✨ Features + +- Add the left time in the Voice Broadcast tile recorder. ([#7103](https://github.com/vector-im/element-ios/pull/7103)) + +🙌 Improvements + +- CryptoV2: Import progress for room keys ([#7078](https://github.com/vector-im/element-ios/pull/7078)) +- Add support in the new Device Manager to sessions without crypto support. ([#7083](https://github.com/vector-im/element-ios/pull/7083)) +- Loading: Display sync progress on the loading screen ([#7101](https://github.com/vector-im/element-ios/pull/7101)) +- Refactor bottom sheet presentation in the device manager. ([#7107](https://github.com/vector-im/element-ios/pull/7107)) +- Upgrade MatrixSDK version ([v0.24.5](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.24.5)). +- Rich Text Composer: Fullscreen mode now is matching the design requirements. ([#7058](https://github.com/vector-im/element-ios/issues/7058)) +- Rich Text Editor: on iPhones when in landscape mode the fullscreen mode is disabled. ([#7096](https://github.com/vector-im/element-ios/issues/7096)) + +🐛 Bugfixes + +- Fix scroll issues with VoiceBroadcast and Poll cells ([#7105](https://github.com/vector-im/element-ios/pull/7105)) +- VoiceBroadcast: Display the playback duration in the default state ([#7110](https://github.com/vector-im/element-ios/pull/7110)) +- Polls: mitigate flickering on vote. ([#5329](https://github.com/vector-im/element-ios/issues/5329)) +- Labs: Rich text editor: Fix smart punctuation (e.g. double space transforms into dot) ([#6930](https://github.com/vector-im/element-ios/issues/6930)) +- Labs: Rich text editor: Fix input for keyboards that use symbols composition and replacement (e.g. Japanese Romaji, Korean) ([#6983](https://github.com/vector-im/element-ios/issues/6983)) +- Labs: Rich text editor: Fix keyboard suggestions for non-latin keyboards (e.g. Chinese Pinyin) ([#7042](https://github.com/vector-im/element-ios/issues/7042)) +- Voice Messages: Fix crash when voice message finishes playing. ([#7074](https://github.com/vector-im/element-ios/issues/7074)) +- Rich Text Composer: Bottom Sheet is sized to always show all the elements inside, and in case it reaches the top, is also scrollable. ([#7082](https://github.com/vector-im/element-ios/issues/7082)) +- Labs: Rich text editor: Fix broken backspace around some type of whitespaces ([#7086](https://github.com/vector-im/element-ios/issues/7086)) +- Support voice broadcast live playback ([#7094](https://github.com/vector-im/element-ios/issues/7094)) +- Rich Text Editor: Fixed a bug that prevented the drag gesture to dismiss the fullscreen mode when there is a lot of text. ([#7116](https://github.com/vector-im/element-ios/issues/7116)) + +🚧 In development 🚧 + +- Labs: VoiceBroadcast - Add the Voice Broadcast option in the room functionalities ([#6721](https://github.com/vector-im/element-ios/issues/6721)) + + +## Changes in 1.9.12 (2022-11-15) + +✨ Features + +- Threads: added support to read receipts (MSC3771) ([#6663](https://github.com/vector-im/element-ios/issues/6663)) +- Threads: added support to notifications count (MSC3773) ([#6664](https://github.com/vector-im/element-ios/issues/6664)) +- Threads: added support to labs flag for read receipts ([#7029](https://github.com/vector-im/element-ios/issues/7029)) +- Threads: notification count in main timeline including un participated threads ([#7038](https://github.com/vector-im/element-ios/issues/7038)) +- Unverified sessions alert. ([#7056](https://github.com/vector-im/element-ios/issues/7056)) +- Labs: Rich-text editor: enable translations between Markdown and HTML when toggling text formatting ([#7061](https://github.com/vector-im/element-ios/issues/7061)) + +🙌 Improvements + +- Add informational sheets for user's session states. ([#6992](https://github.com/vector-im/element-ios/pull/6992)) +- Add the sign out option in the menu in the session overview. ([#7001](https://github.com/vector-im/element-ios/pull/7001)) +- Add show/hide sessions' ip address in the new session manager. ([#7028](https://github.com/vector-im/element-ios/pull/7028)) +- Updated GBDeviceInfo pod. ([#7051](https://github.com/vector-im/element-ios/pull/7051)) +- Improve device manager code coverage. ([#7065](https://github.com/vector-im/element-ios/pull/7065)) +- Initial sync: Remove 10s wait on failed initial sync ([#7068](https://github.com/vector-im/element-ios/pull/7068)) +- Labs: Rich text-editor - Add support for plain text mode ([#6980](https://github.com/vector-im/element-ios/issues/6980)) + +🐛 Bugfixes + +- Prevent autolayout crashes when showing toast notifications ([#7046](https://github.com/vector-im/element-ios/pull/7046)) +- Fixed timeline layout issues for reactions and attachments ([#7064](https://github.com/vector-im/element-ios/pull/7064)) +- Rich Text Composer: Voice Dictation is supported (only plain text can be dictated). ([#6945](https://github.com/vector-im/element-ios/issues/6945)) +- Rich Text Composer dismisses the keyboard when sending custom iOS emojis as images, like the normal composer. ([#6946](https://github.com/vector-im/element-ios/issues/6946)) +- Fixed IRC-style message and commands support in Rich text editor ([#6962](https://github.com/vector-im/element-ios/issues/6962)) +- Fixed the missing keystrokes issue on the Rich Text Editor ([#7005](https://github.com/vector-im/element-ios/issues/7005)) +- Fixed the long press deleting issue skipping some text on the Rich Text Editor ([#7006](https://github.com/vector-im/element-ios/issues/7006)) +- Hide push toggles for http pushers when there is no server support. ([#7022](https://github.com/vector-im/element-ios/issues/7022)) +- Synchronise composer and toolbar resizing animation duration for smoother height updates. ([#7025](https://github.com/vector-im/element-ios/issues/7025)) +- Device Manager: Session list item is not tappable everywhere. ([#7035](https://github.com/vector-im/element-ios/issues/7035)) +- Labs: Rich-text editor - Fix text formatting enabled inconsistent state ([#7052](https://github.com/vector-im/element-ios/issues/7052)) +- Labs: Rich-text editor - Fix text formatting switch losing the current content of the composer ([#7054](https://github.com/vector-im/element-ios/issues/7054)) +- Threads: removed "unread_thread_notifications" from sync filters for server that doesn't support MSC3773 ([#7066](https://github.com/vector-im/element-ios/issues/7066)) +- Poll not usable after logging out and back in. ([#7070](https://github.com/vector-im/element-ios/issues/7070)) +- Threads: Display number of unread messages above threads button ([#7076](https://github.com/vector-im/element-ios/issues/7076)) + +🚧 In development 🚧 + +- Device Manager: Multi-session sign out. ([#6963](https://github.com/vector-im/element-ios/issues/6963)) + + ## Changes in 1.9.12 (2022-11-15) ✨ Features diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index ffb7d901a1..c5f0aa65da 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.9.12 -CURRENT_PROJECT_VERSION = 1.9.12 +MARKETING_VERSION = 1.9.13 +CURRENT_PROJECT_VERSION = 1.9.13 diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index d6b022948b..2f85f3c131 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -409,7 +409,7 @@ final class BuildSettings: NSObject { // MARK: - Voice Broadcast static let voiceBroadcastChunkLength: Int = 120 - static let voiceBroadcastMaxLength: UInt64 = 144000 + static let voiceBroadcastMaxLength: UInt = 14400 // 240min. // MARK: - MXKAppSettings static let enableBotCreation: Bool = false diff --git a/Podfile b/Podfile index 154c119d42..aaf64e8acf 100644 --- a/Podfile +++ b/Podfile @@ -16,7 +16,7 @@ use_frameworks! # - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI # # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it -$matrixSDKVersion = '= 0.24.3' +$matrixSDKVersion = '= 0.24.5' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 816ccb018f..a3f2f6fc96 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" : "2469f27b7e1e51aaa135e09f9005eb10fda686e6" + "revision" : "1fbffd0321eb47abcd664ad19c6c943b60abf399" } }, { diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_time_left.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_time_left.imageset/Contents.json new file mode 100644 index 0000000000..6dbed56480 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_time_left.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_time_left.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_time_left.imageset/voice_broadcast_time_left.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_time_left.imageset/voice_broadcast_time_left.svg new file mode 100644 index 0000000000..82b9eb425a --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_time_left.imageset/voice_broadcast_time_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index fa68764c3e..e9295cc9e2 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -1968,6 +1968,12 @@ Tap the + to start adding people."; "call_transfer_error_title" = "Error"; "call_transfer_error_message" = "Call transfer failed"; +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Syncing with the server"; +"launch_loading_server_syncing_nth_attempt" = "Syncing with the server\n(%@ attempt)"; +"launch_loading_processing_response" = "Processing data\n%@ %%"; + // MARK: - Home "home_empty_view_title" = "Welcome to %@,\n%@"; @@ -2196,6 +2202,7 @@ Tap the + to start adding people."; "voice_broadcast_playback_loading_error" = "Unable to play this voice broadcast."; "voice_broadcast_live" = "Live"; "voice_broadcast_tile" = "Voice broadcast"; +"voice_broadcast_time_left" = "%@ left"; // Mark: - Version check @@ -2444,6 +2451,7 @@ To enable access, tap Settings> Location and select Always"; "user_session_unverified_additional_info" = "Verify your current session for enhanced secure messaging."; "user_session_verification_unknown_additional_info" = "Verify your current session to reveal this session's verification status."; "user_other_session_unverified_additional_info" = "Verify or sign out from this session for best security and reliability."; +"user_other_session_permanently_unverified_additional_info" = "This session cannot be verified because it does not support encryption."; "user_other_session_verified_additional_info" = "This session is ready for secure messaging."; "user_session_push_notifications" = "Push notifications"; "user_session_push_notifications_message" = "When turned on, this session will receive push notifications."; diff --git a/Riot/Assets/es.lproj/InfoPlist.strings b/Riot/Assets/es.lproj/InfoPlist.strings index 6b8c191124..da6fdb6c67 100644 --- a/Riot/Assets/es.lproj/InfoPlist.strings +++ b/Riot/Assets/es.lproj/InfoPlist.strings @@ -1,8 +1,8 @@ // Permissions usage explanations "NSCameraUsageDescription" = "La cámara se usa para sacar fotos, vídeos y hacer videollamadas."; -"NSPhotoLibraryUsageDescription" = "La biblioteca de fotos se usa para enviar fotos y vídeos."; +"NSPhotoLibraryUsageDescription" = "Permite el acceso a fotos para subir fotos y vídeos desde tu galería."; "NSMicrophoneUsageDescription" = "Element necesita usar tu micrófono para hacer y recibir llamadas y grabar vídeos y mensajes de voz."; -"NSContactsUsageDescription" = "Element te mostrará tus contactos para que les puedas invitar a una conversación."; +"NSContactsUsageDescription" = "Se compartirán con tu servidor de identidad para ayudarte a encontrar tus contactos en Matrix."; "NSFaceIDUsageDescription" = "Face ID se usa para acceder a tu aplicación."; "NSCalendarsUsageDescription" = "Mostrar tus reuniones en la aplicación."; "NSLocationWhenInUseUsageDescription" = "Cuando compartes tu ubicación con otras personas, Element necesita acceso para que puedan verla en el mapa."; diff --git a/Riot/Assets/nl.lproj/InfoPlist.strings b/Riot/Assets/nl.lproj/InfoPlist.strings index 2ef529d616..f31554ac87 100644 --- a/Riot/Assets/nl.lproj/InfoPlist.strings +++ b/Riot/Assets/nl.lproj/InfoPlist.strings @@ -16,10 +16,10 @@ // Permissions usage explanations "NSCameraUsageDescription" = "De camera wordt gebruikt om te videobellen, of om foto's en video's te maken en te uploaden."; -"NSPhotoLibraryUsageDescription" = "Geef toegang tot foto's om foto's en video's uit uw bibliotheek te uploaden."; -"NSMicrophoneUsageDescription" = "Element heeft toegang nodig tot uw microfoon nodig voor oproepen, maken van video's en spraakberichten opnemen."; -"NSContactsUsageDescription" = "Ze worden gedeeld met uw identiteitsserver om uw contacten op Matrix te vinden."; -"NSCalendarsUsageDescription" = "Bekijk uw geplande afspraken in de app."; -"NSFaceIDUsageDescription" = "Face ID wordt gebruikt om toegang te krijgen tot uw app."; -"NSLocationWhenInUseUsageDescription" = "Wanneer u uw locatie deelt met mensen heeft Element toegang nodig om dit op een kaart te tonen."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Wanneer u locatie met mensen deelt, heeft Element toegang nodig om ze een kaart te laten zien."; +"NSPhotoLibraryUsageDescription" = "Geef toegang tot foto's om foto's en video's uit je bibliotheek te uploaden."; +"NSMicrophoneUsageDescription" = "Element heeft toegang nodig tot je microfoon nodig voor oproepen, maken van video's en spraakberichten opnemen."; +"NSContactsUsageDescription" = "Ze worden gedeeld met je identiteitsserver om je contacten op Matrix te vinden."; +"NSCalendarsUsageDescription" = "Bekijk jouw geplande afspraken in de app."; +"NSFaceIDUsageDescription" = "Face ID wordt gebruikt om toegang te krijgen tot je app."; +"NSLocationWhenInUseUsageDescription" = "Wanneer je jouw locatie deelt met mensen heeft Element toegang nodig om dit op een kaart te tonen."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Wanneer je jouw locatie met mensen deelt, heeft Element toegang nodig om ze een kaart te laten zien."; diff --git a/Riot/Assets/nl.lproj/Localizable.strings b/Riot/Assets/nl.lproj/Localizable.strings index 0176086d14..eeab4c8072 100644 --- a/Riot/Assets/nl.lproj/Localizable.strings +++ b/Riot/Assets/nl.lproj/Localizable.strings @@ -54,11 +54,11 @@ /** Invites **/ /* A user has invited you to a chat */ -"USER_INVITE_TO_CHAT" = "%@ heeft u uitgenodigd om te chatten"; +"USER_INVITE_TO_CHAT" = "%@ heeft je uitgenodigd om te chatten"; /* A user has invited you to an (unamed) group chat */ -"USER_INVITE_TO_CHAT_GROUP_CHAT" = "%@ heeft u uitgenodigd in een groepschat"; +"USER_INVITE_TO_CHAT_GROUP_CHAT" = "%@ heeft je uitgenodigd in een groepschat"; /* A user has invited you to a named room */ -"USER_INVITE_TO_NAMED_ROOM" = "%@ heeft u in %@-kamer uitgenodigd"; +"USER_INVITE_TO_NAMED_ROOM" = "%@ heeft je in %@-kamer uitgenodigd"; /** Calls **/ /* Incoming one-to-one voice call */ @@ -74,9 +74,9 @@ /* Incoming named video conference invite from a specific person */ "VIDEO_CONF_NAMED_FROM_USER" = "Video-groepsoproep van %@: ‘%@’"; /* A single unread message in a room */ -"SINGLE_UNREAD_IN_ROOM" = "U heeft een bericht ontvangen in %@"; +"SINGLE_UNREAD_IN_ROOM" = "Je hebt een bericht ontvangen in %@"; /* A single unread message */ -"SINGLE_UNREAD" = "U heeft een bericht ontvangen"; +"SINGLE_UNREAD" = "Je hebt een bericht ontvangen"; /* Message title for a specific person in a named room */ "MSG_FROM_USER_IN_ROOM_TITLE" = "%@ in %@"; /* Sticker from a specific person, not referencing a room. */ diff --git a/Riot/Assets/ru.lproj/InfoPlist.strings b/Riot/Assets/ru.lproj/InfoPlist.strings index c3bb844cb8..b1ddc44b64 100644 --- a/Riot/Assets/ru.lproj/InfoPlist.strings +++ b/Riot/Assets/ru.lproj/InfoPlist.strings @@ -1,8 +1,8 @@ // Permissions usage explanations -"NSCameraUsageDescription" = "Камера используется для съемки фото и видео, совершения видеозвонков."; -"NSPhotoLibraryUsageDescription" = "Галерея используется для отправки фото и видео."; +"NSCameraUsageDescription" = "Камера используется для совершения видеозвонков, съёмки и загрузки фотографий и видео."; +"NSPhotoLibraryUsageDescription" = "Разрешите доступ к фото для отправки фото и видео из вашей библиотеки."; "NSMicrophoneUsageDescription" = "Element необходим доступ к вашему микрофону, чтобы совершать и принимать звонки, снимать видео и записывать голосовые сообщения."; -"NSContactsUsageDescription" = "Element покажет ваши контакты, чтобы вы могли пригласить их в чат."; +"NSContactsUsageDescription" = "Они будут переданы вашему серверу идентификации, чтобы помочь найти ваши контакты в Matrix."; "NSCalendarsUsageDescription" = "Просматривайте запланированные встречи в приложении."; "NSFaceIDUsageDescription" = "Face ID используется для доступа к вашему приложению."; "NSLocationWhenInUseUsageDescription" = "Когда вы делитесь с людьми своим местоположением, Element необходим доступ, чтобы показать им карту."; diff --git a/Riot/Assets/sq.lproj/InfoPlist.strings b/Riot/Assets/sq.lproj/InfoPlist.strings index 740651215f..a4d354d560 100644 --- a/Riot/Assets/sq.lproj/InfoPlist.strings +++ b/Riot/Assets/sq.lproj/InfoPlist.strings @@ -1,8 +1,8 @@ // Permissions usage explanations -"NSCameraUsageDescription" = "Kamera përdoret për të bërë foto dhe regjistruar video, dhe për të bërë thirrje video."; -"NSPhotoLibraryUsageDescription" = "Fototeka përdoret për të dërguar foto dhe video."; +"NSCameraUsageDescription" = "Kamera përdoret për të bërë thirrje video, ose për të bërë dhe ngarkuar diku foto dhe video."; +"NSPhotoLibraryUsageDescription" = "Që të ngarkohen foto dhe video që nga mediateka juaj, lejoni hyrje te fotot."; "NSMicrophoneUsageDescription" = "Element-it i duhet të përdorë mikrofonin tuaj për të bërë dhe marrë thirrje, për të regjistruar video, dhe për të regjistruar mesazhe zanorë."; -"NSContactsUsageDescription" = "Element-i do të shfaqë kontaktet tuaja, që kështu të mund t’i ftoni për të biseduar."; +"NSContactsUsageDescription" = "Do t’i jepen shërbyesit tuaj të identiteteve, për ta ndihmuar të gjejë kontakte tuajt në Matrix."; "NSCalendarsUsageDescription" = "Shihini te aplikacioni takimet tuaja të planifikuara."; "NSFaceIDUsageDescription" = "Face ID përdoret që të hyni në aplikacionin tuaj."; "NSLocationWhenInUseUsageDescription" = "Kur ndani vendndodhjen tuaj me persona, Element-i ka nevojë për hyrje në të, që t’u trgojë atyre një hartë."; diff --git a/Riot/Assets/sq.lproj/Vector.strings b/Riot/Assets/sq.lproj/Vector.strings index 847de8e424..8d6e3cbfd4 100644 --- a/Riot/Assets/sq.lproj/Vector.strings +++ b/Riot/Assets/sq.lproj/Vector.strings @@ -2552,7 +2552,7 @@ "all_chats_empty_unreads_placeholder_message" = "Ky është vendi ku do të shfaqen mesazhet tuaj të palexuar, kur të ketë të tillë."; "all_chats_empty_view_information" = "Aplikacioni “all-in-one” i fjalosjeve të siguruara, për ekipe, shokë dhe ente. Që t’ia filloni, krijoni një fjalosje, ose hyni në një dhomë ekzistuese."; "all_chats_empty_space_information" = "Hapësirat janë një mënyrë e re për të grupuar dhoma dhe persona. Shtoni një dhomë ekzistuese, ose krijoni një të re, duke përdorur butonin poshtë djathtas."; -"all_chats_empty_view_title" = "%s\nduket paksa si i zbrazët."; +"all_chats_empty_view_title" = "%@\nduket paksa si i zbrazët."; "all_chats_all_filter" = "Krejt"; "all_chats_edit_layout_alphabetical_order" = "Renditi si A-Z"; "all_chats_edit_layout_activity_order" = "Renditi sipas veprimtarish"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 94352b0be0..dcf78a2e13 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -347,6 +347,7 @@ internal class Asset: NSObject { internal static let voiceBroadcastStop = ImageAsset(name: "voice_broadcast_stop") internal static let voiceBroadcastTileLive = ImageAsset(name: "voice_broadcast_tile_live") internal static let voiceBroadcastTileMic = ImageAsset(name: "voice_broadcast_tile_mic") + internal static let voiceBroadcastTimeLeft = ImageAsset(name: "voice_broadcast_time_left") internal static let launchScreenLogo = ImageAsset(name: "launch_screen_logo") } @objcMembers diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 36091cc12f..f773146b2d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -3179,6 +3179,18 @@ public class VectorL10n: NSObject { public static var later: String { return VectorL10n.tr("Vector", "later") } + /// Processing data\n%@ %% + public static func launchLoadingProcessingResponse(_ p1: String) -> String { + return VectorL10n.tr("Vector", "launch_loading_processing_response", p1) + } + /// Syncing with the server + public static var launchLoadingServerSyncing: String { + return VectorL10n.tr("Vector", "launch_loading_server_syncing") + } + /// Syncing with the server\n(%@ attempt) + public static func launchLoadingServerSyncingNthAttempt(_ p1: String) -> String { + return VectorL10n.tr("Vector", "launch_loading_server_syncing_nth_attempt", p1) + } /// Leave public static var leave: String { return VectorL10n.tr("Vector", "leave") @@ -8699,6 +8711,10 @@ public class VectorL10n: NSObject { public static var userOtherSessionNoVerifiedSessions: String { return VectorL10n.tr("Vector", "user_other_session_no_verified_sessions") } + /// This session cannot be verified because it does not support encryption. + public static var userOtherSessionPermanentlyUnverifiedAdditionalInfo: String { + return VectorL10n.tr("Vector", "user_other_session_permanently_unverified_additional_info") + } /// Security recommendation public static var userOtherSessionSecurityRecommendationTitle: String { return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title") @@ -9139,6 +9155,10 @@ public class VectorL10n: NSObject { public static var voiceBroadcastTile: String { return VectorL10n.tr("Vector", "voice_broadcast_tile") } + /// %@ left + public static func voiceBroadcastTimeLeft(_ p1: String) -> String { + return VectorL10n.tr("Vector", "voice_broadcast_time_left", p1) + } /// Can't start a new voice broadcast public static var voiceBroadcastUnauthorizedTitle: String { return VectorL10n.tr("Vector", "voice_broadcast_unauthorized_title") diff --git a/Riot/Modules/Analytics/SentryMonitoringClient.swift b/Riot/Modules/Analytics/SentryMonitoringClient.swift index d15cf80393..54933a7ab3 100644 --- a/Riot/Modules/Analytics/SentryMonitoringClient.swift +++ b/Riot/Modules/Analytics/SentryMonitoringClient.swift @@ -42,6 +42,10 @@ struct SentryMonitoringClient { options.enableNetworkTracking = false options.beforeSend = { event in + // Use the actual error message as issue fingerprint + if let message = event.message?.formatted { + event.fingerprint = [message] + } MXLog.debug("[SentryMonitoringClient] Issue detected: \(event)") return event } diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 3558a4a4d5..7c2975522f 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -2394,7 +2394,17 @@ - (void)showLaunchAnimation { MXLogDebug(@"[AppDelegate] showLaunchAnimation"); - LaunchLoadingView *launchLoadingView = [LaunchLoadingView instantiate]; + LaunchLoadingView *launchLoadingView; + if (MXSDKOptions.sharedInstance.enableSyncProgress) + { + MXSession *mainSession = self.mxSessions.firstObject; + launchLoadingView = [LaunchLoadingView instantiateWithSyncProgress:mainSession.syncProgress]; + } + else + { + launchLoadingView = [LaunchLoadingView instantiateWithSyncProgress:nil]; + } + launchLoadingView.frame = window.bounds; [launchLoadingView updateWithTheme:ThemeService.shared.theme]; launchLoadingView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 5aa6b3731f..8b98989929 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -613,7 +613,8 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// Replace the contents of the navigation router with a loading animation. private func showLoadingAnimation() { - let loadingViewController = LaunchLoadingViewController() + let syncProgress: MXSessionSyncProgress? = MXSDKOptions.sharedInstance().enableSyncProgress ? session?.syncProgress : nil + let loadingViewController = LaunchLoadingViewController(syncProgress: syncProgress) loadingViewController.modalPresentationStyle = .fullScreen // Replace the navigation stack with the loading animation diff --git a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift index e8ca770abf..583419075f 100644 --- a/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift @@ -106,7 +106,8 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator // MARK: - Private private func showLoadingAnimation() { - let loadingViewController = LaunchLoadingViewController() + let syncProgress: MXSessionSyncProgress? = MXSDKOptions.sharedInstance().enableSyncProgress ? session?.syncProgress : nil + let loadingViewController = LaunchLoadingViewController(syncProgress: syncProgress) loadingViewController.modalPresentationStyle = .fullScreen // Replace the navigation stack with the loading animation diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingBottomSheetPreferences.swift b/Riot/Modules/Common/SwiftUI/VectorHostingBottomSheetPreferences.swift index cb5afa51ae..d7f94c6260 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingBottomSheetPreferences.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingBottomSheetPreferences.swift @@ -25,11 +25,26 @@ class VectorHostingBottomSheetPreferences { case medium case large + /// only available on iOS16, medium behaviour will be used instead + /// - Parameters: + /// - height: The height of the custom detent, if the height is bigger than the maximum possible height for a detent the latter will be returned + /// - identifier: The identifier used to identify the custom detent during detent transitions, by default the value is set to "custom", however if you are supporting multiple custom detents in a bottom sheet, you should specify a different identifier for each + case custom(height: CGFloat, identifier: String = "custom") + @available(iOS 15, *) fileprivate func uiSheetDetent() -> UISheetPresentationController.Detent { switch self { case .medium: return .medium() case .large: return .large() + case let .custom(height, identifier): + if #available(iOS 16, *) { + let identifier = UISheetPresentationController.Detent.Identifier(identifier) + return .custom(identifier: identifier) { context in + return min(height, context.maximumDetentValue) + } + } else { + return .medium() + } } } @@ -38,6 +53,12 @@ class VectorHostingBottomSheetPreferences { switch self { case .medium: return .medium case .large: return .large + case let .custom(_, identifier): + if #available(iOS 16, *) { + return UISheetPresentationController.Detent.Identifier(identifier) + } else { + return .medium + } } } } diff --git a/Riot/Modules/LaunchLoading/LaunchLoadingView.swift b/Riot/Modules/LaunchLoading/LaunchLoadingView.swift index f2843db621..55f3aff054 100644 --- a/Riot/Modules/LaunchLoading/LaunchLoadingView.swift +++ b/Riot/Modules/LaunchLoading/LaunchLoadingView.swift @@ -30,12 +30,20 @@ final class LaunchLoadingView: UIView, NibLoadable, Themable { // MARK: - Properties @IBOutlet private weak var animationView: ElementView! + @IBOutlet private weak var statusLabel: UILabel! + private var animationTimeline: Timeline_1! + private let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .ordinal + return formatter + }() // MARK: - Setup - static func instantiate() -> LaunchLoadingView { + static func instantiate(syncProgress: MXSessionSyncProgress?) -> LaunchLoadingView { let view = LaunchLoadingView.loadFromNib() + syncProgress?.delegate = view return view } @@ -45,6 +53,8 @@ final class LaunchLoadingView: UIView, NibLoadable, Themable { let animationTimeline = Timeline_1(view: self.animationView, duration: LaunchAnimation.duration, repeatCount: LaunchAnimation.repeatCount) animationTimeline.play() self.animationTimeline = animationTimeline + + self.statusLabel.isHidden = !MXSDKOptions.sharedInstance().enableSyncProgress } // MARK: - Public @@ -54,3 +64,31 @@ final class LaunchLoadingView: UIView, NibLoadable, Themable { self.animationView.backgroundColor = theme.backgroundColor } } + +extension LaunchLoadingView: MXSessionSyncProgressDelegate { + func sessionDidUpdateSyncState(_ state: MXSessionSyncState) { + guard MXSDKOptions.sharedInstance().enableSyncProgress else { + return + } + + // Sync may be doing a lot of heavy work on the main thread and the status text + // does not update reliably enough without explicitly refreshing + CATransaction.begin() + statusLabel.text = statusText(for: state) + CATransaction.commit() + } + + private func statusText(for state: MXSessionSyncState) -> String { + switch state { + case .serverSyncing(let attempts): + if attempts > 1, let nth = numberFormatter.string(from: NSNumber(value: attempts)) { + return VectorL10n.launchLoadingServerSyncingNthAttempt(nth) + } else { + return VectorL10n.launchLoadingServerSyncing + } + case .processingResponse(let progress): + let percent = Int(floor(progress * 100)) + return VectorL10n.launchLoadingProcessingResponse("\(percent)") + } + } +} diff --git a/Riot/Modules/LaunchLoading/LaunchLoadingView.xib b/Riot/Modules/LaunchLoading/LaunchLoadingView.xib index c933d1e00c..81a6b64f9b 100644 --- a/Riot/Modules/LaunchLoading/LaunchLoadingView.xib +++ b/Riot/Modules/LaunchLoading/LaunchLoadingView.xib @@ -1,32 +1,51 @@ - + - + + - + - + - + + - + + + + + + + + + + + + + diff --git a/Riot/Modules/LaunchLoading/LaunchLoadingViewController.swift b/Riot/Modules/LaunchLoading/LaunchLoadingViewController.swift index bd7dba4097..1da229b79a 100644 --- a/Riot/Modules/LaunchLoading/LaunchLoadingViewController.swift +++ b/Riot/Modules/LaunchLoading/LaunchLoadingViewController.swift @@ -21,10 +21,10 @@ class LaunchLoadingViewController: UIViewController, Reusable { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - init() { + init(syncProgress: MXSessionSyncProgress?) { super.init(nibName: "LaunchLoadingViewController", bundle: nil) - let launchLoadingView = LaunchLoadingView.instantiate() + let launchLoadingView = LaunchLoadingView.instantiate(syncProgress: syncProgress) launchLoadingView.update(theme: ThemeService.shared().theme) view.vc_addSubViewMatchingParent(launchLoadingView) diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index 2e71969ea0..d7bf9d8fca 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -47,6 +47,7 @@ typedef enum : NSUInteger @class MXKRoomInputToolbarView; +@class MXKImageView; @protocol MXKRoomInputToolbarViewDelegate /** @@ -381,4 +382,6 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; +- (void)dismissValidationView:(MXKImageView*)validationView; + @end diff --git a/Riot/Modules/Room/MXKRoomViewController.h b/Riot/Modules/Room/MXKRoomViewController.h index dd3bfd2051..7c1eaa5772 100644 --- a/Riot/Modules/Room/MXKRoomViewController.h +++ b/Riot/Modules/Room/MXKRoomViewController.h @@ -214,14 +214,14 @@ typedef NS_ENUM(NSUInteger, MXKRoomViewControllerJoinRoomResult) { @property (weak, nonatomic) IBOutlet UITableView *bubblesTableView; @property (weak, nonatomic) IBOutlet UIView *roomTitleViewContainer; -@property (weak, nonatomic) IBOutlet UIView *roomInputToolbarContainer; +@property (strong, nonatomic) IBOutlet UIView *roomInputToolbarContainer; @property (weak, nonatomic) IBOutlet UIView *roomActivitiesContainer; @property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubblesTableViewTopConstraint; @property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubblesTableViewBottomConstraint; @property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomActivitiesContainerHeightConstraint; @property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerHeightConstraint; -@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerBottomConstraint; +@property (strong, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerBottomConstraint; #pragma mark - Class methods diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index af49e8f6ef..5b162b58c8 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -72,6 +72,7 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; @property (weak, nonatomic, nullable) IBOutlet UIView *inputBackgroundView; @property (weak, nonatomic, nullable) IBOutlet UIButton *scrollToBottomButton; @property (weak, nonatomic, nullable) IBOutlet BadgeLabel *scrollToBottomBadgeLabel; +@property (nonatomic, strong) IBOutlet UIView *overlayContainerView; // Remove Jitsi widget container @property (weak, nonatomic, nullable) IBOutlet UIView *removeJitsiWidgetContainer; @@ -115,6 +116,13 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; // The voice broadcast service @property (nonatomic, nullable) VoiceBroadcastService *voiceBroadcastService; +@property (strong, nonatomic) IBOutletCollection(NSLayoutConstraint) NSArray *toolbarContainerConstraints; + +@property (strong, nonatomic, nullable) UIView* maximisedToolbarDimmingView; + +@property (nonatomic) CGFloat wysiwygTranslation; + + /** Retrieve the live data source in cases where the timeline is not live. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index a0834940a7..25170d023b 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -187,7 +187,6 @@ @interface RoomViewController () inputToolbar = (id)self.inputToolbarView; [inputToolbar setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView]; diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 7ac56f0d23..c3857db81d 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -154,6 +154,87 @@ extension RoomViewController { RiotSettings.shared.enableWysiwygTextFormatting.toggle() wysiwygInputToolbar?.textFormattingEnabled.toggle() } + + @objc func didChangeMaximisedState(_ isMaximised: Bool) { + guard let wysiwygInputToolbar = wysiwygInputToolbar else { return } + if isMaximised { + var view: UIView! + // iPhone + if let navView = self.navigationController?.navigationController?.view { + view = navView + // iPad + } else if let navView = self.navigationController?.view { + view = navView + } else { + return + } + var originalRect = roomInputToolbarContainer.convert(roomInputToolbarContainer.frame, to: view) + var optionalTextView: UITextView? + if wysiwygInputToolbar.isFocused { + let textView = UITextView() + optionalTextView = textView + self.view.window?.addSubview(textView) + optionalTextView?.becomeFirstResponder() + originalRect = wysiwygInputToolbar.convert(wysiwygInputToolbar.frame, to: view) + } + wysiwygInputToolbar.showKeyboard() + roomInputToolbarContainer.removeFromSuperview() + let dimmingView = UIView() + dimmingView.translatesAutoresizingMaskIntoConstraints = false + // Same as the system dimming background color + dimmingView.backgroundColor = .black.withAlphaComponent(ThemeService.shared().isCurrentThemeDark() ? 0.29 : 0.12) + maximisedToolbarDimmingView = dimmingView + view.addSubview(dimmingView) + dimmingView.frame = view.bounds + NSLayoutConstraint.activate( + [ + dimmingView.topAnchor.constraint(equalTo: view.topAnchor), + dimmingView.leftAnchor.constraint(equalTo: view.leftAnchor), + dimmingView.rightAnchor.constraint(equalTo: view.rightAnchor), + dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ] + ) + dimmingView.addSubview(self.roomInputToolbarContainer) + roomInputToolbarContainer.frame = originalRect + roomInputToolbarContainer.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor).isActive = true + roomInputToolbarContainer.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor).isActive = true + roomInputToolbarContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true + UIView.animate(withDuration: kResizeComposerAnimationDuration, delay: 0, options: [.curveEaseInOut]) { + view.layoutIfNeeded() + } + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPanRoomToolbarContainer(_ :))) + roomInputToolbarContainer.addGestureRecognizer(panGesture) + optionalTextView?.removeFromSuperview() + } else { + let originalRect = wysiwygInputToolbar.convert(wysiwygInputToolbar.frame, to: view) + var optionalTextView: UITextView? + if wysiwygInputToolbar.isFocused { + let textView = UITextView() + optionalTextView = textView + self.view.window?.addSubview(textView) + optionalTextView?.becomeFirstResponder() + wysiwygInputToolbar.showKeyboard() + } + self.roomInputToolbarContainer.removeFromSuperview() + maximisedToolbarDimmingView?.removeFromSuperview() + maximisedToolbarDimmingView = nil + self.view.insertSubview(self.roomInputToolbarContainer, belowSubview: self.overlayContainerView) + roomInputToolbarContainer.frame = originalRect + NSLayoutConstraint.activate(self.toolbarContainerConstraints) + self.roomInputToolbarContainerBottomConstraint.isActive = true + UIView.animate(withDuration: kResizeComposerAnimationDuration, delay: 0, options: [.curveEaseInOut]) { + self.view.layoutIfNeeded() + } + roomInputToolbarContainer.gestureRecognizers?.removeAll() + optionalTextView?.removeFromSuperview() + } + } + + @objc func setMaximisedToolbarIsHiddenIfNeeded(_ isHidden: Bool) { + if wysiwygInputToolbar?.isMaximised == true { + roomInputToolbarContainer.superview?.isHidden = isHidden + } + } } // MARK: - Private Helpers @@ -165,4 +246,30 @@ private extension RoomViewController { var wysiwygInputToolbar: WysiwygInputToolbarView? { return self.inputToolbarView as? WysiwygInputToolbarView } + + @objc private func didPanRoomToolbarContainer(_ sender: UIPanGestureRecognizer) { + guard let wysiwygInputToolbar = wysiwygInputToolbar else { return } + switch sender.state { + case .began: + wysiwygTranslation = wysiwygInputToolbar.maxExpandedHeight + case .changed: + let translation = sender.translation(in: view.window) + let translatedValue = wysiwygInputToolbar.maxExpandedHeight - translation.y + wysiwygTranslation = translatedValue + guard translatedValue <= wysiwygInputToolbar.maxExpandedHeight, translatedValue >= wysiwygInputToolbar.compressedHeight else { return } + wysiwygInputToolbar.idealHeight = translatedValue + case .ended: + if wysiwygTranslation <= wysiwygInputToolbar.maxCompressedHeight { + wysiwygInputToolbar.minimise() + } else { + wysiwygTranslation = wysiwygInputToolbar.maxExpandedHeight + wysiwygInputToolbar.idealHeight = wysiwygInputToolbar.maxExpandedHeight + } + case .cancelled: + wysiwygTranslation = wysiwygInputToolbar.maxExpandedHeight + wysiwygInputToolbar.idealHeight = wysiwygInputToolbar.maxExpandedHeight + default: + break + } + } } diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index 0732766042..f33d661bd4 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -1,9 +1,9 @@ - + - + @@ -35,6 +35,10 @@ + + + + diff --git a/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift b/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift index f33762144e..b8ba675b2c 100644 --- a/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift +++ b/Riot/Modules/Room/TimelineCells/SizableCell/SizableBaseRoomCell.swift @@ -65,6 +65,12 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType { return self.height(for: roomBubbleCellData, fitting: maxWidth) } + + override func prepareForReuse() { + cleanContentVC() + + super.prepareForReuse() + } // MARK - SizableBaseRoomCellType @@ -173,10 +179,21 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType { } return height - } - + } + + private func cleanContentVC() { + contentVC?.removeFromParent() + contentVC?.view.removeFromSuperview() + contentVC?.didMove(toParent: nil) + contentVC = nil + } + + // MARK: - Public + func addContentViewController(_ controller: UIViewController, on contentView: UIView) { controller.view.invalidateIntrinsicContentSize() + + cleanContentVC() let parent = vc_parentViewController parent?.addChild(controller) @@ -185,13 +202,4 @@ class SizableBaseRoomCell: BaseRoomCell, SizableBaseRoomCellType { 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/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift index 8987cb1de5..f673bebeee 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift @@ -27,7 +27,7 @@ class VoiceBroadcastPlaybackPlainCell: SizableBaseRoomCell, RoomCellReactionsDis let bubbleData = cellData as? RoomBubbleCellData, let event = bubbleData.events.last, let voiceBroadcastContent = VoiceBroadcastInfo(fromJSON: event.content), - voiceBroadcastContent.state == VoiceBroadcastInfo.State.started.rawValue, + voiceBroadcastContent.state == VoiceBroadcastInfoState.started.rawValue, let controller = VoiceBroadcastPlaybackProvider.shared.buildVoiceBroadcastPlaybackVCForEvent(event, senderDisplayName: bubbleData.senderDisplayName, voiceBroadcastState: bubbleData.voiceBroadcastState) 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 index a65254be5d..43047cfbab 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift @@ -28,7 +28,7 @@ class VoiceBroadcastRecorderPlainCell: SizableBaseRoomCell, RoomCellReactionsDis let bubbleData = cellData as? RoomBubbleCellData, let event = bubbleData.events.last, let voiceBroadcastContent = VoiceBroadcastInfo(fromJSON: event.content), - voiceBroadcastContent.state == VoiceBroadcastInfo.State.started.rawValue, + voiceBroadcastContent.state == VoiceBroadcastInfoState.started.rawValue, let view = VoiceBroadcastRecorderProvider.shared.buildVoiceBroadcastRecorderViewForEvent(event, senderDisplayName: bubbleData.senderDisplayName) else { return } diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index 4bdea353b6..4e351806ce 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -75,6 +75,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) */ - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage; +- (void)didChangeMaximisedState: (BOOL) isMaximised; + @end /** diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index e6fad8e094..cee4a9c119 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -32,14 +32,23 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp // MARK: - Properties // MARK: Private + private var keyboardHeight: CGFloat = .zero { + didSet { + updateTextViewHeight() + } + } private var voiceMessageToolbarView: VoiceMessageToolbarView? private var cancellables = Set() private var heightConstraint: NSLayoutConstraint! + private var voiceMessageBottomConstraint: NSLayoutConstraint? private var hostingViewController: VectorHostingController! private var wysiwygViewModel = WysiwygComposerViewModel(textColor: ThemeService.shared().theme.colors.primaryContent) - private var viewModel: ComposerViewModelProtocol = ComposerViewModel( - initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, - bindings: ComposerBindings(focused: false))) + private var viewModel: ComposerViewModelProtocol! + + private var isLandscapePhone: Bool { + let device = UIDevice.current + return device.isPhone && device.orientation.isLandscape + } // MARK: Public @@ -52,6 +61,35 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } } + override var isFocused: Bool { + viewModel.isFocused + } + + var isMaximised: Bool { + wysiwygViewModel.maximised + } + + var idealHeight: CGFloat { + get { + wysiwygViewModel.idealHeight + } + set { + wysiwygViewModel.idealHeight = newValue + } + } + + var compressedHeight: CGFloat { + wysiwygViewModel.compressedHeight + } + + var maxExpandedHeight: CGFloat { + wysiwygViewModel.maxExpandedHeight + } + + var maxCompressedHeight: CGFloat { + wysiwygViewModel.maxCompressedHeight + } + // MARK: - Setup override class func instantiate() -> MXKRoomInputToolbarView! { @@ -64,6 +102,9 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override func awakeFromNib() { super.awakeFromNib() + viewModel = ComposerViewModel( + initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, + isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false))) viewModel.callback = { [weak self] result in self?.handleViewModelResult(result) @@ -115,11 +156,33 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp .removeDuplicates() .sink { [weak hostingViewController] _ in hostingViewController?.view.setNeedsLayout() + }, + + wysiwygViewModel.$maximised + .dropFirst() + .removeDuplicates() + .sink { [weak self] value in + guard let self = self else { return } + self.toolbarViewDelegate?.didChangeMaximisedState(value) + self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0 } ] update(theme: ThemeService.shared().theme) registerThemeServiceDidChangeThemeNotification() + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) } override func customizeRendering() { @@ -131,8 +194,54 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp self.viewModel.dismissKeyboard() } + override func dismissValidationView(_ validationView: MXKImageView!) { + super.dismissValidationView(validationView) + if isMaximised { + showKeyboard() + } + } + + func showKeyboard() { + self.viewModel.showKeyboard() + } + + func minimise() { + wysiwygViewModel.maximised = false + } + // MARK: - Private + @objc private func keyboardWillShow(_ notification: Notification) { + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardRectangle = keyboardFrame.cgRectValue + keyboardHeight = keyboardRectangle.height + UIView.performWithoutAnimation { + if self.isMaximised { + self.voiceMessageBottomConstraint?.constant = keyboardHeight - (window?.safeAreaInsets.bottom ?? 0) + 4 + } else { + self.voiceMessageBottomConstraint?.constant = 4 + } + self.layoutIfNeeded() + } + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + if self.isMaximised { + UIView.performWithoutAnimation { + self.voiceMessageBottomConstraint?.constant = 4 + self.layoutIfNeeded() + } + } + } + + @objc private func deviceDidRotate(_ notification: Notification) { + viewModel.isLandscapePhone = isLandscapePhone + DispatchQueue.main.async { + self.updateTextViewHeight() + } + } + private func updateToolbarHeight(wysiwygHeight: CGFloat) { self.heightConstraint.constant = wysiwygHeight toolbarViewDelegate?.roomInputToolbarView?(self, heightDidChanged: wysiwygHeight, completion: nil) @@ -140,6 +249,9 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private func sendWysiwygMessage(content: WysiwygComposerContent) { delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown) + if isMaximised { + minimise() + } } private func showSendMediaActions() { @@ -179,6 +291,19 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp wysiwygViewModel.textColor = theme.colors.primaryContent } + private func updateTextViewHeight() { + let height = UIScreen.main.bounds.height + let barOffset: CGFloat = 68 + let toolbarHeight: CGFloat = 96 + let finalHeight = height - keyboardHeight - toolbarHeight - barOffset + wysiwygViewModel.maxExpandedHeight = finalHeight + if finalHeight < 200 { + wysiwygViewModel.maxCompressedHeight = finalHeight > wysiwygViewModel.minHeight ? finalHeight : wysiwygViewModel.minHeight + } else { + wysiwygViewModel.maxCompressedHeight = 200 + } + } + // MARK: - HtmlRoomInputToolbarViewProtocol var isEncryptionEnabled = false { didSet { @@ -225,9 +350,6 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp set { self.viewModel.textFormattingEnabled = newValue self.wysiwygViewModel.plainTextMode = !newValue - if !newValue { - self.wysiwygViewModel.maximised = false - } } } @@ -239,17 +361,21 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.deactivate(voiceMessageToolbarView.containersTopConstraints) addSubview(voiceMessageToolbarView) + let bottomConstraint = hostingViewController.view.bottomAnchor.constraint(equalTo: voiceMessageToolbarView.bottomAnchor, constant: 4) + voiceMessageBottomConstraint = bottomConstraint NSLayoutConstraint.activate( [ - hostingViewController.view.topAnchor.constraint(equalTo: voiceMessageToolbarView.topAnchor), - hostingViewController.view.leftAnchor.constraint(equalTo: voiceMessageToolbarView.leftAnchor), - hostingViewController.view.bottomAnchor.constraint(equalTo: voiceMessageToolbarView.bottomAnchor, constant: 4), - hostingViewController.view.rightAnchor.constraint(equalTo: voiceMessageToolbarView.rightAnchor) + hostingViewController.view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: voiceMessageToolbarView.topAnchor), + hostingViewController.view.safeAreaLayoutGuide.leftAnchor.constraint(equalTo: voiceMessageToolbarView.leftAnchor), + bottomConstraint, + hostingViewController.view.safeAreaLayoutGuide.rightAnchor.constraint(equalTo: voiceMessageToolbarView.rightAnchor) ] ) } else { self.voiceMessageToolbarView?.removeFromSuperview() self.voiceMessageToolbarView = nil + self.voiceMessageBottomConstraint?.isActive = false + self.voiceMessageBottomConstraint = nil } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift index 231773f2b5..4a242a91ce 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift @@ -61,7 +61,8 @@ class VoiceMessageAudioPlayer: NSObject { } var currentTime: TimeInterval { - return abs(CMTimeGetSeconds(audioPlayer?.currentTime() ?? .zero)) + let currentTime = abs(CMTimeGetSeconds(audioPlayer?.currentTime() ?? .zero)) + return currentTime.isFinite ? currentTime : .zero } var playerItems: [AVPlayerItem] { diff --git a/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupTableViewSection.swift b/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupTableViewSection.swift index ffeed21214..58982bb2a9 100644 --- a/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupTableViewSection.swift +++ b/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupTableViewSection.swift @@ -140,8 +140,20 @@ private enum BackupRows { .info(text: infoText), .createSecureBackupAction ] - case .keyBackup(let keyBackupVersion, _, _), - .keyBackupNotTrusted(let keyBackupVersion, _): // Manage the key backup in the same way for the moment + case .keyBackup(let keyBackupVersion, _, let progress): + if let progress = progress { + backupRows = [ + .info(text: importProgressText(for: progress)), + .deleteKeyBackupAction(keyBackupVersion: keyBackupVersion) + ] + } else { + backupRows = [ + .info(text: VectorL10n.securitySettingsSecureBackupInfoValid), + .restoreFromKeyBackupAction(keyBackupVersion: keyBackupVersion, title: VectorL10n.securitySettingsSecureBackupRestore), + .deleteKeyBackupAction(keyBackupVersion: keyBackupVersion) + ] + } + case .keyBackupNotTrusted(let keyBackupVersion, _): backupRows = [ .info(text: VectorL10n.securitySettingsSecureBackupInfoValid), .restoreFromKeyBackupAction(keyBackupVersion: keyBackupVersion, title: VectorL10n.securitySettingsSecureBackupRestore), @@ -160,8 +172,22 @@ private enum BackupRows { .createKeyBackupAction, .resetSecureBackupAction ] - case .keyBackup(let keyBackupVersion, _, _), - .keyBackupNotTrusted(let keyBackupVersion, _): // Manage the key backup in the same way for the moment + case .keyBackup(let keyBackupVersion, _, let progress): + if let progress = progress { + backupRows = [ + .info(text: importProgressText(for: progress)), + .deleteKeyBackupAction(keyBackupVersion: keyBackupVersion), + .resetSecureBackupAction + ] + } else { + backupRows = [ + .info(text: VectorL10n.securitySettingsSecureBackupInfoValid), + .restoreFromKeyBackupAction(keyBackupVersion: keyBackupVersion, title: VectorL10n.securitySettingsSecureBackupRestore), + .deleteKeyBackupAction(keyBackupVersion: keyBackupVersion), + .resetSecureBackupAction + ] + } + case .keyBackupNotTrusted(let keyBackupVersion, _): backupRows = [ .info(text: VectorL10n.securitySettingsSecureBackupInfoValid), .restoreFromKeyBackupAction(keyBackupVersion: keyBackupVersion, title: VectorL10n.securitySettingsSecureBackupRestore), @@ -172,6 +198,11 @@ private enum BackupRows { } self.backupRows = backupRows } + + private func importProgressText(for progress: Progress) -> String { + let percentage = Int(round(progress.fractionCompleted * 100)) + return VectorL10n.keyBackupRecoverFromPrivateKeyInfo + " \(percentage)%" + } // MARK: - Cells - diff --git a/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewModel.swift b/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewModel.swift index 66b7208615..5039bef6f6 100644 --- a/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewModel.swift +++ b/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewModel.swift @@ -24,6 +24,7 @@ final class SettingsSecureBackupViewModel: SettingsSecureBackupViewModelType { // MARK: Private private let recoveryService: MXRecoveryService private let keyBackup: MXKeyBackup + private var progressUpdateTimer: Timer? init(recoveryService: MXRecoveryService, keyBackup: MXKeyBackup) { self.recoveryService = recoveryService @@ -106,17 +107,13 @@ final class SettingsSecureBackupViewModel: SettingsSecureBackupViewModelType { guard let keyBackupVersion = self.keyBackup.keyBackupVersion, let keyBackupVersionTrust = keyBackupVersionTrust else { return } - - // Get the backup progress before updating the state - self.keyBackup.backupProgress { [weak self] (progress) in - guard let self = self else { - return - } - - let keyBackupState: SettingsSecureBackupViewState.KeyBackupState = .keyBackup(keyBackupVersion, keyBackupVersionTrust, progress) - let viewState: SettingsSecureBackupViewState = self.recoveryService.hasRecovery() ? .secureBackup(keyBackupState) : .noSecureBackup(keyBackupState) - self.viewDelegate?.settingsSecureBackupViewModel(self, didUpdateViewState: viewState) - } + + let importProgress = keyBackup.importProgress + let keyBackupState: SettingsSecureBackupViewState.KeyBackupState = .keyBackup(keyBackupVersion, keyBackupVersionTrust, importProgress) + let viewState: SettingsSecureBackupViewState = self.recoveryService.hasRecovery() ? .secureBackup(keyBackupState) : .noSecureBackup(keyBackupState) + self.viewDelegate?.settingsSecureBackupViewModel(self, didUpdateViewState: viewState) + scheduleProgressUpdateIfNecessary(keyBackupVersionTrust: keyBackupVersionTrust, progress: importProgress) + default: break } @@ -130,6 +127,17 @@ final class SettingsSecureBackupViewModel: SettingsSecureBackupViewModelType { self.viewDelegate?.settingsSecureBackupViewModel(self, didUpdateViewState: viewState) } } + + private func scheduleProgressUpdateIfNecessary(keyBackupVersionTrust: MXKeyBackupVersionTrust, progress: Progress?) { + if progress != nil { + progressUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in + self?.computeState(withBackupVersionTrust: keyBackupVersionTrust) + } + } else { + progressUpdateTimer?.invalidate() + progressUpdateTimer = nil + } + } private func deleteKeyBackupVersion(_ keyBackupVersion: MXKeyBackupVersion) { guard let keyBackupVersionVersion = keyBackupVersion.version else { diff --git a/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewState.swift b/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewState.swift index 1390f56ba7..96e698a423 100644 --- a/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewState.swift +++ b/Riot/Modules/Settings/Security/SecureBackup/SettingsSecureBackupViewState.swift @@ -35,7 +35,7 @@ enum SettingsSecureBackupViewState { /// - keyBackupNotTrusted: There is a backup on the homeserver but it is not trusted enum KeyBackupState { case noKeyBackup - case keyBackup(MXKeyBackupVersion, MXKeyBackupVersionTrust, Progress) + case keyBackup(MXKeyBackupVersion, MXKeyBackupVersionTrust, Progress?) case keyBackupNotTrusted(MXKeyBackupVersion, MXKeyBackupVersionTrust) } } diff --git a/Riot/Modules/Settings/Security/SecurityViewController.m b/Riot/Modules/Settings/Security/SecurityViewController.m index d3c7c6c357..cb72f38df1 100644 --- a/Riot/Modules/Settings/Security/SecurityViewController.m +++ b/Riot/Modules/Settings/Security/SecurityViewController.m @@ -1218,9 +1218,9 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N cell = [secureBackupSection cellForRowAtRow:rowTag]; } #ifdef CROSS_SIGNING_AND_BACKUP_DEV - else if (section == SECTION_KEYBACKUP) + else if (sectionTag == SECTION_KEYBACKUP) { - cell = [keyBackupSection cellForRowAtRow:row]; + cell = [keyBackupSection cellForRowAtRow:rowTag]; } #endif else if (sectionTag == SECTION_CROSSSIGNING) diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift index fb90d834de..31dd0045bf 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift @@ -21,12 +21,19 @@ public enum VoiceBroadcastAggregatorError: Error { case invalidVoiceBroadcastStartEvent } +public enum VoiceBroadcastAggregatorLaunchState { + case idle + case starting + case loaded + case error +} + 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 voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfoState) func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) } @@ -56,8 +63,8 @@ public class VoiceBroadcastAggregator { } } - public private(set) var isStarted: Bool = false - public private(set) var voiceBroadcastState: VoiceBroadcastInfo.State + private(set) var launchState: VoiceBroadcastAggregatorLaunchState = .idle + public private(set) var voiceBroadcastState: VoiceBroadcastInfoState public var delegate: VoiceBroadcastAggregatorDelegate? deinit { @@ -66,7 +73,7 @@ public class VoiceBroadcastAggregator { } } - public init(session: MXSession, room: MXRoom, voiceBroadcastStartEventId: String, voiceBroadcastState: VoiceBroadcastInfo.State) throws { + public init(session: MXSession, room: MXRoom, voiceBroadcastStartEventId: String, voiceBroadcastState: VoiceBroadcastInfoState) throws { self.session = session self.room = room self.voiceBroadcastStartEventId = voiceBroadcastStartEventId @@ -111,7 +118,7 @@ public class VoiceBroadcastAggregator { event.stateKey == self.voiceBroadcastSenderId, let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: event.content), (event.eventId == self.voiceBroadcastStartEventId || voiceBroadcastInfo.voiceBroadcastId == self.voiceBroadcastStartEventId), - let state = VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) else { + let state = VoiceBroadcastInfoState(rawValue: voiceBroadcastInfo.state) else { return } @@ -120,10 +127,10 @@ public class VoiceBroadcastAggregator { } func start() { - if isStarted { + guard launchState == .idle else { return } - isStarted = true + launchState = .starting delegate?.voiceBroadcastAggregatorDidStartLoading(self) @@ -156,16 +163,14 @@ public class VoiceBroadcastAggregator { 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 - }) { + if !self.events.contains(where: { $0.eventId == event.eventId }) { self.events.append(event) MXLog.debug("[VoiceBroadcastAggregator] Got a new chunk for broadcast \(relatedEventId). Total: \(self.events.count)") + if let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) { + self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk) + } + self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId, voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent, @@ -177,7 +182,6 @@ public class VoiceBroadcastAggregator { } } as Any - self.events.forEach { event in guard let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) else { return @@ -195,6 +199,7 @@ public class VoiceBroadcastAggregator { MXLog.debug("[VoiceBroadcastAggregator] Start aggregation with \(self.voiceBroadcast.chunks.count) chunks for broadcast \(self.voiceBroadcastStartEventId)") + self.launchState = .loaded self.delegate?.voiceBroadcastAggregatorDidEndLoading(self) } failure: { [weak self] error in @@ -203,7 +208,7 @@ public class VoiceBroadcastAggregator { } MXLog.error("[VoiceBroadcastAggregator] start failed", context: error) - self.isStarted = false + self.launchState = .error self.delegate?.voiceBroadcastAggregator(self, didFailWithError: error) } } diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift index b2bc1afe42..5e6218f298 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift @@ -19,36 +19,29 @@ import Foundation extension VoiceBroadcastInfo { // MARK: - Constants - public enum State: String { - case started - case paused - case resumed - case stopped - } - // MARK: - Public @objc static func isStarted(for name: String) -> Bool { - return name == State.started.rawValue + return name == VoiceBroadcastInfoState.started.rawValue } @objc static func isStopped(for name: String) -> Bool { - return name == State.stopped.rawValue + return name == VoiceBroadcastInfoState.stopped.rawValue } @objc static func startedValue() -> String { - return State.started.rawValue + return VoiceBroadcastInfoState.started.rawValue } @objc static func pausedValue() -> String { - return State.paused.rawValue + return VoiceBroadcastInfoState.paused.rawValue } @objc static func resumedValue() -> String { - return State.resumed.rawValue + return VoiceBroadcastInfoState.resumed.rawValue } @objc static func stoppedValue() -> String { - return State.stopped.rawValue + return VoiceBroadcastInfoState.stopped.rawValue } } diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift new file mode 100644 index 0000000000..e808ddeb38 --- /dev/null +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +public enum VoiceBroadcastInfoState: String { + case started + case paused + case resumed + case stopped +} diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift index e6d6171a8a..6a3072ec3e 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift @@ -25,13 +25,13 @@ public class VoiceBroadcastService: NSObject { public let room: MXRoom public private(set) var voiceBroadcastId: String? - public private(set) var state: VoiceBroadcastInfo.State + public private(set) var state: VoiceBroadcastInfoState // Mechanism to process one call of sendVoiceBroadcastInfo() at a time private let asyncTaskQueue: MXAsyncTaskQueue // MARK: - Setup - public init(room: MXRoom, state: VoiceBroadcastInfo.State) { + public init(room: MXRoom, state: VoiceBroadcastInfoState) { self.room = room self.state = state self.asyncTaskQueue = MXAsyncTaskQueue(label: "VoiceBroadcastServiceQueueEventSerialQueue-" + MXTools.generateSecret()) @@ -47,7 +47,7 @@ public class VoiceBroadcastService: NSObject { /// - Parameters: /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. func startVoiceBroadcast(completion: @escaping (MXResponse) -> Void) { - sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.started) { [weak self] response in + sendVoiceBroadcastInfo(state: VoiceBroadcastInfoState.started) { [weak self] response in guard let self = self else { return } switch response { @@ -64,21 +64,21 @@ public class VoiceBroadcastService: NSObject { /// - Parameters: /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. func pauseVoiceBroadcast(completion: @escaping (MXResponse) -> Void) { - sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.paused, completion: completion) + sendVoiceBroadcastInfo(state: VoiceBroadcastInfoState.paused, completion: completion) } /// resume a voice broadcast. /// - Parameters: /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. func resumeVoiceBroadcast(completion: @escaping (MXResponse) -> Void) { - sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.resumed, completion: completion) + sendVoiceBroadcastInfo(state: VoiceBroadcastInfoState.resumed, completion: completion) } /// stop a voice broadcast info. /// - Parameters: /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. func stopVoiceBroadcast(completion: @escaping (MXResponse) -> Void) { - sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State.stopped, completion: completion) + sendVoiceBroadcastInfo(state: VoiceBroadcastInfoState.stopped, completion: completion) } func getState() -> String { @@ -121,7 +121,7 @@ public class VoiceBroadcastService: NSObject { // MARK: - Private - private func allowedStates(from state: VoiceBroadcastInfo.State) -> [VoiceBroadcastInfo.State] { + private func allowedStates(from state: VoiceBroadcastInfoState) -> [VoiceBroadcastInfoState] { switch state { case .started: return [.paused, .stopped] @@ -134,7 +134,7 @@ public class VoiceBroadcastService: NSObject { } } - private func sendVoiceBroadcastInfo(state: VoiceBroadcastInfo.State, completion: @escaping (MXResponse) -> Void) { + private func sendVoiceBroadcastInfo(state: VoiceBroadcastInfoState, completion: @escaping (MXResponse) -> Void) { guard let userId = self.room.mxSession.myUserId else { completion(.failure(VoiceBroadcastServiceError.missingUserId)) return @@ -156,7 +156,7 @@ public class VoiceBroadcastService: NSObject { voiceBroadcastInfo.state = state.rawValue - if state != VoiceBroadcastInfo.State.started { + if state != VoiceBroadcastInfoState.started { guard let voiceBroadcastId = self.voiceBroadcastId else { completion(.failure(VoiceBroadcastServiceError.notStarted)) taskCompleted() diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift index e39c838b73..83051e1d5f 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastServiceProvider.swift @@ -70,9 +70,9 @@ class VoiceBroadcastServiceProvider { } } - private func createVoiceBroadcastService(for room: MXRoom, state: VoiceBroadcastInfo.State) { + private func createVoiceBroadcastService(for room: MXRoom, state: VoiceBroadcastInfoState) { - let voiceBroadcastService = VoiceBroadcastService(room: room, state: VoiceBroadcastInfo.State.stopped) + let voiceBroadcastService = VoiceBroadcastService(room: room, state: VoiceBroadcastInfoState.stopped) self.currentVoiceBroadcastService = voiceBroadcastService @@ -95,22 +95,22 @@ class VoiceBroadcastServiceProvider { private func setupVoiceBroadcastService(for room: MXRoom, completion: @escaping (VoiceBroadcastService?) -> Void) { self.getLastVoiceBroadcastInfo(for: room) { event in guard let voiceBroadcastInfoEvent = event else { - self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State.stopped) + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfoState.stopped) completion(self.currentVoiceBroadcastService) return } guard let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: voiceBroadcastInfoEvent.content) else { - self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State.stopped) + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfoState.stopped) completion(self.currentVoiceBroadcastService) return } - if voiceBroadcastInfo.state == VoiceBroadcastInfo.State.stopped.rawValue { - self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State.stopped) + if voiceBroadcastInfo.state == VoiceBroadcastInfoState.stopped.rawValue { + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfoState.stopped) completion(self.currentVoiceBroadcastService) } else if voiceBroadcastInfoEvent.stateKey == room.mxSession.myUserId { - self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) ?? VoiceBroadcastInfo.State.stopped) + self.createVoiceBroadcastService(for: room, state: VoiceBroadcastInfoState(rawValue: voiceBroadcastInfo.state) ?? VoiceBroadcastInfoState.stopped) completion(self.currentVoiceBroadcastService) } else { completion(nil) diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift b/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift index 6fb7fc1103..54a9719b21 100644 --- a/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift +++ b/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift @@ -64,28 +64,22 @@ private extension InfoSheetCoordinator { // The bottom sheet should be presented with the content intrinsic height as for design requirement // We can do it easily just on iOS 16+ func setupPresentation(of viewController: VectorHostingController) { - let cornerRadius: CGFloat = 24 + let detents: [VectorHostingBottomSheetPreferences.Detent] - guard + if #available(iOS 16, *), - let parentSize = parameters.parentSize, - let presentationController = viewController.sheetPresentationController - else { - viewController.bottomSheetPreferences = .init(cornerRadius: cornerRadius) - return + let parentSize = parameters.parentSize { + + let intrisincSize = viewController.view.systemLayoutSizeFitting(.init(width: parentSize.width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .defaultHigh, + verticalFittingPriority: .defaultLow) + + detents = [.custom(height: intrisincSize.height), .large] + } else { + detents = [.medium, .large] } - let intrisincSize = viewController.view.systemLayoutSizeFitting(.init(width: parentSize.width, height: 0), - withHorizontalFittingPriority: .defaultHigh, - verticalFittingPriority: .defaultLow) - - presentationController.preferredCornerRadius = cornerRadius - presentationController.prefersGrabberVisible = true - presentationController.detents = [ - .custom { context in - min(context.maximumDetentValue, intrisincSize.height) - }, - .large() - ] + viewController.bottomSheetPreferences = .init(detents: detents, cornerRadius: 24) + viewController.bottomSheetPreferences?.setup(viewController: viewController) } } diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift index cb52281eb8..f5adcc75ac 100644 --- a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/Coordinator/ComposerCreateActionListCoordinator.swift @@ -48,9 +48,10 @@ final class ComposerCreateActionListCoordinator: NSObject, Coordinator, Presenta view = ComposerCreateActionList(viewModel: viewModel.context) let hostingVC = VectorHostingController(rootView: view) hostingVC.bottomSheetPreferences = VectorHostingBottomSheetPreferences( - detents: [.medium], + detents: [.custom(height: 470)], prefersGrabberVisible: true, - cornerRadius: 20 + cornerRadius: 20, + prefersScrollingExpandsWhenScrolledToEdge: false ) hostingController = hostingVC super.init() diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift index 7f2733e2b2..5da2b1d119 100644 --- a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/View/ComposerCreateActionList.swift @@ -34,7 +34,7 @@ struct ComposerCreateActionList: View { @ObservedObject var viewModel: ComposerCreateActionListViewModel.Context var body: some View { - VStack { + ScrollView { VStack(alignment: .leading) { ForEach(viewModel.viewState.actions) { action in HStack(spacing: 16) { @@ -78,9 +78,10 @@ struct ComposerCreateActionList: View { } } - .padding(.top, 8) Spacer() - }.background(theme.colors.background.ignoresSafeArea()) + } + .padding(.top, 23) + .background(theme.colors.background.ignoresSafeArea()) } } diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index c0602ab035..35a628d020 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -32,9 +32,9 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { let bindings = ComposerBindings(focused: false) switch self { - case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, bindings: bindings)) - case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, bindings: bindings)) - case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, bindings: bindings)) + case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) + case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) + case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) } let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360) diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index badcd2b20c..84b5f8f955 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -34,8 +34,8 @@ struct FormatItem { enum FormatType { case bold case italic - case strikethrough case underline + case strikethrough } extension FormatType: CaseIterable, Identifiable { diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift index c4293eafcd..66724cdceb 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerViewState.swift @@ -20,6 +20,7 @@ struct ComposerViewState: BindableState { var eventSenderDisplayName: String? var sendMode: ComposerSendMode = .send var textFormattingEnabled: Bool + var isLandscapePhone: Bool var placeholder: String? var bindings: ComposerBindings @@ -47,6 +48,10 @@ extension ComposerViewState { default: return nil } } + + var isMinimiseForced: Bool { + isLandscapePhone || !textFormattingEnabled + } } struct ComposerBindings { diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift index 073c6f357e..a49314062f 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift @@ -23,8 +23,13 @@ final class ComposerViewModelTests: XCTestCase { var context: ComposerViewModel.Context! override func setUpWithError() throws { - viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, - bindings: ComposerBindings(focused: false))) + viewModel = ComposerViewModel( + initialViewState: ComposerViewState( + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: ComposerBindings(focused: false) + ) + ) context = viewModel.context } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index f255c25a54..87c7cbe7a7 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -14,7 +14,6 @@ // limitations under the License. // -import DSBottomSheet import SwiftUI import WysiwygComposer @@ -22,7 +21,6 @@ struct Composer: View { // MARK: - Properties // MARK: Private - @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel private let resizeAnimationDuration: Double @@ -36,9 +34,8 @@ struct Composer: View { private let horizontalPadding: CGFloat = 12 private let borderHeight: CGFloat = 40 - private var minTextViewHeight: CGFloat = 22 private var verticalPadding: CGFloat { - (borderHeight - minTextViewHeight) / 2 + (borderHeight - wysiwygViewModel.minHeight) / 2 } private var topPadding: CGFloat { @@ -46,7 +43,7 @@ struct Composer: View { } private var cornerRadius: CGFloat { - if viewModel.viewState.shouldDisplayContext || wysiwygViewModel.idealHeight > minTextViewHeight { + if viewModel.viewState.shouldDisplayContext || wysiwygViewModel.idealHeight > wysiwygViewModel.minHeight { return 14 } else { return borderHeight / 2 @@ -78,7 +75,7 @@ struct Composer: View { ) } } - + private var composerContainer: some View { let rect = RoundedRectangle(cornerRadius: cornerRadius) return VStack(spacing: 12) { @@ -119,7 +116,7 @@ struct Composer: View { wysiwygViewModel.setup() } } - if viewModel.viewState.textFormattingEnabled { + if !viewModel.viewState.isMinimiseForced { Button { wysiwygViewModel.maximised.toggle() } label: { @@ -147,7 +144,7 @@ struct Composer: View { } } } - + private var sendMediaButton: some View { return Button { showSendMediaActions() @@ -162,7 +159,7 @@ struct Composer: View { .padding(.trailing, 8) .accessibilityLabel(VectorL10n.create) } - + private var sendButton: some View { return Button { sendMessageAction(wysiwygViewModel.content) @@ -204,6 +201,12 @@ struct Composer: View { var body: some View { VStack(spacing: 8) { + if wysiwygViewModel.maximised { + RoundedRectangle(cornerRadius: 4) + .fill(theme.colors.quinaryContent) + .frame(width: 36, height: 5) + .padding(.top, 10) + } HStack(alignment: .bottom, spacing: 0) { if !viewModel.viewState.textFormattingEnabled { sendMediaButton @@ -227,6 +230,11 @@ struct Composer: View { } .padding(.horizontal, horizontalPadding) .padding(.bottom, 4) + .onChange(of: viewModel.viewState.isMinimiseForced) { newValue in + if wysiwygViewModel.maximised && newValue { + wysiwygViewModel.maximised = false + } + } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift index 8ad3ebd272..4e64423034 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift @@ -63,6 +63,19 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol } } + var isLandscapePhone: Bool { + get { + state.isLandscapePhone + } + set { + state.isLandscapePhone = newValue + } + } + + var isFocused: Bool { + state.bindings.focused + } + // MARK: - Public override func process(viewAction: ComposerViewAction) { @@ -77,4 +90,8 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol func dismissKeyboard() { state.bindings.focused = false } + + func showKeyboard() { + state.bindings.focused = true + } } diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift index a1674ff4d5..0d23be3cc7 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift @@ -23,6 +23,9 @@ protocol ComposerViewModelProtocol { var textFormattingEnabled: Bool { get set } var eventSenderDisplayName: String? { get set } var placeholder: String? { get set } + var isFocused: Bool { get } + var isLandscapePhone: Bool { get set } func dismissKeyboard() + func showKeyboard() } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index 1acd907a41..74cfaf65b7 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -60,7 +60,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } selectedAnswerIdentifiersSubject - .debounce(for: 1.0, scheduler: RunLoop.main) + .debounce(for: 2.0, scheduler: RunLoop.main) .removeDuplicates() .sink { [weak self] identifiers in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift index d353e2f557..652d6f7b2f 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift @@ -22,7 +22,7 @@ struct VoiceBroadcastPlaybackCoordinatorParameters { let session: MXSession let room: MXRoom let voiceBroadcastStartEvent: MXEvent - let voiceBroadcastState: VoiceBroadcastInfo.State + let voiceBroadcastState: VoiceBroadcastInfoState let senderDisplayName: String? } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift index 29b6252dfc..eaae1b11f1 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift @@ -47,7 +47,7 @@ import Foundation let parameters = VoiceBroadcastPlaybackCoordinatorParameters(session: session, room: room, voiceBroadcastStartEvent: event, - voiceBroadcastState: VoiceBroadcastInfo.State(rawValue: voiceBroadcastState) ?? VoiceBroadcastInfo.State.stopped, + voiceBroadcastState: VoiceBroadcastInfoState(rawValue: voiceBroadcastState) ?? VoiceBroadcastInfoState.stopped, senderDisplayName: senderDisplayName) guard let coordinator = try? VoiceBroadcastPlaybackCoordinator(parameters: parameters) else { return nil diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift index ff237a320c..0d17cc35fb 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift @@ -36,10 +36,25 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic private var audioPlayer: VoiceMessageAudioPlayer? private var displayLink: CADisplayLink! - private var isLivePlayback = false - private var acceptProgressUpdates = true - + private var isPlaybackInitialized: Bool = false + private var acceptProgressUpdates: Bool = true private var isActuallyPaused: Bool = false + private var isProcessingVoiceBroadcastChunk: Bool = false + private var reloadVoiceBroadcastChunkQueue: Bool = false + private var seekToChunkTime: TimeInterval? + + private var isPlayingLastChunk: Bool { + let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks)) + guard let chunkDuration = chunks.last?.duration else { + return false + } + + return state.bindings.progress + 1000 >= state.playingState.duration - Float(chunkDuration) + } + + private var isLivePlayback: Bool { + return (!isPlaybackInitialized || isPlayingLastChunk) && (state.broadcastState == .started || state.broadcastState == .resumed) + } // MARK: Public @@ -54,9 +69,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic self.voiceBroadcastAggregator = voiceBroadcastAggregator let viewState = VoiceBroadcastPlaybackViewState(details: details, - broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState), + broadcastState: voiceBroadcastAggregator.voiceBroadcastState, playbackState: .stopped, - playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration)), + playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration), isLive: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)) super.init(initialViewState: viewState) @@ -65,6 +80,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic displayLink.add(to: .current, forMode: .common) self.voiceBroadcastAggregator.delegate = self + self.voiceBroadcastAggregator.start() } private func release() { @@ -81,8 +97,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic switch viewAction { case .play: play() - case .playLive: - playLive() case .pause: pause() case .sliderChange(let didChange): @@ -95,59 +109,22 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic /// Listen voice broadcast private func play() { - isLivePlayback = false displayLink.isPaused = false isActuallyPaused = 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() - - updateDuration() - } else if let audioPlayer = audioPlayer { + 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 chunks 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 - displayLink.isPaused = false - isActuallyPaused = false - - // 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() - - state.playingState.duration = Float(voiceBroadcastAggregator.voiceBroadcast.duration) - } else { - let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks - MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: restart from the last chunk: \(chunks.count) chunks") - - // Reinject all the chunks we already have and play the last one - voiceBroadcastChunkQueue.append(contentsOf: chunks) - processPendingVoiceBroadcastChunksForLivePlayback() + if voiceBroadcastAggregator.launchState == .loaded { + let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks + MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: restart from the beginning: \(chunks.count) chunks") + + // Reinject all the chunks we already have and play them + voiceBroadcastChunkQueue = Array(chunks) + handleVoiceBroadcastChunksProcessing() + } } } @@ -155,7 +132,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic private func pause() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause") - isLivePlayback = false displayLink.isPaused = true isActuallyPaused = true @@ -169,9 +145,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic // Check if the broadcast is over before stopping everything // If not, the player should not stopped. The view state must be move to buffering - // TODO: Define with more accuracy the threshold to detect the end of the playback - let remainingTime = state.playingState.duration - state.bindings.progress - if remainingTime < 500 { + if state.broadcastState == .stopped, isPlayingLastChunk { stop() } else { state.playbackState = .buffering @@ -181,7 +155,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic private func stop() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop") - isLivePlayback = false displayLink.isPaused = true // Objects will be released on audioPlayerDidStopPlaying @@ -192,9 +165,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic // MARK: - Voice broadcast chunks playback /// Start the playback from the beginning or push more chunks to it - private func processPendingVoiceBroadcastChunks(_ time: TimeInterval? = nil) { + private func processPendingVoiceBroadcastChunks() { reorderPendingVoiceBroadcastChunks() - processNextVoiceBroadcastChunk(time) + processNextVoiceBroadcastChunk() } /// Start the playback from the last known chunk @@ -215,7 +188,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic chunks.sorted(by: {$0.sequence < $1.sequence}) } - private func processNextVoiceBroadcastChunk(_ time: TimeInterval? = nil) { + private func processNextVoiceBroadcastChunk() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: \(voiceBroadcastChunkQueue.count) chunks remaining") guard voiceBroadcastChunkQueue.count > 0 else { @@ -223,10 +196,17 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic return } - if (isActuallyPaused == false && state.playbackState == .paused) || state.playbackState == .stopped { + if (isActuallyPaused == false && state.playbackState == .paused) { state.playbackState = .buffering } + guard !isProcessingVoiceBroadcastChunk else { + // Chunks caching is already in progress + return + } + + isProcessingVoiceBroadcastChunk = true + // 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) @@ -238,9 +218,12 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic 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. + self.isProcessingVoiceBroadcastChunk = false + if self.reloadVoiceBroadcastChunkQueue { + self.reloadVoiceBroadcastChunkQueue = false + self.processNextVoiceBroadcastChunk() + return + } switch result { case .success(let result): @@ -254,13 +237,20 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic // Append the chunk to the current playlist audioPlayer.addContentFromURL(result.url) + if let time = self.seekToChunkTime { + audioPlayer.seekToTime(time) + self.seekToChunkTime = nil + } + // Resume the player. Needed after a buffering - if audioPlayer.isPlaying == false && self.state.playbackState == .buffering { - MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player") - self.displayLink.isPaused = false - audioPlayer.play() - if let time = time { - audioPlayer.seekToTime(time) + if self.state.playbackState == .buffering { + if audioPlayer.isPlaying == false { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player") + self.displayLink.isPaused = false + audioPlayer.play() + } else { + self.state.playbackState = .playing + self.state.playingState.isLive = self.isLivePlayback } } } else { @@ -271,8 +261,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName) self.displayLink.isPaused = false audioPlayer.play() - if let time = time { + if let time = self.seekToChunkTime { audioPlayer.seekToTime(time) + self.seekToChunkTime = nil } self.audioPlayer = audioPlayer } @@ -305,7 +296,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic audioPlayer?.pause() displayLink.isPaused = true } else { - // Flush the current audio player playlist + // Flush the chunks queue and the current audio player playlist + voiceBroadcastChunkQueue = [] + reloadVoiceBroadcastChunkQueue = isProcessingVoiceBroadcastChunk audioPlayer?.removeAllPlayerItems() let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks)) @@ -323,7 +316,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic MXLog.debug("[VoiceBroadcastPlaybackViewModel] didSliderChanged: restart to time: \(state.bindings.progress) milliseconds") let time = state.bindings.progress - state.playingState.duration + Float(chunksDuration) - processPendingVoiceBroadcastChunks(TimeInterval(time / 1000)) + seekToChunkTime = TimeInterval(time / 1000) + processPendingVoiceBroadcastChunks() } } @@ -348,20 +342,14 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic state.bindings.progress = Float(progress) } - 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 + private func handleVoiceBroadcastChunksProcessing() { + // Handle specifically the case where we were waiting data to start playing a live playback + if isLivePlayback, state.playbackState == .buffering { + // Start the playback on the latest one + processPendingVoiceBroadcastChunksForLivePlayback() + } else { + processPendingVoiceBroadcastChunks() } - - return broadcastState } } @@ -381,17 +369,19 @@ extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate { voiceBroadcastChunkQueue.append(didReceiveChunk) } - func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State) { - state.broadcastState = VoiceBroadcastPlaybackViewModel.getBroadcastState(from: didReceiveState) + func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfoState) { + state.broadcastState = didReceiveState + + // Handle the live icon appearance + state.playingState.isLive = isLivePlayback } func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) { - if isLivePlayback && state.playbackState == .buffering { - // We started directly with a live playback but there was no known chunks at that time - // These are the first chunks we get. Start the playback on the latest one - processPendingVoiceBroadcastChunksForLivePlayback() - } else { - processPendingVoiceBroadcastChunks() + + updateDuration() + + if state.playbackState != .stopped { + handleVoiceBroadcastChunksProcessing() } } } @@ -403,20 +393,20 @@ extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate { } func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { - if isLivePlayback { - state.playbackState = .playingLive - } else { - state.playbackState = .playing - } + state.playbackState = .playing + state.playingState.isLive = isLivePlayback + isPlaybackInitialized = true } func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) { state.playbackState = .paused + state.playingState.isLive = false } func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying") state.playbackState = .stopped + state.playingState.isLive = false release() } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift index fb2da1ddf4..c06d749765 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift @@ -32,7 +32,7 @@ struct VoiceBroadcastPlaybackView: View { @Environment(\.theme) private var theme: ThemeSwiftUI private var backgroundColor: Color { - if viewModel.viewState.playbackState == .playingLive { + if viewModel.viewState.playingState.isLive { return theme.colors.alert } return theme.colors.quarterlyContent @@ -70,20 +70,17 @@ struct VoiceBroadcastPlaybackView: View { } }.frame(maxWidth: .infinity, alignment: .leading) - if viewModel.viewState.broadcastState == .live { - Button { viewModel.send(viewAction: .playLive) } label: - { - Label { - Text(VectorL10n.voiceBroadcastLive) - .font(theme.fonts.caption1SB) - .foregroundColor(Color.white) - } icon: { - Image(uiImage: Asset.Images.voiceBroadcastLive.image) - } + if viewModel.viewState.broadcastState != .stopped { + Label { + Text(VectorL10n.voiceBroadcastLive) + .font(theme.fonts.caption1SB) + .foregroundColor(Color.white) + } icon: { + Image(uiImage: Asset.Images.voiceBroadcastLive.image) } .padding(.horizontal, 5) .background(RoundedRectangle(cornerRadius: 4, style: .continuous).fill(backgroundColor)) - .accessibilityIdentifier("liveButton") + .accessibilityIdentifier("liveLabel") } } .frame(maxWidth: .infinity, alignment: .leading) @@ -92,22 +89,14 @@ struct VoiceBroadcastPlaybackView: View { VoiceBroadcastPlaybackErrorView() } else { ZStack { - if viewModel.viewState.playbackState == .playing || - viewModel.viewState.playbackState == .playingLive { + if viewModel.viewState.playbackState == .playing { 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: { + Button { viewModel.send(viewAction: .play) } label: { Image(uiImage: Asset.Images.voiceBroadcastPlay.image) .renderingMode(.original) } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift index c9133f68eb..18d80d3af1 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift @@ -19,7 +19,6 @@ import SwiftUI enum VoiceBroadcastPlaybackViewAction { case play - case playLive case pause case sliderChange(didChange: Bool) } @@ -28,7 +27,6 @@ enum VoiceBroadcastPlaybackState { case stopped case buffering case playing - case playingLive case paused case error } @@ -38,21 +36,15 @@ struct VoiceBroadcastPlaybackDetails { let avatarData: AvatarInputProtocol } -enum VoiceBroadcastState { - case unknown - case stopped - case live - case paused -} - struct VoiceBroadcastPlayingState { var duration: Float var durationLabel: String? + var isLive: Bool } struct VoiceBroadcastPlaybackViewState: BindableState { var details: VoiceBroadcastPlaybackDetails - var broadcastState: VoiceBroadcastState + var broadcastState: VoiceBroadcastInfoState var playbackState: VoiceBroadcastPlaybackState var playingState: VoiceBroadcastPlayingState var bindings: VoiceBroadcastPlaybackViewStateBindings diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift index 4159d9aa7a..d88f7dfa8d 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift @@ -43,7 +43,7 @@ enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room")) - let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))) + let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))) return ( [false, viewModel], diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift index f2e28e5da9..10538095d2 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift @@ -34,7 +34,13 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { private var chunkFile: AVAudioFile! = nil private var chunkFrames: AVAudioFrameCount = 0 private var chunkFileNumber: Int = 0 - + + private var currentElapsedTime: UInt = 0 // Time in seconds. + private var currentRemainingTime: UInt { // Time in seconds. + BuildSettings.voiceBroadcastMaxLength - currentElapsedTime + } + private var elapsedTimeTimer: Timer? + // MARK: Public weak var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? @@ -67,12 +73,14 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } try audioEngine.start() + startTimer() // Disable the sleep mode during the recording until we are able to handle it UIApplication.shared.isIdleTimerDisabled = true } catch { MXLog.debug("[VoiceBroadcastRecorderService] startRecordingVoiceBroadcast error", context: error) stopRecordingVoiceBroadcast() + invalidateTimer() } } @@ -81,6 +89,7 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: audioNodeBus) UIApplication.shared.isIdleTimerDisabled = false + invalidateTimer() voiceBroadcastService?.stopVoiceBroadcast(success: { [weak self] _ in MXLog.debug("[VoiceBroadcastRecorderService] Stopped") @@ -110,6 +119,7 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { func pauseRecordingVoiceBroadcast() { audioEngine.pause() UIApplication.shared.isIdleTimerDisabled = false + invalidateTimer() voiceBroadcastService?.pauseVoiceBroadcast(success: { [weak self] _ in guard let self = self else { return } @@ -126,6 +136,7 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { func resumeRecordingVoiceBroadcast() { try? audioEngine.start() + startTimer() voiceBroadcastService?.resumeVoiceBroadcast(success: { [weak self] _ in guard let self = self else { return } @@ -143,12 +154,14 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { private func resetValues() { chunkFrames = 0 chunkFileNumber = 0 + currentElapsedTime = 0 } /// Release the service private func tearDownVoiceBroadcastService() { resetValues() session.tearDownVoiceBroadcastService() + invalidateTimer() do { try AVAudioSession.sharedInstance().setActive(false) @@ -157,6 +170,31 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { } } + /// Start ElapsedTimeTimer. + private func startTimer() { + elapsedTimeTimer = Timer.scheduledTimer(timeInterval: 1.0, + target: self, + selector: #selector(updateCurrentElapsedTimeValue), + userInfo: nil, + repeats: true) + } + + /// Invalidate ElapsedTimeTimer. + private func invalidateTimer() { + elapsedTimeTimer?.invalidate() + elapsedTimeTimer = nil + } + + /// Update currentElapsedTime value. + @objc private func updateCurrentElapsedTimeValue() { + guard currentRemainingTime > 0 else { + stopRecordingVoiceBroadcast() + return + } + currentElapsedTime += 1 + serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateRemainingTime: self.currentRemainingTime) + } + /// Write audio buffer to chunk file. private func writeBuffer(_ buffer: AVAudioPCMBuffer) { let sampleRate = buffer.format.sampleRate diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift index 7b97eb83a2..e457eb843c 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift @@ -18,6 +18,7 @@ import Foundation protocol VoiceBroadcastRecorderServiceDelegate: AnyObject { func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState) + func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateRemainingTime remainingTime: UInt) } protocol VoiceBroadcastRecorderServiceProtocol { diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift index 411ce0333b..6c2c21e3cb 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift @@ -53,6 +53,14 @@ struct VoiceBroadcastRecorderView: View { } icon: { Image(uiImage: Asset.Images.voiceBroadcastTileLive.image) } + + Label { + Text(viewModel.viewState.currentRecordingState.remainingTimeLabel) + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.caption1) + } icon: { + Image(uiImage: Asset.Images.voiceBroadcastTimeLeft.image) + } }.frame(maxWidth: .infinity, alignment: .leading) Label { diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift index 7a2566aad7..cb807a430c 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderModels.swift @@ -35,9 +35,15 @@ struct VoiceBroadcastRecorderDetails { let avatarData: AvatarInputProtocol } +struct VoiceBroadcastRecordingState { + var remainingTime: UInt + var remainingTimeLabel: String +} + struct VoiceBroadcastRecorderViewState: BindableState { var details: VoiceBroadcastRecorderDetails var recordingState: VoiceBroadcastRecorderState + var currentRecordingState: VoiceBroadcastRecordingState var bindings: VoiceBroadcastRecorderViewStateBindings } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift index bc915d36a3..c2b57dc5ce 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderScreenState.swift @@ -32,7 +32,8 @@ enum MockVoiceBroadcastRecorderScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let details = VoiceBroadcastRecorderDetails(senderDisplayName: "", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room")) - let viewModel = MockVoiceBroadcastRecorderViewModel(initialViewState: VoiceBroadcastRecorderViewState(details: details, recordingState: .started, bindings: VoiceBroadcastRecorderViewStateBindings())) + let recordingState = VoiceBroadcastRecordingState(remainingTime: BuildSettings.voiceBroadcastMaxLength, remainingTimeLabel: "1h 20m 47s left") + let viewModel = MockVoiceBroadcastRecorderViewModel(initialViewState: VoiceBroadcastRecorderViewState(details: details, recordingState: .started, currentRecordingState: recordingState, bindings: VoiceBroadcastRecorderViewStateBindings())) return ( [false, viewModel], diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift index 6e14441620..ba9690bfb1 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/VoiceBroadcastRecorderViewModel.swift @@ -34,8 +34,10 @@ class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, Voic init(details: VoiceBroadcastRecorderDetails, recorderService: VoiceBroadcastRecorderServiceProtocol) { self.voiceBroadcastRecorderService = recorderService + let currentRecordingState = VoiceBroadcastRecorderViewModel.currentRecordingState(from: BuildSettings.voiceBroadcastMaxLength) super.init(initialViewState: VoiceBroadcastRecorderViewState(details: details, recordingState: .stopped, + currentRecordingState: currentRecordingState, bindings: VoiceBroadcastRecorderViewStateBindings())) self.voiceBroadcastRecorderService.serviceDelegate = self @@ -77,10 +79,27 @@ class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, Voic self.state.recordingState = .resumed voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast() } + + private func updateRemainingTime(_ remainingTime: UInt) { + state.currentRecordingState = VoiceBroadcastRecorderViewModel.currentRecordingState(from: remainingTime) + } + + private static func currentRecordingState(from remainingTime: UInt) -> VoiceBroadcastRecordingState { + let time = TimeInterval(Double(remainingTime)) + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + + return VoiceBroadcastRecordingState(remainingTime: remainingTime, + remainingTimeLabel: VectorL10n.voiceBroadcastTimeLeft(formatter.string(from: time) ?? "0s")) + } } extension VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderServiceDelegate { func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState) { self.state.recordingState = state } + + func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateRemainingTime remainingTime: UInt) { + self.updateRemainingTime(remainingTime) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift index d3e7690eaa..bee2862946 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift @@ -79,6 +79,12 @@ struct UserSessionInfo: Identifiable { case unverified /// The session has been verified. case verified + /// A session which cannot be never verified due to lack of crypto support + case permanentlyUnverified + + var isUnverified: Bool { + self == .unverified || self == .permanentlyUnverified + } } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift index 1fcf65cf13..ce820eedcc 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift @@ -28,7 +28,7 @@ struct DeviceAvatarViewData: Hashable { switch verificationState { case .verified: return Asset.Images.userSessionVerified.name - case .unverified: + case .unverified, .permanentlyUnverified: return Asset.Images.userSessionUnverified.name case .unknown: return Asset.Images.userSessionVerificationUnknown.name diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift index 60adcb4c8a..d997567f3b 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift @@ -47,7 +47,7 @@ struct UserSessionCardViewData { switch verificationState { case .verified: return Asset.Images.userSessionVerified.name - case .unverified: + case .unverified, .permanentlyUnverified: return Asset.Images.userSessionUnverified.name case .unknown: return Asset.Images.userSessionVerificationUnknown.name @@ -59,7 +59,7 @@ struct UserSessionCardViewData { switch verificationState { case .verified: return VectorL10n.userSessionVerified - case .unverified: + case .unverified, .permanentlyUnverified: return VectorL10n.userSessionUnverified case .unknown: return VectorL10n.userSessionVerificationUnknown @@ -71,7 +71,7 @@ struct UserSessionCardViewData { switch verificationState { case .verified: return \.accent - case .unverified: + case .unverified, .permanentlyUnverified: return \.alert case .unknown: return \.secondaryContent @@ -85,6 +85,8 @@ struct UserSessionCardViewData { return isCurrentSessionDisplayMode ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userOtherSessionVerifiedAdditionalInfo + " %@" case .unverified: return isCurrentSessionDisplayMode ? VectorL10n.userSessionUnverifiedAdditionalInfo : VectorL10n.userOtherSessionUnverifiedAdditionalInfo + " %@" + case .permanentlyUnverified: + return VectorL10n.userOtherSessionPermanentlyUnverifiedAdditionalInfo case .unknown: return VectorL10n.userSessionVerificationUnknownAdditionalInfo } diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 78df04c0b5..76c1be95b3 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -465,7 +465,7 @@ private extension InfoSheetCoordinatorParameters { private extension UserSessionInfo { var bottomSheetTitle: String { switch verificationState { - case .unverified: + case .unverified, .permanentlyUnverified: return VectorL10n.userSessionUnverifiedSessionTitle case .verified: return VectorL10n.userSessionVerifiedSessionTitle @@ -476,7 +476,7 @@ private extension UserSessionInfo { var bottomSheetDescription: String { switch verificationState { - case .unverified: + case .unverified, .permanentlyUnverified: return VectorL10n.userSessionUnverifiedSessionDescription case .verified: return VectorL10n.userSessionVerifiedSessionDescription diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift index bce987575c..270891d912 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -87,10 +87,15 @@ class UserOtherSessionsViewModelTests: XCTestCase { func test_whenModelCreated_withUnverifiedFilter_viewStateIsCorrect() { let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), - createUserSessionInfo(sessionId: "session 2")] + createUserSessionInfo(sessionId: "session 2", verificationState: .permanentlyUnverified), + createUserSessionInfo(sessionId: "session 3", verificationState: .unknown)] let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified) - let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData() + let expectedItems = sessionInfos + .filter { + !$0.isCurrent && $0.verificationState.isUnverified + } + .asViewData() let bindings = UserOtherSessionsBindings(filter: .unverified, isEditModeEnabled: false) let expectedState = UserOtherSessionsViewState(bindings: bindings, title: "Title", @@ -100,12 +105,13 @@ class UserOtherSessionsViewModelTests: XCTestCase { allItemsSelected: false, enableSignOutButton: false, showLocationInfo: false) + XCTAssertEqual(expectedItems.count, 2) XCTAssertEqual(sut.state, expectedState) } func test_whenModelCreated_withVerifiedFilter_viewStateIsCorrect() { - let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true), - createUserSessionInfo(sessionId: "session 2", isVerified: true)] + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", verificationState: .verified), + createUserSessionInfo(sessionId: "session 2", verificationState: .verified)] let sut = createSUT(sessionInfos: sessionInfos, filter: .verified) let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData() @@ -122,8 +128,8 @@ class UserOtherSessionsViewModelTests: XCTestCase { } func test_whenModelCreated_withVerifiedFilterWithNoVerifiedSessions_viewStateIsCorrect() { - let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: false), - createUserSessionInfo(sessionId: "session 2", isVerified: false)] + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), + createUserSessionInfo(sessionId: "session 2")] let sut = createSUT(sessionInfos: sessionInfos, filter: .verified) let bindings = UserOtherSessionsBindings(filter: .verified, isEditModeEnabled: false) let expectedState = UserOtherSessionsViewState(bindings: bindings, @@ -138,8 +144,8 @@ class UserOtherSessionsViewModelTests: XCTestCase { } func test_whenModelCreated_withUnverifiedFilterWithNoUnverifiedSessions_viewStateIsCorrect() { - let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true), - createUserSessionInfo(sessionId: "session 2", isVerified: true)] + let sessionInfos = [createUserSessionInfo(sessionId: "session 1", verificationState: .verified), + createUserSessionInfo(sessionId: "session 2", verificationState: .verified)] let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified) let bindings = UserOtherSessionsBindings(filter: .unverified, isEditModeEnabled: false) let expectedState = UserOtherSessionsViewState(bindings: bindings, @@ -350,13 +356,13 @@ class UserOtherSessionsViewModelTests: XCTestCase { } private func createUserSessionInfo(sessionId: String, - isVerified: Bool = false, + verificationState: UserSessionInfo.VerificationState = .unverified, isActive: Bool = true, isCurrent: Bool = false) -> UserSessionInfo { UserSessionInfo(id: sessionId, name: "iOS", deviceType: .mobile, - verificationState: isVerified ? .verified : .unverified, + verificationState: verificationState, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, applicationName: nil, diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index c82532dd21..84ea6f9ad1 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -172,7 +172,7 @@ private extension UserOtherSessionsFilter { case .inactive: return sessionInfos.filter { !$0.isActive } case .unverified: - return sessionInfos.filter { $0.verificationState != .verified } + return sessionInfos.filter { $0.verificationState.isUnverified } case .verified: return sessionInfos.filter { $0.verificationState == .verified } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift index 6b7040a2c9..adb01d9ec2 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift @@ -39,6 +39,7 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { .currentSession(sessionState: .verified), .otherSession(sessionState: .verified), .otherSession(sessionState: .unverified), + .otherSession(sessionState: .permanentlyUnverified), .sessionWithPushNotifications(enabled: true), .sessionWithPushNotifications(enabled: false), .remotelyTogglingPushersNotAvailable] diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift index af71412477..746fa38ba9 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift @@ -93,4 +93,9 @@ class UserSessionOverviewUITests: MockScreenTestCase { let button = app.buttons[buttonId] XCTAssertTrue(button.exists) } + + func test_whenPermanentlySessionSelected_copyIsCorrect() { + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession(sessionState: .permanentlyUnverified).title) + XCTAssertTrue(app.buttons[VectorL10n.userOtherSessionPermanentlyUnverifiedAdditionalInfo].exists) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift index 1028dd3cbf..a5c90d65dd 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift @@ -45,7 +45,9 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol { } func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState { - guard let deviceInfo = deviceInfo else { return .unknown } + guard let deviceInfo = deviceInfo else { + return .permanentlyUnverified + } guard session.crypto?.crossSigning.canCrossSign == true else { return deviceInfo.deviceId == session.myDeviceId ? .unverified : .unknown diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 666232a0cc..9869ecd1ba 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -117,7 +117,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { private func sessionsOverviewData(from allSessions: [UserSessionInfo], linkDeviceEnabled: Bool) -> UserSessionsOverviewData { UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, - unverifiedSessions: allSessions.filter { $0.verificationState == .unverified && !$0.isCurrent }, + unverifiedSessions: allSessions.filter { $0.verificationState.isUnverified && !$0.isCurrent }, inactiveSessions: allSessions.filter { !$0.isActive }, otherSessions: allSessions.filter { !$0.isCurrent }, linkDeviceEnabled: linkDeviceEnabled) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift index 250283410c..227ed5d012 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -67,7 +67,7 @@ struct UserSessionListItemViewDataFactory { switch sessionInfo.verificationState { case .verified: sessionStatusText = VectorL10n.userSessionVerifiedShort - case .unverified: + case .unverified, .permanentlyUnverified: sessionStatusText = VectorL10n.userSessionUnverifiedShort case .unknown: sessionStatusText = nil diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index 99c555cc87..dbcf4854a1 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -63,6 +63,7 @@ targets: - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index e2db2be617..533efab5f0 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -72,6 +72,7 @@ targets: - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotTests/UserSessionsDataProviderTests.swift b/RiotTests/UserSessionsDataProviderTests.swift index 3780dcd653..4695d9fc31 100644 --- a/RiotTests/UserSessionsDataProviderTests.swift +++ b/RiotTests/UserSessionsDataProviderTests.swift @@ -82,6 +82,24 @@ class UserSessionCardViewDataTests: XCTestCase { XCTAssertEqual(verificationStateVerified, .unverified) XCTAssertEqual(verificationStateUnverified, .unverified) } + + func testDeviceNotHavingCryptoSupportOnVerifiedDevice() { + let mxSession = MockSession(canCrossSign: true) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + let verificationState = dataProvider.verificationState(for: nil) + + XCTAssertEqual(verificationState, .permanentlyUnverified) + } + + func testDeviceNotHavingCryptoSupportOnUnverifiedDevice() { + let mxSession = MockSession(canCrossSign: false) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + let verificationState = dataProvider.verificationState(for: nil) + + XCTAssertEqual(verificationState, .permanentlyUnverified) + } } // MARK: Mocks diff --git a/project.yml b/project.yml index 0fe95db190..6a08562d4d 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: 2469f27b7e1e51aaa135e09f9005eb10fda686e6 + revision: 1fbffd0321eb47abcd664ad19c6c943b60abf399 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0