mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-19 22:22:14 +02:00
Merge branch 'release/1.9.13/master'
This commit is contained in:
@@ -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:
|
||||
|
||||
+79
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } }
|
||||
|
||||
+9
-9
@@ -55,9 +55,9 @@ PODS:
|
||||
- LoggerAPI (1.9.200):
|
||||
- Logging (~> 1.1)
|
||||
- Logging (1.4.0)
|
||||
- MatrixSDK (0.24.3):
|
||||
- MatrixSDK/Core (= 0.24.3)
|
||||
- MatrixSDK/Core (0.24.3):
|
||||
- MatrixSDK (0.24.5):
|
||||
- MatrixSDK/Core (= 0.24.5)
|
||||
- MatrixSDK/Core (0.24.5):
|
||||
- AFNetworking (~> 4.0.0)
|
||||
- GZIP (~> 1.3.0)
|
||||
- libbase58 (~> 0.1.4)
|
||||
@@ -65,9 +65,9 @@ PODS:
|
||||
- OLMKit (~> 3.2.5)
|
||||
- Realm (= 10.27.0)
|
||||
- SwiftyBeaver (= 1.9.5)
|
||||
- MatrixSDK/CryptoSDK (0.24.3):
|
||||
- MatrixSDK/CryptoSDK (0.24.5):
|
||||
- MatrixSDKCrypto (= 0.1.5)
|
||||
- MatrixSDK/JingleCallStack (0.24.3):
|
||||
- MatrixSDK/JingleCallStack (0.24.5):
|
||||
- JitsiMeetSDK (= 5.0.2)
|
||||
- MatrixSDK/Core
|
||||
- MatrixSDKCrypto (0.1.5)
|
||||
@@ -122,8 +122,8 @@ DEPENDENCIES:
|
||||
- KeychainAccess (~> 4.2.2)
|
||||
- KTCenterFlowLayout (~> 1.3.1)
|
||||
- libPhoneNumber-iOS (~> 0.9.13)
|
||||
- MatrixSDK (= 0.24.3)
|
||||
- MatrixSDK/JingleCallStack (= 0.24.3)
|
||||
- MatrixSDK (= 0.24.5)
|
||||
- MatrixSDK/JingleCallStack (= 0.24.5)
|
||||
- OLMKit
|
||||
- PostHog (~> 1.4.4)
|
||||
- ReadMoreTextView (~> 3.0.1)
|
||||
@@ -220,7 +220,7 @@ SPEC CHECKSUMS:
|
||||
libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75
|
||||
LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d
|
||||
Logging: beeb016c9c80cf77042d62e83495816847ef108b
|
||||
MatrixSDK: caa83fd5fa63872295bf868afbd334e0ca67dda2
|
||||
MatrixSDK: 1557b3ed0a211db43a865cfdad93f07c2be92c9e
|
||||
MatrixSDKCrypto: dcab554bc7157cad31c01fc1137cf5acb01959a4
|
||||
OLMKit: da115f16582e47626616874e20f7bb92222c7a51
|
||||
PostHog: 4b6321b521569092d4ef3a02238d9435dbaeb99f
|
||||
@@ -241,6 +241,6 @@ SPEC CHECKSUMS:
|
||||
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 154d5d74ff0efd23cb8c97e9ee2a000103270e09
|
||||
PODFILE CHECKSUM: c93b326deaf9de3916d42a49d39d737612ab1d94
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift",
|
||||
"state" : {
|
||||
"revision" : "2469f27b7e1e51aaa135e09f9005eb10fda686e6"
|
||||
"revision" : "1fbffd0321eb47abcd664ad19c6c943b60abf399"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_broadcast_time_left.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 1H6V2.33333H10V1ZM7.33333 9.66667H8.66667V5.66667H7.33333V9.66667ZM12.6867 5.26L13.6333 4.31333C13.3467 3.97333 13.0333 3.65333 12.6933 3.37333L11.7467 4.32C10.7133 3.49333 9.41333 3 8 3C4.68667 3 2 5.68667 2 9C2 12.3133 4.68 15 8 15C11.32 15 14 12.3133 14 9C14 7.58667 13.5067 6.28667 12.6867 5.26ZM8 13.6667C5.42 13.6667 3.33333 11.58 3.33333 9C3.33333 6.42 5.42 4.33333 8 4.33333C10.58 4.33333 12.6667 6.42 12.6667 9C12.6667 11.58 10.58 13.6667 8 13.6667Z" fill="#737D8C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Permissions usage explanations
|
||||
"NSCameraUsageDescription" = "Камера используется для съемки фото и видео, совершения видеозвонков.";
|
||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для отправки фото и видео.";
|
||||
"NSCameraUsageDescription" = "Камера используется для совершения видеозвонков, съёмки и загрузки фотографий и видео.";
|
||||
"NSPhotoLibraryUsageDescription" = "Разрешите доступ к фото для отправки фото и видео из вашей библиотеки.";
|
||||
"NSMicrophoneUsageDescription" = "Element необходим доступ к вашему микрофону, чтобы совершать и принимать звонки, снимать видео и записывать голосовые сообщения.";
|
||||
"NSContactsUsageDescription" = "Element покажет ваши контакты, чтобы вы могли пригласить их в чат.";
|
||||
"NSContactsUsageDescription" = "Они будут переданы вашему серверу идентификации, чтобы помочь найти ваши контакты в Matrix.";
|
||||
"NSCalendarsUsageDescription" = "Просматривайте запланированные встречи в приложении.";
|
||||
"NSFaceIDUsageDescription" = "Face ID используется для доступа к вашему приложению.";
|
||||
"NSLocationWhenInUseUsageDescription" = "Когда вы делитесь с людьми своим местоположением, Element необходим доступ, чтобы показать им карту.";
|
||||
|
||||
@@ -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ë.";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2394,7 +2394,17 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_0" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16097"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="iN0-l3-epB" customClass="LaunchLoadingView" customModule="Riot" customModuleProvider="target">
|
||||
<view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="iN0-l3-epB" customClass="LaunchLoadingView" customModule="Element" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleAspectFit" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3KG-IR-FPV" customClass="ElementView" customModule="Riot" customModuleProvider="target">
|
||||
<view contentMode="scaleAspectFit" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3KG-IR-FPV" customClass="ElementView" customModule="Element" customModuleProvider="target">
|
||||
<rect key="frame" x="95" y="219" width="130" height="130"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</view>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wzS-bN-Pht">
|
||||
<rect key="frame" x="20" y="528" width="280" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" systemColor="systemGrayColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="wzS-bN-Pht" secondAttribute="trailing" constant="20" id="Naf-Cc-qLq"/>
|
||||
<constraint firstAttribute="bottom" secondItem="wzS-bN-Pht" secondAttribute="bottom" constant="40" id="cnE-Pn-Wb2"/>
|
||||
<constraint firstItem="3KG-IR-FPV" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="ig4-YX-FoT"/>
|
||||
<constraint firstItem="3KG-IR-FPV" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="r9K-7c-fjh"/>
|
||||
<constraint firstItem="wzS-bN-Pht" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" id="uZP-JW-dVR"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="animationView" destination="3KG-IR-FPV" id="Are-fn-laY"/>
|
||||
<outlet property="statusLabel" destination="wzS-bN-Pht" id="Mj2-rn-i5x"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="136.875" y="132.5"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<systemColor name="systemGrayColor">
|
||||
<color red="0.55686274509803924" green="0.55686274509803924" blue="0.57647058823529407" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ typedef enum : NSUInteger
|
||||
|
||||
|
||||
@class MXKRoomInputToolbarView;
|
||||
@class MXKImageView;
|
||||
@protocol MXKRoomInputToolbarViewDelegate <NSObject>
|
||||
|
||||
/**
|
||||
@@ -381,4 +382,6 @@ typedef enum : NSUInteger
|
||||
*/
|
||||
@property (nonatomic) NSAttributedString *attributedTextMessage;
|
||||
|
||||
- (void)dismissValidationView:(MXKImageView*)validationView;
|
||||
|
||||
@end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<NSLayoutConstraint*> *toolbarContainerConstraints;
|
||||
|
||||
@property (strong, nonatomic, nullable) UIView* maximisedToolbarDimmingView;
|
||||
|
||||
@property (nonatomic) CGFloat wysiwygTranslation;
|
||||
|
||||
|
||||
/**
|
||||
Retrieve the live data source in cases where the timeline is not live.
|
||||
|
||||
|
||||
@@ -187,7 +187,6 @@ static CGSize kThreadListBarButtonItemImageSize;
|
||||
MXTaskProfile *notificationTaskProfile;
|
||||
}
|
||||
|
||||
@property (nonatomic, weak) IBOutlet UIView *overlayContainerView;
|
||||
@property (nonatomic, strong) RemoveJitsiWidgetView *removeJitsiWidgetView;
|
||||
|
||||
|
||||
@@ -470,6 +469,9 @@ static CGSize kThreadListBarButtonItemImageSize;
|
||||
self.jumpToLastUnreadBanner.backgroundColor = ThemeService.shared.theme.colors.navigation;
|
||||
[self.jumpToLastUnreadBanner vc_removeShadow];
|
||||
self.resetReadMarkerButton.tintColor = ThemeService.shared.theme.colors.quarterlyContent;
|
||||
if (self.maximisedToolbarDimmingView) {
|
||||
self.maximisedToolbarDimmingView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.29];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -481,6 +483,9 @@ static CGSize kThreadListBarButtonItemImageSize;
|
||||
radius:8
|
||||
opacity:0.1];
|
||||
self.resetReadMarkerButton.tintColor = ThemeService.shared.theme.colors.tertiaryContent;
|
||||
if (self.maximisedToolbarDimmingView) {
|
||||
self.maximisedToolbarDimmingView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.12];
|
||||
}
|
||||
}
|
||||
|
||||
self.scrollToBottomBadgeLabel.badgeColor = ThemeService.shared.theme.tintColor;
|
||||
@@ -603,6 +608,8 @@ static CGSize kThreadListBarButtonItemImageSize;
|
||||
|
||||
// Stop the loading indicator even if the session is still in progress
|
||||
[self stopLoadingUserIndicator];
|
||||
|
||||
[self setMaximisedToolbarIsHiddenIfNeeded: YES];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
@@ -678,6 +685,8 @@ static CGSize kThreadListBarButtonItemImageSize;
|
||||
// Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early)
|
||||
self.inputToolbarView.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage;
|
||||
}
|
||||
|
||||
[self setMaximisedToolbarIsHiddenIfNeeded: NO];
|
||||
}
|
||||
|
||||
- (void)viewDidDisappear:(BOOL)animated
|
||||
@@ -1212,8 +1221,6 @@ static CGSize kThreadListBarButtonItemImageSize;
|
||||
if (!self.inputToolbarView || ![self.inputToolbarView isMemberOfClass:roomInputToolbarViewClass])
|
||||
{
|
||||
[super setRoomInputToolbarViewClass:roomInputToolbarViewClass];
|
||||
|
||||
|
||||
if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) {
|
||||
id<RoomInputToolbarViewProtocol> inputToolbar = (id<RoomInputToolbarViewProtocol>)self.inputToolbarView;
|
||||
[inputToolbar setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
@@ -35,6 +35,10 @@
|
||||
<outlet property="userSuggestionContainerHeightConstraint" destination="1Cd-cT-gOr" id="au5-3q-r54"/>
|
||||
<outlet property="userSuggestionContainerView" destination="oni-F4-X1U" id="0js-Ji-8Mm"/>
|
||||
<outlet property="view" destination="iN0-l3-epB" id="ieV-u7-rXU"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="T1Y-r9-bYV" id="wax-9P-KGn"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="pRw-S0-6WL" id="q4S-0g-sqQ"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="QO8-nF-xys" id="aQe-20-4Pq"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="acJ-g8-R7x" id="uEo-Ez-seV"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
|
||||
*/
|
||||
- (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage;
|
||||
|
||||
- (void)didChangeMaximisedState: (BOOL) isMaximised;
|
||||
|
||||
@end
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
+35
-4
@@ -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 -
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1218,9 +1218,9 @@ TableViewSectionsDelegate>
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String?>) -> 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<String?>) -> 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<String?>) -> 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<String?>) -> 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<String?>) -> Void) {
|
||||
private func sendVoiceBroadcastInfo(state: VoiceBroadcastInfoState, completion: @escaping (MXResponse<String?>) -> 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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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()
|
||||
|
||||
+4
-3
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,8 +34,8 @@ struct FormatItem {
|
||||
enum FormatType {
|
||||
case bold
|
||||
case italic
|
||||
case strikethrough
|
||||
case underline
|
||||
case strikethrough
|
||||
}
|
||||
|
||||
extension FormatType: CaseIterable, Identifiable {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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?
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+90
-100
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+11
-22
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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],
|
||||
|
||||
+39
-1
@@ -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
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-10
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
+1
@@ -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]
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user