diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt new file mode 100644 index 000000000..3d3198311 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + onVoiceTaken(recording) + }, + ) { isRecording -> + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + modifier = Modifier.height(22.dp), + tint = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 581d6825d..6617d5b1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -405,12 +406,16 @@ private fun NewPostScreenBody( ) } - BottomRowActions(postViewModel) + BottomRowActions(postViewModel, accountViewModel) } } @Composable -private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { +private fun BottomRowActions( + postViewModel: ShortNotePostViewModel, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current val scrollState = rememberScrollState() Row( modifier = @@ -440,6 +445,17 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { }, ) + RecordVoiceButton( + onVoiceTaken = { recording -> + postViewModel.selectVoiceRecording(recording) + postViewModel.uploadVoiceMessage( + accountViewModel.account.settings.defaultFileServer, + accountViewModel.toastManager::toast, + context, + ) + }, + ) + if (postViewModel.canUsePoll) { // These should be hashtag recommendations the user selects in the future. // val hashtag = stringRes(R.string.poll_hashtag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index bb07d2c19..624a999e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -116,6 +119,9 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -177,6 +183,11 @@ open class ShortNotePostViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Voice Messages + var voiceRecording by mutableStateOf(null) + var isUploadingVoice by mutableStateOf(false) + var voiceMetadata by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -504,6 +515,23 @@ open class ShortNotePostViewModel : } private suspend fun createTemplate(): EventTemplate? { + // Check if this is a voice message + voiceMetadata?.let { audioMeta -> + return if (originalNote != null) { + // Create voice reply event + @Suppress("UNCHECKED_CAST") + VoiceReplyEvent.build( + voiceMessage = audioMeta, + replyingTo = originalNote!!.toEventHint() as com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle, + ) + } else { + // Create root voice event + VoiceEvent.build( + voiceMessage = audioMeta, + ) + } + } + val tagger = NewMessageTagger( message.text, @@ -715,6 +743,9 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + voiceRecording = null + isUploadingVoice = false + voiceMetadata = null pTags = null wantsPoll = false @@ -833,9 +864,16 @@ open class ShortNotePostViewModel : private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) - fun canPost(): Boolean = - message.text.isNotBlank() && + fun canPost(): Boolean { + // Voice messages can be posted without text + if (voiceMetadata != null) { + return !isUploadingVoice && !isUploadingImage + } + + // Regular text/media posts require text + return message.text.isNotBlank() && !isUploadingImage && + !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && ( @@ -846,7 +884,9 @@ open class ShortNotePostViewModel : isValidValueMaximum.value ) ) && - multiOrchestrator == null + multiOrchestrator == null && + voiceRecording == null + } fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -856,6 +896,85 @@ open class ShortNotePostViewModel : multiOrchestrator = MultiOrchestrator(uris) } + fun selectVoiceRecording(recording: RecordingResult) { + voiceRecording = recording + } + + fun uploadVoiceMessage( + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + val recording = voiceRecording ?: return + + viewModelScope.launch(Dispatchers.IO) { + isUploadingVoice = true + + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = context, + useH265 = false, + ) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + voiceMetadata = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + voiceRecording = null + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + // For NIP95, we need to create the event and get the nevent URL + // This is handled differently - skip for now + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "NIP95 not yet supported for voice messages", + ) + } + } + } + is UploadingState.Error -> { + val errorMessage = stringRes(context, result.errorResource, *result.params) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessage) + voiceRecording = null + } + else -> { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Unexpected upload state", + ) + } + } + } catch (e: Exception) { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + e.message ?: e.javaClass.simpleName, + ) + voiceRecording = null + } finally { + isUploadingVoice = false + } + } + } + override fun locationFlow(): StateFlow { if (location == null) { location = locationManager().geohashStateFlow