From 13e07eb2e8ee1d0149edf52905720f84542fa1a5 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Dec 2025 12:45:18 +0100 Subject: [PATCH 1/8] Add VoiceReply Route Create VoiceReplyViewModel and VoiceReplyScreen Add String Resource(s) --- .../amethyst/ui/navigation/routes/Routes.kt | 10 + .../screen/loggedIn/home/VoiceReplyScreen.kt | 321 ++++++++++++++++++ .../loggedIn/home/VoiceReplyViewModel.kt | 242 +++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 574 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9c3ccf5a9..631edb320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -285,6 +285,15 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class VoiceReply( + val replyToNoteId: String, + val recordingFilePath: String, + val mimeType: String, + val duration: Int, + val amplitudes: String, // JSON-encoded List + ) : Route() + @Serializable data class ManualZapSplitPayment( val paymentId: String, @@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt new file mode 100644 index 000000000..05c9f88ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -0,0 +1,321 @@ +/** + * 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.screen.loggedIn.home + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceReplyScreen( + replyToNoteId: String, + recordingFilePath: String, + mimeType: String, + duration: Int, + amplitudesJson: String, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val viewModel: VoiceReplyViewModel = viewModel() + viewModel.init(accountViewModel) + + LaunchedEffect(replyToNoteId, recordingFilePath) { + viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson) + } + + BackHandler { + viewModel.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + isActive = viewModel::canSend, + onPost = { + viewModel.sendVoiceReply { + nav.popBack() + } + }, + onCancel = { + viewModel.cancel() + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad), + ) { + VoiceReplyScreenBody(viewModel, accountViewModel, nav) + } + } +} + +@Composable +private fun VoiceReplyScreenBody( + viewModel: VoiceReplyViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val scrollState = rememberScrollState() + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = Size10dp), + ) { + // Show the note being replied to + viewModel.replyToNote?.let { note -> + Spacer(modifier = StdVertSpacer) + NoteCompose( + baseNote = note, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = false, + makeItShort = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + + // Voice preview or upload progress + viewModel.voiceOrchestrator?.let { orchestrator -> + VoiceUploadingProgress(orchestrator) + } ?: run { + viewModel.getVoicePreviewMetadata()?.let { metadata -> + VoiceMessagePreview( + voiceMetadata = metadata, + localFile = viewModel.voiceLocalFile, + onRemove = { + viewModel.cancel() + nav.popBack() + }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Server selection + ServerSelectionRow(viewModel, accountViewModel) + + Spacer(modifier = Modifier.weight(1f)) + + // Re-record button at bottom + ReRecordButton(viewModel) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun ServerSelectionRow( + viewModel: VoiceReplyViewModel, + accountViewModel: AccountViewModel, +) { + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + val fileServersState = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + val fileServers = fileServersState.value + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it == (viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } + ?.name + ?: fileServers.firstOrNull()?.name + ?: "", + options = fileServerOptions, + onSelect = { viewModel.voiceSelectedServer = fileServers[it] }, + ) + } +} + +@Composable +private fun ReRecordButton(viewModel: VoiceReplyViewModel) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + viewModel.selectRecording(recording) + }, + ) { isRecording, _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = + if (isRecording) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + Text( + text = stringRes(id = R.string.re_record), + color = + if (isRecording) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + } + } +} + +@Composable +private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt new file mode 100644 index 000000000..fe99743f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -0,0 +1,242 @@ +/** + * 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.screen.loggedIn.home + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +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.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import java.io.File + +@Stable +class VoiceReplyViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + var replyToNote: Note? by mutableStateOf(null) + + var voiceRecording: RecordingResult? by mutableStateOf(null) + var voiceLocalFile: File? by mutableStateOf(null) + var voiceMetadata: AudioMeta? by mutableStateOf(null) + var voiceSelectedServer: ServerName? by mutableStateOf(null) + var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) + var isUploading: Boolean by mutableStateOf(false) + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + } + + fun load( + replyToNoteId: String, + recordingFilePath: String, + mimeType: String, + duration: Int, + amplitudesJson: String, + ) { + replyToNote = accountViewModel.getNoteIfExists(replyToNoteId) + + val amplitudes = + try { + Json.decodeFromString>(amplitudesJson) + } catch (e: Exception) { + Log.w("VoiceReplyViewModel", "Failed to parse amplitudes", e) + emptyList() + } + + val file = File(recordingFilePath) + voiceLocalFile = file + voiceRecording = + RecordingResult( + file = file, + mimeType = mimeType, + duration = duration, + amplitudes = amplitudes, + ) + } + + fun getVoicePreviewMetadata(): AudioMeta? = + voiceRecording?.let { recording -> + AudioMeta( + url = "", + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + fun selectRecording(recording: RecordingResult) { + deleteVoiceLocalFile() + voiceRecording = recording + voiceLocalFile = recording.file + voiceMetadata = null + } + + fun removeVoiceMessage() { + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploading = false + voiceOrchestrator = null + } + + private fun deleteVoiceLocalFile() { + voiceLocalFile?.let { file -> + try { + if (file.exists()) { + file.delete() + Log.d("VoiceReplyViewModel", "Deleted voice file: ${file.absolutePath}") + } + } catch (e: Exception) { + Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + } + } + } + + fun canSend(): Boolean = (voiceRecording != null || voiceMetadata != null) && !isUploading + + fun sendVoiceReply(onSuccess: () -> Unit) { + val note = replyToNote ?: return + val recording = voiceRecording ?: return + val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer + + viewModelScope.launch(Dispatchers.IO) { + uploadAndSend(note, recording, serverToUse, onSuccess) + } + } + + private suspend fun uploadAndSend( + note: Note, + recording: RecordingResult, + server: ServerName, + onSuccess: () -> Unit, + ) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) + val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + + isUploading = true + + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = accountViewModel.account, + context = appContext, + useH265 = false, + ) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + val audioMeta = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + val hint = note.toEventHint() + if (hint != null) { + accountViewModel.account.signAndComputeBroadcast( + VoiceReplyEvent.build(audioMeta, hint), + ) + } + + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + voiceMetadata = audioMeta + + onSuccess() + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) + } + } + } + is UploadingState.Error -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + } + } catch (e: Exception) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) + } finally { + isUploading = false + voiceOrchestrator = null + } + } + + fun cancel() { + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploading = false + voiceOrchestrator = null + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8db1152f4..7893b01aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -170,6 +170,7 @@ Record a message Record a message Click and hold to record a message + Re-record Recording Recording %1$s Uploading… From 8eee85a05ed1dde20e33e18419e44e290d64cf5a Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Dec 2025 12:59:11 +0100 Subject: [PATCH 2/8] Register Route in AppNavigation Modify ReplyViaVoiceReaction to Navigate --- .../amethyst/ui/navigation/AppNavigation.kt | 13 +++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b1557de3a..30d7e9a14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen @@ -289,6 +290,18 @@ fun AppNavigation( nav = nav, ) } + + composableFromBottomArgs { + VoiceReplyScreen( + replyToNoteId = it.replyToNoteId, + recordingFilePath = it.recordingFilePath, + mimeType = it.mimeType, + duration = it.duration, + amplitudesJson = it.amplitudes, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 716d0191f..7a1e53541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -169,6 +169,7 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import kotlin.math.roundToInt import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -585,7 +586,7 @@ private fun ReplyReactionWithDialog( nav: INav, ) { if (baseNote.event is BaseVoiceEvent) { - ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel) + ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel, nav) } else { ReplyReaction(baseNote, grayTint, accountViewModel) { nav.nav { routeReplyTo(baseNote, accountViewModel.account) } @@ -599,15 +600,22 @@ fun ReplyViaVoiceReaction( baseNote: Note, grayTint: Color, accountViewModel: AccountViewModel, + nav: INav, showCounter: Boolean = true, iconSizeModifier: Modifier = Size19Modifier, ) { - val context = LocalContext.current - RecordAudioBox( modifier = iconSizeModifier, onRecordTaken = { audio -> - accountViewModel.sendVoiceReply(baseNote, audio, context) + nav.nav { + Route.VoiceReply( + replyToNoteId = baseNote.idHex, + recordingFilePath = audio.file.absolutePath, + mimeType = audio.mimeType, + duration = audio.duration, + amplitudes = Json.encodeToString(audio.amplitudes), + ) + } }, ) { _, _ -> VoiceReplyIcon(iconSizeModifier, grayTint) From 9b2ef76250d11deb6993f9444e111fa54bf22115 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Dec 2025 18:21:31 +0100 Subject: [PATCH 3/8] TOOD: Move re-record higher up tp avoid google assistant on longpress Reply to a reply fails to upload Verify deletion of temp voice files Code review: - temp file cleanup on disposal - Made uploads cancellable - recording must exist; metadata-only no longer enables Send) - Removed duplication --- .../mediaServers/FileServerSelectionRow.kt | 67 +++++++ .../uploads/UploadProgressIndicator.kt | 101 ++++++++++ .../loggedIn/home/ShortNotePostScreen.kt | 103 +---------- .../screen/loggedIn/home/VoiceReplyScreen.kt | 172 +++++------------- .../loggedIn/home/VoiceReplyViewModel.kt | 165 ++++++++++------- 5 files changed, 322 insertions(+), 286 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt new file mode 100644 index 000000000..d7ecec0e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt @@ -0,0 +1,67 @@ +/** + * 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.mediaServers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun FileServerSelectionRow( + fileServers: List, + selectedServer: ServerName?, + onSelect: (ServerName) -> Unit, +) { + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + val placeholder = + fileServers + .firstOrNull { it == selectedServer } + ?.name + ?: fileServers.firstOrNull()?.name + ?: "" + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = placeholder, + options = fileServerOptions, + onSelect = { index -> fileServers.getOrNull(index)?.let(onSelect) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt new file mode 100644 index 000000000..2720dc877 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt @@ -0,0 +1,101 @@ +/** + * 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.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun UploadProgressIndicator( + orchestrator: UploadOrchestrator, + modifier: Modifier = Modifier, +) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} 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 b347e68b3..504c723be 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 @@ -24,10 +24,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,47 +35,37 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow 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 import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -111,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -365,24 +351,11 @@ private fun NewPostScreenBody( // Show preview for both uploaded messages (voiceMetadata) and pending recordings (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) val fileServersState = accountViewModel.account.serverLists.liveServerList .collectAsState() val fileServers = fileServersState.value - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - Column( modifier = Modifier @@ -391,7 +364,7 @@ private fun NewPostScreenBody( ) { // Display voice preview or uploading progress postViewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { VoiceMessagePreview( voiceMetadata = metadata, @@ -400,18 +373,11 @@ private fun NewPostScreenBody( ) } - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers[0].name, - options = fileServerOptions, - onSelect = { postViewModel.voiceSelectedServer = fileServers[it] }, - ) - } + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { postViewModel.voiceSelectedServer = it }, + ) } } @@ -584,56 +550,3 @@ private fun AddPollButton( } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index 05c9f88ac..95e0227d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -21,10 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,49 +30,37 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier -import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -89,9 +74,9 @@ fun VoiceReplyScreen( nav: Nav, ) { val viewModel: VoiceReplyViewModel = viewModel() - viewModel.init(accountViewModel) - LaunchedEffect(replyToNoteId, recordingFilePath) { + LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) { + viewModel.init(accountViewModel) viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson) } @@ -100,6 +85,12 @@ fun VoiceReplyScreen( nav.popBack() } + DisposableEffect(Unit) { + onDispose { + viewModel.cancel() + } + } + Scaffold( topBar = { PostingTopBar( @@ -115,6 +106,9 @@ fun VoiceReplyScreen( }, ) }, + bottomBar = { + ReRecordButton(viewModel) + }, ) { pad -> Surface( modifier = @@ -160,7 +154,7 @@ private fun VoiceReplyScreenBody( // Voice preview or upload progress viewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { viewModel.getVoicePreviewMetadata()?.let { metadata -> VoiceMessagePreview( @@ -177,62 +171,47 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(16.dp)) // Server selection - ServerSelectionRow(viewModel, accountViewModel) - - Spacer(modifier = Modifier.weight(1f)) - - // Re-record button at bottom - ReRecordButton(viewModel) - - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@Composable -private fun ServerSelectionRow( - viewModel: VoiceReplyViewModel, - accountViewModel: AccountViewModel, -) { - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServersState = - accountViewModel.account.serverLists.liveServerList - .collectAsState() - val fileServers = fileServersState.value - - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers.firstOrNull()?.name - ?: "", - options = fileServerOptions, - onSelect = { viewModel.voiceSelectedServer = fileServers[it] }, + val fileServers = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + .value + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { viewModel.voiceSelectedServer = it }, ) + + Spacer(modifier = Modifier.height(80.dp)) } } @Composable private fun ReRecordButton(viewModel: VoiceReplyViewModel) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = Size10dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { + if (viewModel.isUploading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(id = R.string.re_record), + color = MaterialTheme.colorScheme.onBackground, + ) + } + return + } + RecordAudioBox( modifier = Modifier, onRecordTaken = { recording -> @@ -266,56 +245,3 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index fe99743f2..f5010cb4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -41,8 +41,11 @@ 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.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File @@ -59,6 +62,8 @@ class VoiceReplyViewModel : ViewModel() { var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) var isUploading: Boolean by mutableStateOf(false) + private var uploadJob: Job? = null + fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM } @@ -109,6 +114,7 @@ class VoiceReplyViewModel : ViewModel() { } fun removeVoiceMessage() { + cancelUpload() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -118,6 +124,11 @@ class VoiceReplyViewModel : ViewModel() { voiceOrchestrator = null } + private fun cancelUpload() { + uploadJob?.cancel() + uploadJob = null + } + private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { @@ -131,101 +142,118 @@ class VoiceReplyViewModel : ViewModel() { } } - fun canSend(): Boolean = (voiceRecording != null || voiceMetadata != null) && !isUploading + fun canSend(): Boolean = voiceRecording != null && !isUploading fun sendVoiceReply(onSuccess: () -> Unit) { val note = replyToNote ?: return val recording = voiceRecording ?: return val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer - viewModelScope.launch(Dispatchers.IO) { - uploadAndSend(note, recording, serverToUse, onSuccess) - } + cancelUpload() + uploadJob = + viewModelScope.launch { + isUploading = true + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + try { + val result = + withContext(Dispatchers.IO) { + val uri = android.net.Uri.fromFile(recording.file) + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = serverToUse, + account = accountViewModel.account, + context = Amethyst.instance.appContext, + useH265 = false, + ) + } + + handleUploadResult(note, recording, serverToUse, result, onSuccess) + } catch (e: CancellationException) { + // User canceled, or ViewModel cleared. + } catch (e: Exception) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + accountViewModel.toastManager.toast( + uploadErrorTitle, + uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName), + ) + } finally { + isUploading = false + voiceOrchestrator = null + uploadJob = null + } + } } - private suspend fun uploadAndSend( + private suspend fun handleUploadResult( note: Note, recording: RecordingResult, server: ServerName, + result: UploadingState, onSuccess: () -> Unit, ) { val appContext = Amethyst.instance.appContext val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) - val uploadVoiceExceptionMessage: (String) -> String = { detail -> - stringRes(appContext, R.string.upload_error_voice_message_exception, detail) - } - isUploading = true - - try { - val uri = android.net.Uri.fromFile(recording.file) - val orchestrator = UploadOrchestrator() - voiceOrchestrator = orchestrator - - val result = - orchestrator.upload( - uri = uri, - mimeType = recording.mimeType, - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.UNCOMPRESSED, - server = server, - account = accountViewModel.account, - context = appContext, - useH265 = false, - ) - - when (result) { - is UploadingState.Finished -> { - when (val orchestratorResult = result.result) { - is UploadOrchestrator.OrchestratorResult.ServerResult -> { - val audioMeta = - AudioMeta( - url = orchestratorResult.url, - mimeType = recording.mimeType, - hash = orchestratorResult.fileHeader.hash, - duration = recording.duration, - waveform = recording.amplitudes, - ) - - val hint = note.toEventHint() - if (hint != null) { - accountViewModel.account.signAndComputeBroadcast( - VoiceReplyEvent.build(audioMeta, hint), - ) - } - - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } - - deleteVoiceLocalFile() - voiceLocalFile = null - voiceRecording = null - voiceMetadata = audioMeta - - onSuccess() + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + val hint = note.toEventHint() + if (hint == null) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + return } - is UploadOrchestrator.OrchestratorResult.NIP95Result -> { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) + + val audioMeta = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint)) + + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) } + + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + voiceMetadata = audioMeta + + onSuccess() + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) } } - is UploadingState.Error -> { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) - } } - } catch (e: Exception) { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) - } finally { - isUploading = false - voiceOrchestrator = null + is UploadingState.Error -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + else -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } } } fun cancel() { + cancelUpload() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -236,6 +264,7 @@ class VoiceReplyViewModel : ViewModel() { } override fun onCleared() { + cancel() super.onCleared() Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") } From 64fe4dfac656c0817d84a27b164371bd2d327912 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 11:31:32 +0000 Subject: [PATCH 4/8] Code review: - Removed DisposableEffect cleanup - Added cancelUpload() to selectRecording() --- .../ui/screen/loggedIn/home/VoiceReplyScreen.kt | 7 ------- .../ui/screen/loggedIn/home/VoiceReplyViewModel.kt | 14 ++------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index 95e0227d6..feea98085 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment @@ -85,12 +84,6 @@ fun VoiceReplyScreen( nav.popBack() } - DisposableEffect(Unit) { - onDispose { - viewModel.cancel() - } - } - Scaffold( topBar = { PostingTopBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index f5010cb4b..12c6be189 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -107,23 +107,13 @@ class VoiceReplyViewModel : ViewModel() { } fun selectRecording(recording: RecordingResult) { + cancelUpload() deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file voiceMetadata = null } - fun removeVoiceMessage() { - cancelUpload() - deleteVoiceLocalFile() - voiceRecording = null - voiceLocalFile = null - voiceMetadata = null - voiceSelectedServer = null - isUploading = false - voiceOrchestrator = null - } - private fun cancelUpload() { uploadJob?.cancel() uploadJob = null @@ -175,7 +165,7 @@ class VoiceReplyViewModel : ViewModel() { handleUploadResult(note, recording, serverToUse, result, onSuccess) } catch (e: CancellationException) { - // User canceled, or ViewModel cleared. + Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e) } catch (e: Exception) { val appContext = Amethyst.instance.appContext val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) From 2422e77dd04cd4b96930a637939457d8624143bf Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 11:42:41 +0000 Subject: [PATCH 5/8] TODO: Reply to a reply fails to upload Verify deletion of temp voice files Code review: - move re-record button higher --- .../amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index feea98085..da1dd4b1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -184,7 +185,8 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = Size10dp), + .navigationBarsPadding() + .padding(vertical = 16.dp, horizontal = Size10dp), horizontalAlignment = Alignment.CenterHorizontally, ) { if (viewModel.isUploading) { From 6785f124e927860e0d03d825c72b6f998f8c0827 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 13:07:56 +0000 Subject: [PATCH 6/8] bug fix: - Allow reply to reply by using BaseVoiceEvent --- .../main/java/com/vitorpamplona/amethyst/model/Account.kt | 3 ++- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 4 ++-- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 8 +++----- .../ui/screen/loggedIn/home/VoiceReplyViewModel.kt | 4 ++-- .../quartz/nipA0VoiceMessages/VoiceReplyEvent.kt | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d32ca043b..c38e80510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -202,6 +202,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log @@ -1002,7 +1003,7 @@ class Account( hash: String, duration: Int, waveform: List, - replyTo: EventHintBundle, + replyTo: EventHintBundle, ) { signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3e44b809e..8264d3cd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -1154,7 +1154,7 @@ class AccountViewModel( context: Context, ) { if (isWriteable()) { - val hint = note.toEventHint() ?: return + val hint = note.toEventHint() ?: return launchSigner { val uploader = UploadOrchestrator() 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 87b87b5fc..652cc425f 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 @@ -121,6 +121,7 @@ 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.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log @@ -539,8 +540,8 @@ open class ShortNotePostViewModel : private suspend fun createTemplate(): EventTemplate? { // Check if this is a voice message voiceMetadata?.let { audioMeta -> - // Only create voice reply if original note is also a VoiceEvent - val originalVoiceHint = originalNote?.toEventHint() + // Only create voice reply if original note is also a voice message + val originalVoiceHint = originalNote?.toEventHint() return if (originalVoiceHint != null) { // Create voice reply event VoiceReplyEvent.build( @@ -1024,9 +1025,6 @@ open class ShortNotePostViewModel : onError(uploadErrorTitle, uploadVoiceFailed) voiceRecording = null } - else -> { - onError(uploadErrorTitle, uploadVoiceUnexpected) - } } } catch (e: Exception) { onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 12c6be189..faa5d677a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -200,7 +200,7 @@ class VoiceReplyViewModel : ViewModel() { is UploadingState.Finished -> { when (val orchestratorResult = result.result) { is UploadOrchestrator.OrchestratorResult.ServerResult -> { - val hint = note.toEventHint() + val hint = note.toEventHint() if (hint == null) { accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) return diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt index 2b064deb3..dfb3158d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -65,12 +65,12 @@ class VoiceReplyEvent( hash: String, duration: Int, waveform: List, - replyingTo: EventHintBundle, + replyingTo: EventHintBundle, ) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo) fun build( voiceMessage: AudioMeta, - replyingTo: EventHintBundle, + replyingTo: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) { From 0a0365c26666b35e41fb639534dc1a705c4054be Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 16:28:39 +0000 Subject: [PATCH 7/8] translations --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 1 + amethyst/src/main/res/values-de-rDE/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values-sv-rSE/strings.xml | 1 + 4 files changed, 4 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 5cc85b46e..8177aed50 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1183,4 +1183,5 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení + Znovu nahrát diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 4dea3cb35..d5904f0be 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1188,4 +1188,5 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen + Neu aufnehmen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c8278d893..6ad5d17cf 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1183,4 +1183,5 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações + Gravar novamente diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index a3393dcdf..d862ca5f1 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1182,4 +1182,5 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket + Spela in igen From 6f8072d1bbf431b7bd35aa4e201c39c951824a56 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 24 Dec 2025 18:28:22 +0000 Subject: [PATCH 8/8] enhanced recording button --- .../ui/actions/uploads/RecordingIndicators.kt | 19 ++++++-- .../amethyst/ui/note/ReactionsRow.kt | 46 +++++++++++++++++-- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 4 +- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 9b81d710f..a4e3ecd65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -175,18 +175,27 @@ fun FloatingRecordingIndicator( modifier: Modifier = Modifier, isRecording: Boolean, elapsedSeconds: Int, + isCompact: Boolean = false, ) { if (!isRecording) return val recordingLabel = stringRes(id = R.string.recording_indicator_description) - val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + val recordingWithTime = + if (isCompact) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + } + val horizontalPadding = if (isCompact) 8.dp else 16.dp + val innerPadding = if (isCompact) 6.dp else 12.dp + val textSize = if (isCompact) 12.sp else 14.sp Box( modifier = modifier .fillMaxWidth() .height(48.dp) - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .background( color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(12.dp), @@ -195,7 +204,7 @@ fun FloatingRecordingIndicator( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.padding(horizontal = innerPadding), ) { // Pulsing red dot val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") @@ -223,8 +232,10 @@ fun FloatingRecordingIndicator( Text( text = recordingWithTime, color = Color.White, - fontSize = 14.sp, + fontSize = textSize, fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7a1e53541..279a3414f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -47,6 +47,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -61,6 +63,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -109,6 +112,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -205,9 +209,12 @@ private fun InnerReactionRow( accountViewModel: AccountViewModel, nav: INav, ) { + val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) } + GenericInnerReactionRow( showReactionDetail = showReactionDetail, addPadding = addPadding, + weightTwo = if (voiceRecordingState.value) 2f else 1f, one = { WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) { RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel) @@ -219,6 +226,7 @@ private fun InnerReactionRow( MaterialTheme.colorScheme.placeholderText, accountViewModel, nav, + voiceRecordingState = voiceRecordingState, ) }, three = { @@ -289,6 +297,7 @@ fun ShareReaction( private fun GenericInnerReactionRow( showReactionDetail: Boolean, addPadding: Boolean, + weightTwo: Float = 1f, one: @Composable () -> Unit, two: @Composable () -> Unit, three: @Composable () -> Unit, @@ -310,7 +319,7 @@ private fun GenericInnerReactionRow( } } - Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { two() } + Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(weightTwo)) { two() } Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { three() } @@ -584,9 +593,16 @@ private fun ReplyReactionWithDialog( grayTint: Color, accountViewModel: AccountViewModel, nav: INav, + voiceRecordingState: MutableState? = null, ) { if (baseNote.event is BaseVoiceEvent) { - ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel, nav) + ReplyViaVoiceReaction( + baseNote, + grayTint, + accountViewModel, + nav, + voiceRecordingState = voiceRecordingState, + ) } else { ReplyReaction(baseNote, grayTint, accountViewModel) { nav.nav { routeReplyTo(baseNote, accountViewModel.account) } @@ -603,9 +619,10 @@ fun ReplyViaVoiceReaction( nav: INav, showCounter: Boolean = true, iconSizeModifier: Modifier = Size19Modifier, + voiceRecordingState: MutableState? = null, ) { RecordAudioBox( - modifier = iconSizeModifier, + modifier = Modifier, onRecordTaken = { audio -> nav.nav { Route.VoiceReply( @@ -617,8 +634,27 @@ fun ReplyViaVoiceReaction( ) } }, - ) { _, _ -> - VoiceReplyIcon(iconSizeModifier, grayTint) + ) { isRecording, elapsedSeconds -> + if (voiceRecordingState != null) { + SideEffect { + if (voiceRecordingState.value != isRecording) { + voiceRecordingState.value = isRecording + } + } + } + if (isRecording) { + FloatingRecordingIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp), + isRecording = true, + elapsedSeconds = elapsedSeconds, + isCompact = true, + ) + } else { + VoiceReplyIcon(iconSizeModifier, grayTint) + } } if (showCounter) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 1fcd3daab..05bf255fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -190,8 +190,8 @@ val VideoReactionColumnPadding = Modifier.padding(bottom = 75.dp) val DividerThickness = 0.25.dp -val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp) -val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp) +val ReactionRowHeight = Modifier.padding(vertical = 7.dp).heightIn(min = 24.dp) +val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).heightIn(min = 24.dp).padding(horizontal = 10.dp) val ReactionRowHeightChat = Modifier.height(20.dp) val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth() val UserNameRowHeight = Modifier.fillMaxWidth()