Add UI to record a voice message from the composer toolbar (#1892)

This commit is contained in:
Nicolas Mauri
2023-10-16 17:18:51 +02:00
committed by GitHub
parent 0d72554e6f
commit 9ab3b20a49
43 changed files with 1241 additions and 87 deletions
@@ -30,6 +30,11 @@ enum ComposerToolbarViewModelAction {
case composerModeChanged(mode: RoomScreenComposerMode)
case composerFocusedChanged(isFocused: Bool)
case startRecordingVoiceMessage
case stopRecordingVoiceMessage
case deleteRecordedVoiceMessage
case sendVoiceMessage
}
enum ComposerToolbarViewAction {
@@ -46,6 +51,9 @@ enum ComposerToolbarViewAction {
case enableTextFormatting
case composerAction(action: ComposerAction)
case selectedSuggestion(_ suggestion: SuggestionItem)
case startRecordingVoiceMessage
case stopRecordingVoiceMessage
case deleteRecordedVoiceMessage
}
struct ComposerToolbarViewState: BindableState {
@@ -53,11 +61,33 @@ struct ComposerToolbarViewState: BindableState {
var composerEmpty = true
var areSuggestionsEnabled = true
var suggestions: [SuggestionItem] = []
var enableVoiceMessageComposer: Bool
var audioPlayerState: AudioPlayerState
var audioRecorderState: AudioRecorderState
var bindings: ComposerToolbarViewStateBindings
var showSendButton: Bool {
switch composerMode {
case .recordVoiceMessage:
return false
case .previewVoiceMessage:
return true
default:
if enableVoiceMessageComposer {
return !composerEmpty
} else {
return true
}
}
}
var sendButtonDisabled: Bool {
composerEmpty
if case .previewVoiceMessage = composerMode {
return false
}
return composerEmpty
}
}
@@ -45,7 +45,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
self.completionSuggestionService = completionSuggestionService
self.appSettings = appSettings
super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled, bindings: .init()), imageProvider: mediaProvider)
super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled,
enableVoiceMessageComposer: appSettings.voiceMessageEnabled,
audioPlayerState: .init(duration: 0),
audioRecorderState: .init(),
bindings: .init()),
imageProvider: mediaProvider)
context.$viewState
.map(\.composerMode)
@@ -98,10 +103,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
wysiwygViewModel.setup()
case .sendMessage:
guard !state.sendButtonDisabled else { return }
let sendHTML = ServiceLocator.shared.settings.richTextEditorEnabled
actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown,
html: sendHTML ? wysiwygViewModel.content.html : nil,
mode: state.composerMode))
switch state.composerMode {
case .previewVoiceMessage:
actionsSubject.send(.sendVoiceMessage)
default:
let sendHTML = ServiceLocator.shared.settings.richTextEditorEnabled
actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown,
html: sendHTML ? wysiwygViewModel.content.html : nil,
mode: state.composerMode))
}
case .cancelReply:
set(mode: .default)
case .cancelEdit:
@@ -130,6 +141,13 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
case .selectedSuggestion(let suggestion):
handleSuggestion(suggestion)
case .startRecordingVoiceMessage:
state.bindings.composerActionsEnabled = false
actionsSubject.send(.startRecordingVoiceMessage)
case .stopRecordingVoiceMessage:
actionsSubject.send(.stopRecordingVoiceMessage)
case .deleteRecordedVoiceMessage:
actionsSubject.send(.deleteRecordedVoiceMessage)
}
}
@@ -197,7 +215,15 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
guard mode != state.composerMode else { return }
state.composerMode = mode
if mode != .default {
switch mode {
case .default:
break
case .recordVoiceMessage(let audioRecorderState):
state.bindings.composerFocused = false
state.audioRecorderState = audioRecorderState
case .previewVoiceMessage(let audioPlayerState):
state.audioPlayerState = audioPlayerState
case .edit, .reply:
// Focus composer when switching to reply/edit
state.bindings.composerFocused = true
}
@@ -22,16 +22,21 @@ struct ComposerToolbar: View {
@ObservedObject var context: ComposerToolbarViewModel.Context
let wysiwygViewModel: WysiwygComposerViewModel
let keyCommandHandler: KeyCommandHandler
@FocusState private var composerFocused: Bool
@ScaledMetric private var sendButtonIconSize = 16
@ScaledMetric private var trashButtonIconSize = 24
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
@State private var showVoiceMessageRecordingTooltip = false
@ScaledMetric private var voiceMessageTooltipPointerHeight = 6
@State private var frame: CGRect = .zero
var body: some View {
VStack(spacing: 8) {
topBar
if context.composerActionsEnabled {
bottomBar
}
@@ -45,6 +50,10 @@ struct ComposerToolbar: View {
.offset(y: -frame.height)
}
}
.overlay(alignment: .bottomTrailing) {
voiceMessageRecordingButtonTooltipView
.offset(y: -frame.height - voiceMessageTooltipPointerHeight)
}
.alert(item: $context.alertInfo)
}
@@ -53,42 +62,55 @@ struct ComposerToolbar: View {
context.send(viewAction: .selectedSuggestion(suggestion))
}
}
private var topBar: some View {
HStack(alignment: .bottom, spacing: 5) {
if !context.composerActionsEnabled {
RoomAttachmentPicker(context: context)
switch context.viewState.composerMode {
case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
VoiceMessageRecordingComposer(recorderState: state)
.padding(.leading, 12)
case .previewVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
voiceMessageTrashButton
VoiceMessagePreviewComposer(playerState: state)
default:
if !context.composerActionsEnabled {
RoomAttachmentPicker(context: context)
}
messageComposer
.environmentObject(context)
.onTapGesture {
guard !composerFocused else { return }
composerFocused = true
}
.padding(.leading, context.composerActionsEnabled ? 7 : 0)
.padding(.trailing, context.composerActionsEnabled ? 4 : 0)
}
messageComposer
.environmentObject(context)
.onTapGesture {
guard !composerFocused else { return }
composerFocused = true
}
.padding(.leading, context.composerActionsEnabled ? 7 : 0)
.padding(.trailing, context.composerActionsEnabled ? 4 : 0)
if !context.composerActionsEnabled {
sendButton
.padding(.leading, 3)
if context.viewState.showSendButton {
sendButton
.padding(.leading, 3)
} else if context.viewState.enableVoiceMessageComposer {
voiceMessageRecordingButton
.padding(.leading, 4)
}
}
}
}
private var bottomBar: some View {
HStack(alignment: .center, spacing: 9) {
closeRTEButton
FormattingToolbar(formatItems: context.formatItems) { action in
context.send(viewAction: .composerAction(action: action.composerAction))
}
sendButton
.padding(.leading, 7)
}
}
private var closeRTEButton: some View {
Button {
context.composerActionsEnabled = false
@@ -103,7 +125,7 @@ struct ComposerToolbar: View {
.accessibilityLabel(L10n.actionClose)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
}
private var sendButton: some View {
Button {
context.send(viewAction: .sendMessage)
@@ -141,7 +163,7 @@ struct ComposerToolbar: View {
.focused($composerFocused)
.onChange(of: context.composerFocused) { newValue in
guard composerFocused != newValue else { return }
composerFocused = newValue
}
.onChange(of: composerFocused) { newValue in
@@ -167,7 +189,7 @@ struct ComposerToolbar: View {
context.send(viewAction: .handlePasteOrDrop(provider: provider))
}
}
private var submitButtonImage: some View {
// ZStack with opacity so the button size is consistent.
ZStack {
@@ -184,12 +206,44 @@ struct ComposerToolbar: View {
.accessibilityHidden(context.viewState.composerMode.isEdit)
}
}
private class ItemProviderHelper: WysiwygItemProviderHelper {
func isPasteSupported(for itemProvider: NSItemProvider) -> Bool {
itemProvider.isSupportedForPasteOrDrop
}
}
// MARK: - Voice message
private var voiceMessageRecordingButton: some View {
VoiceMessageRecordingButton(showRecordTooltip: $showVoiceMessageRecordingTooltip, startRecording: {
context.send(viewAction: .startRecordingVoiceMessage)
}, stopRecording: {
context.send(viewAction: .stopRecordingVoiceMessage)
})
.padding(4)
}
private var voiceMessageTrashButton: some View {
Button {
context.send(viewAction: .deleteRecordedVoiceMessage)
} label: {
CompoundIcon(\.delete)
.font(.compound.bodyLG)
.foregroundColor(.compound.textCriticalPrimary)
.frame(width: trashButtonIconSize, height: trashButtonIconSize)
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 11))
.fixedSize()
.accessibilityLabel(L10n.a11yDelete)
}
}
private var voiceMessageRecordingButtonTooltipView: some View {
VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, pointerHeight: voiceMessageTooltipPointerHeight)
.allowsHitTesting(false)
.opacity(showVoiceMessageRecordingTooltip ? 1.0 : 0.0)
.animation(.elementDefault, value: showVoiceMessageRecordingTooltip)
}
}
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
@@ -203,7 +257,7 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))]
static var previews: some View {
ComposerToolbar.mock()
ComposerToolbar.mock(focused: true)
// Putting them is VStack allows the completion suggestion preview to work properly in tests
VStack {
@@ -213,19 +267,82 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
keyCommandHandler: { _ in false })
}
.previewDisplayName("With Suggestions")
VStack {
ComposerToolbar.textWithVoiceMessage(focused: false)
ComposerToolbar.textWithVoiceMessage(focused: true)
ComposerToolbar.voiceMessageRecordingMock(recording: true)
ComposerToolbar.voiceMessagePreviewMock(recording: false)
}
.previewDisplayName("Voice Message")
}
}
// MARK: - Mock
extension ComposerToolbar {
static func mock() -> ComposerToolbar {
static func mock(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(),
appSettings: ServiceLocator.shared.settings,
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(),
appSettings: ServiceLocator.shared.settings,
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
model.state.composerEmpty = focused
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommandHandler: { _ in false })
}
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(),
appSettings: ServiceLocator.shared.settings,
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
model.state.composerEmpty = focused
model.state.enableVoiceMessageComposer = true
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommandHandler: { _ in false })
}
static func voiceMessageRecordingMock(recording: Bool) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(),
appSettings: ServiceLocator.shared.settings,
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
model.state.enableVoiceMessageComposer = true
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommandHandler: { _ in false })
}
static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
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.enableVoiceMessageComposer = true
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommandHandler: { _ in false })
@@ -90,7 +90,7 @@ struct MessageComposer: View {
MessageComposerReplyHeader(replyDetails: replyDetails, action: replyCancellationAction)
case .edit:
MessageComposerEditHeader(action: editCancellationAction)
case .default:
case .recordVoiceMessage, .previewVoiceMessage, .default:
EmptyView()
}
}
@@ -0,0 +1,63 @@
//
// 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 VoiceMessagePreviewComposer: View {
@ObservedObject var playerState: AudioPlayerState
@State private var resumePlaybackAfterScrubbing = false
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)
}
private func onPlaybackPlayPause() { }
private func onPlaybackSeek(_ progress: Double) { }
private func onPlaybackScrubbing(_ dragging: Bool) { }
}
struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview {
static let playerState = AudioPlayerState(duration: 10.0,
waveform: Waveform.mockWaveform,
progress: 0.4)
static var previews: some View {
VStack {
VoiceMessagePreviewComposer(playerState: playerState)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -0,0 +1,83 @@
//
// 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 Compound
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)?
var body: some 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?()
}
} else {
longPressTask.task?.cancel()
showRecordTooltip = false
guard longPressConfirmed else { return }
longPressConfirmed = false
stopRecording?()
}
})
.fixedSize()
}
@ViewBuilder
private var voiceMessageButtonImage: some View {
(buttonPressed ? Image(asset: Asset.Images.micFill) : Image(asset: Asset.Images.mic))
.resizable()
.frame(width: buttonIconSize, height: buttonIconSize)
.foregroundColor(.compound.iconSecondary)
.padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6))
.accessibilityLabel(L10n.a11yVoiceMessageRecord)
}
}
private class VoiceMessageButtonTask {
@CancellableTask var task: Task<Void, Never>?
}
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VoiceMessageRecordingButton(showRecordTooltip: .constant(false))
.fixedSize(horizontal: true, vertical: true)
}
}
@@ -0,0 +1,101 @@
//
// 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 Compound
import Foundation
import SwiftUI
struct VoiceMessageRecordingButtonTooltipView: View {
var text: String
var radius: CGFloat = 4
var corners: UIRectCorner = .allCorners
@ScaledMetric var pointerHeight: CGFloat = 6
@ScaledMetric var pointerWidth: CGFloat = 10
@ScaledMetric var pointerOffset: CGFloat = 8
var body: some View {
Text(text)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textOnSolidPrimary)
.padding(6)
.background(
TooltipShape(radius: radius,
corners: corners,
pointerHeight: pointerHeight,
pointerWidth: pointerWidth,
pointerOffset: pointerOffset)
.fill(.compound.bgActionPrimaryRest)
)
.padding(.trailing, 8)
}
}
private struct TooltipShape: Shape {
var radius: CGFloat = 4
var corners: UIRectCorner = .allCorners
var pointerHeight: CGFloat = 6
var pointerWidth: CGFloat = 10
var pointerOffset: CGFloat = 8
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.size.width
let height = rect.size.height
var topLeft: CGFloat = corners.contains(.topLeft) ? radius : 0.0
var topRight: CGFloat = corners.contains(.topRight) ? radius : 0.0
var bottomLeft: CGFloat = corners.contains(.bottomLeft) ? radius : 0.0
var bottomRight: CGFloat = corners.contains(.bottomRight) ? radius : 0.0
// Make sure we do not exceed the size of the rectangle
topRight = min(min(topRight, height / 2), width / 2)
topLeft = min(min(topLeft, height / 2), width / 2)
bottomLeft = min(min(bottomLeft, height / 2), width / 2)
bottomRight = min(min(bottomRight, height / 2), width / 2)
path.move(to: CGPoint(x: width / 2.0, y: 0))
path.addLine(to: CGPoint(x: width - topRight, y: 0))
path.addArc(center: CGPoint(x: width - topRight, y: topRight), radius: topRight,
startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
path.addLine(to: CGPoint(x: width, y: height - bottomRight))
path.addArc(center: CGPoint(x: width - bottomRight, y: height - bottomRight), radius: bottomRight,
startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false)
path.addLine(to: CGPoint(x: width - pointerOffset, y: height))
path.addLine(to: CGPoint(x: width - pointerOffset - (pointerWidth / 2.0), y: height + pointerHeight))
path.addLine(to: CGPoint(x: width - pointerOffset - pointerWidth, y: height))
path.addLine(to: CGPoint(x: bottomLeft, y: height))
path.addArc(center: CGPoint(x: bottomLeft, y: height - bottomLeft), radius: bottomLeft,
startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false)
path.addLine(to: CGPoint(x: 0, y: topLeft))
path.addArc(center: CGPoint(x: topLeft, y: topLeft), radius: topLeft,
startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false)
path.closeSubpath()
return path
}
}
struct VoiceMessageRecordingButtonTooltipView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VoiceMessageRecordingButtonTooltipView(text: "Hold to record")
.fixedSize()
}
}
@@ -0,0 +1,57 @@
//
// 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 Compound
import Foundation
import SwiftUI
struct VoiceMessageRecordingComposer: View {
@ObservedObject var recorderState: AudioRecorderState
var body: some View {
VoiceMessageRecordingView(recorderState: recorderState)
.padding(.vertical, 8.0)
.padding(.horizontal, 12.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 onPlaybackPlayPause() { }
private func onPlaybackSeek(_ progress: Double) { }
private func onPlaybackScrubbing(_ dragging: Bool) { }
}
struct VoiceMessageRecordingComposer_Previews: PreviewProvider, TestablePreview {
static let recorderState = AudioRecorderState()
static var previews: some View {
VStack {
VoiceMessageRecordingComposer(recorderState: recorderState)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -0,0 +1,67 @@
//
// 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 Compound
import Foundation
import SwiftUI
struct VoiceMessageRecordingView: View {
@ObservedObject var recorderState: AudioRecorderState
@ScaledMetric private var waveformLineWidth = 2.0
@ScaledMetric private var waveformLinePadding = 2.0
@ScaledMetric private var recordingIndicatorSize = 8
private static let elapsedTimeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "mm:ss"
return dateFormatter
}()
var timeLabelContent: String {
Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: recorderState.duration))
}
var body: some View {
HStack {
Circle()
.frame(width: recordingIndicatorSize, height: recordingIndicatorSize)
.foregroundColor(.red)
Text(timeLabelContent)
.lineLimit(1)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
.fixedSize()
WaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: recorderState.waveform, progress: 0, showCursor: false)
}
.padding(.leading, 2)
.padding(.trailing, 8)
}
}
struct VoiceMessageRecordingView_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 recorderState = AudioRecorderState()
static var previews: some View {
VoiceMessageRecordingView(recorderState: recorderState)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -40,6 +40,8 @@ enum RoomScreenComposerMode: Equatable {
case `default`
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
case edit(originalItemId: TimelineItemIdentifier)
case recordVoiceMessage(state: AudioRecorderState)
case previewVoiceMessage(state: AudioPlayerState)
var isEdit: Bool {
switch self {
@@ -186,6 +186,15 @@ 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 .sendVoiceMessage:
Task { await sendCurrentVoiceMessage() }
}
}
@@ -466,9 +475,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
await timelineController.editMessage(message, html: html, original: originalItemId)
case .default:
await timelineController.sendMessage(message, html: html)
case .recordVoiceMessage, .previewVoiceMessage:
fatalError("invalid composer mode.")
}
}
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
var isEdit = false
var isReply = false
@@ -888,6 +899,34 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
timelineController.audioPlayerState(for: itemID)
}
// MARK: - Voice message
private func startRecordingVoiceMessage() {
// Partially implemented
let audioRecordState = AudioRecorderState()
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
}
private func stopRecordingVoiceMessage() {
// Partially implemented
let audioPlayerState = AudioPlayerState(duration: 0)
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState))))
}
private func deleteCurrentVoiceMessage() {
// Partially implemented
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
private func sendCurrentVoiceMessage() async {
// Partially implemented
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
}
private extension RoomProxyProtocol {