Merge pull request #1622 from davotoula/voice-notes-replies

Christmas delivery: Voice notes replies
This commit is contained in:
Vitor Pamplona
2025-12-28 10:57:52 -05:00
committed by GitHub
19 changed files with 785 additions and 119 deletions
@@ -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<Float>,
replyTo: EventHintBundle<VoiceEvent>,
replyTo: EventHintBundle<BaseVoiceEvent>,
) {
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
}
@@ -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<ServerName>,
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) },
)
}
}
@@ -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,
)
}
}
@@ -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,
)
}
}
}
@@ -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<Route.VoiceReply> {
VoiceReplyScreen(
replyToNoteId = it.replyToNoteId,
recordingFilePath = it.recordingFilePath,
mimeType = it.mimeType,
duration = it.duration,
amplitudesJson = it.amplitudes,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -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<Float>
) : Route()
@Serializable
data class ManualZapSplitPayment(
val paymentId: String,
@@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
dest.hasRoute<Route.NewShortNote>() -> entry.toRoute<Route.NewShortNote>()
dest.hasRoute<Route.VoiceReply>() -> entry.toRoute<Route.VoiceReply>()
dest.hasRoute<Route.NewProduct>() -> entry.toRoute<Route.NewProduct>()
dest.hasRoute<Route.GeoPost>() -> entry.toRoute<Route.GeoPost>()
dest.hasRoute<Route.HashtagPost>() -> entry.toRoute<Route.HashtagPost>()
@@ -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
@@ -169,6 +173,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
@@ -204,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)
@@ -218,6 +226,7 @@ private fun InnerReactionRow(
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
voiceRecordingState = voiceRecordingState,
)
},
three = {
@@ -288,6 +297,7 @@ fun ShareReaction(
private fun GenericInnerReactionRow(
showReactionDetail: Boolean,
addPadding: Boolean,
weightTwo: Float = 1f,
one: @Composable () -> Unit,
two: @Composable () -> Unit,
three: @Composable () -> Unit,
@@ -309,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() }
@@ -583,9 +593,16 @@ private fun ReplyReactionWithDialog(
grayTint: Color,
accountViewModel: AccountViewModel,
nav: INav,
voiceRecordingState: MutableState<Boolean>? = null,
) {
if (baseNote.event is BaseVoiceEvent) {
ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel)
ReplyViaVoiceReaction(
baseNote,
grayTint,
accountViewModel,
nav,
voiceRecordingState = voiceRecordingState,
)
} else {
ReplyReaction(baseNote, grayTint, accountViewModel) {
nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
@@ -599,18 +616,45 @@ fun ReplyViaVoiceReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
nav: INav,
showCounter: Boolean = true,
iconSizeModifier: Modifier = Size19Modifier,
voiceRecordingState: MutableState<Boolean>? = null,
) {
val context = LocalContext.current
RecordAudioBox(
modifier = iconSizeModifier,
modifier = Modifier,
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)
) { 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) {
@@ -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<VoiceEvent>() ?: return
val hint = note.toEventHint<BaseVoiceEvent>() ?: return
launchSigner {
val uploader = UploadOrchestrator()
@@ -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,
)
}
}
}
@@ -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<out Event>? {
// 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<VoiceEvent>()
// Only create voice reply if original note is also a voice message
val originalVoiceHint = originalNote?.toEventHint<BaseVoiceEvent>()
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))
@@ -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.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
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.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.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VoiceReplyScreen(
replyToNoteId: String,
recordingFilePath: String,
mimeType: String,
duration: Int,
amplitudesJson: String,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val viewModel: VoiceReplyViewModel = viewModel()
LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) {
viewModel.init(accountViewModel)
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()
},
)
},
bottomBar = {
ReRecordButton(viewModel)
},
) { 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 ->
UploadProgressIndicator(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
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) {
Column(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(vertical = 16.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 ->
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
},
)
}
}
}
}
@@ -0,0 +1,261 @@
/**
* 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.BaseVoiceEvent
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
@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)
private var uploadJob: Job? = null
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<List<Float>>(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) {
cancelUpload()
deleteVoiceLocalFile()
voiceRecording = recording
voiceLocalFile = recording.file
voiceMetadata = null
}
private fun cancelUpload() {
uploadJob?.cancel()
uploadJob = 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 && !isUploading
fun sendVoiceReply(onSuccess: () -> Unit) {
val note = replyToNote ?: return
val recording = voiceRecording ?: return
val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer
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) {
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)
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 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)
when (result) {
is UploadingState.Finished -> {
when (val orchestratorResult = result.result) {
is UploadOrchestrator.OrchestratorResult.ServerResult -> {
val hint = note.toEventHint<BaseVoiceEvent>()
if (hint == null) {
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
return
}
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)
}
else -> {
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
}
}
}
fun cancel() {
cancelUpload()
deleteVoiceLocalFile()
voiceRecording = null
voiceLocalFile = null
voiceMetadata = null
voiceSelectedServer = null
isUploading = false
voiceOrchestrator = null
}
override fun onCleared() {
cancel()
super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
}
}
@@ -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()
@@ -1183,4 +1183,5 @@
<string name="follow_pack_broadcast">Broadcast sady doporučení</string>
<string name="follow_set_delete">Smazat seznam</string>
<string name="follow_pack_delete">Smazat sadu doporučení</string>
<string name="re_record">Znovu nahrát</string>
</resources>
@@ -1188,4 +1188,5 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_pack_broadcast">Empfehlungspaket veröffentlichen</string>
<string name="follow_set_delete">Liste löschen</string>
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
<string name="re_record">Neu aufnehmen</string>
</resources>
@@ -1183,4 +1183,5 @@
<string name="follow_pack_broadcast">Transmitir pacote de recomendações</string>
<string name="follow_set_delete">Excluir lista</string>
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
<string name="re_record">Gravar novamente</string>
</resources>
@@ -1182,4 +1182,5 @@
<string name="follow_pack_broadcast">Sänd rekommendationspaket</string>
<string name="follow_set_delete">Ta bort lista</string>
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
<string name="re_record">Spela in igen</string>
</resources>
+1
View File
@@ -170,6 +170,7 @@
<string name="record_a_message">Record a message</string>
<string name="record_a_message_title">Record a message</string>
<string name="record_a_message_description">Click and hold to record a message</string>
<string name="re_record">Re-record</string>
<string name="recording_indicator_description">Recording</string>
<string name="recording_indicator_with_time">Recording %1$s</string>
<string name="uploading">Uploading…</string>
@@ -65,12 +65,12 @@ class VoiceReplyEvent(
hash: String,
duration: Int,
waveform: List<Float>,
replyingTo: EventHintBundle<VoiceEvent>,
replyingTo: EventHintBundle<BaseVoiceEvent>,
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
fun build(
voiceMessage: AudioMeta,
replyingTo: EventHintBundle<VoiceEvent>,
replyingTo: EventHintBundle<BaseVoiceEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {