mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Add UI to record a voice message from the composer toolbar (#1892)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
+101
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user