Add voice message rendering to the timeline (#1776)

This commit is contained in:
Nicolas Mauri
2023-09-21 16:36:21 +02:00
committed by GitHub
parent 9b7f94436f
commit ec76c2973f
31 changed files with 660 additions and 20 deletions

View File

@@ -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 */,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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))))

View File

@@ -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))
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)))
}
}

View File

@@ -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 { }

View File

@@ -52,6 +52,12 @@ struct DeveloperOptionsScreen: View {
Text("User suggestions")
}
}
Section("Voice message") {
Toggle(isOn: $context.voiceMessageEnabled) {
Text("Enable voice messages")
}
}
Section {
Button {

View File

@@ -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.

View File

@@ -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

View File

@@ -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 {

View File

@@ -25,6 +25,7 @@ enum EventBasedMessageTimelineItemContentType: Hashable {
case text(TextRoomTimelineItemContent)
case video(VideoRoomTimelineItemContent)
case location(LocationRoomTimelineItemContent)
case voice(AudioRoomTimelineItemContent)
}
protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {

View File

@@ -20,6 +20,7 @@ import UniformTypeIdentifiers
struct AudioRoomTimelineItemContent: Hashable {
let body: String
let duration: TimeInterval
let waveform: Waveform?
let source: MediaSourceProxy?
let contentType: UTType?
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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):

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09dc1ab2bf9b5f9576a7f222ee84bd821ae1e608974a5b341cf2c41ce5bfab5e
size 63967

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de7c4ad957c37ad1f46c965985ed4f8884f86a934595c1d4a2ece836b5d5b2c7
size 72812

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9de9d8875b3a9182562fe07f11777d3cb6132d2c97368f15b7f14ca14004a7af
size 69146

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9632b9c96b960ef1ee36ca7980253aaad8e21ded28d741f5d62b33cc4e8a0eba
size 63913