Merge pull request #1622 from davotoula/voice-notes-replies
Christmas delivery: Voice notes replies
This commit is contained in:
@@ -202,6 +202,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
|||||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||||
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
@@ -1002,7 +1003,7 @@ class Account(
|
|||||||
hash: String,
|
hash: String,
|
||||||
duration: Int,
|
duration: Int,
|
||||||
waveform: List<Float>,
|
waveform: List<Float>,
|
||||||
replyTo: EventHintBundle<VoiceEvent>,
|
replyTo: EventHintBundle<BaseVoiceEvent>,
|
||||||
) {
|
) {
|
||||||
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
|
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
|
||||||
}
|
}
|
||||||
|
|||||||
+67
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -175,18 +175,27 @@ fun FloatingRecordingIndicator(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isRecording: Boolean,
|
isRecording: Boolean,
|
||||||
elapsedSeconds: Int,
|
elapsedSeconds: Int,
|
||||||
|
isCompact: Boolean = false,
|
||||||
) {
|
) {
|
||||||
if (!isRecording) return
|
if (!isRecording) return
|
||||||
|
|
||||||
val recordingLabel = stringRes(id = R.string.recording_indicator_description)
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = horizontalPadding)
|
||||||
.background(
|
.background(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
@@ -195,7 +204,7 @@ fun FloatingRecordingIndicator(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
modifier = Modifier.padding(horizontal = innerPadding),
|
||||||
) {
|
) {
|
||||||
// Pulsing red dot
|
// Pulsing red dot
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot")
|
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot")
|
||||||
@@ -223,8 +232,10 @@ fun FloatingRecordingIndicator(
|
|||||||
Text(
|
Text(
|
||||||
text = recordingWithTime,
|
text = recordingWithTime,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 14.sp,
|
fontSize = textSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
@@ -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.hashtag.HashtagScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
|
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.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.lists.PeopleListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
|
||||||
@@ -289,6 +290,18 @@ fun AppNavigation(
|
|||||||
nav = nav,
|
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,
|
val draft: String? = null,
|
||||||
) : Route()
|
) : 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
|
@Serializable
|
||||||
data class ManualZapSplitPayment(
|
data class ManualZapSplitPayment(
|
||||||
val paymentId: String,
|
val paymentId: String,
|
||||||
@@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
|||||||
dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
|
dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
|
||||||
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
|
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
|
||||||
dest.hasRoute<Route.NewShortNote>() -> entry.toRoute<Route.NewShortNote>()
|
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.NewProduct>() -> entry.toRoute<Route.NewProduct>()
|
||||||
dest.hasRoute<Route.GeoPost>() -> entry.toRoute<Route.GeoPost>()
|
dest.hasRoute<Route.GeoPost>() -> entry.toRoute<Route.GeoPost>()
|
||||||
dest.hasRoute<Route.HashtagPost>() -> entry.toRoute<Route.HashtagPost>()
|
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.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.foundation.layout.padding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -61,6 +63,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
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.event.observeNoteZaps
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
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.actions.uploads.RecordAudioBox
|
||||||
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
|
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
|
||||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||||
@@ -169,6 +173,7 @@ import kotlinx.collections.immutable.toImmutableSet
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -204,9 +209,12 @@ private fun InnerReactionRow(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
|
val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) }
|
||||||
|
|
||||||
GenericInnerReactionRow(
|
GenericInnerReactionRow(
|
||||||
showReactionDetail = showReactionDetail,
|
showReactionDetail = showReactionDetail,
|
||||||
addPadding = addPadding,
|
addPadding = addPadding,
|
||||||
|
weightTwo = if (voiceRecordingState.value) 2f else 1f,
|
||||||
one = {
|
one = {
|
||||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
|
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
|
||||||
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
|
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
|
||||||
@@ -218,6 +226,7 @@ private fun InnerReactionRow(
|
|||||||
MaterialTheme.colorScheme.placeholderText,
|
MaterialTheme.colorScheme.placeholderText,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
|
voiceRecordingState = voiceRecordingState,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
three = {
|
three = {
|
||||||
@@ -288,6 +297,7 @@ fun ShareReaction(
|
|||||||
private fun GenericInnerReactionRow(
|
private fun GenericInnerReactionRow(
|
||||||
showReactionDetail: Boolean,
|
showReactionDetail: Boolean,
|
||||||
addPadding: Boolean,
|
addPadding: Boolean,
|
||||||
|
weightTwo: Float = 1f,
|
||||||
one: @Composable () -> Unit,
|
one: @Composable () -> Unit,
|
||||||
two: @Composable () -> Unit,
|
two: @Composable () -> Unit,
|
||||||
three: @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() }
|
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { three() }
|
||||||
|
|
||||||
@@ -583,9 +593,16 @@ private fun ReplyReactionWithDialog(
|
|||||||
grayTint: Color,
|
grayTint: Color,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
|
voiceRecordingState: MutableState<Boolean>? = null,
|
||||||
) {
|
) {
|
||||||
if (baseNote.event is BaseVoiceEvent) {
|
if (baseNote.event is BaseVoiceEvent) {
|
||||||
ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel)
|
ReplyViaVoiceReaction(
|
||||||
|
baseNote,
|
||||||
|
grayTint,
|
||||||
|
accountViewModel,
|
||||||
|
nav,
|
||||||
|
voiceRecordingState = voiceRecordingState,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ReplyReaction(baseNote, grayTint, accountViewModel) {
|
ReplyReaction(baseNote, grayTint, accountViewModel) {
|
||||||
nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
|
nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
|
||||||
@@ -599,19 +616,46 @@ fun ReplyViaVoiceReaction(
|
|||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
grayTint: Color,
|
grayTint: Color,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
showCounter: Boolean = true,
|
showCounter: Boolean = true,
|
||||||
iconSizeModifier: Modifier = Size19Modifier,
|
iconSizeModifier: Modifier = Size19Modifier,
|
||||||
|
voiceRecordingState: MutableState<Boolean>? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
RecordAudioBox(
|
RecordAudioBox(
|
||||||
modifier = iconSizeModifier,
|
modifier = Modifier,
|
||||||
onRecordTaken = { audio ->
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { _, _ ->
|
) { 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)
|
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCounter) {
|
if (showCounter) {
|
||||||
ReplyCounter(baseNote, grayTint, accountViewModel)
|
ReplyCounter(baseNote, grayTint, accountViewModel)
|
||||||
|
|||||||
+2
-2
@@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
|||||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
|
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
|
||||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
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.Hex
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
@@ -1154,7 +1154,7 @@ class AccountViewModel(
|
|||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
if (isWriteable()) {
|
if (isWriteable()) {
|
||||||
val hint = note.toEventHint<VoiceEvent>() ?: return
|
val hint = note.toEventHint<BaseVoiceEvent>() ?: return
|
||||||
|
|
||||||
launchSigner {
|
launchSigner {
|
||||||
val uploader = UploadOrchestrator()
|
val uploader = UploadOrchestrator()
|
||||||
|
|||||||
+7
-94
@@ -24,10 +24,7 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.activity.compose.BackHandler
|
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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.heightIn
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
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.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
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.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.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
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.Size10dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
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.Size5dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||||
@@ -365,24 +351,11 @@ private fun NewPostScreenBody(
|
|||||||
|
|
||||||
// Show preview for both uploaded messages (voiceMetadata) and pending recordings
|
// Show preview for both uploaded messages (voiceMetadata) and pending recordings
|
||||||
(postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata ->
|
(postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata ->
|
||||||
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
|
||||||
val fileServersState =
|
val fileServersState =
|
||||||
accountViewModel.account.serverLists.liveServerList
|
accountViewModel.account.serverLists.liveServerList
|
||||||
.collectAsState()
|
.collectAsState()
|
||||||
val fileServers = fileServersState.value
|
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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -391,7 +364,7 @@ private fun NewPostScreenBody(
|
|||||||
) {
|
) {
|
||||||
// Display voice preview or uploading progress
|
// Display voice preview or uploading progress
|
||||||
postViewModel.voiceOrchestrator?.let { orchestrator ->
|
postViewModel.voiceOrchestrator?.let { orchestrator ->
|
||||||
VoiceUploadingProgress(orchestrator)
|
UploadProgressIndicator(orchestrator)
|
||||||
} ?: run {
|
} ?: run {
|
||||||
VoiceMessagePreview(
|
VoiceMessagePreview(
|
||||||
voiceMetadata = metadata,
|
voiceMetadata = metadata,
|
||||||
@@ -400,20 +373,13 @@ private fun NewPostScreenBody(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
FileServerSelectionRow(
|
||||||
TextSpinner(
|
fileServers = fileServers,
|
||||||
label = "",
|
selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer,
|
||||||
placeholder =
|
onSelect = { postViewModel.voiceSelectedServer = it },
|
||||||
fileServers
|
|
||||||
.firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) }
|
|
||||||
?.name
|
|
||||||
?: fileServers[0].name,
|
|
||||||
options = fileServerOptions,
|
|
||||||
onSelect = { postViewModel.voiceSelectedServer = fileServers[it] },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (postViewModel.wantsInvoice) {
|
if (postViewModel.wantsInvoice) {
|
||||||
postViewModel.lnAddress()?.let { lud16 ->
|
postViewModel.lnAddress()?.let { lud16 ->
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-5
@@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
|||||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||||
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
@@ -539,8 +540,8 @@ open class ShortNotePostViewModel :
|
|||||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||||
// Check if this is a voice message
|
// Check if this is a voice message
|
||||||
voiceMetadata?.let { audioMeta ->
|
voiceMetadata?.let { audioMeta ->
|
||||||
// Only create voice reply if original note is also a VoiceEvent
|
// Only create voice reply if original note is also a voice message
|
||||||
val originalVoiceHint = originalNote?.toEventHint<VoiceEvent>()
|
val originalVoiceHint = originalNote?.toEventHint<BaseVoiceEvent>()
|
||||||
return if (originalVoiceHint != null) {
|
return if (originalVoiceHint != null) {
|
||||||
// Create voice reply event
|
// Create voice reply event
|
||||||
VoiceReplyEvent.build(
|
VoiceReplyEvent.build(
|
||||||
@@ -1024,9 +1025,6 @@ open class ShortNotePostViewModel :
|
|||||||
onError(uploadErrorTitle, uploadVoiceFailed)
|
onError(uploadErrorTitle, uploadVoiceFailed)
|
||||||
voiceRecording = null
|
voiceRecording = null
|
||||||
}
|
}
|
||||||
else -> {
|
|
||||||
onError(uploadErrorTitle, uploadVoiceUnexpected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName))
|
onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName))
|
||||||
|
|||||||
+242
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+261
@@ -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 DividerThickness = 0.25.dp
|
||||||
|
|
||||||
val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp)
|
val ReactionRowHeight = Modifier.padding(vertical = 7.dp).heightIn(min = 24.dp)
|
||||||
val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp)
|
val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).heightIn(min = 24.dp).padding(horizontal = 10.dp)
|
||||||
val ReactionRowHeightChat = Modifier.height(20.dp)
|
val ReactionRowHeightChat = Modifier.height(20.dp)
|
||||||
val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth()
|
val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth()
|
||||||
val UserNameRowHeight = Modifier.fillMaxWidth()
|
val UserNameRowHeight = Modifier.fillMaxWidth()
|
||||||
|
|||||||
@@ -1183,4 +1183,5 @@
|
|||||||
<string name="follow_pack_broadcast">Broadcast sady doporučení</string>
|
<string name="follow_pack_broadcast">Broadcast sady doporučení</string>
|
||||||
<string name="follow_set_delete">Smazat seznam</string>
|
<string name="follow_set_delete">Smazat seznam</string>
|
||||||
<string name="follow_pack_delete">Smazat sadu doporučení</string>
|
<string name="follow_pack_delete">Smazat sadu doporučení</string>
|
||||||
|
<string name="re_record">Znovu nahrát</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1188,4 +1188,5 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="follow_pack_broadcast">Empfehlungspaket veröffentlichen</string>
|
<string name="follow_pack_broadcast">Empfehlungspaket veröffentlichen</string>
|
||||||
<string name="follow_set_delete">Liste löschen</string>
|
<string name="follow_set_delete">Liste löschen</string>
|
||||||
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
|
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
|
||||||
|
<string name="re_record">Neu aufnehmen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1183,4 +1183,5 @@
|
|||||||
<string name="follow_pack_broadcast">Transmitir pacote de recomendações</string>
|
<string name="follow_pack_broadcast">Transmitir pacote de recomendações</string>
|
||||||
<string name="follow_set_delete">Excluir lista</string>
|
<string name="follow_set_delete">Excluir lista</string>
|
||||||
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
|
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
|
||||||
|
<string name="re_record">Gravar novamente</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1182,4 +1182,5 @@
|
|||||||
<string name="follow_pack_broadcast">Sänd rekommendationspaket</string>
|
<string name="follow_pack_broadcast">Sänd rekommendationspaket</string>
|
||||||
<string name="follow_set_delete">Ta bort lista</string>
|
<string name="follow_set_delete">Ta bort lista</string>
|
||||||
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
|
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
|
||||||
|
<string name="re_record">Spela in igen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
<string name="record_a_message">Record a message</string>
|
<string name="record_a_message">Record a message</string>
|
||||||
<string name="record_a_message_title">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="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_description">Recording</string>
|
||||||
<string name="recording_indicator_with_time">Recording %1$s</string>
|
<string name="recording_indicator_with_time">Recording %1$s</string>
|
||||||
<string name="uploading">Uploading…</string>
|
<string name="uploading">Uploading…</string>
|
||||||
|
|||||||
+2
-2
@@ -65,12 +65,12 @@ class VoiceReplyEvent(
|
|||||||
hash: String,
|
hash: String,
|
||||||
duration: Int,
|
duration: Int,
|
||||||
waveform: List<Float>,
|
waveform: List<Float>,
|
||||||
replyingTo: EventHintBundle<VoiceEvent>,
|
replyingTo: EventHintBundle<BaseVoiceEvent>,
|
||||||
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
|
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
|
||||||
|
|
||||||
fun build(
|
fun build(
|
||||||
voiceMessage: AudioMeta,
|
voiceMessage: AudioMeta,
|
||||||
replyingTo: EventHintBundle<VoiceEvent>,
|
replyingTo: EventHintBundle<BaseVoiceEvent>,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
|
||||||
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {
|
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user