diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 910991d39..d2678ca68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -249,7 +249,9 @@ object LocalPreferences { val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { if (it.contains(npub)) { - File(prefsDir, it).delete() + if (!File(prefsDir, it).delete()) { + Log.w("LocalPreferences", "Failed to delete preference file: $it") + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b1e5d6ad4..d32ca043b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -214,7 +214,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.math.BigDecimal @@ -602,7 +601,7 @@ class Account( url: String, method: String, body: ByteArray? = null, - ): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) + ): HTTPAuthorizationEvent = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) suspend fun createBlossomUploadAuth( hash: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index c81a0b755..aa694f42d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -43,7 +43,6 @@ import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal -import kotlin.plus interface UserDependencies @@ -219,7 +218,7 @@ class User( fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size - suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } + fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } fun reportsOrNull(): UserReportCache? = reports diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 983e623a4..59ab65117 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -43,9 +43,6 @@ import com.vitorpamplona.quartz.nip51Lists.followList.personFirst import com.vitorpamplona.quartz.nip51Lists.followList.removePerson import com.vitorpamplona.quartz.nip51Lists.followList.title import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip51Lists.peopleList.description -import com.vitorpamplona.quartz.nip51Lists.peopleList.image -import com.vitorpamplona.quartz.nip51Lists.peopleList.name import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -83,14 +80,14 @@ class FollowListsState( .map { existingPeopleListNotes() } .onStart { emit(existingPeopleListNotes()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) val followListsEventIds = followListNotes .map { it.eventIdSet() } .onStart { emit(followListNotes.value.eventIdSet()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) @OptIn(ExperimentalCoroutinesApi::class) val latestLists: StateFlow> = @@ -107,7 +104,7 @@ class FollowListsState( .map { it.mapToUserIdSet() } .onStart { emit(latestLists.value.mapToUserIdSet()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) fun FollowListEvent.toUI() = PeopleList( @@ -126,7 +123,7 @@ class FollowListsState( .map { it.toUI() } .onStart { emit(latestLists.value.toUI()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) fun List.select(dTag: String) = this.firstOrNull { @@ -142,7 +139,7 @@ class FollowListsState( fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) - fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value) + fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.KIND) || deletesAnyEventIn(followListsEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index 7dfd32898..77e6d0a54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip51Lists.followList.description import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.description diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index a9cd1a484..17b3d2bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt index 0ca26eccf..c59521c07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt @@ -41,10 +41,10 @@ class WssDataStreamCollector : WebSocketListener() { reason: String, ) { super.onClosing(webSocket, code, reason) - wssData.removeAll(wssData) + wssData.clear() } - fun canStream(): Boolean = wssData.size > 0 + fun canStream(): Boolean = wssData.isNotEmpty() fun getNextStream(): ByteString = wssData.pollFirst() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index b8879fa77..f8fbd4542 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -33,8 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -import kotlin.collections.component1 -import kotlin.collections.component2 class UserCardsSubAssembler( client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 889a29a9b..c5083fb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -23,10 +23,14 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.Manifest import android.widget.Toast import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -35,33 +39,78 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive @OptIn(ExperimentalPermissionsApi::class) @Composable fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, - content: @Composable (Boolean) -> Unit, + content: @Composable (Boolean, Int) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current + var elapsedSeconds by remember { mutableIntStateOf(0) } + var wantsToRecord by remember { mutableStateOf(false) } + + // Must be called at Composable scope, not in callback + val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val scope = rememberCoroutineScope() + + DisposableEffect(Unit) { + onDispose { + wantsToRecord = false + mediaRecorder.value?.stop() + mediaRecorder.value = null + } + } + + fun startRecording() { + if (mediaRecorder.value == null) { + elapsedSeconds = 0 + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) + } + } + + LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { + if (recordPermissionState.status.isGranted && wantsToRecord) { + startRecording() + } + } + + // Track elapsed time while recording + LaunchedEffect(mediaRecorder.value) { + // Capture the current recorder state to avoid repeated reads of volatile state + val currentRecorder = mediaRecorder.value + if (currentRecorder != null) { + // Loop while coroutine is active - LaunchedEffect will cancel when mediaRecorder.value changes + while (isActive) { + delay(1000) + elapsedSeconds++ + } + } else { + // Reset elapsed time when not recording + elapsedSeconds = 0 + } + } ClickAndHoldBoxComposable( modifier = modifier, onPress = { - val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - if (!recordPermissionState.status.isGranted) { - recordPermissionState.launchPermissionRequest() - } else { - mediaRecorder.value = VoiceMessageRecorder() - mediaRecorder.value?.start(context, scope) - } + wantsToRecord = true + if (!recordPermissionState.status.isGranted) { + recordPermissionState.launchPermissionRequest() + } else { + // Start immediately for responsive UX when permission already granted + startRecording() } }, onRelease = { + wantsToRecord = false val result = mediaRecorder.value?.stop() + mediaRecorder.value = null if (result != null) { onRecordTaken(result) } else { @@ -75,8 +124,10 @@ fun RecordAudioBox( } }, onCancel = { + wantsToRecord = false mediaRecorder.value?.stop() + mediaRecorder.value = null }, - content, + content = @Composable { isRecording -> content(isRecording, elapsedSeconds) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt new file mode 100644 index 000000000..9cedeeab6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { + var isRecording by remember { mutableStateOf(false) } + var elapsedSeconds by remember { mutableIntStateOf(0) } + + Column { + // Floating recording indicator at the top + FloatingRecordingIndicator( + modifier = Modifier.height(50.dp), + isRecording = isRecording, + elapsedSeconds = elapsedSeconds, + ) + + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + isRecording = false + elapsedSeconds = 0 + onVoiceTaken(recording) + }, + ) { recordingState, elapsed -> + // Update parent state after composition completes + SideEffect { + if (isRecording != recordingState) { + isRecording = recordingState + } + if (elapsedSeconds != elapsed) { + elapsedSeconds = elapsed + } + } + + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + // Expanding circles background animation + ExpandingCirclesAnimation( + modifier = Modifier.size(48.dp), + isRecording = recordingState, + primaryColor = MaterialTheme.colorScheme.primary, + ) + + // Microphone icon + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + modifier = Modifier.height(22.dp), + tint = + if (recordingState) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + } + + // Empty space at the bottom for layout balance + Box( + modifier = Modifier.height(50.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt new file mode 100644 index 000000000..9b81d710f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -0,0 +1,231 @@ +/** + * 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.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Animated expanding circles that pulse outward from the recording button + */ +@Composable +fun ExpandingCirclesAnimation( + modifier: Modifier = Modifier, + isRecording: Boolean, + primaryColor: Color = MaterialTheme.colorScheme.primary, +) { + val infiniteTransition = rememberInfiniteTransition(label = "expanding_circles") + + // First circle animation + val scale1 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle1_scale", + ) + + val alpha1 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle1_alpha", + ) + + // Second circle animation (offset by 500ms) + val scale2 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle2_scale", + ) + + val alpha2 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle2_alpha", + ) + + // Third circle animation (offset by 1000ms) + val scale3 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle3_scale", + ) + + val alpha3 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle3_alpha", + ) + + if (!isRecording) return + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + // Circle 1 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale1) + .alpha(alpha1) + .background(primaryColor.copy(alpha = 0.3f), CircleShape), + ) + + // Circle 2 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale2) + .alpha(alpha2) + .background(primaryColor.copy(alpha = 0.2f), CircleShape), + ) + + // Circle 3 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale3) + .alpha(alpha3) + .background(primaryColor.copy(alpha = 0.1f), CircleShape), + ) + } +} + +/** + * Floating recording indicator showing elapsed time + */ +@Composable +fun FloatingRecordingIndicator( + modifier: Modifier = Modifier, + isRecording: Boolean, + elapsedSeconds: Int, +) { + if (!isRecording) return + + val recordingLabel = stringRes(id = R.string.recording_indicator_description) + val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + + Box( + modifier = + modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 16.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + // Pulsing red dot + val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") + val dotAlpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + ), + label = "dot_alpha", + ) + + Icon( + imageVector = Icons.Default.FiberManualRecord, + contentDescription = recordingLabel, + tint = Color.White, + modifier = + Modifier + .alpha(dotAlpha) + .padding(end = 8.dp), + ) + + Text( + text = recordingWithTime, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt new file mode 100644 index 000000000..80cac54e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt @@ -0,0 +1,39 @@ +/** + * 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 java.util.Locale + +/** + * Formats seconds into a human-readable time string (M:SS or MM:SS format). + * + * @param seconds The number of seconds to format + * @return Formatted time string (e.g., "0:05", "1:23", "12:45") + */ +fun formatSecondsToTime(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return if (minutes > 0) { + String.format(Locale.getDefault(), "%d:%02d", minutes, secs) + } else { + String.format(Locale.getDefault(), "0:%02d", secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt new file mode 100644 index 000000000..a601837be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -0,0 +1,259 @@ +/** + * 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 android.media.MediaPlayer +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.io.File + +@Composable +fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: File? = null, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var mediaPlayer by remember { mutableStateOf(null) } + + // Initialize MediaPlayer + DisposableEffect(voiceMetadata.url, localFile) { + val player = createMediaPlayer(voiceMetadata.url, localFile) + player?.setOnCompletionListener { + isPlaying = false + progress = 0f + } + mediaPlayer = player + + onDispose { + // Stop playback and clean up + try { + player?.stop() + } catch (e: IllegalStateException) { + // Player might already be stopped + Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) + } + player?.release() + mediaPlayer = null + isPlaying = false + } + } + + // Update progress while playing + LaunchedEffect(mediaPlayer, isPlaying) { + // Capture player reference to avoid reading volatile state repeatedly + val player = mediaPlayer + if (player != null && isPlaying) { + while (isActive) { + try { + if (player.isPlaying) { + val current = player.currentPosition.toFloat() + val duration = player.duration.toFloat() + // Validate values before calculating progress + val newProgress = + if (duration > 0 && current >= 0) { + (current / duration).coerceIn(0f, 1f) + } else { + 0f + } + // Only update if value is valid (not NaN or Infinity) + if (newProgress.isFinite()) { + progress = newProgress + } + } else { + // Player stopped, exit loop and let LaunchedEffect restart + break + } + } catch (e: IllegalStateException) { + // Player in invalid state, stop tracking + Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) + isPlaying = false + break + } + delay(100) + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Play/Pause Button + IconButton( + onClick = { + val player = mediaPlayer + if (player != null) { + try { + if (isPlaying) { + player.pause() + isPlaying = false + } else { + // Validate progress before comparison + if (progress.isFinite() && progress >= 1f) { + player.seekTo(0) + progress = 0f + } + player.start() + isPlaying = true + } + } catch (e: IllegalStateException) { + // MediaPlayer in invalid state, ignore + Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) + isPlaying = false + } + } + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Waveform and Duration + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + AudioWaveformReadOnly( + amplitudes = voiceMetadata.waveform ?: emptyList(), + progress = progress, + waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), + progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), + onProgressChange = { newProgress -> + // Validate incoming progress value + if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) { + val player = mediaPlayer + if (player != null) { + try { + val duration = player.duration + // Only seek if duration is valid + if (duration > 0) { + val newPosition = (newProgress * duration).toInt() + player.seekTo(newPosition) + progress = newProgress + } + } catch (e: IllegalStateException) { + // MediaPlayer in invalid state, ignore + Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) + } + } + } + }, + ) + + Text( + text = formatSecondsToTime(voiceMetadata.duration ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Remove Button + IconButton( + onClick = onRemove, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringRes(context, R.string.remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun createMediaPlayer( + url: String, + localFile: File?, +): MediaPlayer? = + try { + MediaPlayer().apply { + if (localFile != null && localFile.exists()) { + setDataSource(localFile.absolutePath) + } else { + setDataSource(url) + } + prepare() + } + } catch (e: Exception) { + Log.w("VoiceMessagePreview", "Failed to create MediaPlayer", e) + null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 5c5115eae..dae416df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -24,11 +24,16 @@ import android.content.Context import android.media.MediaRecorder import android.os.Build import androidx.media3.common.MimeTypes +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File @@ -40,30 +45,44 @@ class RecordingResult( ) class VoiceMessageRecorder { + @Volatile private var recorder: MediaRecorder? = null private var outputFile: File? = null private var startTime: Long = 0 - private var job: Job? = null + + // Own scope to manage lifecycle independently from caller + private var recorderScope: CoroutineScope? = null + + @Volatile + private var amplitudeSamplingJob: Job? = null private var amplitudes: MutableList = mutableListOf() private fun createRecorder(context: Context): MediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(context) + MediaRecorder(context.applicationContext) } else { MediaRecorder() } - suspend fun start( + @Synchronized + fun start( context: Context, - scope: CoroutineScope, + parentScope: CoroutineScope, ) { + // Clean up any existing recording first + cleanup() + val fileName = RandomInstance.randomChars(16) + ".mp4" - val outputFile = File(context.cacheDir, "/voice/$fileName") + val outputFile = File(context.cacheDir, "voice/$fileName") outputFile.parentFile?.mkdirs() this.outputFile = outputFile this.startTime = TimeUtils.now() this.amplitudes.clear() + // Create own scope with SupervisorJob so failures don't cascade + val scopeJob = SupervisorJob(parentScope.coroutineContext[Job]) + recorderScope = CoroutineScope(Dispatchers.Main.immediate + scopeJob) + createRecorder(context).apply { setAudioEncodingBitRate(16 * 44100) setAudioSamplingRate(44100) // Set the desired audio sampling rate (e.g., 44.1 kHz) @@ -78,31 +97,103 @@ class VoiceMessageRecorder { recorder = this } - job?.cancel() - job = - scope.launch { - while (recorder != null) { - amplitudes.add(recorder?.maxAmplitude?.toFloat() ?: 0f) + // Launch amplitude sampling in our own scope + amplitudeSamplingJob = + recorderScope?.launch { + while (isActive) { + val recorderRef = recorder ?: break + try { + val amplitude = recorderRef.maxAmplitude.toFloat() + synchronized(amplitudes) { + amplitudes.add(amplitude) + } + } catch (e: IllegalStateException) { + // MediaRecorder might be in invalid state, stop sampling + Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e) + break + } delay(1000) } } } - suspend fun stop(): RecordingResult? { - recorder?.stop() - recorder?.reset() - recorder = null + @Synchronized + fun stop(): RecordingResult? { + if (recorder == null) { + cleanup() + return null + } val currentTime = TimeUtils.now() val file = outputFile - return if (currentTime - startTime >= 1 && file != null) { + + // Capture amplitudes before cleanup + val amplitudesCopy = + synchronized(amplitudes) { + amplitudes.toList() + } + val duration = (currentTime - startTime).toInt() + + // Clean up recorder and scope + cleanup() + + return if (duration >= 1 && file != null) { RecordingResult( file, MimeTypes.AUDIO_AAC, - amplitudes, - (currentTime - startTime).toInt(), + amplitudesCopy, + duration, ) } else { null } } + + /** + * Cleans up all resources: stops recorder, cancels jobs, cancels scope. + * Safe to call multiple times. + */ + @Synchronized + private fun cleanup() { + // Cancel amplitude sampling job + amplitudeSamplingJob?.cancel() + amplitudeSamplingJob = null + + // Stop any remaining coroutines before touching the recorder + recorderScope?.cancel() + recorderScope = null + + // Swap local reference so we always null out the volatile field + val recorderToRelease = recorder + recorder = null + + recorderToRelease?.let { mediaRecorder -> + try { + mediaRecorder.stop() + } catch (e: IllegalStateException) { + Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder due to illegal state", e) + } catch (e: RuntimeException) { + // MediaRecorder.stop() can throw RuntimeException if the recording is too short + // or if no valid audio data was captured. This is a known Android issue. + Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder (recording may be too short or invalid)", e) + } finally { + try { + mediaRecorder.reset() + } catch (resetError: Exception) { + Log.w("VoiceMessageRecorder", "Failed to reset MediaRecorder before release", resetError) + } + try { + mediaRecorder.release() + } catch (releaseError: Exception) { + Log.w("VoiceMessageRecorder", "Failed to release MediaRecorder resources", releaseError) + } + } + } + + // Reset transient state so a fresh recording always starts cleanly + outputFile = null + startTime = 0 + synchronized(amplitudes) { + amplitudes.clear() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 100f12398..6df79b5cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -115,7 +115,7 @@ fun ClickAndHoldBox( @Composable fun ClickAndHoldBoxComposable( modifier: Modifier = Modifier, - onPress: @Composable () -> Unit, + onPress: () -> Unit, onRelease: suspend () -> Unit, onCancel: suspend () -> Unit, content: @Composable (Boolean) -> Unit, @@ -123,15 +123,16 @@ fun ClickAndHoldBoxComposable( val interactionSource = remember { MutableInteractionSource() } var isPressed by remember { mutableStateOf(false) } - if (isPressed) { - onPress() - } - LaunchedEffect(interactionSource) { val pressInteractions = mutableListOf() interactionSource.interactions.collect { interaction -> when (interaction) { - is PressInteraction.Press -> pressInteractions.add(interaction) + is PressInteraction.Press -> { + if (pressInteractions.isEmpty()) { + onPress() + } + pressInteractions.add(interaction) + } is PressInteraction.Release -> { onRelease() pressInteractions.remove(interaction.press) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index 89dc89d79..6d2d01c4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -20,10 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.layouts.listItem -import android.view.Surface import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -41,7 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -111,13 +108,13 @@ fun ChannelNamePreview() { SlimListItem( headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is my author", Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is a message from this person", Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() @@ -137,13 +134,13 @@ fun ChannelNamePreview() { ListItem( headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is my author", Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is a message from this person", Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() @@ -509,7 +506,7 @@ private class ListItemMeasurePolicy : MultiContentMeasurePolicy { } } -private fun IntrinsicMeasureScope.calculateWidth( +private fun calculateWidth( leadingWidth: Int, trailingWidth: Int, headlineWidth: Int, @@ -646,7 +643,7 @@ private value class ListItemType private constructor( /** Three line list item */ val ThreeLine = ListItemType(3) - internal operator fun invoke( + operator fun invoke( hasOverline: Boolean, hasSupporting: Boolean, isSupportingMultiline: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index baa2930bb..fb16001a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.drawer -import android.R.attr.fontWeight import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -122,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes.followerCount import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index a30068dbe..9c3ccf5a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Community import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index bd8d3d7f4..716d0191f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -609,7 +609,7 @@ fun ReplyViaVoiceReaction( onRecordTaken = { audio -> accountViewModel.sendVoiceReply(baseNote, audio, context) }, - ) { + ) { _, _ -> VoiceReplyIcon(iconSizeModifier, grayTint) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index ea1beb2c8..c79454a9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,10 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index d51cb49de..2bdfbca8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlin.collections.emptyList @Stable class BookmarkGroupViewModel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt index ae744bdcf..2f8680c6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import kotlin.toString @Composable fun NewBookmarkGroupCreationDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index be8594ff1..86d975767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 581d6825d..b347e68b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,7 +24,10 @@ 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 @@ -35,32 +38,47 @@ 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.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.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 @@ -93,6 +111,7 @@ 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 @@ -260,18 +279,21 @@ private fun NewPostScreenBody( } } - Row( - modifier = Modifier.padding(vertical = Size10dp), - ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) - MessageField( - R.string.what_s_on_your_mind, - postViewModel, - ) + // Only show text input if no voice message is being posted + if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField( + R.string.what_s_on_your_mind, + postViewModel, + ) + } } if (postViewModel.wantsPoll) { @@ -341,6 +363,58 @@ 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 + .fillMaxWidth() + .padding(vertical = Size5dp, horizontal = Size10dp), + ) { + // Display voice preview or uploading progress + postViewModel.voiceOrchestrator?.let { orchestrator -> + VoiceUploadingProgress(orchestrator) + } ?: run { + VoiceMessagePreview( + voiceMetadata = metadata, + localFile = postViewModel.voiceLocalFile, + onRemove = { postViewModel.removeVoiceMessage() }, + ) + } + + 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] }, + ) + } + } + } + if (postViewModel.wantsInvoice) { postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( @@ -440,6 +514,12 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { }, ) + RecordVoiceButton( + onVoiceTaken = { recording -> + postViewModel.selectVoiceRecording(recording) + }, + ) + if (postViewModel.canUsePoll) { // These should be hashtag recommendations the user selects in the future. // val hashtag = stringRes(R.string.poll_hashtag) @@ -504,3 +584,56 @@ private fun AddPollButton( } } } + +@Composable +private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index bb07d2c19..87b87b5fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -116,6 +120,9 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -177,6 +184,14 @@ open class ShortNotePostViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Voice Messages + var voiceRecording by mutableStateOf(null) + var voiceLocalFile by mutableStateOf(null) + var isUploadingVoice by mutableStateOf(false) + var voiceMetadata by mutableStateOf(null) + var voiceSelectedServer by mutableStateOf(null) + var voiceOrchestrator by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -220,7 +235,7 @@ open class ShortNotePostViewModel : fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account.userProfile() + fun user(): User = account.userProfile() open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -466,6 +481,24 @@ open class ShortNotePostViewModel : } suspend fun sendPostSync() { + // Upload voice message first if it hasn't been uploaded yet + if (voiceRecording != null && voiceMetadata == null) { + val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer + uploadVoiceMessageSync( + serverToUse, + { _, _ -> }, // Error handling is done by checking voiceMetadata below + ) + // Abort if upload failed - don't post without voice data + if (voiceMetadata == null) { + Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post") + return + } + // Update default server if voice message was successfully uploaded + if (voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { + account.settings.changeDefaultFileServer(voiceSelectedServer!!) + } + } + val template = createTemplate() ?: return val extraNotesToBroadcast = mutableListOf() @@ -504,6 +537,24 @@ open class ShortNotePostViewModel : } private suspend fun createTemplate(): EventTemplate? { + // Check if this is a voice message + voiceMetadata?.let { audioMeta -> + // Only create voice reply if original note is also a VoiceEvent + val originalVoiceHint = originalNote?.toEventHint() + return if (originalVoiceHint != null) { + // Create voice reply event + VoiceReplyEvent.build( + voiceMessage = audioMeta, + replyingTo = originalVoiceHint, + ) + } else { + // Create root voice event (no reply or original is not a voice message) + VoiceEvent.build( + voiceMessage = audioMeta, + ) + } + } + val tagger = NewMessageTagger( message.text, @@ -715,6 +766,13 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + isUploadingVoice = false + voiceMetadata = null + voiceSelectedServer = null + voiceOrchestrator = null pTags = null wantsPoll = false @@ -833,9 +891,16 @@ open class ShortNotePostViewModel : private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) - fun canPost(): Boolean = - message.text.isNotBlank() && + fun canPost(): Boolean { + // Voice messages can be posted without text (with either uploaded or pending recording) + if (voiceMetadata != null || voiceRecording != null) { + return !isUploadingVoice && !isUploadingImage + } + + // Regular text/media posts require text + return message.text.isNotBlank() && !isUploadingImage && + !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && ( @@ -847,6 +912,7 @@ open class ShortNotePostViewModel : ) ) && multiOrchestrator == null + } fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -856,6 +922,121 @@ open class ShortNotePostViewModel : multiOrchestrator = MultiOrchestrator(uris) } + fun selectVoiceRecording(recording: RecordingResult) { + // Delete any existing temp file before replacing + deleteVoiceLocalFile() + voiceRecording = recording + voiceLocalFile = recording.file + } + + fun getVoicePreviewMetadata(): AudioMeta? = + voiceRecording?.let { recording -> + AudioMeta( + url = "", // Empty URL for preview (local file will be used) + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + fun removeVoiceMessage() { + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploadingVoice = false + voiceOrchestrator = null + } + + private fun deleteVoiceLocalFile() { + voiceLocalFile?.let { file -> + try { + if (file.exists()) { + file.delete() + Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}") + } + } catch (e: Exception) { + Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + } + } + } + + suspend fun uploadVoiceMessageSync( + server: ServerName, + onError: (title: String, message: String) -> Unit, + ) { + val recording = voiceRecording ?: return + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) + val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) + val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + + isUploadingVoice = true + + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = appContext, + useH265 = false, + ) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + voiceMetadata = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + // Delete the local file after successful upload + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + // For NIP95, we need to create the event and get the nevent URL + // This is handled differently - skip for now + onError(uploadErrorTitle, uploadVoiceNip95NotSupported) + } + } + } + is UploadingState.Error -> { + onError(uploadErrorTitle, uploadVoiceFailed) + voiceRecording = null + } + else -> { + onError(uploadErrorTitle, uploadVoiceUnexpected) + } + } + } catch (e: Exception) { + onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) + voiceRecording = null + } finally { + isUploadingVoice = false + voiceOrchestrator = null + } + } + override fun locationFlow(): StateFlow { if (location == null) { location = locationManager().geohashStateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 48b57a643..6f4a4dc8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -73,6 +72,8 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims +private const val IDENTITY_ICON_CACHE_KEY = 0 + @Composable fun DrawAdditionalInfo( baseUser: User, @@ -192,7 +193,7 @@ fun DrawAdditionalInfo( Row(verticalAlignment = Alignment.CenterVertically) { Icon( tint = Color.Unspecified, - painter = painterRes(resourceId = getIdentityClaimIcon(identity), getIdentityClaimIconReference(identity)), + painter = painterRes(resourceId = getIdentityClaimIcon(identity), IDENTITY_ICON_CACHE_KEY), contentDescription = stringRes(getIdentityClaimDescription(identity)), modifier = Modifier.size(16.dp), ) @@ -247,14 +248,5 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is TelegramIdentity -> R.string.telegram is MastodonIdentity -> R.string.mastodon is GitHubIdentity -> R.string.github - else -> R.drawable.github - } - -fun getIdentityClaimIconReference(identity: IdentityClaimTag): Int = - when (identity) { - is TwitterIdentity -> 0 - is TelegramIdentity -> 0 - is MastodonIdentity -> 0 - is GitHubIdentity -> 0 - else -> 0 + else -> R.string.github } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 39ef191b9..5cc85b46e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -21,6 +21,7 @@ Nezákonné jednání Jiný Obtěžování + Násilí Neznámý Ikona předávání Neznámý autor @@ -157,7 +158,14 @@ Nahrajte zprávu Nahrajte zprávu Stiskněte a podržte pro nahrání zprávy + Nahrávání + Nahrávání %1$s Nahrávání… + Chyba nahrávání + Nepodařilo se nahrát hlasovou zprávu + Neočekávaný stav nahrávání + NIP-95 zatím není pro hlasové zprávy podporován + Nahrávání hlasu selhalo: %1$s Uživatel nemá nastavenou LN adresu pro přijímání sats "Odpověď zde…" Zkopíruje ID poznámky do schránky pro sdílení diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 6927c4cf6..4dea3cb35 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -21,6 +21,7 @@ Illegales Verhalten Andere Belästigung + Gewalt Unbekannt Relay-Symbol Unbekannter Autor @@ -159,7 +160,14 @@ erie gespeichert Eine Nachricht aufnehmen Eine Nachricht aufnehmen Zum Aufnehmen einer Nachricht gedrückt halten + Aufnahme + Aufnahme %1$s Hochladen… + Upload-Fehler + Sprachnachricht konnte nicht hochgeladen werden + Unerwarteter Upload-Status + NIP-95 wird für Sprachnachrichten noch nicht unterstützt + Sprach-Upload fehlgeschlagen: %1$s Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen "Hier antworten…" Kopiert die Notiz-ID zum Teilen in die Zwischenablage diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 73c5573db..d323a304d 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -21,6 +21,7 @@ अवैध बरताव अन्य उत्पीडन + हिंसा अज्ञात पनःप्रसारक चिह्न अज्ञात लेखक diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index cdad165fb..74edb8bcc 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -21,6 +21,7 @@ Antyspołeczne zachowanie Inne Nękanie + Przemoc Nieznani Ikona transmitera Autor nieznany @@ -60,8 +61,8 @@ Czy aplikacja sygnatariusza została odinstalowana? Sprawdź, czy aplikacja sygnatariusza jest zainstalowana i czy ma to konto. Wyloguj się i zaloguj ponownie, jeśli aplikacja sygnatariusza uległa zmianie. Zapy Liczba wyświetleń - Promuj - promowany + Powtórz + powtórzony edytowano edytuj #%1$s oryginalny diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7df7cf9fb..c8278d893 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -21,6 +21,7 @@ Comportamento ilegal Outro Assédio + Violência Desconhecido Ícone do relay Autor desconhecido @@ -157,7 +158,14 @@ Gravar uma mensagem Gravar uma mensagem Clique e segure para gravar uma mensagem + Gravando + Gravando %1$s Enviando… + Erro de upload + Falha ao enviar mensagem de voz + Estado de upload inesperado + O NIP-95 ainda não é suportado para mensagens de voz + Falha no upload de voz: %1$s Usuário não tem um endereço lightning configurado para receber sats "responda aqui.. " Copia o ID do canal (note) para compartilhar diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index aa75a9622..86b75dcd2 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -21,6 +21,7 @@ Nedovoljeno vedenje Drugo Nadlegovanje + Nasilje Nepoznano Ikona releja Avtor neznan @@ -41,7 +42,7 @@ Zlonamerna programska vsebina Prijavi moderatorja Zlonamerna programska vsebina - Administrator + Moderator Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, da omogočiš tudi pisanje Uporabljaš javni ključ in javni ključi omogočajo le branje. @@ -153,8 +154,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem URL pasice URL spletne strani Zaimki - LN Naslov - Zastarel LN (lightning) URL + Lightning naslov (LUD-16) + Zastarel lightning naslov (LUD-06) Shrani v galerijo Slika shranjena v foto galerijo telefona Prenos videa se je začel… @@ -401,6 +402,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Dodaj kot zasebni zaznamek Odstrani iz seznama zaznamkov Metapodatki seznama zaznamkov so vidni vsem na Nostru. Le tvoji zasebni člani so šifrirani. + Premakni v javno + Premakni v zasebno Storitev Wallet Connect Pooblasti Nostr skrivnost (Nostr secret) za plačevanje z Zapi brez zapuščanja aplikacije. Nostr skrivnost (Nostr secret) hranite na varnem in, če je mogoče, uporabite zasebni rele Wallet Connect javni ključ diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 41cf307f0..a3393dcdf 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -21,6 +21,7 @@ Olagligt beteende Annat Trakasseri + Våld Okänd Relä ikon Okänd användare @@ -157,7 +158,14 @@ Spela in ett meddelande Spela in ett meddelande Tryck och håll in för att spela in ett meddelande + Spelar in + Spelar in %1$s Laddar upp… + Uppladdningsfel + Misslyckades med att ladda upp röstmeddelandet + Oväntat uppladdningstillstånd + NIP-95 stöds ännu inte för röstmeddelanden + Uppladdning av röst misslyckades: %1$s Användaren har inte en Lightningadressinställning för att ta emot sats "svara här.. " Kopierar antecknings-ID till urklipp för delning diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 61221bc95..8db1152f4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -170,7 +170,14 @@ Record a message Record a message Click and hold to record a message + Recording + Recording %1$s Uploading… + Upload Error + Failed to upload voice message + Unexpected upload state + NIP-95 is not supported for voice messages yet + Voice upload failed: %1$s User does not have a lightning address set up to receive sats "reply here.. " Copies the Note ID to the clipboard for sharing in Nostr diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index c5b947c87..a9f1d39e6 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -74,6 +74,13 @@ kotlin { // https://developer.android.com/kotlin/multiplatform/migrate val xcfName = "quartz-kmpKit" + + iosX64 { + binaries.framework { + baseName = xcfName + } + } + iosArm64 { binaries.framework { baseName = xcfName @@ -208,6 +215,10 @@ kotlin { } } + val iosX64Main by getting { + dependsOn(iosMain.get()) // iosX64Main depends on iosMain + } + val iosArm64Main by getting { dependsOn(iosMain.get()) // iosArm64Main depends on iosMain } @@ -223,12 +234,16 @@ kotlin { } } + val iosX64Test by getting { + dependsOn(iosTest.get()) // iosX64Test depends on iosTest + } + val iosArm64Test by getting { - dependsOn(iosTest.get()) // iosArm64Main depends on iosMain + dependsOn(iosTest.get()) // iosArm64Test depends on iosTest } val iosSimulatorArm64Test by getting { - dependsOn(iosTest.get()) // iosSimulatorArm64Main depends on iosMain + dependsOn(iosTest.get()) // iosSimulatorArm64Test depends on iosTest } } } @@ -283,4 +298,4 @@ mavenPublishing { connection = "https://github.com/vitorpamplona/amethyst/.git" } } -} \ No newline at end of file +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt index de6eec972..3d76052a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.experimental.trustedAssertions.list -import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip01Core.core.TagArray