mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Voice message loader & error handling when sending (#1945)
* Add loader & error handling * Refactor PlainButtonStyle() -> .plain
This commit is contained in:
@@ -188,6 +188,7 @@
|
|||||||
"error_failed_loading_map" = "%1$@ could not load the map. Please try again later.";
|
"error_failed_loading_map" = "%1$@ could not load the map. Please try again later.";
|
||||||
"error_failed_loading_messages" = "Failed loading messages";
|
"error_failed_loading_messages" = "Failed loading messages";
|
||||||
"error_failed_locating_user" = "%1$@ could not access your location. Please try again later.";
|
"error_failed_locating_user" = "%1$@ could not access your location. Please try again later.";
|
||||||
|
"error_failed_uploading_voice_message" = "Failed to upload your voice message.";
|
||||||
"error_missing_location_auth_ios" = "%1$@ does not have permission to access your location. You can enable access in Settings > Location";
|
"error_missing_location_auth_ios" = "%1$@ does not have permission to access your location. You can enable access in Settings > Location";
|
||||||
"error_no_compatible_app_found" = "No compatible app was found to handle this action.";
|
"error_no_compatible_app_found" = "No compatible app was found to handle this action.";
|
||||||
"error_some_messages_have_not_been_sent" = "Some messages have not been sent";
|
"error_some_messages_have_not_been_sent" = "Some messages have not been sent";
|
||||||
@@ -539,7 +540,7 @@
|
|||||||
"screen_welcome_button" = "Let's go!";
|
"screen_welcome_button" = "Let's go!";
|
||||||
"screen_welcome_subtitle" = "Here’s what you need to know:";
|
"screen_welcome_subtitle" = "Here’s what you need to know:";
|
||||||
"screen_welcome_title" = "Welcome to %1$@!";
|
"screen_welcome_title" = "Welcome to %1$@!";
|
||||||
"session_verification_banner_message" = "Looks like you’re using a new device. Verify with another device to access your encrypted messages moving forwards.";
|
"session_verification_banner_message" = "Looks like you’re using a new device. Verify with another device to access your encrypted messages.";
|
||||||
"session_verification_banner_title" = "Verify it’s you";
|
"session_verification_banner_title" = "Verify it’s you";
|
||||||
"settings_rageshake" = "Rageshake";
|
"settings_rageshake" = "Rageshake";
|
||||||
"settings_rageshake_detection_threshold" = "Detection threshold";
|
"settings_rageshake_detection_threshold" = "Detection threshold";
|
||||||
|
|||||||
@@ -422,6 +422,8 @@ public enum L10n {
|
|||||||
public static func errorFailedLocatingUser(_ p1: Any) -> String {
|
public static func errorFailedLocatingUser(_ p1: Any) -> String {
|
||||||
return L10n.tr("Localizable", "error_failed_locating_user", String(describing: p1))
|
return L10n.tr("Localizable", "error_failed_locating_user", String(describing: p1))
|
||||||
}
|
}
|
||||||
|
/// Failed to upload your voice message.
|
||||||
|
public static var errorFailedUploadingVoiceMessage: String { return L10n.tr("Localizable", "error_failed_uploading_voice_message") }
|
||||||
/// %1$@ does not have permission to access your location. You can enable access in Settings > Location
|
/// %1$@ does not have permission to access your location. You can enable access in Settings > Location
|
||||||
public static func errorMissingLocationAuthIos(_ p1: Any) -> String {
|
public static func errorMissingLocationAuthIos(_ p1: Any) -> String {
|
||||||
return L10n.tr("Localizable", "error_missing_location_auth_ios", String(describing: p1))
|
return L10n.tr("Localizable", "error_missing_location_auth_ios", String(describing: p1))
|
||||||
@@ -1286,7 +1288,7 @@ public enum L10n {
|
|||||||
public static func screenWelcomeTitle(_ p1: Any) -> String {
|
public static func screenWelcomeTitle(_ p1: Any) -> String {
|
||||||
return L10n.tr("Localizable", "screen_welcome_title", String(describing: p1))
|
return L10n.tr("Localizable", "screen_welcome_title", String(describing: p1))
|
||||||
}
|
}
|
||||||
/// Looks like you’re using a new device. Verify with another device to access your encrypted messages moving forwards.
|
/// Looks like you’re using a new device. Verify with another device to access your encrypted messages.
|
||||||
public static var sessionVerificationBannerMessage: String { return L10n.tr("Localizable", "session_verification_banner_message") }
|
public static var sessionVerificationBannerMessage: String { return L10n.tr("Localizable", "session_verification_banner_message") }
|
||||||
/// Verify it’s you
|
/// Verify it’s you
|
||||||
public static var sessionVerificationBannerTitle: String { return L10n.tr("Localizable", "session_verification_banner_title") }
|
public static var sessionVerificationBannerTitle: String { return L10n.tr("Localizable", "session_verification_banner_title") }
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ struct ComposerToolbarViewState: BindableState {
|
|||||||
|
|
||||||
var bindings: ComposerToolbarViewStateBindings
|
var bindings: ComposerToolbarViewStateBindings
|
||||||
|
|
||||||
|
var isUploading: Bool {
|
||||||
|
switch composerMode {
|
||||||
|
case .previewVoiceMessage(_, _, let isUploading):
|
||||||
|
return isUploading
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var showSendButton: Bool {
|
var showSendButton: Bool {
|
||||||
switch composerMode {
|
switch composerMode {
|
||||||
case .recordVoiceMessage:
|
case .recordVoiceMessage:
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
|||||||
case .recordVoiceMessage(let audioRecorderState):
|
case .recordVoiceMessage(let audioRecorderState):
|
||||||
state.bindings.composerFocused = false
|
state.bindings.composerFocused = false
|
||||||
state.audioRecorderState = audioRecorderState
|
state.audioRecorderState = audioRecorderState
|
||||||
case .previewVoiceMessage(let audioPlayerState, _):
|
case .previewVoiceMessage(let audioPlayerState, _, _):
|
||||||
state.audioPlayerState = audioPlayerState
|
state.audioPlayerState = audioPlayerState
|
||||||
case .edit, .reply:
|
case .edit, .reply:
|
||||||
// Focus composer when switching to reply/edit
|
// Focus composer when switching to reply/edit
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ struct ComposerToolbar: View {
|
|||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@ScaledMetric private var sendButtonIconSize = 16
|
@ScaledMetric private var sendButtonIconSize = 16
|
||||||
@ScaledMetric private var trashButtonIconSize = 24
|
@ScaledMetric private var trashButtonIconSize = 24
|
||||||
|
@ScaledMetric(relativeTo: .title) private var spinnerSize = 44
|
||||||
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
|
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
|
||||||
|
|
||||||
@State private var voiceMessageRecordingStartTime: Date?
|
@State private var voiceMessageRecordingStartTime: Date?
|
||||||
@@ -71,29 +72,14 @@ struct ComposerToolbar: View {
|
|||||||
|
|
||||||
private var topBar: some View {
|
private var topBar: some View {
|
||||||
HStack(alignment: .bottom, spacing: 5) {
|
HStack(alignment: .bottom, spacing: 5) {
|
||||||
switch context.viewState.composerMode {
|
mainTopBarContent
|
||||||
case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
|
|
||||||
VoiceMessageRecordingComposer(recorderState: state)
|
|
||||||
.padding(.leading, 12)
|
|
||||||
case .previewVoiceMessage(let state, let waveform) where context.viewState.enableVoiceMessageComposer:
|
|
||||||
voiceMessageTrashButton
|
|
||||||
voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !context.composerActionsEnabled {
|
if !context.composerActionsEnabled {
|
||||||
if context.viewState.showSendButton {
|
if context.viewState.isUploading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: spinnerSize, height: spinnerSize)
|
||||||
|
.padding(.leading, 3)
|
||||||
|
} else if context.viewState.showSendButton {
|
||||||
sendButton
|
sendButton
|
||||||
.padding(.leading, 3)
|
.padding(.leading, 3)
|
||||||
} else if context.viewState.enableVoiceMessageComposer {
|
} else if context.viewState.enableVoiceMessageComposer {
|
||||||
@@ -117,7 +103,34 @@ struct ComposerToolbar: View {
|
|||||||
.padding(.leading, 7)
|
.padding(.leading, 7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainTopBarContent: some View {
|
||||||
|
switch context.viewState.composerMode {
|
||||||
|
case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
|
||||||
|
VoiceMessageRecordingComposer(recorderState: state)
|
||||||
|
.padding(.leading, 12)
|
||||||
|
case .previewVoiceMessage(let state, let waveform, let isUploading) where context.viewState.enableVoiceMessageComposer:
|
||||||
|
Group {
|
||||||
|
voiceMessageTrashButton
|
||||||
|
voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform)
|
||||||
|
}
|
||||||
|
.disabled(isUploading)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var closeRTEButton: some View {
|
private var closeRTEButton: some View {
|
||||||
Button {
|
Button {
|
||||||
context.composerActionsEnabled = false
|
context.composerActionsEnabled = false
|
||||||
@@ -252,6 +265,7 @@ struct ComposerToolbar: View {
|
|||||||
.fixedSize()
|
.fixedSize()
|
||||||
.accessibilityLabel(L10n.a11yDelete)
|
.accessibilityLabel(L10n.a11yDelete)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var voiceMessageRecordingButtonTooltipView: some View {
|
private var voiceMessageRecordingButtonTooltipView: some View {
|
||||||
@@ -303,7 +317,7 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
|||||||
ComposerToolbar.textWithVoiceMessage(focused: false)
|
ComposerToolbar.textWithVoiceMessage(focused: false)
|
||||||
ComposerToolbar.textWithVoiceMessage(focused: true)
|
ComposerToolbar.textWithVoiceMessage(focused: true)
|
||||||
ComposerToolbar.voiceMessageRecordingMock(recording: true)
|
ComposerToolbar.voiceMessageRecordingMock(recording: true)
|
||||||
ComposerToolbar.voiceMessagePreviewMock(recording: false)
|
ComposerToolbar.voiceMessagePreviewMock(recording: false, uploading: false)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Voice Message")
|
.previewDisplayName("Voice Message")
|
||||||
}
|
}
|
||||||
@@ -362,7 +376,7 @@ extension ComposerToolbar {
|
|||||||
keyCommandHandler: { _ in false })
|
keyCommandHandler: { _ in false })
|
||||||
}
|
}
|
||||||
|
|
||||||
static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar {
|
static func voiceMessagePreviewMock(recording: Bool, uploading: Bool) -> ComposerToolbar {
|
||||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||||
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
||||||
var composerViewModel: ComposerToolbarViewModel {
|
var composerViewModel: ComposerToolbarViewModel {
|
||||||
@@ -371,7 +385,7 @@ extension ComposerToolbar {
|
|||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||||
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData))
|
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: uploading)
|
||||||
model.state.enableVoiceMessageComposer = true
|
model.state.enableVoiceMessageComposer = true
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ struct VoiceMessagePreviewComposer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.disabled(playerState.playbackState == .loading)
|
.disabled(playerState.playbackState == .loading)
|
||||||
.frame(width: playPauseButtonSize,
|
.frame(width: playPauseButtonSize, height: playPauseButtonSize)
|
||||||
height: playPauseButtonSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onPlayPause() {
|
private func onPlayPause() {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ private struct CreatePollOptionView: View {
|
|||||||
.foregroundColor(.compound.iconCriticalPrimary)
|
.foregroundColor(.compound.iconCriticalPrimary)
|
||||||
}
|
}
|
||||||
.disabled(!canDeleteItem)
|
.disabled(!canDeleteItem)
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(L10n.actionRemove)
|
.accessibilityLabel(L10n.actionRemove)
|
||||||
}
|
}
|
||||||
TextField(text: $text) {
|
TextField(text: $text) {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ enum RoomScreenComposerMode: Equatable {
|
|||||||
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
|
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
|
||||||
case edit(originalItemId: TimelineItemIdentifier)
|
case edit(originalItemId: TimelineItemIdentifier)
|
||||||
case recordVoiceMessage(state: AudioRecorderState)
|
case recordVoiceMessage(state: AudioRecorderState)
|
||||||
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource)
|
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)
|
||||||
|
|
||||||
var isEdit: Bool {
|
var isEdit: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .edit:
|
case .edit:
|
||||||
|
|||||||
@@ -980,7 +980,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
||||||
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL)))))
|
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: false))))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cancelRecordingVoiceMessage() async {
|
private func cancelRecordingVoiceMessage() async {
|
||||||
@@ -994,12 +994,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendCurrentVoiceMessage() async {
|
private func sendCurrentVoiceMessage() async {
|
||||||
|
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState, let recordingURL = voiceMessageRecorder.recordingURL else {
|
||||||
|
displayError(.alert(L10n.errorFailedUploadingVoiceMessage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: true))))
|
||||||
await voiceMessageRecorder.stopPlayback()
|
await voiceMessageRecorder.stopPlayback()
|
||||||
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) {
|
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) {
|
||||||
case .success:
|
case .success:
|
||||||
await deleteCurrentVoiceMessage()
|
await deleteCurrentVoiceMessage()
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
MXLog.error("failed to send the voice message", context: error)
|
MXLog.error("failed to send the voice message", context: error)
|
||||||
|
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: false))))
|
||||||
|
displayError(.alert(L10n.errorFailedUploadingVoiceMessage))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user