diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 4cc4943e3..a93b46adc 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -28,12 +28,14 @@ 06D3942496E9E0E655F14D21 /* NotificationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */; }; 071A017E415AD378F2961B11 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227AC5D71A4CE43512062243 /* URL.swift */; }; 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */; }; + 086D01E79C8E8D3F004FAF21 /* AudioPlayerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */; }; 08CB4BD12CEEDE6AAE4A18DD /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035177BCD8E8308B098AC3C2 /* WindowManager.swift */; }; 095C0ACFC234E0550A6404C5 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */; }; 095D3906CF2F940C2D2D17CC /* RoomFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */; }; 09713669577CDA8D012EE380 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6647C55D93508C7CE9D954A5 /* MatrixRustSDK */; }; 09AAF04B27732046C755D914 /* SoftLogoutViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */; }; 09C83DDDB07C28364F325209 /* MockRoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */; }; + 09EF4222EEBBA1A7B8F4071E /* VoiceMessageRecordingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */; }; 0A194F5E70B5A628C1BF4476 /* AdvancedSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */; }; 0AA0477E063E72B786A983CF /* AnalyticsPromptScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF2DA52B1DE7CAEC5422 /* AnalyticsPromptScreenViewModel.swift */; }; 0ACAA31FD0399CEEBA3ECC21 /* UserDetailsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */; }; @@ -65,6 +67,7 @@ 13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; }; 13CBC470FB619A6393A21908 /* RoomNotificationSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.swift */; }; 14343C2F9AD2BFEA92CA28FF /* MapTilerStyleBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AE92E7BFF71797BDE1D261 /* MapTilerStyleBuilder.swift */; }; + 1471A080552631358D152C18 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BDB3E65A79779EDA5D33D8A /* AudioPlayerState.swift */; }; 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */; }; 14E99D27628B1A6F0CB46FEA /* SeparatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */; }; 152AE2B8650FB23AFD2E28B9 /* MockAuthenticationServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2B80DD0BF6F10BB5FA922 /* MockAuthenticationServiceProxy.swift */; }; @@ -146,6 +149,7 @@ 2C4C750D0039AFABDF24236C /* TemplateScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */; }; 2C5E832434EE94E21AB3B238 /* EmojiPickerScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3EAE3E9D5EF4A6D5D9C6CFD /* EmojiPickerScreenViewModel.swift */; }; 2CA6ABBC9A88EB89EA52FCCB /* ConfettiScene.scn in Resources */ = {isa = PBXBuildFile; fileRef = B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */; }; + 2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */; }; 2DA90E38FF4E696825810C1A /* WaitlistScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB08484CD5D77C9BF97AA78 /* WaitlistScreenUITests.swift */; }; 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; }; 2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */; }; @@ -153,6 +157,7 @@ 2F623DA1122140A987B34D08 /* NotificationSettingsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */; }; 2F66701B15657A87B4AC3A0A /* WaitlistScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CE2B7AD979BDEE09FEDB08 /* WaitlistScreenModels.swift */; }; 2F94054F50E312AF30BE07F3 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B21E611DADDEF00307E7AC /* String.swift */; }; + 3042527CB344A9EF1157FC26 /* AudioRecorderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */; }; 308BD9343B95657FAA583FB7 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 19CD5B074D7DD44AF4C58BB6 /* SwiftState */; }; 3097A0A867D2B19CE32DAE58 /* UIKitBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1FFC3336EB23374BBBFCC /* UIKitBackgroundTaskService.swift */; }; 30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; }; @@ -162,6 +167,7 @@ 32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; }; 33094DB91C3A4131E76B2C07 /* AppLockScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5574FD6FC3C2DC0DF160A85 /* AppLockScreenViewModelProtocol.swift */; }; 339BC18777912E1989F2F17D /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584A61D9C459FAFEF038A7C0 /* Section.swift */; }; + 33CA777C9DF263582D77A67F /* VoiceMessagePreviewComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */; }; 33CAC1226DFB8B5D8447D286 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 1BCD21310B997A6837B854D6 /* GZIP */; }; 340D39DB87F3800D53A6A621 /* EmojiPickerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */; }; 34357B287357BC0B9715DD51 /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */; }; @@ -194,6 +200,7 @@ 3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */; }; 3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; }; 3B28408450BCAED911283AA2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; }; + 3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */; }; 3C549A0BF39F8A854D45D9FD /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 0DD568A494247444A4B56031 /* Kingfisher */; }; 3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; }; 3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DC38E64A5ED3FDB201029A /* BugReportService.swift */; }; @@ -229,6 +236,7 @@ 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */; }; 46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */; }; 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565F1B2B300597C616B37888 /* FullscreenDialog.swift */; }; + 46C9F8FE3810A04A005FE16B /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B19B2BCC779ED934E0BBC2A /* AudioPlayer.swift */; }; 46D1E2940ED8CCBF62FE8854 /* CreatePollScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EA0F71A3A400A202E15318 /* CreatePollScreen.swift */; }; 4714991754A08B58B4D7ED85 /* OnboardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F27BAB69EB568369F1F6B3 /* OnboardingScreenViewModelProtocol.swift */; }; 47305C0911C9E1AA774A4000 /* TemplateScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */; }; @@ -288,6 +296,7 @@ 588411C8FD72B2A2DFE5F7DE /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E992D7B8BE54B2AB454613AF /* XCUIElement.swift */; }; 5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */; }; 5897A59DDBD3592282092223 /* MediaSourceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49B9785E3AD7D1C15A29F2F /* MediaSourceProxy.swift */; }; + 5992EF10AA157EBD97D88910 /* AudioRecorderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6569593FA36B22259E806A67 /* AudioRecorderState.swift */; }; 5995C63B1C61DE1373AA2BCE /* ComposerToolbarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD95B3714F806AC9CF9A557B /* ComposerToolbarViewModel.swift */; }; 59F940FCBE6BC343AECEF75E /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2245243369B99216C7D84E /* ImageCache.swift */; }; 5B2D1210B40570D87B11BD3B /* ThreadDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA3F8E905DF50BF22ECC18F /* ThreadDecorator.swift */; }; @@ -308,7 +317,6 @@ 5F06AD3C66884CE793AE6119 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; 5F28C9146694B381BB82E18C /* AnalyticsPromptScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65A314DF40B6BBF775C2BC /* AnalyticsPromptScreenCoordinator.swift */; }; 5F5488FBC9CFEB6F433D74A4 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7109E709A7738E6BCC4553E6 /* Localizable.strings */; }; - 5F8E96263497FFB7D3254EB2 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */; }; 60ED66E63A169E47489348A8 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 886A0A498FA01E8EDD451D05 /* Sentry */; }; 6146996D5C4DDD5DA816FC87 /* AuthenticationTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCACD75595C40EACD6AD4A74 /* AuthenticationTextFieldStyle.swift */; }; 617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */; }; @@ -319,6 +327,7 @@ 62418EA4E3EB597AD184AEB6 /* PillConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8D34E94AB07128DB73D6C7 /* PillConstants.swift */; }; 62910B515BCB4B455E24D7C1 /* AdvancedSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D086854995173E897F993C26 /* AdvancedSettingsScreenViewModelProtocol.swift */; }; 6298AB0906DDD3525CD78C6B /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = 1081D3630AAD3ACEDDEC3A98 /* LRUCache */; }; + 62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; }; 63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */; }; 642DF13C49ED4121C148230E /* TestablePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E227F34BE43B08E098796E /* TestablePreview.swift */; }; 6448F8D1D3CA4CD27BB4CADD /* RoomMemberProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */; }; @@ -432,7 +441,6 @@ 829062DD3C3F7016FE1A6476 /* RoomDetailsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFDAF6918BB096C44788FC9 /* RoomDetailsScreenUITests.swift */; }; 8317E1314C00DCCC99D30DA8 /* TextBasedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9227F7495DA43324050A863 /* TextBasedRoomTimelineItem.swift */; }; 83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; }; - 8418688282763F4B9DDC42FB /* AudioConverterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB4D7B3FBCA261927536FE64 /* AudioConverterProtocol.swift */; }; 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; 84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */; }; @@ -472,6 +480,7 @@ 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */; }; 8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.swift */; }; + 8C27BEB00B903D953F31F962 /* VoiceMessageRecordingButtonTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */; }; 8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; }; 8C706DA7EAC0974CA2F8F1CD /* MentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15748C254911E3654C93B0ED /* MentionBuilder.swift */; }; 8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */; }; @@ -667,7 +676,6 @@ C11939FDC40716C4387275A4 /* NotificationSettingsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */; }; C13128AAA787A4C2CBE4EE82 /* MessageForwardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */; }; C1429699A6A5BB09A25775C1 /* AudioPlayerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */; }; - C19085A284D54A166A64A86C /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */; }; C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */; }; C1D0AB8222D7BAFC9AF9C8C0 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */; }; @@ -699,6 +707,7 @@ C97325EFDCCEE457432A9E82 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0B4A34E69BD2132BEC521 /* MessageText.swift */; }; C9BE065FA7D4E77E4C61CB69 /* MapLibreModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81B6170DB690013CEB646F4 /* MapLibreModels.swift */; }; C9F5B48D15B9BCAE1F8D564E /* RoomNotificationModeProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1511766C534367700C8DD75 /* RoomNotificationModeProxy.swift */; }; + CA5BFF0C2EF5A8EF40CA2D69 /* VoiceMessageRecordingComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB6F36CCE44A29A06FCAF1C /* VoiceMessageRecordingComposer.swift */; }; CAF8755E152204F55F8D6B5B /* RoomMembersListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */; }; CB137BFB3E083C33E398A6CB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 020597E28A4BC8E1BE8EDF6E /* KeychainAccess */; }; CB498F4E27AA0545DCEF0F6F /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 36B7FC232711031AA2B0D188 /* DTCoreText */; }; @@ -711,7 +720,6 @@ CCBEC2100CAF2EEBE9DB4156 /* TemplateScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */; }; CCC3802A3C019A6FFAAA547A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E65E613F057697A1A0BC03 /* NotificationViewController.swift */; }; CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5E97E9615A158C76B2AB77 /* DateTests.swift */; }; - CD1C6943F42F29079E5E7511 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */; }; CD6A72B65D3B6076F4045C30 /* PHGPostHogConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */; }; CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */; }; CE1694C7BB93C3311524EF28 /* Untranslated.strings in Resources */ = {isa = PBXBuildFile; fileRef = D2F7194F440375338F8E2487 /* Untranslated.strings */; }; @@ -809,6 +817,7 @@ EAC6FE2CD4F50A43068ADCD8 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; EAF2B3E6C6AEC4AD3A8BD454 /* RoomMemberDetailsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A87D0471D438A233C2CF4A /* RoomMemberDetailsScreenViewModel.swift */; }; EB88DBD77221E2CFE463018C /* NSE.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D8F620C8B314840D8602E3F /* NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + EBDB339A7C127F068B6E52E5 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A634D8DD1E10D858CF7995D /* VoiceMessageRecordingView.swift */; }; EBE13FAB4E29738AC41BD3E5 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; EC280623A42904341363EAAF /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = A20EA00CCB9DBE0FFB17DD09 /* Collections */; }; EC658A57E715699C52DFBC77 /* CreatePollScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB1D0C69FEDD93404DF927E /* CreatePollScreenViewModelTests.swift */; }; @@ -829,7 +838,6 @@ F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */; }; - F0B196905CD23E3B4505CB7B /* AudioPlayerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */; }; F0F82C3C848C865C3098AA52 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */; }; F12F6BED7B6D7EE4BEE55039 /* PlainMentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */; }; @@ -839,6 +847,7 @@ F32B271F60531BE92C6E62A1 /* StickerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612EF972F2A1800682D32C5E /* StickerRoomTimelineView.swift */; }; F37629BAA5E8F50AAF2A131D /* SoftLogoutScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */; }; F3E2D3F7ACDED65A4E5CD8DE /* RoomMembersListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */; }; + F3F38062C6CA21CF403C5C90 /* AudioConverterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */; }; F40B097470D3110DFDB1FAAA /* LegalInformationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47873756E45B46683D97DC32 /* LegalInformationScreenModels.swift */; }; F4433EF57B4BB3C077F8B00E /* SessionVerificationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD9E0FFA29EAACFF3AB9732 /* SessionVerificationScreenViewModel.swift */; }; F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BCC8A9C73C1F838122C645 /* TimelineItemPlainStylerView.swift */; }; @@ -942,7 +951,6 @@ 03DD998E523D4EC93C7ED703 /* RoomNotificationSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModelTests.swift; sourceTree = ""; }; 045253F9967A535EE5B16691 /* Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Label.swift; sourceTree = ""; }; - 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = ""; }; 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = ""; }; 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; 052B2F924572AFD70B5F500E /* StartChatScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenViewModel.swift; sourceTree = ""; }; @@ -962,6 +970,7 @@ 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 09CE2B7AD979BDEE09FEDB08 /* WaitlistScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenModels.swift; sourceTree = ""; }; 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = ""; }; + 0A634D8DD1E10D858CF7995D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = ""; }; 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSenderAvatarView.swift; sourceTree = ""; }; @@ -1056,6 +1065,7 @@ 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelProtocol.swift; sourceTree = ""; }; 25F7FE40EF7490A7E09D7BE6 /* NotificationItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItemProxy.swift; sourceTree = ""; }; 260004737C573A56FA01E86E /* Encodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Encodable.swift; sourceTree = ""; }; + 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigurationScreenViewStateTests.swift; sourceTree = ""; }; 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; @@ -1070,13 +1080,13 @@ 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineTests.swift; sourceTree = ""; }; 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 2BB385E148DE55C85C0A02D6 /* SoftLogoutScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenModels.swift; sourceTree = ""; }; + 2BDB3E65A79779EDA5D33D8A /* AudioPlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerState.swift; sourceTree = ""; }; 2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponents.swift; sourceTree = ""; }; 2C0197EAE9D45A662B8847B6 /* RoomTimelineControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerProtocol.swift; sourceTree = ""; }; 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskProtocol.swift; sourceTree = ""; }; 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = ""; }; 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenModels.swift; sourceTree = ""; }; 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProxy.swift; sourceTree = ""; }; - 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsViewModelTests.swift; sourceTree = ""; }; 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; 2FD0E68C42CA7DDCD4CAD68D /* MentionSuggestionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSuggestionItemView.swift; sourceTree = ""; }; @@ -1243,6 +1253,7 @@ 6493AC9979CEB1410302BFE3 /* RoomDetailsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenCoordinator.swift; sourceTree = ""; }; 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerScreenCoordinator.swift; sourceTree = ""; }; 653610CB5F9776EAAAB98155 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = ""; }; + 6569593FA36B22259E806A67 /* AudioRecorderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderState.swift; sourceTree = ""; }; 65AAD845E53B0C8B5E0812C2 /* UserDiscoveryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryService.swift; sourceTree = ""; }; 65C2B80DD0BF6F10BB5FA922 /* MockAuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthenticationServiceProxy.swift; sourceTree = ""; }; 6654859746B0BE9611459391 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = cs; path = cs.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -1307,6 +1318,7 @@ 7A5D2323D7B6BF4913EB7EED /* landscape_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = landscape_test_image.jpg; sourceTree = ""; }; 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModelTests.swift; sourceTree = ""; }; 7B04BD3874D736127A8156B8 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 7B19B2BCC779ED934E0BBC2A /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTaskRunner.swift; sourceTree = ""; }; 7B849D2FF2CC12BA411A1651 /* CreateRoomModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomModels.swift; sourceTree = ""; }; 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowCoordinatorProtocol.swift; sourceTree = ""; }; @@ -1370,6 +1382,7 @@ 8F61A0DD8243B395499C99A2 /* InvitesScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenUITests.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorder.swift; sourceTree = ""; }; 90A55430639712CFACA34F43 /* TextRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItem.swift; sourceTree = ""; }; 90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = ""; }; 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillTextAttachmentData.swift; sourceTree = ""; }; @@ -1451,6 +1464,7 @@ AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = ""; }; AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = ""; }; + AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = ""; }; ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxy.swift; sourceTree = ""; }; @@ -1505,6 +1519,7 @@ BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreen.swift; sourceTree = ""; }; BB23BEAF8831DC6A57E39F52 /* CreatePollScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreenCoordinator.swift; sourceTree = ""; }; BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenCoordinator.swift; sourceTree = ""; }; + BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderProtocol.swift; sourceTree = ""; }; BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMocks.swift; sourceTree = ""; }; BC930E5F7F138112CAE5AC63 /* AppLockScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenModels.swift; sourceTree = ""; }; BCF54536699ACEE3DB6BA3CB /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = ""; }; @@ -1514,6 +1529,7 @@ BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenCoordinator.swift; sourceTree = ""; }; BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = ""; }; BF34A2FD6797535C95AC918D /* PlaceholderScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderScreenCoordinator.swift; sourceTree = ""; }; + BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessagePreviewComposer.swift; sourceTree = ""; }; BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreen.swift; sourceTree = ""; }; C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = ""; }; @@ -1535,6 +1551,7 @@ C4C89820BB2B88D4EA28131C /* BugReportScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelProtocol.swift; sourceTree = ""; }; C4CD503F5E0938FE53C7C6E7 /* UserDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenCoordinator.swift; sourceTree = ""; }; C54464351F170D570110AFCA /* WelcomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreen.swift; sourceTree = ""; }; + C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderStateTests.swift; sourceTree = ""; }; C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = ""; }; C5B7A755E985FA14469E86B2 /* RoomMembersListScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenUITests.swift; sourceTree = ""; }; C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = ""; }; @@ -1570,13 +1587,13 @@ CC680E0E79D818706CB28CF8 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarHeaderView.swift; sourceTree = ""; }; CCACD75595C40EACD6AD4A74 /* AuthenticationTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationTextFieldStyle.swift; sourceTree = ""; }; + CCB6F36CCE44A29A06FCAF1C /* VoiceMessageRecordingComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingComposer.swift; sourceTree = ""; }; CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreen.swift; sourceTree = ""; }; CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineItemContent.swift; sourceTree = ""; }; CD6B0C4639E066915B5E6463 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; CD700E035C85738EE4B97129 /* PerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; CD95B3714F806AC9CF9A557B /* ComposerToolbarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModel.swift; sourceTree = ""; }; CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; - CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverter.swift; sourceTree = ""; }; CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = ""; }; CF48AF076424DBC1615C74AD /* AuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxy.swift; sourceTree = ""; }; @@ -1591,6 +1608,7 @@ D1BC84BA0AF11C2128D58ABD /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = ""; }; D26813CCE39221FE30BF22CD /* PlatformViewVersionPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformViewVersionPredicate.swift; sourceTree = ""; }; + D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingButton.swift; sourceTree = ""; }; D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelProtocol.swift; sourceTree = ""; }; D33116993D54FADC0C721C1F /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; D38391154120264910D19528 /* PollMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollMock.swift; sourceTree = ""; }; @@ -1641,6 +1659,7 @@ E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = ""; }; E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorModalView.swift; sourceTree = ""; }; E44928D844E16EE48A311FCA /* ComposerToolbarViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModelProtocol.swift; sourceTree = ""; }; + E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverter.swift; sourceTree = ""; }; E461B3C8BBBFCA400B268D14 /* AppRouteURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteURLParserTests.swift; sourceTree = ""; }; E51E3D86A84341C3A0CB8A40 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = ""; }; E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; @@ -1709,9 +1728,7 @@ F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = ""; }; F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = ""; }; FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = ""; }; - FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerState.swift; sourceTree = ""; }; FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = ""; }; - FB4D7B3FBCA261927536FE64 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = ""; }; FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = ""; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = ""; }; @@ -1722,6 +1739,7 @@ FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProtocol.swift; sourceTree = ""; }; FEC2E8E1B20BB2EA07B0B61E /* WelcomeScreenScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenViewModel.swift; sourceTree = ""; }; FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsScreen.swift; sourceTree = ""; }; + FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingButtonTooltipView.swift; sourceTree = ""; }; FFECCE59967018204876D0A5 /* LocationMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMarkerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1850,6 +1868,16 @@ path = RoomNotificationSettingsScreen; sourceTree = ""; }; + 0371482D36C95ABAF9D4C651 /* Recorder */ = { + isa = PBXGroup; + children = ( + 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */, + BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */, + 6569593FA36B22259E806A67 /* AudioRecorderState.swift */, + ); + path = Recorder; + sourceTree = ""; + }; 040A58C2A22F7195740EBF5C /* NCE */ = { isa = PBXGroup; children = ( @@ -1896,7 +1924,7 @@ children = ( 4BF8D11D9ED15CFC373D0119 /* Analytics */, 7803E03F759061C948D66B7E /* AppLock */, - 984A887BA0294FE3B00CE9B1 /* AudioPlayer */, + FCE7249621F507F34A8122FB /* Audio */, AAFDD509929A0CCF8BCE51EB /* Authentication */, EBBEB5471737E9D116DF4738 /* Background */, 0ED3F5C21537519389C07644 /* BugReport */, @@ -2281,6 +2309,16 @@ path = Emojis; sourceTree = ""; }; + 3A2CAA4ABF5E66C3C8BBA3E9 /* Player */ = { + isa = PBXGroup; + children = ( + 7B19B2BCC779ED934E0BBC2A /* AudioPlayer.swift */, + AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */, + 2BDB3E65A79779EDA5D33D8A /* AudioPlayerState.swift */, + ); + path = Player; + sourceTree = ""; + }; 3A304097A59704AC9B869EC6 /* Helpers */ = { isa = PBXGroup; children = ( @@ -2491,6 +2529,11 @@ 2FD0E68C42CA7DDCD4CAD68D /* MentionSuggestionItemView.swift */, A0A01AECFF54281CF35909A6 /* MessageComposer.swift */, 3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */, + BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */, + D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */, + FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */, + CCB6F36CCE44A29A06FCAF1C /* VoiceMessageRecordingComposer.swift */, + 0A634D8DD1E10D858CF7995D /* VoiceMessageRecordingView.swift */, ); path = View; sourceTree = ""; @@ -2820,6 +2863,7 @@ AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */, 37CA26F55123E36B50DB0B3A /* AttributedStringTests.swift */, 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */, + C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */, 6DFCAA239095A116976E32C4 /* BackgroundTaskTests.swift */, EFFD3200F9960D4996159F10 /* BugReportServiceTests.swift */, 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */, @@ -3346,18 +3390,6 @@ path = TimelineItems; sourceTree = ""; }; - 984A887BA0294FE3B00CE9B1 /* AudioPlayer */ = { - isa = PBXGroup; - children = ( - CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */, - FB4D7B3FBCA261927536FE64 /* AudioConverterProtocol.swift */, - 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */, - 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */, - FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */, - ); - path = AudioPlayer; - sourceTree = ""; - }; 99B9B46F2D621380428E68F7 /* ElementX */ = { isa = PBXGroup; children = ( @@ -4146,6 +4178,17 @@ path = Timeline; sourceTree = ""; }; + FCE7249621F507F34A8122FB /* Audio */ = { + isa = PBXGroup; + children = ( + E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */, + 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */, + 3A2CAA4ABF5E66C3C8BBA3E9 /* Player */, + 0371482D36C95ABAF9D4C651 /* Recorder */, + ); + path = Audio; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -4641,6 +4684,7 @@ 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */, 5100F53E6884A15F9BA07CC3 /* AttributedStringTests.swift in Sources */, C1429699A6A5BB09A25775C1 /* AudioPlayerStateTests.swift in Sources */, + 3042527CB344A9EF1157FC26 /* AudioRecorderStateTests.swift in Sources */, 0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */, 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */, @@ -4788,11 +4832,14 @@ D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */, 3ED2725734568F6B8CC87544 /* AttributedStringBuilder.swift in Sources */, A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */, - 5F8E96263497FFB7D3254EB2 /* AudioConverter.swift in Sources */, - 8418688282763F4B9DDC42FB /* AudioConverterProtocol.swift in Sources */, - CD1C6943F42F29079E5E7511 /* AudioPlayer.swift in Sources */, - F0B196905CD23E3B4505CB7B /* AudioPlayerProtocol.swift in Sources */, - C19085A284D54A166A64A86C /* AudioPlayerState.swift in Sources */, + 2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */, + F3F38062C6CA21CF403C5C90 /* AudioConverterProtocol.swift in Sources */, + 46C9F8FE3810A04A005FE16B /* AudioPlayer.swift in Sources */, + 086D01E79C8E8D3F004FAF21 /* AudioPlayerProtocol.swift in Sources */, + 1471A080552631358D152C18 /* AudioPlayerState.swift in Sources */, + 62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */, + 3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */, + 5992EF10AA157EBD97D88910 /* AudioRecorderState.swift in Sources */, F8E725D42023ECA091349245 /* AudioRoomTimelineItem.swift in Sources */, 88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */, E62EC30B39354A391E32A126 /* AudioRoomTimelineView.swift in Sources */, @@ -5316,6 +5363,11 @@ 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */, 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */, 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */, + 33CA777C9DF263582D77A67F /* VoiceMessagePreviewComposer.swift in Sources */, + 09EF4222EEBBA1A7B8F4071E /* VoiceMessageRecordingButton.swift in Sources */, + 8C27BEB00B903D953F31F962 /* VoiceMessageRecordingButtonTooltipView.swift in Sources */, + CA5BFF0C2EF5A8EF40CA2D69 /* VoiceMessageRecordingComposer.swift in Sources */, + EBDB339A7C127F068B6E52E5 /* VoiceMessageRecordingView.swift in Sources */, A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */, 024E70451A7CD9E4E034D8A9 /* VoiceMessageRoomTimelineItem.swift in Sources */, 87B4E59A4467F8EC75F82372 /* VoiceMessageRoomTimelineView.swift in Sources */, diff --git a/ElementX/Resources/Assets.xcassets/images/composer/mic-fill.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/mic-fill.imageset/Contents.json new file mode 100644 index 000000000..11ad3f3af --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/mic-fill.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mic-fill.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/mic-fill.imageset/mic-fill.svg b/ElementX/Resources/Assets.xcassets/images/composer/mic-fill.imageset/mic-fill.svg new file mode 100644 index 000000000..7808332fc --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/mic-fill.imageset/mic-fill.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/mic.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/mic.imageset/Contents.json new file mode 100644 index 000000000..03a8f8274 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/mic.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mic.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/mic.imageset/mic.svg b/ElementX/Resources/Assets.xcassets/images/composer/mic.imageset/mic.svg new file mode 100644 index 000000000..a0a78f3a8 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/mic.imageset/mic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 8ce95ae7a..cc0585e4e 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -17,4 +17,3 @@ "soft_logout_clear_data_submit" = "Clear all data"; "soft_logout_clear_data_dialog_title" = "Clear data"; "soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages."; - diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index a3d3bccfc..30e66ccb0 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -49,6 +49,8 @@ internal enum Asset { internal static let inlineCode = ImageAsset(name: "images/inline-code") internal static let italic = ImageAsset(name: "images/italic") internal static let link = ImageAsset(name: "images/link") + internal static let micFill = ImageAsset(name: "images/mic-fill") + internal static let mic = ImageAsset(name: "images/mic") internal static let numberedList = ImageAsset(name: "images/numbered-list") internal static let quote = ImageAsset(name: "images/quote") internal static let sendMessage = ImageAsset(name: "images/send-message") diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index be6a1ca7c..ea9c6eb1b 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -246,6 +246,74 @@ class AudioPlayerMock: AudioPlayerProtocol { await seekToClosure?(progress) } } +class AudioRecorderMock: AudioRecorderProtocol { + var actions: AnyPublisher { + get { return underlyingActions } + set(value) { underlyingActions = value } + } + var underlyingActions: AnyPublisher! + var currentTime: TimeInterval { + get { return underlyingCurrentTime } + set(value) { underlyingCurrentTime = value } + } + var underlyingCurrentTime: TimeInterval! + var isRecording: Bool { + get { return underlyingIsRecording } + set(value) { underlyingIsRecording = value } + } + var underlyingIsRecording: Bool! + var url: URL? + + //MARK: - recordWithOutputURL + + var recordWithOutputURLCallsCount = 0 + var recordWithOutputURLCalled: Bool { + return recordWithOutputURLCallsCount > 0 + } + var recordWithOutputURLReceivedUrl: URL? + var recordWithOutputURLReceivedInvocations: [URL] = [] + var recordWithOutputURLClosure: ((URL) -> Void)? + + func recordWithOutputURL(_ url: URL) { + recordWithOutputURLCallsCount += 1 + recordWithOutputURLReceivedUrl = url + recordWithOutputURLReceivedInvocations.append(url) + recordWithOutputURLClosure?(url) + } + //MARK: - stopRecording + + var stopRecordingCallsCount = 0 + var stopRecordingCalled: Bool { + return stopRecordingCallsCount > 0 + } + var stopRecordingClosure: (() -> Void)? + + func stopRecording() { + stopRecordingCallsCount += 1 + stopRecordingClosure?() + } + //MARK: - averagePowerForChannelNumber + + var averagePowerForChannelNumberCallsCount = 0 + var averagePowerForChannelNumberCalled: Bool { + return averagePowerForChannelNumberCallsCount > 0 + } + var averagePowerForChannelNumberReceivedChannelNumber: Int? + var averagePowerForChannelNumberReceivedInvocations: [Int] = [] + var averagePowerForChannelNumberReturnValue: Float! + var averagePowerForChannelNumberClosure: ((Int) -> Float)? + + func averagePowerForChannelNumber(_ channelNumber: Int) -> Float { + averagePowerForChannelNumberCallsCount += 1 + averagePowerForChannelNumberReceivedChannelNumber = channelNumber + averagePowerForChannelNumberReceivedInvocations.append(channelNumber) + if let averagePowerForChannelNumberClosure = averagePowerForChannelNumberClosure { + return averagePowerForChannelNumberClosure(channelNumber) + } else { + return averagePowerForChannelNumberReturnValue + } + } +} class BugReportServiceMock: BugReportServiceProtocol { var isRunning: Bool { get { return underlyingIsRunning } diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index cde05dbf2..27c3f0179 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -30,6 +30,11 @@ enum ComposerToolbarViewModelAction { case composerModeChanged(mode: RoomScreenComposerMode) case composerFocusedChanged(isFocused: Bool) + + case startRecordingVoiceMessage + case stopRecordingVoiceMessage + case deleteRecordedVoiceMessage + case sendVoiceMessage } enum ComposerToolbarViewAction { @@ -46,6 +51,9 @@ enum ComposerToolbarViewAction { case enableTextFormatting case composerAction(action: ComposerAction) case selectedSuggestion(_ suggestion: SuggestionItem) + case startRecordingVoiceMessage + case stopRecordingVoiceMessage + case deleteRecordedVoiceMessage } struct ComposerToolbarViewState: BindableState { @@ -53,11 +61,33 @@ struct ComposerToolbarViewState: BindableState { var composerEmpty = true var areSuggestionsEnabled = true var suggestions: [SuggestionItem] = [] - + + var enableVoiceMessageComposer: Bool + var audioPlayerState: AudioPlayerState + var audioRecorderState: AudioRecorderState + var bindings: ComposerToolbarViewStateBindings + var showSendButton: Bool { + switch composerMode { + case .recordVoiceMessage: + return false + case .previewVoiceMessage: + return true + default: + if enableVoiceMessageComposer { + return !composerEmpty + } else { + return true + } + } + } + var sendButtonDisabled: Bool { - composerEmpty + if case .previewVoiceMessage = composerMode { + return false + } + return composerEmpty } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index 085d9f332..333c11037 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -45,7 +45,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool self.completionSuggestionService = completionSuggestionService self.appSettings = appSettings - super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled, bindings: .init()), imageProvider: mediaProvider) + super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled, + enableVoiceMessageComposer: appSettings.voiceMessageEnabled, + audioPlayerState: .init(duration: 0), + audioRecorderState: .init(), + bindings: .init()), + imageProvider: mediaProvider) context.$viewState .map(\.composerMode) @@ -98,10 +103,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool wysiwygViewModel.setup() case .sendMessage: guard !state.sendButtonDisabled else { return } - let sendHTML = ServiceLocator.shared.settings.richTextEditorEnabled - actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown, - html: sendHTML ? wysiwygViewModel.content.html : nil, - mode: state.composerMode)) + + switch state.composerMode { + case .previewVoiceMessage: + actionsSubject.send(.sendVoiceMessage) + default: + let sendHTML = ServiceLocator.shared.settings.richTextEditorEnabled + actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown, + html: sendHTML ? wysiwygViewModel.content.html : nil, + mode: state.composerMode)) + } case .cancelReply: set(mode: .default) case .cancelEdit: @@ -130,6 +141,13 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } case .selectedSuggestion(let suggestion): handleSuggestion(suggestion) + case .startRecordingVoiceMessage: + state.bindings.composerActionsEnabled = false + actionsSubject.send(.startRecordingVoiceMessage) + case .stopRecordingVoiceMessage: + actionsSubject.send(.stopRecordingVoiceMessage) + case .deleteRecordedVoiceMessage: + actionsSubject.send(.deleteRecordedVoiceMessage) } } @@ -197,7 +215,15 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool guard mode != state.composerMode else { return } state.composerMode = mode - if mode != .default { + switch mode { + case .default: + break + case .recordVoiceMessage(let audioRecorderState): + state.bindings.composerFocused = false + state.audioRecorderState = audioRecorderState + case .previewVoiceMessage(let audioPlayerState): + state.audioPlayerState = audioPlayerState + case .edit, .reply: // Focus composer when switching to reply/edit state.bindings.composerFocused = true } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index c2566ea77..84aa36c5d 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -22,16 +22,21 @@ struct ComposerToolbar: View { @ObservedObject var context: ComposerToolbarViewModel.Context let wysiwygViewModel: WysiwygComposerViewModel let keyCommandHandler: KeyCommandHandler - + @FocusState private var composerFocused: Bool @ScaledMetric private var sendButtonIconSize = 16 + @ScaledMetric private var trashButtonIconSize = 24 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 + @State private var showVoiceMessageRecordingTooltip = false + @ScaledMetric private var voiceMessageTooltipPointerHeight = 6 + @State private var frame: CGRect = .zero - + var body: some View { VStack(spacing: 8) { topBar + if context.composerActionsEnabled { bottomBar } @@ -45,6 +50,10 @@ struct ComposerToolbar: View { .offset(y: -frame.height) } } + .overlay(alignment: .bottomTrailing) { + voiceMessageRecordingButtonTooltipView + .offset(y: -frame.height - voiceMessageTooltipPointerHeight) + } .alert(item: $context.alertInfo) } @@ -53,42 +62,55 @@ struct ComposerToolbar: View { context.send(viewAction: .selectedSuggestion(suggestion)) } } - + private var topBar: some View { HStack(alignment: .bottom, spacing: 5) { - if !context.composerActionsEnabled { - RoomAttachmentPicker(context: context) + switch context.viewState.composerMode { + case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer: + VoiceMessageRecordingComposer(recorderState: state) + .padding(.leading, 12) + case .previewVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer: + voiceMessageTrashButton + VoiceMessagePreviewComposer(playerState: state) + default: + if !context.composerActionsEnabled { + RoomAttachmentPicker(context: context) + } + messageComposer + .environmentObject(context) + .onTapGesture { + guard !composerFocused else { return } + composerFocused = true + } + .padding(.leading, context.composerActionsEnabled ? 7 : 0) + .padding(.trailing, context.composerActionsEnabled ? 4 : 0) } - messageComposer - .environmentObject(context) - .onTapGesture { - guard !composerFocused else { return } - composerFocused = true - } - .padding(.leading, context.composerActionsEnabled ? 7 : 0) - .padding(.trailing, context.composerActionsEnabled ? 4 : 0) - if !context.composerActionsEnabled { - sendButton - .padding(.leading, 3) + if context.viewState.showSendButton { + sendButton + .padding(.leading, 3) + } else if context.viewState.enableVoiceMessageComposer { + voiceMessageRecordingButton + .padding(.leading, 4) + } } } } - + private var bottomBar: some View { HStack(alignment: .center, spacing: 9) { closeRTEButton - + FormattingToolbar(formatItems: context.formatItems) { action in context.send(viewAction: .composerAction(action: action.composerAction)) } - + sendButton .padding(.leading, 7) } } - + private var closeRTEButton: some View { Button { context.composerActionsEnabled = false @@ -103,7 +125,7 @@ struct ComposerToolbar: View { .accessibilityLabel(L10n.actionClose) .accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions) } - + private var sendButton: some View { Button { context.send(viewAction: .sendMessage) @@ -141,7 +163,7 @@ struct ComposerToolbar: View { .focused($composerFocused) .onChange(of: context.composerFocused) { newValue in guard composerFocused != newValue else { return } - + composerFocused = newValue } .onChange(of: composerFocused) { newValue in @@ -167,7 +189,7 @@ struct ComposerToolbar: View { context.send(viewAction: .handlePasteOrDrop(provider: provider)) } } - + private var submitButtonImage: some View { // ZStack with opacity so the button size is consistent. ZStack { @@ -184,12 +206,44 @@ struct ComposerToolbar: View { .accessibilityHidden(context.viewState.composerMode.isEdit) } } - + private class ItemProviderHelper: WysiwygItemProviderHelper { func isPasteSupported(for itemProvider: NSItemProvider) -> Bool { itemProvider.isSupportedForPasteOrDrop } } + + // MARK: - Voice message + + private var voiceMessageRecordingButton: some View { + VoiceMessageRecordingButton(showRecordTooltip: $showVoiceMessageRecordingTooltip, startRecording: { + context.send(viewAction: .startRecordingVoiceMessage) + }, stopRecording: { + context.send(viewAction: .stopRecordingVoiceMessage) + }) + .padding(4) + } + + private var voiceMessageTrashButton: some View { + Button { + context.send(viewAction: .deleteRecordedVoiceMessage) + } label: { + CompoundIcon(\.delete) + .font(.compound.bodyLG) + .foregroundColor(.compound.textCriticalPrimary) + .frame(width: trashButtonIconSize, height: trashButtonIconSize) + .padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 11)) + .fixedSize() + .accessibilityLabel(L10n.a11yDelete) + } + } + + private var voiceMessageRecordingButtonTooltipView: some View { + VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, pointerHeight: voiceMessageTooltipPointerHeight) + .allowsHitTesting(false) + .opacity(showVoiceMessageRecordingTooltip ? 1.0 : 0.0) + .animation(.elementDefault, value: showVoiceMessageRecordingTooltip) + } } struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { @@ -203,7 +257,7 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))] static var previews: some View { - ComposerToolbar.mock() + ComposerToolbar.mock(focused: true) // Putting them is VStack allows the completion suggestion preview to work properly in tests VStack { @@ -213,19 +267,82 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { keyCommandHandler: { _ in false }) } .previewDisplayName("With Suggestions") + + VStack { + ComposerToolbar.textWithVoiceMessage(focused: false) + ComposerToolbar.textWithVoiceMessage(focused: true) + ComposerToolbar.voiceMessageRecordingMock(recording: true) + ComposerToolbar.voiceMessagePreviewMock(recording: false) + } + .previewDisplayName("Voice Message") } } // MARK: - Mock extension ComposerToolbar { - static func mock() -> ComposerToolbar { + static func mock(focused: Bool = true) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() - let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, - completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), - mediaProvider: MockMediaProvider(), - appSettings: ServiceLocator.shared.settings, - mentionDisplayHelper: ComposerMentionDisplayHelper.mock) + var composerViewModel: ComposerToolbarViewModel { + let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider(), + appSettings: ServiceLocator.shared.settings, + mentionDisplayHelper: ComposerMentionDisplayHelper.mock) + model.state.composerEmpty = focused + return model + } + return ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommandHandler: { _ in false }) + } + + static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar { + let wysiwygViewModel = WysiwygComposerViewModel() + var composerViewModel: ComposerToolbarViewModel { + let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider(), + appSettings: ServiceLocator.shared.settings, + mentionDisplayHelper: ComposerMentionDisplayHelper.mock) + model.state.composerEmpty = focused + model.state.enableVoiceMessageComposer = true + return model + } + return ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommandHandler: { _ in false }) + } + + static func voiceMessageRecordingMock(recording: Bool) -> ComposerToolbar { + let wysiwygViewModel = WysiwygComposerViewModel() + var composerViewModel: ComposerToolbarViewModel { + let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider(), + appSettings: ServiceLocator.shared.settings, + mentionDisplayHelper: ComposerMentionDisplayHelper.mock) + model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState()) + model.state.enableVoiceMessageComposer = true + return model + } + return ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommandHandler: { _ in false }) + } + + static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar { + let wysiwygViewModel = WysiwygComposerViewModel() + var composerViewModel: ComposerToolbarViewModel { + let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider(), + appSettings: ServiceLocator.shared.settings, + mentionDisplayHelper: ComposerMentionDisplayHelper.mock) + model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(duration: 10.0)) + model.state.enableVoiceMessageComposer = true + return model + } return ComposerToolbar(context: composerViewModel.context, wysiwygViewModel: wysiwygViewModel, keyCommandHandler: { _ in false }) diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index 48c746771..42dbee8b7 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -90,7 +90,7 @@ struct MessageComposer: View { MessageComposerReplyHeader(replyDetails: replyDetails, action: replyCancellationAction) case .edit: MessageComposerEditHeader(action: editCancellationAction) - case .default: + case .recordVoiceMessage, .previewVoiceMessage, .default: EmptyView() } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift new file mode 100644 index 000000000..e7fcf7b62 --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift @@ -0,0 +1,63 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +struct VoiceMessagePreviewComposer: View { + @ObservedObject var playerState: AudioPlayerState + + @State private var resumePlaybackAfterScrubbing = false + + var body: some View { + VoiceMessageRoomPlaybackView(playerState: playerState, + onPlayPause: onPlaybackPlayPause, + onSeek: { onPlaybackSeek($0) }, + onScrubbing: { onPlaybackScrubbing($0) }) + .padding(.vertical, 4.0) + .padding(.horizontal, 6.0) + .background { + let roundedRectangle = RoundedRectangle(cornerRadius: 12) + ZStack { + roundedRectangle + .fill(Color.compound.bgSubtleSecondary) + roundedRectangle + .stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5) + } + } + .frame(minHeight: 42) + .fixedSize(horizontal: false, vertical: true) + } + + private func onPlaybackPlayPause() { } + + private func onPlaybackSeek(_ progress: Double) { } + + private func onPlaybackScrubbing(_ dragging: Bool) { } +} + +struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview { + static let playerState = AudioPlayerState(duration: 10.0, + waveform: Waveform.mockWaveform, + progress: 0.4) + + static var previews: some View { + VStack { + VoiceMessagePreviewComposer(playerState: playerState) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift new file mode 100644 index 000000000..28b399ba8 --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift @@ -0,0 +1,83 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Compound +import SwiftUI + +struct VoiceMessageRecordingButton: View { + @ScaledMetric private var buttonIconSize = 24 + @State private var longPressConfirmed = false + @State private var buttonPressed = false + @State private var longPressTask = VoiceMessageButtonTask() + + private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) + private let delayBeforeRecording = 500 + + @Binding var showRecordTooltip: Bool + var startRecording: (() -> Void)? + var stopRecording: (() -> Void)? + + var body: some View { + Button { } label: { + voiceMessageButtonImage + } + .onLongPressGesture(perform: { }, onPressingChanged: { pressing in + buttonPressed = pressing + if pressing { + showRecordTooltip = true + feedbackGenerator.prepare() + longPressTask.task = Task { + try? await Task.sleep(for: .milliseconds(delayBeforeRecording)) + guard !Task.isCancelled else { + return + } + feedbackGenerator.impactOccurred() + showRecordTooltip = false + longPressConfirmed = true + startRecording?() + } + } else { + longPressTask.task?.cancel() + showRecordTooltip = false + guard longPressConfirmed else { return } + longPressConfirmed = false + stopRecording?() + } + }) + .fixedSize() + } + + @ViewBuilder + private var voiceMessageButtonImage: some View { + (buttonPressed ? Image(asset: Asset.Images.micFill) : Image(asset: Asset.Images.mic)) + .resizable() + .frame(width: buttonIconSize, height: buttonIconSize) + .foregroundColor(.compound.iconSecondary) + .padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)) + .accessibilityLabel(L10n.a11yVoiceMessageRecord) + } +} + +private class VoiceMessageButtonTask { + @CancellableTask var task: Task? +} + +struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VoiceMessageRecordingButton(showRecordTooltip: .constant(false)) + .fixedSize(horizontal: true, vertical: true) + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift new file mode 100644 index 000000000..2d996652c --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift @@ -0,0 +1,101 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Compound +import Foundation +import SwiftUI + +struct VoiceMessageRecordingButtonTooltipView: View { + var text: String + var radius: CGFloat = 4 + var corners: UIRectCorner = .allCorners + @ScaledMetric var pointerHeight: CGFloat = 6 + @ScaledMetric var pointerWidth: CGFloat = 10 + @ScaledMetric var pointerOffset: CGFloat = 8 + + var body: some View { + Text(text) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textOnSolidPrimary) + .padding(6) + .background( + TooltipShape(radius: radius, + corners: corners, + pointerHeight: pointerHeight, + pointerWidth: pointerWidth, + pointerOffset: pointerOffset) + .fill(.compound.bgActionPrimaryRest) + ) + .padding(.trailing, 8) + } +} + +private struct TooltipShape: Shape { + var radius: CGFloat = 4 + var corners: UIRectCorner = .allCorners + var pointerHeight: CGFloat = 6 + var pointerWidth: CGFloat = 10 + var pointerOffset: CGFloat = 8 + + func path(in rect: CGRect) -> Path { + var path = Path() + + let width = rect.size.width + let height = rect.size.height + + var topLeft: CGFloat = corners.contains(.topLeft) ? radius : 0.0 + var topRight: CGFloat = corners.contains(.topRight) ? radius : 0.0 + var bottomLeft: CGFloat = corners.contains(.bottomLeft) ? radius : 0.0 + var bottomRight: CGFloat = corners.contains(.bottomRight) ? radius : 0.0 + + // Make sure we do not exceed the size of the rectangle + topRight = min(min(topRight, height / 2), width / 2) + topLeft = min(min(topLeft, height / 2), width / 2) + bottomLeft = min(min(bottomLeft, height / 2), width / 2) + bottomRight = min(min(bottomRight, height / 2), width / 2) + + path.move(to: CGPoint(x: width / 2.0, y: 0)) + path.addLine(to: CGPoint(x: width - topRight, y: 0)) + path.addArc(center: CGPoint(x: width - topRight, y: topRight), radius: topRight, + startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false) + + path.addLine(to: CGPoint(x: width, y: height - bottomRight)) + path.addArc(center: CGPoint(x: width - bottomRight, y: height - bottomRight), radius: bottomRight, + startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false) + + path.addLine(to: CGPoint(x: width - pointerOffset, y: height)) + path.addLine(to: CGPoint(x: width - pointerOffset - (pointerWidth / 2.0), y: height + pointerHeight)) + path.addLine(to: CGPoint(x: width - pointerOffset - pointerWidth, y: height)) + + path.addLine(to: CGPoint(x: bottomLeft, y: height)) + path.addArc(center: CGPoint(x: bottomLeft, y: height - bottomLeft), radius: bottomLeft, + startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false) + + path.addLine(to: CGPoint(x: 0, y: topLeft)) + path.addArc(center: CGPoint(x: topLeft, y: topLeft), radius: topLeft, + startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false) + path.closeSubpath() + + return path + } +} + +struct VoiceMessageRecordingButtonTooltipView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VoiceMessageRecordingButtonTooltipView(text: "Hold to record") + .fixedSize() + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift new file mode 100644 index 000000000..9679a2f71 --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift @@ -0,0 +1,57 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Compound +import Foundation +import SwiftUI + +struct VoiceMessageRecordingComposer: View { + @ObservedObject var recorderState: AudioRecorderState + + var body: some View { + VoiceMessageRecordingView(recorderState: recorderState) + .padding(.vertical, 8.0) + .padding(.horizontal, 12.0) + .background { + let roundedRectangle = RoundedRectangle(cornerRadius: 12) + ZStack { + roundedRectangle + .fill(Color.compound.bgSubtleSecondary) + roundedRectangle + .stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5) + } + } + .frame(minHeight: 42) + .fixedSize(horizontal: false, vertical: true) + } + + private func onPlaybackPlayPause() { } + + private func onPlaybackSeek(_ progress: Double) { } + + private func onPlaybackScrubbing(_ dragging: Bool) { } +} + +struct VoiceMessageRecordingComposer_Previews: PreviewProvider, TestablePreview { + static let recorderState = AudioRecorderState() + + static var previews: some View { + VStack { + VoiceMessageRecordingComposer(recorderState: recorderState) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift new file mode 100644 index 000000000..060c71c4c --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift @@ -0,0 +1,67 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Compound +import Foundation +import SwiftUI + +struct VoiceMessageRecordingView: View { + @ObservedObject var recorderState: AudioRecorderState + @ScaledMetric private var waveformLineWidth = 2.0 + @ScaledMetric private var waveformLinePadding = 2.0 + @ScaledMetric private var recordingIndicatorSize = 8 + + private static let elapsedTimeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "mm:ss" + return dateFormatter + }() + + var timeLabelContent: String { + Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: recorderState.duration)) + } + + var body: some View { + HStack { + Circle() + .frame(width: recordingIndicatorSize, height: recordingIndicatorSize) + .foregroundColor(.red) + Text(timeLabelContent) + .lineLimit(1) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textSecondary) + .monospacedDigit() + .fixedSize() + WaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: recorderState.waveform, progress: 0, showCursor: false) + } + .padding(.leading, 2) + .padding(.trailing, 8) + } +} + +struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview { + static let waveform = Waveform(data: [3, 127, 400, 266, 126, 122, 373, 251, 45, 112, + 334, 205, 99, 138, 397, 354, 125, 361, 199, 51, + 294, 131, 19, 2, 3, 3, 1, 2, 0, 0, + 0, 0, 0, 0, 0, 3]) + + static let recorderState = AudioRecorderState() + + static var previews: some View { + VoiceMessageRecordingView(recorderState: recorderState) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 6d09637fc..592493980 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -40,6 +40,8 @@ enum RoomScreenComposerMode: Equatable { case `default` case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool) case edit(originalItemId: TimelineItemIdentifier) + case recordVoiceMessage(state: AudioRecorderState) + case previewVoiceMessage(state: AudioPlayerState) var isEdit: Bool { switch self { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 4294bf517..a493e35bf 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -186,6 +186,15 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol trackComposerMode(mode) case .composerFocusedChanged(isFocused: let isFocused): composerFocusedSubject.send(isFocused) + case .startRecordingVoiceMessage: + timelineController.pauseAudio() + startRecordingVoiceMessage() + case .stopRecordingVoiceMessage: + stopRecordingVoiceMessage() + case .deleteRecordedVoiceMessage: + deleteCurrentVoiceMessage() + case .sendVoiceMessage: + Task { await sendCurrentVoiceMessage() } } } @@ -466,9 +475,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol await timelineController.editMessage(message, html: html, original: originalItemId) case .default: await timelineController.sendMessage(message, html: html) + case .recordVoiceMessage, .previewVoiceMessage: + fatalError("invalid composer mode.") } } - + private func trackComposerMode(_ mode: RoomScreenComposerMode) { var isEdit = false var isReply = false @@ -888,6 +899,34 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { timelineController.audioPlayerState(for: itemID) } + + // MARK: - Voice message + + private func startRecordingVoiceMessage() { + // Partially implemented + + let audioRecordState = AudioRecorderState() + actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState)))) + } + + private func stopRecordingVoiceMessage() { + // Partially implemented + + let audioPlayerState = AudioPlayerState(duration: 0) + actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState)))) + } + + private func deleteCurrentVoiceMessage() { + // Partially implemented + + actionsSubject.send(.composer(action: .setMode(mode: .default))) + } + + private func sendCurrentVoiceMessage() async { + // Partially implemented + + actionsSubject.send(.composer(action: .setMode(mode: .default))) + } } private extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift b/ElementX/Sources/Services/Audio/AudioConverter.swift similarity index 95% rename from ElementX/Sources/Services/AudioPlayer/AudioConverter.swift rename to ElementX/Sources/Services/Audio/AudioConverter.swift index 8b3273864..284325a66 100644 --- a/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift +++ b/ElementX/Sources/Services/Audio/AudioConverter.swift @@ -30,6 +30,7 @@ enum AudioConverterPreferredFileExtension: String { struct AudioConverter: AudioConverterProtocol { func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws { do { + MXLog.debug("converting \(sourceURL) to \(destinationURL)") try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL) } catch { MXLog.error("failed to convert to OpusOgg: \(error)") diff --git a/ElementX/Sources/Services/AudioPlayer/AudioConverterProtocol.swift b/ElementX/Sources/Services/Audio/AudioConverterProtocol.swift similarity index 100% rename from ElementX/Sources/Services/AudioPlayer/AudioConverterProtocol.swift rename to ElementX/Sources/Services/Audio/AudioConverterProtocol.swift diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayer.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift similarity index 100% rename from ElementX/Sources/Services/AudioPlayer/AudioPlayer.swift rename to ElementX/Sources/Services/Audio/Player/AudioPlayer.swift diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift similarity index 100% rename from ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift rename to ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift similarity index 94% rename from ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift rename to ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift index fa3bde919..9c1a86f4b 100644 --- a/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift @@ -27,7 +27,8 @@ enum AudioPlayerPlaybackState { } @MainActor -class AudioPlayerState: ObservableObject { +class AudioPlayerState: ObservableObject, Identifiable { + let id = UUID() let duration: Double let waveform: Waveform @Published private(set) var playbackState: AudioPlayerPlaybackState @@ -150,3 +151,9 @@ class AudioPlayerState: ObservableObject { await audioPlayer.seek(to: progress) } } + +extension AudioPlayerState: Equatable { + nonisolated static func == (lhs: AudioPlayerState, rhs: AudioPlayerState) -> Bool { + lhs.id == rhs.id + } +} diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift new file mode 100644 index 000000000..f34ec79c5 --- /dev/null +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift @@ -0,0 +1,115 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AVFoundation +import Combine +import Foundation + +enum AudioRecorderError: Error { + case genericError +} + +class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { + private let silenceThreshold: Float = -50.0 + + private var audioRecorder: AVAudioRecorder? + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + var url: URL? { + audioRecorder?.url + } + + var currentTime: TimeInterval { + audioRecorder?.currentTime ?? 0 + } + + var isRecording: Bool { + audioRecorder?.isRecording ?? false + } + + func recordWithOutputURL(_ url: URL) { + let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 48000, + AVEncoderBitRateKey: 128_000, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue] + + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + audioRecorder = try AVAudioRecorder(url: url, settings: settings) + audioRecorder?.delegate = self + audioRecorder?.isMeteringEnabled = true + audioRecorder?.record() + actionsSubject.send(.didStartRecording) + } catch { + MXLog.error("audio recording failed: \(error)") + actionsSubject.send(.didFailWithError(error: error)) + } + } + + func stopRecording() { + audioRecorder?.stop() + + do { + try AVAudioSession.sharedInstance().setActive(false) + } catch { + actionsSubject.send(.didFailWithError(error: error)) + } + } + + func peakPowerForChannelNumber(_ channelNumber: Int) -> Float { + guard isRecording, let audioRecorder else { + return 0.0 + } + + audioRecorder.updateMeters() + + return normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber)) + } + + func averagePowerForChannelNumber(_ channelNumber: Int) -> Float { + guard isRecording, let audioRecorder else { + return 0.0 + } + + audioRecorder.updateMeters() + + return normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber)) + } + + // MARK: - AVAudioRecorderDelegate + + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) { + if success { + actionsSubject.send(.didStopRecording) + } else { + actionsSubject.send(.didFailWithError(error: AudioRecorderError.genericError)) + } + } + + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + actionsSubject.send(.didFailWithError(error: error ?? AudioRecorderError.genericError)) + } + + private func normalizedPowerLevelFromDecibels(_ decibels: Float) -> Float { + decibels / silenceThreshold + } +} diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift new file mode 100644 index 000000000..b1b47b84a --- /dev/null +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift @@ -0,0 +1,38 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +enum AudioRecorderAction { + case didStartRecording + case didStopRecording + case didFailWithError(error: Error) +} + +protocol AudioRecorderProtocol: AnyObject { + var actions: AnyPublisher { get } + var currentTime: TimeInterval { get } + var isRecording: Bool { get } + var url: URL? { get } + + func recordWithOutputURL(_ url: URL) + func stopRecording() + func averagePowerForChannelNumber(_ channelNumber: Int) -> Float +} + +// sourcery: AutoMockable +extension AudioRecorderProtocol { } diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift new file mode 100644 index 000000000..5348cd311 --- /dev/null +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift @@ -0,0 +1,114 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import UIKit + +enum AudioRecorderRecordingState { + case recording + case stopped + case error +} + +@MainActor +class AudioRecorderState: ObservableObject, Identifiable { + let id = UUID() + + @Published private(set) var recordingState: AudioRecorderRecordingState = .stopped + @Published private(set) var duration = 0.0 + @Published private(set) var waveform = Waveform(data: Array(repeating: 0, count: 100)) + + private weak var audioRecorder: AudioRecorderProtocol? + private var cancellables: Set = [] + private var displayLink: CADisplayLink? + + func attachAudioRecorder(_ audioRecorder: AudioRecorderProtocol) { + if self.audioRecorder != nil { + detachAudioRecorder() + } + recordingState = .stopped + self.audioRecorder = audioRecorder + subscribeToAudioRecorder(audioRecorder) + } + + func detachAudioRecorder() { + guard audioRecorder != nil else { return } + audioRecorder?.stopRecording() + stopPublishUpdates() + cancellables = [] + audioRecorder = nil + recordingState = .stopped + } + + func reportError(_ error: Error) { + recordingState = .error + } + + // MARK: - Private + + private func subscribeToAudioRecorder(_ audioRecorder: AudioRecorderProtocol) { + audioRecorder.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + guard let self else { + return + } + self.handleAudioRecorderAction(action) + } + .store(in: &cancellables) + } + + private func handleAudioRecorderAction(_ action: AudioRecorderAction) { + switch action { + case .didStartRecording: + startPublishUpdates() + recordingState = .recording + case .didStopRecording: + stopPublishUpdates() + recordingState = .stopped + case .didFailWithError: + stopPublishUpdates() + recordingState = .stopped + } + } + + private func startPublishUpdates() { + if displayLink != nil { + stopPublishUpdates() + } + displayLink = CADisplayLink(target: self, selector: #selector(publishUpdate)) + displayLink?.preferredFrameRateRange = .init(minimum: 10, maximum: 20) + displayLink?.add(to: .current, forMode: .common) + } + + @objc private func publishUpdate(displayLink: CADisplayLink) { + if let currentTime = audioRecorder?.currentTime { + duration = currentTime + } + } + + private func stopPublishUpdates() { + displayLink?.invalidate() + displayLink = nil + } +} + +extension AudioRecorderState: Equatable { + nonisolated static func == (lhs: AudioRecorderState, rhs: AudioRecorderState) -> Bool { + lhs.id == rhs.id + } +} diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index edf6b8622..0144f5831 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -115,7 +115,7 @@ protocol RoomProxyProtocol { audioInfo: AudioInfo, progressSubject: CurrentValueSubject?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result - + func sendFile(url: URL, fileInfo: FileInfo, progressSubject: CurrentValueSubject?, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 3342c9564..1a7ab9c84 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -67,7 +67,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { .none } func sendMessage(_ message: String, html: String?, inReplyTo itemID: TimelineItemIdentifier?) async { } - + func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async { } func editMessage(_ newMessage: String, html: String?, original itemID: TimelineItemIdentifier) async { } @@ -90,6 +90,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func playPauseAudio(for itemID: TimelineItemIdentifier) async { } + func pauseAudio() { } + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { } // MARK: - UI Test signalling diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index d20805b88..96cd06ff2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -240,6 +240,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { if let playerState = timelineAudioPlayerStates[itemID] { return playerState } + let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration, waveform: voiceMessageRoomTimelineItem.content.waveform) timelineAudioPlayerStates[itemID] = playerState @@ -278,6 +279,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { // Load content do { let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil) + // Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded. if playerState.isAttached { player.load(mediaSource: source, using: url) @@ -297,10 +299,16 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } + func pauseAudio() { + timelineAudioPlayerStates.forEach { _, playerState in + playerState.detachAudioPlayer() + } + } + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { await timelineAudioPlayerStates[itemID]?.updateState(progress: progress) } - + // MARK: - Private @objc private func contentSizeCategoryDidChange() { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index c90254cc4..a979cf12f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -52,7 +52,7 @@ protocol RoomTimelineControllerProtocol { func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result func sendMessage(_ message: String, html: String?, inReplyTo itemID: TimelineItemIdentifier?) async - + func editMessage(_ newMessage: String, html: String?, original itemID: TimelineItemIdentifier) async func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async @@ -69,6 +69,8 @@ protocol RoomTimelineControllerProtocol { func playPauseAudio(for itemID: TimelineItemIdentifier) async + func pauseAudio() + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift index b5da7df4d..88d29edd7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift @@ -18,16 +18,16 @@ import SwiftUI struct VoiceMessageRoomPlaybackView: View { @ObservedObject var playerState: AudioPlayerState + @ScaledMetric private var waveformLineWidth = 2.0 + @ScaledMetric private var waveformLinePadding = 2.0 + let onPlayPause: () -> Void let onSeek: (Double) -> Void let onScrubbing: (Bool) -> Void private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) @State private var sendFeedback = false - - @ScaledMetric private var waveformLineWidth = 2.0 - @ScaledMetric private var waveformLinePadding = 2.0 - private let waveformMaxWidth: CGFloat = 150 + @ScaledMetric private var playPauseButtonSize = 32 @ScaledMetric private var playPauseImagePadding = 8 @@ -67,6 +67,7 @@ struct VoiceMessageRoomPlaybackView: View { HStack { playPauseButton Text(timeLabelContent) + .lineLimit(1) .font(.compound.bodySMSemibold) .foregroundColor(.compound.textSecondary) .monospacedDigit() @@ -99,7 +100,6 @@ struct VoiceMessageRoomPlaybackView: View { } }) } - .frame(maxWidth: waveformMaxWidth) } .onChange(of: dragState) { newDragState in switch newDragState { @@ -139,6 +139,7 @@ struct VoiceMessageRoomPlaybackView: View { .offset(x: playerState.playbackState == .playing ? 0 : 2) .aspectRatio(contentMode: .fit) .foregroundColor(.compound.iconSecondary) + .accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay) } } } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift index bdbe0e47c..6596a76a2 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift @@ -63,7 +63,7 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol { if let fileURL = voiceMessageCache.fileURL(for: source) { return fileURL } - + // Otherwise, load the file from source guard case .success(let fileHandle) = await mediaProvider.loadFileFromSource(source, body: body) else { throw MediaProviderError.failedRetrievingFile diff --git a/UnitTests/Sources/AudioRecorderStateTests.swift b/UnitTests/Sources/AudioRecorderStateTests.swift new file mode 100644 index 000000000..1943a20c5 --- /dev/null +++ b/UnitTests/Sources/AudioRecorderStateTests.swift @@ -0,0 +1,100 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +@testable import ElementX +import Foundation +import XCTest + +@MainActor +class AudioRecorderStateTests: XCTestCase { + private var audioRecorderState: AudioRecorderState! + private var audioRecorderMock: AudioRecorderMock! + + private var audioRecorderActionsSubject: PassthroughSubject! + private var audioRecorderActions: AnyPublisher { + audioRecorderActionsSubject.eraseToAnyPublisher() + } + + private func buildAudioRecorderMock() -> AudioRecorderMock { + let audioRecorderMock = AudioRecorderMock() + audioRecorderMock.underlyingActions = audioRecorderActions + audioRecorderMock.currentTime = 0.0 + return audioRecorderMock + } + + override func setUp() async throws { + audioRecorderActionsSubject = .init() + audioRecorderState = AudioRecorderState() + audioRecorderMock = buildAudioRecorderMock() + } + + func testAttach() async throws { + audioRecorderState.attachAudioRecorder(audioRecorderMock) + + XCTAssertEqual(audioRecorderState.recordingState, .stopped) + } + + func testDetach() async throws { + audioRecorderState.attachAudioRecorder(audioRecorderMock) + + audioRecorderState.detachAudioRecorder() + XCTAssert(audioRecorderMock.stopRecordingCalled) + XCTAssertEqual(audioRecorderState.recordingState, .stopped) + } + + func testReportError() async throws { + XCTAssertEqual(audioRecorderState.recordingState, .stopped) + audioRecorderState.reportError(AudioRecorderError.genericError) + XCTAssertEqual(audioRecorderState.recordingState, .error) + } + + func testHandlingAudioRecorderActionDidStartRecording() async throws { + audioRecorderState.attachAudioRecorder(audioRecorderMock) + + let deferred = deferFulfillment(audioRecorderState.$recordingState) { action in + switch action { + case .recording: + return true + default: + return false + } + } + + audioRecorderActionsSubject.send(.didStartRecording) + try await deferred.fulfill() + XCTAssertEqual(audioRecorderState.recordingState, .recording) + } + + func testHandlingAudioPlayerActionDidStopRecording() async throws { + audioRecorderState.attachAudioRecorder(audioRecorderMock) + + let deferred = deferFulfillment(audioRecorderState.$recordingState) { action in + switch action { + case .stopped: + return true + default: + return false + } + } + + audioRecorderActionsSubject.send(.didStopRecording) + try await deferred.fulfill() + + // The state is expected to be .readyToPlay + XCTAssertEqual(audioRecorderState.recordingState, .stopped) + } +} diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png new file mode 100644 index 000000000..9d7155f8a --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26fdd567b146251130beb6f662ec227ad48d734561c759b072a0e37d8f804789 +size 96929 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png new file mode 100644 index 000000000..0afe573fe --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fe33a68b975f2483b8879f813f69c6b8565ea92cc512e1b469361dca5b30334 +size 69043 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png new file mode 100644 index 000000000..1df5c8c87 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f148b17af2dea5529eaa8a234966d6c5a907c24f47379f392bcbc6098ca6d26d +size 56402 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png new file mode 100644 index 000000000..0351cc491 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b82dbb24ecf09d3fa08a0308181b83106f7e62935ee5f1201ef8ae1533e33ded +size 60029 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png new file mode 100644 index 000000000..cb5e29427 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee69c3ef27115aeee58aacd362b3d382480b47bf1e53ffd1ce72880319d11e67 +size 60199 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png new file mode 100644 index 000000000..0accbb7dd --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76d5359eca0ee480fc6197652baf9b282d342cfd1e84893afb4a469c572f24a9 +size 57410 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png index b6646e8df..2fddc18bb 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94220df542660991357d13105cc681ecb81c054180cf7471e93952a2525d5c0c -size 64635 +oid sha256:5d32a87762fc4db710b3c698d5447dd9357a1f6cebe269df986a6bd5928aba4d +size 64543 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png index b72f79647..23eef6254 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9efd5d8236996e5c954df098f3ec8336283a768e458d9a64ebbb7a9a21e6ac8a -size 73225 +oid sha256:2f9bd66934c88ce2d6bb0b392389b38b9ef24ebff2f78e7a53c9f1618c0f40fe +size 73049 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png index c444669de..5ed8e2989 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b8af911824187b1041c7b1124d347514433e5ed95448b869af11e88a78a06cd -size 69693 +oid sha256:90039cc5805d8f0bbfbb9d48e52831dff02f86e38c573c6a616fa80ce94f22e8 +size 69596