diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 096c49927..2e5cc4031 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 2355289BB0146231DD8AFFC0 /* AnalyticsMessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2133A5FF0C14986E60326115 /* AnalyticsMessageType.swift */; }; 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */; }; 237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; }; + 242D4B5577D4D4494CF22FFA /* VoiceRoomPlaybackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */; }; 245F7FE5961BD10C145A26E0 /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA689E792E679F5E3956F21 /* UITimelineView.swift */; }; 24A75F72EEB7561B82D726FD /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; }; 24B7CD41342C143117ADA768 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B1CC9AA154F4D5435BF60A /* Comparable.swift */; }; @@ -143,6 +144,7 @@ 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 */; }; + 2E980266566100EF909BDFB0 /* VoiceRoomPlaybackViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */; }; 2F1CF90A3460C153154427F0 /* RoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086B997409328F091EBA43CE /* RoomScreenUITests.swift */; }; 2F623DA1122140A987B34D08 /* NotificationSettingsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */; }; 2F66701B15657A87B4AC3A0A /* WaitlistScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CE2B7AD979BDEE09FEDB08 /* WaitlistScreenModels.swift */; }; @@ -323,6 +325,7 @@ 68184EF36396424FE19A727D /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; 6832733838C57A7D3FE8FEB5 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 78A5A8DE1E2B09C978C7F3B0 /* KeychainAccess */; }; 6860721DB3091BE08164C132 /* MapAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B48B7AD4908C5C374517B892 /* MapAssets.xcassets */; }; + 6888D47B4A5479CB9E0FB7F5 /* VoiceRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */; }; 68AC3C84E2B438036B174E30 /* EmoteRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */; }; 695825D20A761C678809345D /* MessageForwardingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */; }; 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */; }; @@ -684,6 +687,7 @@ D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; }; D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */; }; D181AC8FF236B7F91C0A8C28 /* MapTiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AA3F4B285570805CB0CCDD /* MapTiler.swift */; }; + D1DFECA12FBF5346EAC4EE92 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A931ECBDC32FC90A6480751F /* WaveformView.swift */; }; D1EEF0CB0F5D9C15E224E670 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 9A2AC7BE17C05CF7D2A22338 /* landscape_test_video.mov */; }; D2A15D03F81342A09340BD56 /* AnalyticsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */; }; D2D70B5DB1A5E4AF0CD88330 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 033DB41C51865A2E83174E87 /* target.yml */; }; @@ -781,6 +785,7 @@ EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; }; EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */; }; EF7924005216B8189898F370 /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */; }; + EFE7E63F6702F6CB47A8CD6E /* VoiceRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */; }; 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 */; }; @@ -1050,6 +1055,7 @@ 3BFDAF6918BB096C44788FC9 /* RoomDetailsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenUITests.swift; sourceTree = ""; }; 3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCategory.swift; sourceTree = ""; }; 3C368CAB3063EF275357ECD4 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = ""; }; + 3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomTimelineItem.swift; sourceTree = ""; }; 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenModels.swift; sourceTree = ""; }; 3CFD5EB0B0EEA4549FB49784 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelTests.swift; sourceTree = ""; }; @@ -1156,6 +1162,7 @@ 5CD0FAE9EA761DA175D31CC7 /* MigrationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenModels.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinatorUITests.swift; sourceTree = ""; }; + 5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomPlaybackView.swift; sourceTree = ""; }; 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetection.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; @@ -1360,6 +1367,7 @@ A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; + A931ECBDC32FC90A6480751F /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = ""; }; A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = ""; }; AAC9344689121887B74877AF /* UnitTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1525,6 +1533,7 @@ D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServerSelectionScreenState.swift; sourceTree = ""; }; D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModel.swift; sourceTree = ""; }; D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = ""; }; + D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomPlaybackViewState.swift; sourceTree = ""; }; DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = ""; }; DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = ""; }; DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = ""; }; @@ -1561,6 +1570,7 @@ E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxy.swift; sourceTree = ""; }; E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactory.swift; sourceTree = ""; }; + E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomTimelineView.swift; sourceTree = ""; }; E80F9E9B93B6ECE9A937B1C6 /* FormRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRow.swift; sourceTree = ""; }; E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorStateMachine.swift; sourceTree = ""; }; @@ -2174,6 +2184,17 @@ path = Helpers; sourceTree = ""; }; + 3A542DF1C3BB67D829DFDC40 /* VoiceMessages */ = { + isa = PBXGroup; + children = ( + 5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */, + D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */, + E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */, + A931ECBDC32FC90A6480751F /* WaveformView.swift */, + ); + path = VoiceMessages; + sourceTree = ""; + }; 3D22B0A4FC9008F7E353D0EA /* View */ = { isa = PBXGroup; children = ( @@ -2948,6 +2969,8 @@ 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */, F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */, 8C8616254EE40CA8BA5E9BC2 /* VideoRoomTimelineItemContent.swift */, + 3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */, + 3A542DF1C3BB67D829DFDC40 /* VoiceMessages */, ); path = Messages; sourceTree = ""; @@ -5060,12 +5083,17 @@ 1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */, 64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */, 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */, + 242D4B5577D4D4494CF22FFA /* VoiceRoomPlaybackView.swift in Sources */, + 2E980266566100EF909BDFB0 /* VoiceRoomPlaybackViewState.swift in Sources */, + 6888D47B4A5479CB9E0FB7F5 /* VoiceRoomTimelineItem.swift in Sources */, + EFE7E63F6702F6CB47A8CD6E /* VoiceRoomTimelineView.swift in Sources */, 6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */, 9AFEE46B03B7E995B3E1A53D /* WaitlistScreen.swift in Sources */, 7C384A8E54A4B60A14CDE8E5 /* WaitlistScreenCoordinator.swift in Sources */, 2F66701B15657A87B4AC3A0A /* WaitlistScreenModels.swift in Sources */, CF3827071B0BC9638BD44F5D /* WaitlistScreenViewModel.swift in Sources */, B717A820BE02C6FE2CB53F6E /* WaitlistScreenViewModelProtocol.swift in Sources */, + D1DFECA12FBF5346EAC4EE92 /* WaveformView.swift in Sources */, D871C8CF46950F959C9A62C3 /* WelcomeScreen.swift in Sources */, 383055C6ABE5BE058CEE1DDB /* WelcomeScreenScreenCoordinator.swift in Sources */, BD2BF1EC73FFB0C01552ECDA /* WelcomeScreenScreenModels.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 0402fd0f9..2948a7e29 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -40,6 +40,7 @@ final class AppSettings { case readReceiptsEnabled case hasShownWelcomeScreen case swiftUITimelineEnabled + case voiceMessageEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -244,4 +245,7 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.swiftUITimelineEnabled, defaultValue: false, storageType: .volatile) var swiftUITimelineEnabled + + @UserPreference(key: UserDefaultsKeys.voiceMessageEnabled, defaultValue: false, storageType: .userDefaults(store)) + var voiceMessageEnabled } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 15ca828cf..c07fce234 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -312,7 +312,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let timelineItemFactory = RoomTimelineItemFactory(userID: userID, mediaProvider: userSession.mediaProvider, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL), - stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) + stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID), + appSettings: appSettings) let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index 609b56d67..48c746771 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -181,7 +181,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel.mock static let replyTypes: [TimelineItemReplyDetails] = [ - .loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, source: nil, contentType: nil))), + .loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, waveform: nil, source: nil, contentType: nil))), .loaded(sender: .init(id: "James"), contentType: .emote(.init(body: "Emote: James thinks he's the phantom lord"))), .loaded(sender: .init(id: "Robert"), contentType: .file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil))), .loaded(sender: .init(id: "Cliff"), contentType: .image(.init(body: "Image: Pushead", diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index f056dbb69..5d5ccb4ff 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -75,6 +75,11 @@ enum RoomScreenViewAction { case cancelSend(itemID: TimelineItemIdentifier) case scrolledToBottom + + case enableLongPress(itemID: TimelineItemIdentifier) + case disableLongPress(itemID: TimelineItemIdentifier) + case playPauseAudio(itemID: TimelineItemIdentifier) + case seekAudio(itemID: TimelineItemIdentifier, progress: Double) } enum RoomScreenComposerAction { @@ -95,11 +100,15 @@ struct RoomScreenViewState: BindableState { var isEncryptedOneToOneRoom = false var timelineViewState = TimelineViewState() // check the doc before changing this var swiftUITimelineEnabled = false + var longPressDisabledItemID: TimelineItemIdentifier? var bindings: RoomScreenViewStateBindings /// A closure providing the actions to show when long pressing on an item in the timeline. var timelineItemMenuActionProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> TimelineItemMenuActions?)? + + /// A closure providing the associated audio playback view state for an item in the timeline. + var audioPlaybackViewStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState?)? } struct RoomScreenViewStateBindings { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 7327aa34d..2f38bdda2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -76,6 +76,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol return self.timelineItemMenuActionsForItemId(itemId) } + state.audioPlaybackViewStateProvider = { [weak self] itemId -> VoiceRoomPlaybackViewState? in + guard let self else { return nil } + + return self.audioPlaybackViewState(for: itemId) + } + buildTimelineViews() // Note: beware if we get to e.g. restore a reply / edit, @@ -139,6 +145,15 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } case let .selectedPollOption(pollStartID, optionID): sendPollResponse(pollStartID: pollStartID, optionID: optionID) + case .playPauseAudio(let itemID): + Task { await timelineController.playPauseAudio(for: itemID) } + case .seekAudio(let itemID, let progress): + Task { await timelineController.seekAudio(for: itemID, progress: progress) } + case .enableLongPress(let itemID): + guard state.longPressDisabledItemID == itemID else { return } + state.longPressDisabledItemID = nil + case .disableLongPress(let itemID): + state.longPressDisabledItemID = itemID } } @@ -845,6 +860,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } } + + // MARK: - Audio + + private func audioPlaybackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? { + timelineController.playbackViewState(for: itemID) + } } private extension RoomProxyProtocol { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift index 1cc616e99..e4e7321ee 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift @@ -30,7 +30,7 @@ struct TimelineReplyView: View { switch timelineItemReplyDetails { case .loaded(let sender, let content): switch content { - case .audio(let content): + case .audio(let content), .voice(let content): ReplyView(sender: sender, plainBody: content.body, formattedBody: nil, @@ -204,6 +204,7 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview { timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .audio(.init(body: "Some audio", duration: 0, + waveform: nil, source: nil, contentType: nil)))) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/LongPressWithFeedback.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/LongPressWithFeedback.swift index ec6fc2fb7..69ea10b2f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/LongPressWithFeedback.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/LongPressWithFeedback.swift @@ -18,6 +18,7 @@ import SwiftUI struct LongPressWithFeedback: ViewModifier { let action: () -> Void + let disabled: () -> Bool @State private var triggerTask: Task? @State private var isLongPressing = false @@ -48,7 +49,9 @@ struct LongPressWithFeedback: ViewModifier { try? await Task.sleep(for: .seconds(0.35)) if Task.isCancelled { return } - + + guard !disabled() else { return } + action() feedbackGenerator.impactOccurred() } @@ -57,8 +60,8 @@ struct LongPressWithFeedback: ViewModifier { } extension View { - func longPressWithFeedback(action: @escaping () -> Void) -> some View { - modifier(LongPressWithFeedback(action: action)) + func longPressWithFeedback(disabled: @escaping @autoclosure () -> Bool = false, action: @escaping () -> Void) -> some View { + modifier(LongPressWithFeedback(action: action, disabled: disabled)) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index beefbb430..fe8188cf8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -128,7 +128,7 @@ struct TimelineItemBubbledStylerView: View { } // We need a tap gesture before this long one so that it doesn't // steal away the gestures from the scroll view - .longPressWithFeedback { + .longPressWithFeedback(disabled: context.viewState.longPressDisabledItemID == timelineItem.id) { context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { @@ -435,10 +435,12 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview sender: .init(id: ""), content: .init(body: "audio.ogg", duration: 100, + waveform: Waveform.mockWaveform, source: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short"))))) + FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: false, @@ -482,6 +484,21 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short"))))) + + VoiceRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + waveform: Waveform.mockWaveform, + source: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short")))), + playbackViewState: VoiceRoomPlaybackViewState(duration: 10, waveform: Waveform.mockWaveform)) } .environmentObject(viewModel.context) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 8fcda3ae6..0917cba86 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -73,7 +73,7 @@ struct TimelineItemPlainStylerView: View { } // We need a tap gesture before this long one so that it doesn't // steal away the gestures from the scroll view - .longPressWithFeedback { + .longPressWithFeedback(disabled: context.viewState.longPressDisabledItemID == timelineItem.id) { context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { @@ -164,6 +164,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview { sender: .init(id: ""), content: .init(body: "audio.ogg", duration: 100, + waveform: nil, source: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), @@ -211,6 +212,20 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview { geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short"))))) + VoiceRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + waveform: Waveform.mockWaveform, + source: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short")))), + playbackViewState: VoiceRoomPlaybackViewState(duration: 10, waveform: Waveform.mockWaveform)) } .environmentObject(viewModel.context) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift index 8afc65a3b..c1e5f5e19 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift @@ -24,6 +24,7 @@ struct SwipeRightAction: ViewModifier { private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) @State private var canStartAction = false + @State private var animate = false @GestureState private var dragGestureActive = false @State private var hasReachedActionThreshold = false @@ -39,7 +40,7 @@ struct SwipeRightAction: ViewModifier { func body(content: Content) -> some View { content .offset(x: xOffset, y: 0.0) - .animation(.interactiveSpring().speed(0.5), value: xOffset) + .animation(.interactiveSpring().speed(0.5), value: animate) .gesture(DragGesture() .updating($dragGestureActive) { _, state, _ in // Available actions should be computed on the fly so we use a gesture state change @@ -68,6 +69,7 @@ struct SwipeRightAction: ViewModifier { } else { hasReachedActionThreshold = false } + animate = true } .onEnded { _ in if xOffset > actionThreshold { @@ -75,6 +77,7 @@ struct SwipeRightAction: ViewModifier { } xOffset = 0.0 + animate = false } ) .onChange(of: dragGestureActive, perform: { value in diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift index f84ec8b23..9cf6d2f14 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift @@ -52,6 +52,6 @@ struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview { isEditable: false, isThreaded: false, sender: .init(id: "Bob"), - content: .init(body: "audio.ogg", duration: 300, source: nil, contentType: nil))) + content: .init(body: "audio.ogg", duration: 300, waveform: nil, source: nil, contentType: nil))) } } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 187f3f8b5..9a9686331 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -49,6 +49,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var userSuggestionsEnabled: Bool { get set } var readReceiptsEnabled: Bool { get set } var swiftUITimelineEnabled: Bool { get set } + var voiceMessageEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 7704d59c2..2c0e548d7 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -52,6 +52,12 @@ struct DeveloperOptionsScreen: View { Text("User suggestions") } } + + Section("Voice message") { + Toggle(isOn: $context.voiceMessageEnabled) { + Text("Enable voice messages") + } + } Section { Button { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 87d698bc4..43a1b8dc7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -82,6 +82,16 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func retryDecryption(for sessionID: String) async { } + func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? { + VoiceRoomPlaybackViewState(duration: 10.0, + waveform: nil, + progress: 0.0) + } + + func playPauseAudio(for itemID: TimelineItemIdentifier) async { } + + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { } + // MARK: - UI Test signalling /// The cancellable used for UI Tests signalling. diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 62f8da2a0..7569ff10b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -36,7 +36,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() private(set) var timelineItems = [RoomTimelineItemProtocol]() - + private var timelineAudioPlaybackViewStates = [TimelineItemIdentifier: VoiceRoomPlaybackViewState]() + var roomID: String { roomProxy.id } @@ -220,6 +221,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol { func retryDecryption(for sessionID: String) async { await roomProxy.retryDecryption(for: sessionID) } + + func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? { + guard let timelineItem = timelineItems.firstUsingStableID(itemID) else { + MXLog.error("timelineItem not found") + return .none + } + + switch timelineItem { + case let item as VoiceRoomTimelineItem: + if let playbackViewState = timelineAudioPlaybackViewStates[itemID] { + return playbackViewState + } + let playbackViewState = VoiceRoomPlaybackViewState(duration: item.content.duration, + waveform: item.content.waveform) + timelineAudioPlaybackViewStates[itemID] = playbackViewState + return playbackViewState + default: + return .none + } + } + + func playPauseAudio(for itemID: TimelineItemIdentifier) async { } + + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { + Task { + timelineAudioPlaybackViewStates[itemID]?.updateState(progress: progress) + } + } // MARK: - Private diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 03f7b84aa..b29681d9b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -64,6 +64,12 @@ protocol RoomTimelineControllerProtocol { func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo func retryDecryption(for sessionID: String) async + + func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? + + func playPauseAudio(for itemID: TimelineItemIdentifier) async + + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async } extension RoomTimelineControllerProtocol { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift index 1422f8e6b..c8c42ec6e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift @@ -25,6 +25,7 @@ enum EventBasedMessageTimelineItemContentType: Hashable { case text(TextRoomTimelineItemContent) case video(VideoRoomTimelineItemContent) case location(LocationRoomTimelineItemContent) + case voice(AudioRoomTimelineItemContent) } protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift index 0245e26f7..83575e885 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift @@ -20,6 +20,7 @@ import UniformTypeIdentifiers struct AudioRoomTimelineItemContent: Hashable { let body: String let duration: TimeInterval + let waveform: Waveform? let source: MediaSourceProxy? let contentType: UTType? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift new file mode 100644 index 000000000..aafa63e07 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift @@ -0,0 +1,167 @@ +// +// 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 SwiftUI + +struct VoiceRoomPlaybackView: View { + @ObservedObject var playbackViewState: VoiceRoomPlaybackViewState + + private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) + @State private var sendFeedback = false + + private let waveformMaxWidth: CGFloat = 150 + private let playPauseButtonSize = CGSize(width: 32, height: 32) + + private static let elapsedTimeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "m:ss" + return dateFormatter + }() + + var onPlayPause: () -> Void = { } + var onSeek: (Double) -> Void = { _ in } + var onWaveformDragStateChanged: (Bool) -> Void = { _ in } + + private enum DragState: Equatable { + case inactive + case pressing + case dragging(progress: Double) + + var progress: Double { + switch self { + case .inactive, .pressing: + return .zero + case .dragging(let progress): + return progress + } + } + + var isActive: Bool { + switch self { + case .inactive: + return false + case .pressing, .dragging: + return true + } + } + + var isDragging: Bool { + switch self { + case .inactive, .pressing: + return false + case .dragging: + return true + } + } + } + + @GestureState private var dragState = DragState.inactive + + var timeLabelContent: String { + // Display the duration if progress is 0.0 + let percent = playbackViewState.progress > 0.0 ? playbackViewState.progress : 1.0 + return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playbackViewState.duration * percent)) + } + + var body: some View { + HStack { + HStack { + playPauseButton + Text(timeLabelContent) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textSecondary) + .monospacedDigit() + } + .padding(.vertical, 6) + GeometryReader { geometry in + WaveformView(waveform: playbackViewState.waveform, progress: playbackViewState.progress) + // Add a gesture to drag the waveform + .gesture(LongPressGesture() + .sequenced(before: DragGesture(coordinateSpace: .local)) + .updating($dragState) { value, state, _ in + switch value { + // Long press begins. + case .first(true): + state = .pressing + // Long press confirmed, dragging may begin. + case .second(true, let drag): + let progress: Double = (drag?.location.x ?? .zero) / geometry.size.width + state = .dragging(progress: progress) + // Dragging ended or the long press cancelled. + default: + state = .inactive + } + }) + } + .frame(maxWidth: waveformMaxWidth) + } + .onChange(of: dragState) { newDragState in + switch newDragState { + case .inactive: + onWaveformDragStateChanged(false) + case .pressing: + onWaveformDragStateChanged(true) + feedbackGenerator.prepare() + sendFeedback = true + case .dragging(let progress): + if sendFeedback { + feedbackGenerator.impactOccurred() + sendFeedback = false + } + if abs(progress - playbackViewState.progress) > 0.01 { + onSeek(max(0, min(progress, 1.0))) + } + } + } + .padding(.vertical, 2) + .padding(.horizontal, 8) + } + + @ViewBuilder + var playPauseButton: some View { + Button { + onPlayPause() + } label: { + Image(systemName: playbackViewState.playing ? "pause.fill" : "play.fill") + .foregroundColor(.compound.iconSecondary) + .background( + Circle() + .frame(width: playPauseButtonSize.width, + height: playPauseButtonSize.height) + .foregroundColor(.compound.bgCanvasDefault) + ) + .padding(.trailing, 7) + } + } +} + +struct VoiceRoomPlaybackView_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 playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0, + waveform: waveform, + progress: 0.3) + + static var previews: some View { + VoiceRoomPlaybackView(playbackViewState: playbackViewState, + onPlayPause: { playbackViewState.updateState(playing: !playbackViewState.playing) }, + onSeek: { playbackViewState.updateState(progress: $0) }) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift new file mode 100644 index 000000000..f24440eef --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackViewState.swift @@ -0,0 +1,43 @@ +// +// 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 + +@MainActor +class VoiceRoomPlaybackViewState: ObservableObject { + let duration: Double + let waveform: Waveform + @Published private(set) var loading: Bool + @Published private(set) var playing: Bool + @Published private(set) var progress: Double + + init(duration: Double = 0.0, waveform: Waveform? = nil, progress: Double = 0.0) { + self.duration = duration + self.waveform = waveform ?? Waveform(data: []) + self.progress = progress + loading = false + playing = false + } + + func updateState(progress: Double) { + self.progress = max(0.0, min(progress, 1.0)) + } + + func updateState(playing: Bool) { + self.playing = playing + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift new file mode 100644 index 000000000..86c3819ee --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift @@ -0,0 +1,92 @@ +// +// 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 VoiceRoomTimelineView: View { + @EnvironmentObject private var context: RoomScreenViewModel.Context + let timelineItem: VoiceRoomTimelineItem + let playbackViewState: VoiceRoomPlaybackViewState + + init(timelineItem: VoiceRoomTimelineItem, playbackViewState: VoiceRoomPlaybackViewState?) { + self.timelineItem = timelineItem + if playbackViewState == nil { + MXLog.error("[VoiceRoomTimelineView] Voice audio playback state is missing") + } + self.playbackViewState = playbackViewState ?? VoiceRoomPlaybackViewState() + } + + var body: some View { + TimelineStyler(timelineItem: timelineItem) { + VoiceRoomPlaybackView(playbackViewState: playbackViewState, + onPlayPause: onPlaybackPlayPause, + onSeek: onPlaybackSeek(_:), + onWaveformDragStateChanged: onPlaybackDragStateChanged(_:)) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func onPlaybackPlayPause() { + context.send(viewAction: .playPauseAudio(itemID: timelineItem.id)) + } + + private func onPlaybackSeek(_ progress: Double) { + context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress)) + } + + private func onPlaybackDragStateChanged(_ dragging: Bool) { + if dragging { + context.send(viewAction: .disableLongPress(itemID: timelineItem.id)) + } else { + context.send(viewAction: .enableLongPress(itemID: timelineItem.id)) + } + } +} + +struct VoiceRoomTimelineView_Previews: PreviewProvider, TestablePreview { + static let viewModel = RoomScreenViewModel.mock + + static let voiceRoomTimelineItem = VoiceRoomTimelineItem(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + isThreaded: false, + sender: .init(id: "Bob"), + content: .init(body: "audio.ogg", + duration: 300, + waveform: Waveform.mockWaveform, + source: nil, + contentType: nil)) + + static let playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0, + waveform: Waveform.mockWaveform, + progress: 0.4) + + static var previews: some View { + body.environmentObject(viewModel.context) + .previewDisplayName("Bubble") + body + .environment(\.timelineStyle, .plain) + .environmentObject(viewModel.context) + .previewDisplayName("Plain") + } + + static var body: some View { + VoiceRoomTimelineView(timelineItem: voiceRoomTimelineItem, playbackViewState: playbackViewState) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift new file mode 100644 index 000000000..59c16ee1d --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift @@ -0,0 +1,88 @@ +// +// 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 SwiftUI + +struct Waveform: Equatable, Hashable { + let data: [UInt16] +} + +extension Waveform { + func normalisedData(count: Int) -> [Float] { + guard count > 0 else { + return [] + } + let stride = max(1, Int(data.count / count)) + let data = data.striding(by: stride) + let max = data.max().flatMap { Float($0) } ?? 0 + return data.map { Float($0) / max } + } +} + +extension Waveform { + static let mockWaveform = Waveform(data: [0, 0, 0, 3, 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]) +} + +struct WaveformView: View { + let lineWidth: CGFloat = 2 + let linePadding: CGFloat = 2 + var waveform: Waveform + let minimumGraphAmplitude: CGFloat = 1 + var progress: CGFloat = 0.0 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle().fill(Color.compound.iconQuaternary) + .frame(width: geometry.size.width, height: geometry.size.height) + Rectangle().fill(Color.compound.iconSecondary) + .frame(width: max(0.0, geometry.size.width * progress), height: geometry.size.height) + } + .mask(alignment: .leading) { + Path { path in + let width = geometry.size.width + let height = geometry.size.height + let centerY = geometry.size.height / 2 + let visibleSamplesCount = Int(width / (lineWidth + linePadding)) + let normalisedData = waveform.normalisedData(count: visibleSamplesCount) + var xOffset: CGFloat = lineWidth / 2 + var index = 0 + + while xOffset < width - lineWidth { + let sample = CGFloat(index >= normalisedData.count ? 0 : normalisedData[index]) + let drawingAmplitude = max(minimumGraphAmplitude, sample * height) + + path.move(to: CGPoint(x: xOffset, y: centerY - drawingAmplitude / 2)) + path.addLine(to: CGPoint(x: xOffset, y: centerY + drawingAmplitude / 2)) + xOffset += lineWidth + linePadding + index += 1 + } + } + .stroke(Color.compound.iconSecondary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + } + } + } +} + +struct WaveformView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + WaveformView(waveform: Waveform.mockWaveform) + .frame(width: 140, height: 50) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceRoomTimelineItem.swift new file mode 100644 index 000000000..7b9cce464 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceRoomTimelineItem.swift @@ -0,0 +1,40 @@ +// +// 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 + +struct VoiceRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { + let id: TimelineItemIdentifier + let timestamp: String + let isOutgoing: Bool + let isEditable: Bool + let isThreaded: Bool + let sender: TimelineItemSender + + let content: AudioRoomTimelineItemContent + + var replyDetails: TimelineItemReplyDetails? + + var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } + + var contentType: EventBasedMessageTimelineItemContentType { + .voice(content) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 4793a39f6..36fac9c41 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -22,6 +22,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private let mediaProvider: MediaProviderProtocol private let attributedStringBuilder: AttributedStringBuilderProtocol private let stateEventStringBuilder: RoomStateEventStringBuilder + private let appSettings: AppSettings /// The Matrix ID of the current user. private let userID: String @@ -29,11 +30,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { init(userID: String, mediaProvider: MediaProviderProtocol, attributedStringBuilder: AttributedStringBuilderProtocol, - stateEventStringBuilder: RoomStateEventStringBuilder) { + stateEventStringBuilder: RoomStateEventStringBuilder, + appSettings: AppSettings) { self.userID = userID self.mediaProvider = mediaProvider self.attributedStringBuilder = attributedStringBuilder self.stateEventStringBuilder = stateEventStringBuilder + self.appSettings = appSettings } func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? { @@ -95,7 +98,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .emote(content: let content): return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .audio(let content): - return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) + if appSettings.voiceMessageEnabled, content.voice != nil { + return buildVoiceTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) + } else { + return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) + } case .location(let content): return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .none: @@ -256,6 +263,25 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) } + private func buildVoiceTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: AudioMessageContent, + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { + VoiceRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, + sender: eventItemProxy.sender, + content: buildAudioTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus, + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + } + private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: FileMessageContent, @@ -428,10 +454,16 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } private func buildAudioTimelineItemContent(_ messageContent: AudioMessageContent) -> AudioRoomTimelineItemContent { - AudioRoomTimelineItemContent(body: messageContent.body, - duration: messageContent.info?.duration ?? 0, - source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), - contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) + var waveform: Waveform? + if let audioWaveform = messageContent.audio?.waveform { + waveform = Waveform(data: audioWaveform) + } + + return AudioRoomTimelineItemContent(body: messageContent.body, + duration: messageContent.info?.duration ?? 0, + waveform: waveform, + source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), + contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) } private func buildImageTimelineItemContent(_ messageContent: ImageMessageContent) -> ImageRoomTimelineItemContent { @@ -583,7 +615,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { let replyContent: EventBasedMessageTimelineItemContentType switch timelineItem.asMessage()?.msgtype() { case .audio(let content): - replyContent = .audio(buildAudioTimelineItemContent(content)) + if appSettings.voiceMessageEnabled, content.voice != nil { + replyContent = .voice(buildAudioTimelineItemContent(content)) + } else { + replyContent = .audio(buildAudioTimelineItemContent(content)) + } case .emote(let content): replyContent = .emote(buildEmoteTimelineItemContent(senderDisplayName: sender.displayName, senderID: sender.id, messageContent: content)) case .file(let content): diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 69f4fd700..1302a3d7d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -74,6 +74,8 @@ struct RoomTimelineItemView: View { LocationRoomTimelineView(timelineItem: item) case .poll(let item): PollRoomTimelineView(timelineItem: item) + case .voice(let item): + VoiceRoomTimelineView(timelineItem: item, playbackViewState: context.viewState.audioPlaybackViewStateProvider?(item.id)) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index a32a12196..568f4d52d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -69,6 +69,7 @@ enum RoomTimelineItemType: Equatable { case group(CollapsibleTimelineItem) case location(LocationRoomTimelineItem) case poll(PollRoomTimelineItem) + case voice(VoiceRoomTimelineItem) init(item: RoomTimelineItemProtocol) { switch item { @@ -112,6 +113,8 @@ enum RoomTimelineItemType: Equatable { self = .location(item) case let item as PollRoomTimelineItem: self = .poll(item) + case let item as VoiceRoomTimelineItem: + self = .voice(item) default: fatalError("Unknown timeline item") } @@ -138,7 +141,8 @@ enum RoomTimelineItemType: Equatable { .state(let item as RoomTimelineItemProtocol), .group(let item as RoomTimelineItemProtocol), .location(let item as RoomTimelineItemProtocol), - .poll(let item as RoomTimelineItemProtocol): + .poll(let item as RoomTimelineItemProtocol), + .voice(let item as RoomTimelineItemProtocol): return item.id } } @@ -146,7 +150,7 @@ enum RoomTimelineItemType: Equatable { /// Whether or not it is possible to send a reaction to this timeline item. var isReactable: Bool { switch self { - case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location, .poll: + case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location, .poll, .voice: return true case .redacted, .encrypted, .unsupported, .state: // Event based items that aren't reactable return false diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png new file mode 100644 index 000000000..15034988a --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09dc1ab2bf9b5f9576a7f222ee84bd821ae1e608974a5b341cf2c41ce5bfab5e +size 63967 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png new file mode 100644 index 000000000..714156621 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de7c4ad957c37ad1f46c965985ed4f8884f86a934595c1d4a2ece836b5d5b2c7 +size 72812 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png new file mode 100644 index 000000000..69c21763a --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9de9d8875b3a9182562fe07f11777d3cb6132d2c97368f15b7f14ca14004a7af +size 69146 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png new file mode 100644 index 000000000..e22b58359 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9632b9c96b960ef1ee36ca7980253aaad8e21ded28d741f5d62b33cc4e8a0eba +size 63913