Allow to record a voice message (#1926)

This commit is contained in:
Nicolas Mauri
2023-10-23 17:47:36 +02:00
committed by GitHub
parent 65b7c1df20
commit 2f57fbc77d
62 changed files with 1805 additions and 342 deletions
@@ -31,9 +31,13 @@ enum ComposerToolbarViewModelAction {
case composerModeChanged(mode: RoomScreenComposerMode)
case composerFocusedChanged(isFocused: Bool)
case startRecordingVoiceMessage
case stopRecordingVoiceMessage
case deleteRecordedVoiceMessage
case startVoiceMessageRecording
case stopVoiceMessageRecording
case cancelVoiceMessageRecording
case deleteVoiceMessageRecording
case startVoiceMessagePlayback
case pauseVoiceMessagePlayback
case seekVoiceMessagePlayback(progress: Double)
case sendVoiceMessage
}
@@ -51,9 +55,13 @@ enum ComposerToolbarViewAction {
case enableTextFormatting
case composerAction(action: ComposerAction)
case selectedSuggestion(_ suggestion: SuggestionItem)
case startRecordingVoiceMessage
case stopRecordingVoiceMessage
case deleteRecordedVoiceMessage
case startVoiceMessageRecording
case stopVoiceMessageRecording
case cancelVoiceMessageRecording
case deleteVoiceMessageRecording
case startVoiceMessagePlayback
case pauseVoiceMessagePlayback
case seekVoiceMessagePlayback(progress: Double)
}
struct ComposerToolbarViewState: BindableState {
@@ -47,7 +47,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled,
enableVoiceMessageComposer: appSettings.voiceMessageEnabled,
audioPlayerState: .init(duration: 0),
audioPlayerState: .init(id: .recorderPreview, duration: 0),
audioRecorderState: .init(),
bindings: .init()),
imageProvider: mediaProvider)
@@ -144,13 +144,21 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
case .selectedSuggestion(let suggestion):
handleSuggestion(suggestion)
case .startRecordingVoiceMessage:
case .startVoiceMessageRecording:
state.bindings.composerActionsEnabled = false
actionsSubject.send(.startRecordingVoiceMessage)
case .stopRecordingVoiceMessage:
actionsSubject.send(.stopRecordingVoiceMessage)
case .deleteRecordedVoiceMessage:
actionsSubject.send(.deleteRecordedVoiceMessage)
actionsSubject.send(.startVoiceMessageRecording)
case .stopVoiceMessageRecording:
actionsSubject.send(.stopVoiceMessageRecording)
case .cancelVoiceMessageRecording:
actionsSubject.send(.cancelVoiceMessageRecording)
case .deleteVoiceMessageRecording:
actionsSubject.send(.deleteVoiceMessageRecording)
case .startVoiceMessagePlayback:
actionsSubject.send(.startVoiceMessagePlayback)
case .pauseVoiceMessagePlayback:
actionsSubject.send(.pauseVoiceMessagePlayback)
case .seekVoiceMessagePlayback(let progress):
actionsSubject.send(.seekVoiceMessagePlayback(progress: progress))
}
}
@@ -224,7 +232,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
case .recordVoiceMessage(let audioRecorderState):
state.bindings.composerFocused = false
state.audioRecorderState = audioRecorderState
case .previewVoiceMessage(let audioPlayerState):
case .previewVoiceMessage(let audioPlayerState, _):
state.audioPlayerState = audioPlayerState
case .edit, .reply:
// Focus composer when switching to reply/edit
@@ -28,9 +28,13 @@ struct ComposerToolbar: View {
@ScaledMetric private var trashButtonIconSize = 24
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
@State private var voiceMessageRecordingStartTime: Date?
@State private var showVoiceMessageRecordingTooltip = false
@ScaledMetric private var voiceMessageTooltipPointerHeight = 6
private let voiceMessageMinimumRecordingDuration = 1.0
private let voiceMessageTooltipDuration = 1.0
@State private var frame: CGRect = .zero
var body: some View {
@@ -51,8 +55,10 @@ struct ComposerToolbar: View {
}
}
.overlay(alignment: .bottomTrailing) {
voiceMessageRecordingButtonTooltipView
.offset(y: -frame.height - voiceMessageTooltipPointerHeight)
if showVoiceMessageRecordingTooltip {
voiceMessageRecordingButtonTooltipView
.offset(y: -frame.height - voiceMessageTooltipPointerHeight)
}
}
.alert(item: $context.alertInfo)
}
@@ -69,9 +75,9 @@ struct ComposerToolbar: View {
case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
VoiceMessageRecordingComposer(recorderState: state)
.padding(.leading, 12)
case .previewVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
case .previewVoiceMessage(let state, let waveform) where context.viewState.enableVoiceMessageComposer:
voiceMessageTrashButton
VoiceMessagePreviewComposer(playerState: state)
voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform)
default:
if !context.composerActionsEnabled {
RoomAttachmentPicker(context: context)
@@ -96,6 +102,7 @@ struct ComposerToolbar: View {
}
}
}
.animation(.elementDefault, value: context.viewState.composerMode)
}
private var bottomBar: some View {
@@ -216,17 +223,26 @@ struct ComposerToolbar: View {
// MARK: - Voice message
private var voiceMessageRecordingButton: some View {
VoiceMessageRecordingButton(showRecordTooltip: $showVoiceMessageRecordingTooltip, startRecording: {
context.send(viewAction: .startRecordingVoiceMessage)
}, stopRecording: {
context.send(viewAction: .stopRecordingVoiceMessage)
})
VoiceMessageRecordingButton {
showVoiceMessageRecordingTooltip = false
voiceMessageRecordingStartTime = Date.now
context.send(viewAction: .startVoiceMessageRecording)
} stopRecording: {
if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration {
context.send(viewAction: .cancelVoiceMessageRecording)
withAnimation {
showVoiceMessageRecordingTooltip = true
}
} else {
context.send(viewAction: .stopVoiceMessageRecording)
}
}
.padding(4)
}
private var voiceMessageTrashButton: some View {
Button {
context.send(viewAction: .deleteRecordedVoiceMessage)
context.send(viewAction: .deleteVoiceMessageRecording)
} label: {
CompoundIcon(\.delete)
.font(.compound.bodyLG)
@@ -241,8 +257,23 @@ struct ComposerToolbar: View {
private var voiceMessageRecordingButtonTooltipView: some View {
VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, pointerHeight: voiceMessageTooltipPointerHeight)
.allowsHitTesting(false)
.opacity(showVoiceMessageRecordingTooltip ? 1.0 : 0.0)
.animation(.elementDefault, value: showVoiceMessageRecordingTooltip)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) {
withAnimation {
showVoiceMessageRecordingTooltip = false
}
}
}
}
private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View {
VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) {
context.send(viewAction: .startVoiceMessagePlayback)
} onPause: {
context.send(viewAction: .pauseVoiceMessagePlayback)
} onSeek: { progress in
context.send(viewAction: .seekVoiceMessagePlayback(progress: progress))
}
}
}
@@ -333,13 +364,14 @@ extension ComposerToolbar {
static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(),
appSettings: ServiceLocator.shared.settings,
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(duration: 10.0))
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData))
model.state.enableVoiceMessageComposer = true
return model
}
@@ -14,49 +14,161 @@
// limitations under the License.
//
import DSWaveformImage
import DSWaveformImageViews
import Foundation
import SwiftUI
struct VoiceMessagePreviewComposer: View {
@ObservedObject var playerState: AudioPlayerState
let waveform: WaveformSource
@ScaledMetric private var waveformLineWidth = 2.0
@ScaledMetric private var waveformLinePadding = 2.0
@State private var resumePlaybackAfterScrubbing = false
let onPlay: () -> Void
let onPause: () -> Void
let onSeek: (Double) -> Void
var body: some View {
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: onPlaybackPlayPause,
onSeek: { onPlaybackSeek($0) },
onScrubbing: { onPlaybackScrubbing($0) })
.padding(.vertical, 4.0)
.padding(.horizontal, 6.0)
.background {
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
ZStack {
roundedRectangle
.fill(Color.compound.bgSubtleSecondary)
roundedRectangle
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5)
}
}
.frame(minHeight: 42)
.fixedSize(horizontal: false, vertical: true)
@ScaledMetric private var playPauseButtonSize = 32
@ScaledMetric private var playPauseImagePadding = 8
@State var dragState: WaveformViewDragState = .inactive
private static let elapsedTimeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "mm:ss"
return dateFormatter
}()
var timeLabelContent: String {
// Display the duration if progress is 0.0
let percent = playerState.progress > 0.0 ? playerState.progress : 1.0
// If the duration is greater or equal 10 minutes, use the long format
let elapsed = Date(timeIntervalSinceReferenceDate: playerState.duration * percent)
return Self.elapsedTimeFormatter.string(from: elapsed)
}
private func onPlaybackPlayPause() { }
var showWaveformCursor: Bool {
playerState.playbackState == .playing || dragState.isDragging
}
var body: some View {
HStack {
HStack {
playPauseButton
Text(timeLabelContent)
.lineLimit(1)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
.fixedSize(horizontal: true, vertical: true)
}
waveformView
.waveformDragGesture($dragState)
.onChange(of: dragState) { dragState in
switch dragState {
case .inactive:
onScrubbing(false)
case .pressing(let progress):
onScrubbing(true)
onSeek(max(0, min(progress, 1.0)))
case .dragging(let progress):
onSeek(max(0, min(progress, 1.0)))
}
self.dragState = dragState
}
}
.padding(.vertical, 4.0)
.padding(.horizontal, 6.0)
.background {
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
ZStack {
roundedRectangle
.fill(Color.compound.bgSubtleSecondary)
roundedRectangle
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5)
}
}
.frame(minHeight: 42)
.fixedSize(horizontal: false, vertical: true)
}
private func onPlaybackSeek(_ progress: Double) { }
@ViewBuilder
private var waveformView: some View {
let configuration: Waveform.Configuration = .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)),
verticalScalingFactor: 1.0)
switch waveform {
case .url(let url):
WaveformView(audioURL: url,
configuration: configuration)
.progressMask(progress: playerState.progress)
case .data(let array):
WaveformLiveCanvas(samples: array,
configuration: configuration)
.progressMask(progress: playerState.progress)
}
}
@ViewBuilder
private var playPauseButton: some View {
Button {
onPlayPause()
} label: {
ZStack {
Circle()
.foregroundColor(.compound.bgCanvasDefault)
if playerState.playbackState == .loading {
ProgressView()
} else {
Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay)
.resizable()
.padding(playPauseImagePadding)
.offset(x: playerState.playbackState == .playing ? 0 : 2)
.aspectRatio(contentMode: .fit)
.foregroundColor(.compound.iconSecondary)
.accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay)
}
}
}
.disabled(playerState.playbackState == .loading)
.frame(width: playPauseButtonSize,
height: playPauseButtonSize)
}
private func onPlaybackScrubbing(_ dragging: Bool) { }
private func onPlayPause() {
if playerState.playbackState == .playing {
onPause()
} else {
onPlay()
}
}
private func onScrubbing(_ scrubbing: Bool) {
if scrubbing {
if playerState.playbackState == .playing {
resumePlaybackAfterScrubbing = true
onPause()
}
} else {
if resumePlaybackAfterScrubbing {
onPlay()
resumePlaybackAfterScrubbing = false
}
}
}
}
struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview {
static let playerState = AudioPlayerState(duration: 10.0,
static let playerState = AudioPlayerState(id: .recorderPreview,
duration: 10.0,
waveform: EstimatedWaveform.mockWaveform,
progress: 0.4)
static let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
static var previews: some View {
VStack {
VoiceMessagePreviewComposer(playerState: playerState)
VoiceMessagePreviewComposer(playerState: playerState, waveform: .data(waveformData), onPlay: { }, onPause: { }, onSeek: { _ in })
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -19,14 +19,8 @@ import SwiftUI
struct VoiceMessageRecordingButton: View {
@ScaledMetric private var buttonIconSize = 24
@State private var longPressConfirmed = false
@State private var buttonPressed = false
@State private var longPressTask = VoiceMessageButtonTask()
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let delayBeforeRecording = 500
@Binding var showRecordTooltip: Bool
var startRecording: (() -> Void)?
var stopRecording: (() -> Void)?
@@ -34,29 +28,16 @@ struct VoiceMessageRecordingButton: View {
Button { } label: {
voiceMessageButtonImage
}
.onLongPressGesture(perform: { }, onPressingChanged: { pressing in
buttonPressed = pressing
if pressing {
showRecordTooltip = true
feedbackGenerator.prepare()
longPressTask.task = Task {
try? await Task.sleep(for: .milliseconds(delayBeforeRecording))
guard !Task.isCancelled else {
return
}
feedbackGenerator.impactOccurred()
showRecordTooltip = false
longPressConfirmed = true
startRecording?()
}
.onLongPressGesture { } onPressingChanged: { isPressing in
buttonPressed = isPressing
if isPressing {
// Start recording
startRecording?()
} else {
longPressTask.task?.cancel()
showRecordTooltip = false
guard longPressConfirmed else { return }
longPressConfirmed = false
// Stop recording
stopRecording?()
}
})
}
.fixedSize()
}
@@ -71,13 +52,9 @@ struct VoiceMessageRecordingButton: View {
}
}
private class VoiceMessageButtonTask {
@CancellableTask var task: Task<Void, Never>?
}
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VoiceMessageRecordingButton(showRecordTooltip: .constant(false))
VoiceMessageRecordingButton()
.fixedSize(horizontal: true, vertical: true)
}
}
@@ -15,6 +15,8 @@
//
import Compound
import DSWaveformImage
import DSWaveformImageViews
import Foundation
import SwiftUI
@@ -30,10 +32,15 @@ struct VoiceMessageRecordingView: View {
return dateFormatter
}()
var timeLabelContent: String {
private var timeLabelContent: String {
Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: recorderState.duration))
}
private var configuration: Waveform.Configuration {
.init(style: .striped(.init(color: .compound.iconSecondary, width: waveformLineWidth, spacing: waveformLinePadding)),
verticalScalingFactor: 1.0)
}
var body: some View {
HStack {
Circle()
@@ -45,7 +52,9 @@ struct VoiceMessageRecordingView: View {
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
.fixedSize()
EstimatedWaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: recorderState.waveform, progress: 0)
WaveformLiveCanvas(samples: recorderState.waveformSamples,
configuration: configuration)
}
.padding(.leading, 2)
.padding(.trailing, 8)
@@ -53,11 +62,6 @@ struct VoiceMessageRecordingView: View {
}
struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview {
static let waveform = EstimatedWaveform(data: [3, 127, 400, 266, 126, 122, 373, 251, 45, 112,
334, 205, 99, 138, 397, 354, 125, 361, 199, 51,
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
0, 0, 0, 0, 0, 3])
static let recorderState = AudioRecorderState()
static var previews: some View {
@@ -23,6 +23,7 @@ struct RoomScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
let emojiProvider: EmojiProviderProtocol
let completionSuggestionService: CompletionSuggestionServiceProtocol
let appSettings: AppSettings
@@ -60,6 +61,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider,
roomProxy: parameters.roomProxy,
appSettings: parameters.appSettings,
analytics: ServiceLocator.shared.analytics,
@@ -42,7 +42,7 @@ enum RoomScreenComposerMode: Equatable {
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
case edit(originalItemId: TimelineItemIdentifier)
case recordVoiceMessage(state: AudioRecorderState)
case previewVoiceMessage(state: AudioPlayerState)
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource)
var isEdit: Bool {
switch self {
@@ -34,16 +34,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let analytics: AnalyticsService
private unowned let userIndicatorController: UserIndicatorControllerProtocol
private let notificationCenterProtocol: NotificationCenterProtocol
private let voiceMessageRecorder: VoiceMessageRecorderProtocol
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
private var canCurrentUserRedact = false
private var paginateBackwardsTask: Task<Void, Never>?
init(timelineController: RoomTimelineControllerProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
roomProxy: RoomProxyProtocol,
appSettings: AppSettings,
analytics: AnalyticsService,
@@ -55,6 +55,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.analytics = analytics
self.userIndicatorController = userIndicatorController
self.notificationCenterProtocol = notificationCenterProtocol
self.mediaPlayerProvider = mediaPlayerProvider
voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
roomTitle: roomProxy.roomTitle,
@@ -194,15 +196,28 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
trackComposerMode(mode)
case .composerFocusedChanged(isFocused: let isFocused):
composerFocusedSubject.send(isFocused)
case .startRecordingVoiceMessage:
timelineController.pauseAudio()
startRecordingVoiceMessage()
case .stopRecordingVoiceMessage:
stopRecordingVoiceMessage()
case .deleteRecordedVoiceMessage:
deleteCurrentVoiceMessage()
case .startVoiceMessageRecording:
Task {
await mediaPlayerProvider.detachAllStates(except: nil)
await startRecordingVoiceMessage()
}
case .stopVoiceMessageRecording:
Task { await stopRecordingVoiceMessage() }
case .cancelVoiceMessageRecording:
Task { await cancelRecordingVoiceMessage() }
case .deleteVoiceMessageRecording:
Task { await deleteCurrentVoiceMessage() }
case .sendVoiceMessage:
Task { await sendCurrentVoiceMessage() }
case .startVoiceMessagePlayback:
Task {
await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState)
await startPlayingRecordedVoiceMessage()
}
case .pauseVoiceMessagePlayback:
pausePlayingRecordedVoiceMessage()
case .seekVoiceMessagePlayback(let progress):
Task { await seekRecordedVoiceMessage(to: progress) }
}
}
@@ -922,30 +937,89 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Voice message
private func startRecordingVoiceMessage() {
// Partially implemented
private func stopVoiceMessageRecorder() async {
_ = await voiceMessageRecorder.stopRecording()
await voiceMessageRecorder.stopPlayback()
}
private func startRecordingVoiceMessage() async {
let audioRecordState = AudioRecorderState()
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
audioRecordState.attachAudioRecorder(voiceMessageRecorder.audioRecorder)
switch await voiceMessageRecorder.startRecording() {
case .success:
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
case .failure(let error):
switch error {
case .audioRecorderError(.recordPermissionNotGranted):
state.bindings.confirmationAlertInfo = .init(id: .init(),
title: "",
message: L10n.dialogPermissionMicrophone,
primaryButton: .init(title: L10n.actionOpenSettings, action: { [weak self] in self?.openSystemSettings() }),
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
default:
MXLog.error("failed to start voice message recording: \(error)")
}
}
}
private func stopRecordingVoiceMessage() {
// Partially implemented
private func stopRecordingVoiceMessage() async {
if case .failure(let error) = await voiceMessageRecorder.stopRecording() {
MXLog.error("failed to stop the recording", context: error)
return
}
let audioPlayerState = AudioPlayerState(duration: 0)
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState))))
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState else {
MXLog.error("the recorder preview is missing after the recording has been stopped")
return
}
guard let recordingURL = voiceMessageRecorder.recordingURL else {
MXLog.error("the recording URL is missing after the recording has been stopped")
return
}
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL)))))
}
private func deleteCurrentVoiceMessage() {
// Partially implemented
private func cancelRecordingVoiceMessage() async {
await voiceMessageRecorder.cancelRecording()
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
private func deleteCurrentVoiceMessage() async {
await voiceMessageRecorder.deleteRecording()
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
private func sendCurrentVoiceMessage() async {
// Partially implemented
actionsSubject.send(.composer(action: .setMode(mode: .default)))
await voiceMessageRecorder.stopPlayback()
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) {
case .success:
await deleteCurrentVoiceMessage()
case .failure(let error):
MXLog.error("failed to send the voice message", context: error)
}
}
private func startPlayingRecordedVoiceMessage() async {
if case .failure(let error) = await voiceMessageRecorder.startPlayback() {
MXLog.error("failed to play recorded voice message", context: error)
}
}
private func pausePlayingRecordedVoiceMessage() {
voiceMessageRecorder.pausePlayback()
}
private func seekRecordedVoiceMessage(to progress: Double) async {
await voiceMessageRecorder.seekPlayback(to: progress)
}
private func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
@@ -974,6 +1048,7 @@ extension RoomScreenViewModel.Context {
extension RoomScreenViewModel {
static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -57,6 +57,7 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
static var bodyPlain: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name", avatarURL: URL.picturesDirectory)),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -71,6 +72,7 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
static var bodyEncrypted: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -193,6 +193,7 @@ struct RoomScreen: View {
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -505,7 +505,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
playerState: AudioPlayerState(duration: 10, waveform: EstimatedWaveform.mockWaveform))
playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform))
}
.environmentObject(viewModel.context)
}
@@ -232,7 +232,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview {
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
playerState: AudioPlayerState(duration: 10, waveform: EstimatedWaveform.mockWaveform))
playerState: AudioPlayerState(id: .timelineItemIdentifier(.init(timelineID: "")), duration: 10, waveform: EstimatedWaveform.mockWaveform))
}
.environmentObject(viewModel.context)
}
@@ -60,6 +60,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Test",
members: members)),
appSettings: ServiceLocator.shared.settings,
@@ -82,6 +82,7 @@ struct UITimelineView: UIViewControllerRepresentable {
struct UITimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -170,6 +170,7 @@ struct TimelineView: View {
struct TimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,