Skip to content

Commit

Permalink
Allow to record a voice message (#1926)
Browse files Browse the repository at this point in the history
  • Loading branch information
nimau authored Oct 23, 2023
1 parent 65b7c1d commit 2f57fbc
Show file tree
Hide file tree
Showing 62 changed files with 1,795 additions and 332 deletions.
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ line_length:

file_length:
warning: 1000
error: 1000
error: 1200

type_name:
min_length: 3
Expand Down
52 changes: 44 additions & 8 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {

let userID = userSession.clientProxy.userID

let mediaPlayerProvider = MediaPlayerProvider(mediaProvider: userSession.mediaProvider)
let mediaPlayerProvider = MediaPlayerProvider()

let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
mediaProvider: userSession.mediaProvider,
Expand All @@ -357,6 +357,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
emojiProvider: emojiProvider,
completionSuggestionService: completionSuggestionService,
appSettings: appSettings)
Expand Down
381 changes: 336 additions & 45 deletions ElementX/Sources/Mocks/Generated/GeneratedMocks.swift

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions ElementX/Sources/Other/VoiceMessage/WaveformSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// 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

enum WaveformSource: Equatable {
/// File URL of the source audio file
case url(URL)
/// Array of small number of pre-computed samples
case data([Float])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// 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 DSWaveformImageViews
import Foundation
import SwiftUI

struct WaveformViewDragGestureModifier: ViewModifier {
@GestureState private var dragGestureState = WaveformViewDragState.inactive
@Binding var dragState: WaveformViewDragState

let minimumDragDistance: Double

func body(content: Content) -> some View {
GeometryReader { geometry in
content
.gesture(SpatialTapGesture()
.simultaneously(with: LongPressGesture())
.sequenced(before: DragGesture(minimumDistance: minimumDragDistance, coordinateSpace: .local))
.updating($dragGestureState) { value, state, _ in
switch value {
// (SpatialTap, LongPress) begins.
case .first(let spatialLongPress):
// Compute the progress with the spatialTap location
let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width
state = .pressing(progress: progress)
// Long press confirmed, dragging may begin.
case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false:
var progress: Double = dragState.progress
// Compute the progress with drag location
if let location = drag?.location {
progress = location.x / geometry.size.width
}
state = .dragging(progress: progress)
// Dragging ended or the long press cancelled.
default:
state = .inactive
}
})
}
.onChange(of: dragGestureState) { value in
dragState = value
}
}
}

extension View {
func waveformDragGesture(_ dragState: Binding<WaveformViewDragState>, minimumDragDistance: Double = 0) -> some View {
modifier(WaveformViewDragGestureModifier(dragState: dragState,
minimumDragDistance: minimumDragDistance))
}
}

enum WaveformViewDragState: Equatable {
case inactive
case pressing(progress: Double)
case dragging(progress: Double)

var progress: Double {
switch self {
case .inactive:
return .zero
case .pressing(let progress), .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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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
Expand Down
58 changes: 45 additions & 13 deletions ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -96,6 +102,7 @@ struct ComposerToolbar: View {
}
}
}
.animation(.elementDefault, value: context.viewState.composerMode)
}

private var bottomBar: some View {
Expand Down Expand Up @@ -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)
Expand All @@ -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))
}
}
}

Expand Down Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit 2f57fbc

Please sign in to comment.