diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index f32360dbe0..63800d9556 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -188,6 +188,7 @@ "error_failed_loading_map" = "%1$@ could not load the map. Please try again later."; "error_failed_loading_messages" = "Failed loading messages"; "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_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"; @@ -539,7 +540,7 @@ "screen_welcome_button" = "Let's go!"; "screen_welcome_subtitle" = "Here’s what you need to know:"; "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"; "settings_rageshake" = "Rageshake"; "settings_rageshake_detection_threshold" = "Detection threshold"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 9094fd3122..72862c1be1 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -422,6 +422,8 @@ public enum L10n { public static func errorFailedLocatingUser(_ p1: Any) -> String { 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 public static func errorMissingLocationAuthIos(_ p1: Any) -> String { 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 { 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") } /// Verify it’s you public static var sessionVerificationBannerTitle: String { return L10n.tr("Localizable", "session_verification_banner_title") } diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index c5bd3e97a0..ea601aba62 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -76,6 +76,15 @@ struct ComposerToolbarViewState: BindableState { var bindings: ComposerToolbarViewStateBindings + var isUploading: Bool { + switch composerMode { + case .previewVoiceMessage(_, _, let isUploading): + return isUploading + default: + return false + } + } + var showSendButton: Bool { switch composerMode { case .recordVoiceMessage: diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index 26309ca5fd..b47ca3f27d 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -232,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 diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index dcf3ea423a..66a51e6018 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -26,6 +26,7 @@ struct ComposerToolbar: View { @FocusState private var composerFocused: Bool @ScaledMetric private var sendButtonIconSize = 16 @ScaledMetric private var trashButtonIconSize = 24 + @ScaledMetric(relativeTo: .title) private var spinnerSize = 44 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 @State private var voiceMessageRecordingStartTime: Date? @@ -71,29 +72,14 @@ struct ComposerToolbar: View { private var topBar: some View { HStack(alignment: .bottom, spacing: 5) { - switch context.viewState.composerMode { - 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) - } + mainTopBarContent 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 .padding(.leading, 3) } else if context.viewState.enableVoiceMessageComposer { @@ -117,7 +103,34 @@ struct ComposerToolbar: View { .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 { Button { context.composerActionsEnabled = false @@ -252,6 +265,7 @@ struct ComposerToolbar: View { .fixedSize() .accessibilityLabel(L10n.a11yDelete) } + .buttonStyle(.plain) } private var voiceMessageRecordingButtonTooltipView: some View { @@ -303,7 +317,7 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { ComposerToolbar.textWithVoiceMessage(focused: false) ComposerToolbar.textWithVoiceMessage(focused: true) ComposerToolbar.voiceMessageRecordingMock(recording: true) - ComposerToolbar.voiceMessagePreviewMock(recording: false) + ComposerToolbar.voiceMessagePreviewMock(recording: false, uploading: false) } .previewDisplayName("Voice Message") } @@ -362,7 +376,7 @@ extension ComposerToolbar { keyCommandHandler: { _ in false }) } - static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar { + static func voiceMessagePreviewMock(recording: Bool, uploading: Bool) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() let waveformData: [Float] = Array(repeating: 1.0, count: 1000) var composerViewModel: ComposerToolbarViewModel { @@ -371,7 +385,7 @@ extension ComposerToolbar { mediaProvider: MockMediaProvider(), appSettings: ServiceLocator.shared.settings, 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 return model } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift index f4e199ad7e..adadfb0bb9 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift @@ -130,9 +130,9 @@ struct VoiceMessagePreviewComposer: View { } } } + .buttonStyle(.plain) .disabled(playerState.playbackState == .loading) - .frame(width: playPauseButtonSize, - height: playPauseButtonSize) + .frame(width: playPauseButtonSize, height: playPauseButtonSize) } private func onPlayPause() { diff --git a/ElementX/Sources/Screens/CreatePollScreen/View/CreatePollScreen.swift b/ElementX/Sources/Screens/CreatePollScreen/View/CreatePollScreen.swift index 8f557014d4..b413b4d92f 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/View/CreatePollScreen.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/View/CreatePollScreen.swift @@ -144,7 +144,7 @@ private struct CreatePollOptionView: View { .foregroundColor(.compound.iconCriticalPrimary) } .disabled(!canDeleteItem) - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) .accessibilityLabel(L10n.actionRemove) } TextField(text: $text) { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 77a0f97b44..d593578c7c 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -42,8 +42,8 @@ enum RoomScreenComposerMode: Equatable { case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool) case edit(originalItemId: TimelineItemIdentifier) case recordVoiceMessage(state: AudioRecorderState) - case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource) - + case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool) + var isEdit: Bool { switch self { case .edit: diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 674b852e09..7b9b2fd790 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -980,7 +980,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } 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 { @@ -994,12 +994,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } 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() switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) { case .success: await deleteCurrentVoiceMessage() case .failure(let 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)) } }