New voice message recording mode (#2051)

* Lock voice message recording

* Use the VoiceMessageCache to store the recording file

* Rework on some composer toolbar buttons

* Update accessibility labels for voice message recording button

* PreviewTests
This commit is contained in:
Nicolas Mauri
2023-11-10 16:32:22 +01:00
committed by GitHub
parent 5a0dab1ce8
commit da831f6725
34 changed files with 283 additions and 296 deletions
@@ -24,9 +24,13 @@ struct ComposerToolbar: View {
let keyCommandHandler: KeyCommandHandler
@FocusState private var composerFocused: Bool
@ScaledMetric private var sendButtonIconSize = 16
@ScaledMetric(relativeTo: .title) private var sendButtonIconSize = 16
@ScaledMetric(relativeTo: .title) private var sendButtonIconPadding = 10
@ScaledMetric(relativeTo: .title) private var sendButtonIconOffsetX = 1
@ScaledMetric(relativeTo: .title) private var spinnerSize = 44
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
@ScaledMetric(relativeTo: .title) private var deleteRecordingButtonSize = 30
@State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -77,8 +81,8 @@ struct ComposerToolbar: View {
sendButton
.padding(.leading, 3)
} else {
voiceMessageRecordingButton
.padding(.leading, 4)
voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle)
.padding(.leading, 3)
}
}
}
@@ -122,6 +126,7 @@ struct ComposerToolbar: View {
if context.viewState.isVoiceMessageModeActivated {
voiceMessageContent
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -214,8 +219,9 @@ struct ComposerToolbar: View {
.accessibilityHidden(!context.viewState.composerMode.isEdit)
Image(asset: Asset.Images.sendMessage)
.resizable()
.offset(x: sendButtonIconOffsetX)
.frame(width: sendButtonIconSize, height: sendButtonIconSize)
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 9))
.padding(sendButtonIconPadding)
.opacity(context.viewState.composerMode.isEdit ? 0 : 1)
.accessibilityLabel(L10n.actionSend)
.accessibilityHidden(context.viewState.composerMode.isEdit)
@@ -235,8 +241,10 @@ struct ComposerToolbar: View {
// Display the voice message composer above to keep the focus and keep the keyboard open if it's already open.
switch context.viewState.composerMode {
case .recordVoiceMessage(let state):
VoiceMessageRecordingComposer(recorderState: state)
.padding(.leading, 12)
topBarLayout {
voiceMessageTrashButton
VoiceMessageRecordingComposer(recorderState: state)
}
case .previewVoiceMessage(let state, let waveform, let isUploading):
topBarLayout {
voiceMessageTrashButton
@@ -248,17 +256,12 @@ struct ComposerToolbar: View {
}
}
private var voiceMessageRecordingButton: some View {
VoiceMessageRecordingButton {
private func voiceMessageRecordingButton(mode: VoiceMessageRecordingButtonMode) -> some View {
VoiceMessageRecordingButton(mode: mode) {
context.send(viewAction: .voiceMessage(.startRecording))
} stopRecording: { minimumRecordTimeReached in
if minimumRecordTimeReached {
context.send(viewAction: .voiceMessage(.stopRecording))
} else {
context.send(viewAction: .voiceMessage(.cancelRecording))
}
} stopRecording: {
context.send(viewAction: .voiceMessage(.stopRecording))
}
.padding(4)
}
private var voiceMessageTrashButton: some View {
@@ -266,7 +269,9 @@ struct ComposerToolbar: View {
context.send(viewAction: .voiceMessage(.deleteRecording))
} label: {
CompoundIcon(\.delete)
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 11))
.scaledToFit()
.frame(width: deleteRecordingButtonSize, height: deleteRecordingButtonSize)
.padding(7)
}
.buttonStyle(.compound(.plain))
.accessibilityLabel(L10n.a11yDelete)
@@ -72,8 +72,6 @@ struct VoiceMessagePreviewComposer: View {
.fill(Color.compound.bgSubtleSecondary)
}
}
.frame(minHeight: 42)
.fixedSize(horizontal: false, vertical: true)
}
@ViewBuilder
@@ -17,79 +17,71 @@
import Compound
import SwiftUI
enum VoiceMessageRecordingButtonMode {
case idle
case recording
}
struct VoiceMessageRecordingButton: View {
let mode: VoiceMessageRecordingButtonMode
var startRecording: (() -> Void)?
var stopRecording: ((_ minimumRecordTimeReached: Bool) -> Void)?
var stopRecording: (() -> Void)?
@ScaledMetric private var tooltipPointerHeight = 6
@State private var buttonPressed = false
@State private var recordingStartTime: Date?
@State private var showTooltip = false
@State private var frame: CGRect = .zero
private let minimumRecordingDuration = 1.0
private let tooltipDuration = 1.0
private let impactFeedbackGenerator = UIImpactFeedbackGenerator()
private let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
@ScaledMetric(relativeTo: .title) private var idleImageSize = 16
@ScaledMetric(relativeTo: .title) private var idleImagePadding = 10
@ScaledMetric(relativeTo: .title) private var recordingImageSize = 24
@ScaledMetric(relativeTo: .title) private var recordingImagePadding = 6
var body: some View {
Button { } label: {
CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline)
.foregroundColor(.compound.iconSecondary)
.padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6))
}
.readFrame($frame, in: .global)
.accessibilityLabel(L10n.a11yVoiceMessageRecord)
.onLongPressGesture { } onPressingChanged: { isPressing in
buttonPressed = isPressing
if isPressing {
showTooltip = false
recordingStartTime = Date.now
impactFeedbackGenerator.impactOccurred()
Button {
impactFeedbackGenerator.impactOccurred()
switch mode {
case .idle:
startRecording?()
} else {
if let recordingStartTime, Date.now.timeIntervalSince(recordingStartTime) < minimumRecordingDuration {
withElementAnimation {
showTooltip = true
}
notificationFeedbackGenerator.notificationOccurred(.error)
stopRecording?(false)
} else {
impactFeedbackGenerator.impactOccurred()
stopRecording?(true)
}
}
}
.overlay(alignment: .bottomTrailing) {
if showTooltip {
tooltipView
.offset(y: -frame.height - tooltipPointerHeight)
case .recording:
stopRecording?()
}
} label: {
switch mode {
case .idle:
CompoundIcon(\.micOnOutline, size: .medium, relativeTo: .title)
.foregroundColor(.compound.iconSecondary)
.frame(width: idleImageSize, height: idleImageSize)
.padding(idleImagePadding)
.padding(4)
case .recording:
Asset.Images.stopRecording.swiftUIImage
.resizable()
.foregroundColor(.compound.iconOnSolidPrimary)
.frame(width: recordingImageSize, height: recordingImageSize)
.padding(recordingImagePadding)
.background(
Circle()
.foregroundColor(.compound.bgActionPrimaryRest)
)
.padding(4)
}
}
.buttonStyle(VoiceMessageRecordingButtonStyle())
.accessibilityLabel(mode == .idle ? L10n.a11yVoiceMessageRecord : L10n.a11yVoiceMessageStopRecording)
}
private var tooltipView: some View {
VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip,
pointerHeight: tooltipPointerHeight,
pointerLocation: frame.midX,
pointerLocationCoordinateSpace: .global)
.allowsHitTesting(false)
.fixedSize()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + tooltipDuration) {
withElementAnimation {
showTooltip = false
}
}
}
}
private struct VoiceMessageRecordingButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.6 : 1)
}
}
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VoiceMessageRecordingButton()
.fixedSize(horizontal: true, vertical: true)
HStack {
VoiceMessageRecordingButton(mode: .idle)
VoiceMessageRecordingButton(mode: .recording)
}
}
}
@@ -1,110 +0,0 @@
//
// 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
var pointerLocation: CGFloat = 10
var pointerLocationCoordinateSpace: CoordinateSpace = .local
var body: some View {
Text(text)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textOnSolidPrimary)
.padding(6)
.background(
GeometryReader { geometry in
TooltipShape(radius: radius,
corners: corners,
pointerHeight: pointerHeight,
pointerWidth: pointerWidth,
pointerLocation: localPointerLocation(using: geometry))
.fill(.compound.bgActionPrimaryRest)
}
)
}
private func localPointerLocation(using geometry: GeometryProxy) -> CGFloat {
let frame = geometry.frame(in: pointerLocationCoordinateSpace)
let minX = radius + pointerWidth / 2
let maxX = geometry.size.width - radius - pointerWidth / 2
return min(max(minX, pointerLocation - frame.minX), maxX)
}
}
private struct TooltipShape: Shape {
var radius: CGFloat
var corners: UIRectCorner
var pointerHeight: CGFloat
var pointerWidth: CGFloat
var pointerLocation: CGFloat
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: pointerLocation + (pointerWidth / 2.0), y: height))
path.addLine(to: CGPoint(x: pointerLocation, y: height + pointerHeight))
path.addLine(to: CGPoint(x: pointerLocation - (pointerWidth / 2.0), 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()
}
}
@@ -32,8 +32,6 @@ struct VoiceMessageRecordingComposer: View {
.fill(Color.compound.bgSubtleSecondary)
}
}
.frame(minHeight: 42)
.fixedSize(horizontal: false, vertical: true)
}
private func onPlaybackPlayPause() { }
@@ -390,7 +390,12 @@ class RoomScreenInteractionHandler {
}
func deleteCurrentVoiceMessage() async {
await voiceMessageRecorder.deleteRecording()
if voiceMessageRecorder.isRecording {
await voiceMessageRecorder.cancelRecording()
} else {
await voiceMessageRecorder.deleteRecording()
}
voiceMessageRecorderObserver = nil
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
@@ -473,7 +478,12 @@ class RoomScreenInteractionHandler {
MXLog.error("Cannot play a voice message without an audio player")
return
}
// Stop any recording in progress
if voiceMessageRecorder.isRecording {
await voiceMessageRecorder.stopRecording()
}
let audioPlayerState = audioPlayerState(for: itemID)
// Ensure this one is attached