Add voice message rendering to the timeline (#1776)
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCategory.swift; sourceTree = "<group>"; };
|
||||
3C368CAB3063EF275357ECD4 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
3C9831E198AF030255D804D3 /* VoiceRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenModels.swift; sourceTree = "<group>"; };
|
||||
3CFD5EB0B0EEA4549FB49784 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
||||
3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelTests.swift; sourceTree = "<group>"; };
|
||||
@@ -1156,6 +1162,7 @@
|
||||
5CD0FAE9EA761DA175D31CC7 /* MigrationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenModels.swift; sourceTree = "<group>"; };
|
||||
5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = "<group>"; };
|
||||
5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinatorUITests.swift; sourceTree = "<group>"; };
|
||||
5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomPlaybackView.swift; sourceTree = "<group>"; };
|
||||
5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetection.swift; sourceTree = "<group>"; };
|
||||
5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = "<group>"; };
|
||||
5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -1360,6 +1367,7 @@
|
||||
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
|
||||
A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = "<group>"; };
|
||||
A931ECBDC32FC90A6480751F /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = "<group>"; };
|
||||
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
|
||||
AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomPlaybackViewState.swift; sourceTree = "<group>"; };
|
||||
DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = "<group>"; };
|
||||
DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = "<group>"; };
|
||||
DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = "<group>"; };
|
||||
@@ -1561,6 +1570,7 @@
|
||||
E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxy.swift; sourceTree = "<group>"; };
|
||||
E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactory.swift; sourceTree = "<group>"; };
|
||||
E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
E80F9E9B93B6ECE9A937B1C6 /* FormRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRow.swift; sourceTree = "<group>"; };
|
||||
E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorStateMachine.swift; sourceTree = "<group>"; };
|
||||
@@ -2174,6 +2184,17 @@
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3A542DF1C3BB67D829DFDC40 /* VoiceMessages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5D7E0E34814F58A752DDC263 /* VoiceRoomPlaybackView.swift */,
|
||||
D936D4F7AFD395BF14EC2D5A /* VoiceRoomPlaybackViewState.swift */,
|
||||
E75393B98D3841088D41D6E3 /* VoiceRoomTimelineView.swift */,
|
||||
A931ECBDC32FC90A6480751F /* WaveformView.swift */,
|
||||
);
|
||||
path = VoiceMessages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import SwiftUI
|
||||
|
||||
struct LongPressWithFeedback: ViewModifier {
|
||||
let action: () -> Void
|
||||
let disabled: () -> Bool
|
||||
|
||||
@State private var triggerTask: Task<Void, Never>?
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ struct TimelineItemBubbledStylerView<Content: View>: 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)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ struct TimelineItemPlainStylerView<Content: View>: 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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ struct SwipeRightAction<Label: View>: 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<Label: View>: 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<Label: View>: ViewModifier {
|
||||
} else {
|
||||
hasReachedActionThreshold = false
|
||||
}
|
||||
animate = true
|
||||
}
|
||||
.onEnded { _ in
|
||||
if xOffset > actionThreshold {
|
||||
@@ -75,6 +77,7 @@ struct SwipeRightAction<Label: View>: ViewModifier {
|
||||
}
|
||||
|
||||
xOffset = 0.0
|
||||
animate = false
|
||||
}
|
||||
)
|
||||
.onChange(of: dragGestureActive, perform: { value in
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -52,6 +52,12 @@ struct DeveloperOptionsScreen: View {
|
||||
Text("User suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Voice message") {
|
||||
Toggle(isOn: $context.voiceMessageEnabled) {
|
||||
Text("Enable voice messages")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -36,7 +36,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ enum EventBasedMessageTimelineItemContentType: Hashable {
|
||||
case text(TextRoomTimelineItemContent)
|
||||
case video(VideoRoomTimelineItemContent)
|
||||
case location(LocationRoomTimelineItemContent)
|
||||
case voice(AudioRoomTimelineItemContent)
|
||||
}
|
||||
|
||||
protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
|
||||
|
||||
@@ -20,6 +20,7 @@ import UniformTypeIdentifiers
|
||||
struct AudioRoomTimelineItemContent: Hashable {
|
||||
let body: String
|
||||
let duration: TimeInterval
|
||||
let waveform: Waveform?
|
||||
let source: MediaSourceProxy?
|
||||
let contentType: UTType?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09dc1ab2bf9b5f9576a7f222ee84bd821ae1e608974a5b341cf2c41ce5bfab5e
|
||||
size 63967
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de7c4ad957c37ad1f46c965985ed4f8884f86a934595c1d4a2ece836b5d5b2c7
|
||||
size 72812
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9de9d8875b3a9182562fe07f11777d3cb6132d2c97368f15b7f14ca14004a7af
|
||||
size 69146
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9632b9c96b960ef1ee36ca7980253aaad8e21ded28d741f5d62b33cc4e8a0eba
|
||||
size 63913
|
||||
Reference in New Issue
Block a user