This commit is contained in:
Vitor Pamplona
2025-12-18 09:28:49 -05:00
36 changed files with 1231 additions and 108 deletions
@@ -249,7 +249,9 @@ object LocalPreferences {
val prefsDir = File(prefsDirPath) val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach { prefsDir.list()?.forEach {
if (it.contains(npub)) { if (it.contains(npub)) {
File(prefsDir, it).delete() if (!File(prefsDir, it).delete()) {
Log.w("LocalPreferences", "Failed to delete preference file: $it")
}
} }
} }
} }
@@ -214,7 +214,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
@@ -602,7 +601,7 @@ class Account(
url: String, url: String,
method: String, method: String,
body: ByteArray? = null, body: ByteArray? = null,
): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) ): HTTPAuthorizationEvent = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
suspend fun createBlossomUploadAuth( suspend fun createBlossomUploadAuth(
hash: HexKey, hash: HexKey,
@@ -43,7 +43,6 @@ import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal import java.math.BigDecimal
import kotlin.plus
interface UserDependencies interface UserDependencies
@@ -219,7 +218,7 @@ class User(
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size 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 fun reportsOrNull(): UserReportCache? = reports
@@ -43,9 +43,6 @@ import com.vitorpamplona.quartz.nip51Lists.followList.personFirst
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
import com.vitorpamplona.quartz.nip51Lists.followList.title import com.vitorpamplona.quartz.nip51Lists.followList.title
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag 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 com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -83,14 +80,14 @@ class FollowListsState(
.map { existingPeopleListNotes() } .map { existingPeopleListNotes() }
.onStart { emit(existingPeopleListNotes()) } .onStart { emit(existingPeopleListNotes()) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) .stateIn(scope, SharingStarted.Eagerly, emptyList())
val followListsEventIds = val followListsEventIds =
followListNotes followListNotes
.map { it.eventIdSet() } .map { it.eventIdSet() }
.onStart { emit(followListNotes.value.eventIdSet()) } .onStart { emit(followListNotes.value.eventIdSet()) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) .stateIn(scope, SharingStarted.Eagerly, emptySet())
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val latestLists: StateFlow<List<FollowListEvent>> = val latestLists: StateFlow<List<FollowListEvent>> =
@@ -107,7 +104,7 @@ class FollowListsState(
.map { it.mapToUserIdSet() } .map { it.mapToUserIdSet() }
.onStart { emit(latestLists.value.mapToUserIdSet()) } .onStart { emit(latestLists.value.mapToUserIdSet()) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) .stateIn(scope, SharingStarted.Eagerly, emptySet())
fun FollowListEvent.toUI() = fun FollowListEvent.toUI() =
PeopleList( PeopleList(
@@ -126,7 +123,7 @@ class FollowListsState(
.map { it.toUI() } .map { it.toUI() }
.onStart { emit(latestLists.value.toUI()) } .onStart { emit(latestLists.value.toUI()) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) .stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<PeopleList>.select(dTag: String) = fun List<PeopleList>.select(dTag: String) =
this.firstOrNull { this.firstOrNull {
@@ -142,7 +139,7 @@ class FollowListsState(
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) 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<Note>): Boolean = fun hasItemInNoteList(notes: Set<Note>): Boolean =
notes.anyNotNullEvent { event -> notes.anyNotNullEvent { event ->
@@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent 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.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description import com.vitorpamplona.quartz.nip51Lists.peopleList.description
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -41,10 +41,10 @@ class WssDataStreamCollector : WebSocketListener() {
reason: String, reason: String,
) { ) {
super.onClosing(webSocket, code, reason) 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() fun getNextStream(): ByteString = wssData.pollFirst()
} }
@@ -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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.collections.component1
import kotlin.collections.component2
class UserCardsSubAssembler( class UserCardsSubAssembler(
client: INostrClient, client: INostrClient,
@@ -23,10 +23,14 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import android.Manifest import android.Manifest
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.google.accompanist.permissions.ExperimentalPermissionsApi 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.R
import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun RecordAudioBox( fun RecordAudioBox(
modifier: Modifier, modifier: Modifier,
onRecordTaken: (RecordingResult) -> Unit, onRecordTaken: (RecordingResult) -> Unit,
content: @Composable (Boolean) -> Unit, content: @Composable (Boolean, Int) -> Unit,
) { ) {
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) } val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
val context = LocalContext.current 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( ClickAndHoldBoxComposable(
modifier = modifier, modifier = modifier,
onPress = { onPress = {
val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) wantsToRecord = true
val scope = rememberCoroutineScope() if (!recordPermissionState.status.isGranted) {
LaunchedEffect(Unit) { recordPermissionState.launchPermissionRequest()
if (!recordPermissionState.status.isGranted) { } else {
recordPermissionState.launchPermissionRequest() // Start immediately for responsive UX when permission already granted
} else { startRecording()
mediaRecorder.value = VoiceMessageRecorder()
mediaRecorder.value?.start(context, scope)
}
} }
}, },
onRelease = { onRelease = {
wantsToRecord = false
val result = mediaRecorder.value?.stop() val result = mediaRecorder.value?.stop()
mediaRecorder.value = null
if (result != null) { if (result != null) {
onRecordTaken(result) onRecordTaken(result)
} else { } else {
@@ -75,8 +124,10 @@ fun RecordAudioBox(
} }
}, },
onCancel = { onCancel = {
wantsToRecord = false
mediaRecorder.value?.stop() mediaRecorder.value?.stop()
mediaRecorder.value = null
}, },
content, content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
) )
} }
@@ -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),
)
}
}
@@ -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,
)
}
}
}
@@ -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)
}
}
@@ -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<MediaPlayer?>(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
}
@@ -24,11 +24,16 @@ import android.content.Context
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -40,30 +45,44 @@ class RecordingResult(
) )
class VoiceMessageRecorder { class VoiceMessageRecorder {
@Volatile
private var recorder: MediaRecorder? = null private var recorder: MediaRecorder? = null
private var outputFile: File? = null private var outputFile: File? = null
private var startTime: Long = 0 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<Float> = mutableListOf() private var amplitudes: MutableList<Float> = mutableListOf()
private fun createRecorder(context: Context): MediaRecorder = private fun createRecorder(context: Context): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context) MediaRecorder(context.applicationContext)
} else { } else {
MediaRecorder() MediaRecorder()
} }
suspend fun start( @Synchronized
fun start(
context: Context, context: Context,
scope: CoroutineScope, parentScope: CoroutineScope,
) { ) {
// Clean up any existing recording first
cleanup()
val fileName = RandomInstance.randomChars(16) + ".mp4" val fileName = RandomInstance.randomChars(16) + ".mp4"
val outputFile = File(context.cacheDir, "/voice/$fileName") val outputFile = File(context.cacheDir, "voice/$fileName")
outputFile.parentFile?.mkdirs() outputFile.parentFile?.mkdirs()
this.outputFile = outputFile this.outputFile = outputFile
this.startTime = TimeUtils.now() this.startTime = TimeUtils.now()
this.amplitudes.clear() 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 { createRecorder(context).apply {
setAudioEncodingBitRate(16 * 44100) setAudioEncodingBitRate(16 * 44100)
setAudioSamplingRate(44100) // Set the desired audio sampling rate (e.g., 44.1 kHz) setAudioSamplingRate(44100) // Set the desired audio sampling rate (e.g., 44.1 kHz)
@@ -78,31 +97,103 @@ class VoiceMessageRecorder {
recorder = this recorder = this
} }
job?.cancel() // Launch amplitude sampling in our own scope
job = amplitudeSamplingJob =
scope.launch { recorderScope?.launch {
while (recorder != null) { while (isActive) {
amplitudes.add(recorder?.maxAmplitude?.toFloat() ?: 0f) 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) delay(1000)
} }
} }
} }
suspend fun stop(): RecordingResult? { @Synchronized
recorder?.stop() fun stop(): RecordingResult? {
recorder?.reset() if (recorder == null) {
recorder = null cleanup()
return null
}
val currentTime = TimeUtils.now() val currentTime = TimeUtils.now()
val file = outputFile 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( RecordingResult(
file, file,
MimeTypes.AUDIO_AAC, MimeTypes.AUDIO_AAC,
amplitudes, amplitudesCopy,
(currentTime - startTime).toInt(), duration,
) )
} else { } else {
null 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()
}
}
} }
@@ -115,7 +115,7 @@ fun ClickAndHoldBox(
@Composable @Composable
fun ClickAndHoldBoxComposable( fun ClickAndHoldBoxComposable(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onPress: @Composable () -> Unit, onPress: () -> Unit,
onRelease: suspend () -> Unit, onRelease: suspend () -> Unit,
onCancel: suspend () -> Unit, onCancel: suspend () -> Unit,
content: @Composable (Boolean) -> Unit, content: @Composable (Boolean) -> Unit,
@@ -123,15 +123,16 @@ fun ClickAndHoldBoxComposable(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var isPressed by remember { mutableStateOf(false) } var isPressed by remember { mutableStateOf(false) }
if (isPressed) {
onPress()
}
LaunchedEffect(interactionSource) { LaunchedEffect(interactionSource) {
val pressInteractions = mutableListOf<PressInteraction.Press>() val pressInteractions = mutableListOf<PressInteraction.Press>()
interactionSource.interactions.collect { interaction -> interactionSource.interactions.collect { interaction ->
when (interaction) { when (interaction) {
is PressInteraction.Press -> pressInteractions.add(interaction) is PressInteraction.Press -> {
if (pressInteractions.isEmpty()) {
onPress()
}
pressInteractions.add(interaction)
}
is PressInteraction.Release -> { is PressInteraction.Release -> {
onRelease() onRelease()
pressInteractions.remove(interaction.press) pressInteractions.remove(interaction.press)
@@ -20,10 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.layouts.listItem package com.vitorpamplona.amethyst.ui.layouts.listItem
import android.view.Surface
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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
@@ -41,7 +39,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.graphics.Color import androidx.compose.ui.graphics.Color
@@ -111,13 +108,13 @@ fun ChannelNamePreview() {
SlimListItem( SlimListItem(
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = CenterVertically) {
Text("This is my author", Modifier.weight(1f)) Text("This is my author", Modifier.weight(1f))
TimeAgo(TimeUtils.now()) TimeAgo(TimeUtils.now())
} }
}, },
supportingContent = { supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = CenterVertically) {
Text("This is a message from this person", Modifier.weight(1f)) Text("This is a message from this person", Modifier.weight(1f))
Spacer(modifier = Height4dpModifier) Spacer(modifier = Height4dpModifier)
NewItemsBubble() NewItemsBubble()
@@ -137,13 +134,13 @@ fun ChannelNamePreview() {
ListItem( ListItem(
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = CenterVertically) {
Text("This is my author", Modifier.weight(1f)) Text("This is my author", Modifier.weight(1f))
TimeAgo(TimeUtils.now()) TimeAgo(TimeUtils.now())
} }
}, },
supportingContent = { supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = CenterVertically) {
Text("This is a message from this person", Modifier.weight(1f)) Text("This is a message from this person", Modifier.weight(1f))
Spacer(modifier = Height4dpModifier) Spacer(modifier = Height4dpModifier)
NewItemsBubble() NewItemsBubble()
@@ -509,7 +506,7 @@ private class ListItemMeasurePolicy : MultiContentMeasurePolicy {
} }
} }
private fun IntrinsicMeasureScope.calculateWidth( private fun calculateWidth(
leadingWidth: Int, leadingWidth: Int,
trailingWidth: Int, trailingWidth: Int,
headlineWidth: Int, headlineWidth: Int,
@@ -646,7 +643,7 @@ private value class ListItemType private constructor(
/** Three line list item */ /** Three line list item */
val ThreeLine = ListItemType(3) val ThreeLine = ListItemType(3)
internal operator fun invoke( operator fun invoke(
hasOverline: Boolean, hasOverline: Boolean,
hasSupporting: Boolean, hasSupporting: Boolean,
isSupportingMultiline: Boolean, isSupportingMultiline: Boolean,
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.navigation.drawer package com.vitorpamplona.amethyst.ui.navigation.drawer
import android.R.attr.fontWeight
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.drawerSpacing
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier 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.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.toRoute 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.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -609,7 +609,7 @@ fun ReplyViaVoiceReaction(
onRecordTaken = { audio -> onRecordTaken = { audio ->
accountViewModel.sendVoiceReply(baseNote, audio, context) accountViewModel.sendVoiceReply(baseNote, audio, context)
}, },
) { ) { _, _ ->
VoiceReplyIcon(iconSizeModifier, grayTint) VoiceReplyIcon(iconSizeModifier, grayTint)
} }
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.types package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlin.collections.emptyList
@Stable @Stable
class BookmarkGroupViewModel( class BookmarkGroupViewModel(
@@ -37,7 +37,6 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import kotlin.toString
@Composable @Composable
fun NewBookmarkGroupCreationDialog( fun NewBookmarkGroupCreationDialog(
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -24,7 +24,10 @@ 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
@@ -35,32 +38,47 @@ 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.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.ServerType 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.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.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
@@ -93,6 +111,7 @@ 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
@@ -260,18 +279,21 @@ private fun NewPostScreenBody(
} }
} }
Row( // Only show text input if no voice message is being posted
modifier = Modifier.padding(vertical = Size10dp), if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
) { Row(
BaseUserPicture( modifier = Modifier.padding(vertical = Size10dp),
accountViewModel.userProfile(), ) {
Size35dp, BaseUserPicture(
accountViewModel = accountViewModel, accountViewModel.userProfile(),
) Size35dp,
MessageField( accountViewModel = accountViewModel,
R.string.what_s_on_your_mind, )
postViewModel, MessageField(
) R.string.what_s_on_your_mind,
postViewModel,
)
}
} }
if (postViewModel.wantsPoll) { 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) { if (postViewModel.wantsInvoice) {
postViewModel.lnAddress()?.let { lud16 -> postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest( InvoiceRequest(
@@ -440,6 +514,12 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
}, },
) )
RecordVoiceButton(
onVoiceTaken = { recording ->
postViewModel.selectVoiceRecording(recording)
},
)
if (postViewModel.canUsePoll) { if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future. // These should be hashtag recommendations the user selects in the future.
// val hashtag = stringRes(R.string.poll_hashtag) // 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,
)
}
}
}
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.service.location.LocationState 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.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator 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.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState 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.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.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -177,6 +184,14 @@ open class ShortNotePostViewModel :
// Images and Videos // Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null) var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Voice Messages
var voiceRecording by mutableStateOf<RecordingResult?>(null)
var voiceLocalFile by mutableStateOf<java.io.File?>(null)
var isUploadingVoice by mutableStateOf(false)
var voiceMetadata by mutableStateOf<AudioMeta?>(null)
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(null)
// Polls // Polls
var canUsePoll by mutableStateOf(false) var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false)
@@ -220,7 +235,7 @@ open class ShortNotePostViewModel :
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
fun user(): User? = account.userProfile() fun user(): User = account.userProfile()
open fun init(accountVM: AccountViewModel) { open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM this.accountViewModel = accountVM
@@ -466,6 +481,24 @@ open class ShortNotePostViewModel :
} }
suspend fun sendPostSync() { 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 template = createTemplate() ?: return
val extraNotesToBroadcast = mutableListOf<Event>() val extraNotesToBroadcast = mutableListOf<Event>()
@@ -504,6 +537,24 @@ open class ShortNotePostViewModel :
} }
private suspend fun createTemplate(): EventTemplate<out Event>? { private suspend fun createTemplate(): EventTemplate<out Event>? {
// Check if this is a voice message
voiceMetadata?.let { audioMeta ->
// Only create voice reply if original note is also a VoiceEvent
val originalVoiceHint = originalNote?.toEventHint<VoiceEvent>()
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 = val tagger =
NewMessageTagger( NewMessageTagger(
message.text, message.text,
@@ -715,6 +766,13 @@ open class ShortNotePostViewModel :
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false isUploadingImage = false
deleteVoiceLocalFile()
voiceRecording = null
voiceLocalFile = null
isUploadingVoice = false
voiceMetadata = null
voiceSelectedServer = null
voiceOrchestrator = null
pTags = null pTags = null
wantsPoll = false wantsPoll = false
@@ -833,9 +891,16 @@ open class ShortNotePostViewModel :
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, "")) private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
fun canPost(): Boolean = fun canPost(): Boolean {
message.text.isNotBlank() && // 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 && !isUploadingImage &&
!isUploadingVoice &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) && (!wantsZapRaiser || zapRaiserAmount.value != null) &&
( (
@@ -847,6 +912,7 @@ open class ShortNotePostViewModel :
) )
) && ) &&
multiOrchestrator == null multiOrchestrator == null
}
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message = message.insertUrlAtCursor(newElement)
@@ -856,6 +922,121 @@ open class ShortNotePostViewModel :
multiOrchestrator = MultiOrchestrator(uris) 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<LocationState.LocationResult> { override fun locationFlow(): StateFlow<LocationState.LocationResult> {
if (location == null) { if (location == null) {
location = locationManager().geohashStateFlow location = locationManager().geohashStateFlow
@@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
private const val IDENTITY_ICON_CACHE_KEY = 0
@Composable @Composable
fun DrawAdditionalInfo( fun DrawAdditionalInfo(
baseUser: User, baseUser: User,
@@ -192,7 +193,7 @@ fun DrawAdditionalInfo(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
tint = Color.Unspecified, tint = Color.Unspecified,
painter = painterRes(resourceId = getIdentityClaimIcon(identity), getIdentityClaimIconReference(identity)), painter = painterRes(resourceId = getIdentityClaimIcon(identity), IDENTITY_ICON_CACHE_KEY),
contentDescription = stringRes(getIdentityClaimDescription(identity)), contentDescription = stringRes(getIdentityClaimDescription(identity)),
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
@@ -247,14 +248,5 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int =
is TelegramIdentity -> R.string.telegram is TelegramIdentity -> R.string.telegram
is MastodonIdentity -> R.string.mastodon is MastodonIdentity -> R.string.mastodon
is GitHubIdentity -> R.string.github is GitHubIdentity -> R.string.github
else -> R.drawable.github else -> R.string.github
}
fun getIdentityClaimIconReference(identity: IdentityClaimTag): Int =
when (identity) {
is TwitterIdentity -> 0
is TelegramIdentity -> 0
is MastodonIdentity -> 0
is GitHubIdentity -> 0
else -> 0
} }
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Nezákonné jednání</string> <string name="illegal_behavior">Nezákonné jednání</string>
<string name="other">Jiný</string> <string name="other">Jiný</string>
<string name="harassment">Obtěžování</string> <string name="harassment">Obtěžování</string>
<string name="violence">Násilí</string>
<string name="unknown">Neznámý</string> <string name="unknown">Neznámý</string>
<string name="relay_icon">Ikona předávání</string> <string name="relay_icon">Ikona předávání</string>
<string name="unknown_author">Neznámý autor</string> <string name="unknown_author">Neznámý autor</string>
@@ -157,7 +158,14 @@
<string name="record_a_message">Nahrajte zprávu</string> <string name="record_a_message">Nahrajte zprávu</string>
<string name="record_a_message_title">Nahrajte zprávu</string> <string name="record_a_message_title">Nahrajte zprávu</string>
<string name="record_a_message_description">Stiskněte a podržte pro nahrání zprávy</string> <string name="record_a_message_description">Stiskněte a podržte pro nahrání zprávy</string>
<string name="recording_indicator_description">Nahrávání</string>
<string name="recording_indicator_with_time">Nahrávání %1$s</string>
<string name="uploading">Nahrávání…</string> <string name="uploading">Nahrávání…</string>
<string name="upload_error_title">Chyba nahrávání</string>
<string name="upload_error_voice_message_failed">Nepodařilo se nahrát hlasovou zprávu</string>
<string name="upload_error_voice_message_unexpected_state">Neočekávaný stav nahrávání</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 zatím není pro hlasové zprávy podporován</string>
<string name="upload_error_voice_message_exception">Nahrávání hlasu selhalo: %1$s</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel nemá nastavenou LN adresu pro přijímání sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel nemá nastavenou LN adresu pro přijímání sats</string>
<string name="reply_here">"Odpověď zde…"</string> <string name="reply_here">"Odpověď zde…"</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Zkopíruje ID poznámky do schránky pro sdílení</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Zkopíruje ID poznámky do schránky pro sdílení</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Illegales Verhalten</string> <string name="illegal_behavior">Illegales Verhalten</string>
<string name="other">Andere</string> <string name="other">Andere</string>
<string name="harassment">Belästigung</string> <string name="harassment">Belästigung</string>
<string name="violence">Gewalt</string>
<string name="unknown">Unbekannt</string> <string name="unknown">Unbekannt</string>
<string name="relay_icon">Relay-Symbol</string> <string name="relay_icon">Relay-Symbol</string>
<string name="unknown_author">Unbekannter Autor</string> <string name="unknown_author">Unbekannter Autor</string>
@@ -159,7 +160,14 @@ erie gespeichert</string>
<string name="record_a_message">Eine Nachricht aufnehmen</string> <string name="record_a_message">Eine Nachricht aufnehmen</string>
<string name="record_a_message_title">Eine Nachricht aufnehmen</string> <string name="record_a_message_title">Eine Nachricht aufnehmen</string>
<string name="record_a_message_description">Zum Aufnehmen einer Nachricht gedrückt halten</string> <string name="record_a_message_description">Zum Aufnehmen einer Nachricht gedrückt halten</string>
<string name="recording_indicator_description">Aufnahme</string>
<string name="recording_indicator_with_time">Aufnahme %1$s</string>
<string name="uploading">Hochladen…</string> <string name="uploading">Hochladen…</string>
<string name="upload_error_title">Upload-Fehler</string>
<string name="upload_error_voice_message_failed">Sprachnachricht konnte nicht hochgeladen werden</string>
<string name="upload_error_voice_message_unexpected_state">Unerwarteter Upload-Status</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wird für Sprachnachrichten noch nicht unterstützt</string>
<string name="upload_error_voice_message_exception">Sprach-Upload fehlgeschlagen: %1$s</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
<string name="reply_here">"Hier antworten…"</string> <string name="reply_here">"Hier antworten…"</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiert die Notiz-ID zum Teilen in die Zwischenablage</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiert die Notiz-ID zum Teilen in die Zwischenablage</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">अवैध बरताव</string> <string name="illegal_behavior">अवैध बरताव</string>
<string name="other">अन्य</string> <string name="other">अन्य</string>
<string name="harassment">उत्पीडन</string> <string name="harassment">उत्पीडन</string>
<string name="violence">हिंसा</string>
<string name="unknown">अज्ञात</string> <string name="unknown">अज्ञात</string>
<string name="relay_icon">पनःप्रसारक चिह्न</string> <string name="relay_icon">पनःप्रसारक चिह्न</string>
<string name="unknown_author">अज्ञात लेखक</string> <string name="unknown_author">अज्ञात लेखक</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Antyspołeczne zachowanie</string> <string name="illegal_behavior">Antyspołeczne zachowanie</string>
<string name="other">Inne</string> <string name="other">Inne</string>
<string name="harassment">Nękanie</string> <string name="harassment">Nękanie</string>
<string name="violence">Przemoc</string>
<string name="unknown">Nieznani</string> <string name="unknown">Nieznani</string>
<string name="relay_icon">Ikona transmitera</string> <string name="relay_icon">Ikona transmitera</string>
<string name="unknown_author">Autor nieznany</string> <string name="unknown_author">Autor nieznany</string>
@@ -60,8 +61,8 @@
<string name="signer_not_found_exception_description">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.</string> <string name="signer_not_found_exception_description">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.</string>
<string name="zaps">Zapy</string> <string name="zaps">Zapy</string>
<string name="view_count">Liczba wyświetleń</string> <string name="view_count">Liczba wyświetleń</string>
<string name="boost">Promuj</string> <string name="boost">Powtórz</string>
<string name="boosted">promowany</string> <string name="boosted">powtórzony</string>
<string name="edited">edytowano</string> <string name="edited">edytowano</string>
<string name="edited_number">edytuj #%1$s</string> <string name="edited_number">edytuj #%1$s</string>
<string name="original">oryginalny</string> <string name="original">oryginalny</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Comportamento ilegal</string> <string name="illegal_behavior">Comportamento ilegal</string>
<string name="other">Outro</string> <string name="other">Outro</string>
<string name="harassment">Assédio</string> <string name="harassment">Assédio</string>
<string name="violence">Violência</string>
<string name="unknown">Desconhecido</string> <string name="unknown">Desconhecido</string>
<string name="relay_icon">Ícone do relay</string> <string name="relay_icon">Ícone do relay</string>
<string name="unknown_author">Autor desconhecido</string> <string name="unknown_author">Autor desconhecido</string>
@@ -157,7 +158,14 @@
<string name="record_a_message">Gravar uma mensagem</string> <string name="record_a_message">Gravar uma mensagem</string>
<string name="record_a_message_title">Gravar uma mensagem</string> <string name="record_a_message_title">Gravar uma mensagem</string>
<string name="record_a_message_description">Clique e segure para gravar uma mensagem</string> <string name="record_a_message_description">Clique e segure para gravar uma mensagem</string>
<string name="recording_indicator_description">Gravando</string>
<string name="recording_indicator_with_time">Gravando %1$s</string>
<string name="uploading">Enviando…</string> <string name="uploading">Enviando…</string>
<string name="upload_error_title">Erro de upload</string>
<string name="upload_error_voice_message_failed">Falha ao enviar mensagem de voz</string>
<string name="upload_error_voice_message_unexpected_state">Estado de upload inesperado</string>
<string name="upload_error_voice_message_nip95_not_supported">O NIP-95 ainda não é suportado para mensagens de voz</string>
<string name="upload_error_voice_message_exception">Falha no upload de voz: %1$s</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
<string name="reply_here">"responda aqui.. "</string> <string name="reply_here">"responda aqui.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copia o ID do canal (note) para compartilhar</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Copia o ID do canal (note) para compartilhar</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Nedovoljeno vedenje</string> <string name="illegal_behavior">Nedovoljeno vedenje</string>
<string name="other">Drugo</string> <string name="other">Drugo</string>
<string name="harassment">Nadlegovanje</string> <string name="harassment">Nadlegovanje</string>
<string name="violence">Nasilje</string>
<string name="unknown">Nepoznano</string> <string name="unknown">Nepoznano</string>
<string name="relay_icon">Ikona releja</string> <string name="relay_icon">Ikona releja</string>
<string name="unknown_author">Avtor neznan</string> <string name="unknown_author">Avtor neznan</string>
@@ -41,7 +42,7 @@
<string name="report_malware">Zlonamerna programska vsebina</string> <string name="report_malware">Zlonamerna programska vsebina</string>
<string name="report_mod">Prijavi moderatorja</string> <string name="report_mod">Prijavi moderatorja</string>
<string name="malware">Zlonamerna programska vsebina</string> <string name="malware">Zlonamerna programska vsebina</string>
<string name="mod">Administrator</string> <string name="mod">Moderator</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Uporabljaš javni ključ in javni ključi omogočajo le branje. <string name="login_with_a_private_key_to_be_able_to_reply">Uporabljaš javni ključ in javni ključi omogočajo le branje.
Prijavi se s privatnim ključem, da omogočiš tudi pisanje</string> Prijavi se s privatnim ključem, da omogočiš tudi pisanje</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Uporabljaš javni ključ in javni ključi omogočajo le branje. <string name="login_with_a_private_key_to_be_able_to_boost_posts">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</string>
<string name="banner_url">URL pasice</string> <string name="banner_url">URL pasice</string>
<string name="website_url">URL spletne strani</string> <string name="website_url">URL spletne strani</string>
<string name="pronouns">Zaimki</string> <string name="pronouns">Zaimki</string>
<string name="ln_address">LN Naslov</string> <string name="ln_address">Lightning naslov (LUD-16)</string>
<string name="ln_url_outdated">Zastarel LN (lightning) URL</string> <string name="ln_url_outdated">Zastarel lightning naslov (LUD-06)</string>
<string name="save_to_gallery">Shrani v galerijo</string> <string name="save_to_gallery">Shrani v galerijo</string>
<string name="image_saved_to_the_gallery">Slika shranjena v foto galerijo telefona</string> <string name="image_saved_to_the_gallery">Slika shranjena v foto galerijo telefona</string>
<string name="video_download_has_started_toast">Prenos videa se je začel…</string> <string name="video_download_has_started_toast">Prenos videa se je začel…</string>
@@ -401,6 +402,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="private_bookmark_add_action_label">Dodaj kot zasebni zaznamek</string> <string name="private_bookmark_add_action_label">Dodaj kot zasebni zaznamek</string>
<string name="bookmark_remove_action_label">Odstrani iz seznama zaznamkov</string> <string name="bookmark_remove_action_label">Odstrani iz seznama zaznamkov</string>
<string name="bookmark_list_explainer">Metapodatki seznama zaznamkov so vidni vsem na Nostru. Le tvoji zasebni člani so šifrirani.</string> <string name="bookmark_list_explainer">Metapodatki seznama zaznamkov so vidni vsem na Nostru. Le tvoji zasebni člani so šifrirani.</string>
<string name="move_bookmark_to_public_label">Premakni v javno</string>
<string name="move_bookmark_to_private_label">Premakni v zasebno</string>
<string name="wallet_connect_service">Storitev Wallet Connect</string> <string name="wallet_connect_service">Storitev Wallet Connect</string>
<string name="wallet_connect_service_explainer">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</string> <string name="wallet_connect_service_explainer">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</string>
<string name="wallet_connect_service_pubkey">Wallet Connect javni ključ</string> <string name="wallet_connect_service_pubkey">Wallet Connect javni ključ</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Olagligt beteende</string> <string name="illegal_behavior">Olagligt beteende</string>
<string name="other">Annat</string> <string name="other">Annat</string>
<string name="harassment">Trakasseri</string> <string name="harassment">Trakasseri</string>
<string name="violence">Våld</string>
<string name="unknown">Okänd</string> <string name="unknown">Okänd</string>
<string name="relay_icon">Relä ikon</string> <string name="relay_icon">Relä ikon</string>
<string name="unknown_author">Okänd användare</string> <string name="unknown_author">Okänd användare</string>
@@ -157,7 +158,14 @@
<string name="record_a_message">Spela in ett meddelande</string> <string name="record_a_message">Spela in ett meddelande</string>
<string name="record_a_message_title">Spela in ett meddelande</string> <string name="record_a_message_title">Spela in ett meddelande</string>
<string name="record_a_message_description">Tryck och håll in för att spela in ett meddelande</string> <string name="record_a_message_description">Tryck och håll in för att spela in ett meddelande</string>
<string name="recording_indicator_description">Spelar in</string>
<string name="recording_indicator_with_time">Spelar in %1$s</string>
<string name="uploading">Laddar upp…</string> <string name="uploading">Laddar upp…</string>
<string name="upload_error_title">Uppladdningsfel</string>
<string name="upload_error_voice_message_failed">Misslyckades med att ladda upp röstmeddelandet</string>
<string name="upload_error_voice_message_unexpected_state">Oväntat uppladdningstillstånd</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 stöds ännu inte för röstmeddelanden</string>
<string name="upload_error_voice_message_exception">Uppladdning av röst misslyckades: %1$s</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
<string name="reply_here">"svara här.. "</string> <string name="reply_here">"svara här.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopierar antecknings-ID till urklipp för delning</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopierar antecknings-ID till urklipp för delning</string>
+7
View File
@@ -170,7 +170,14 @@
<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="recording_indicator_description">Recording</string>
<string name="recording_indicator_with_time">Recording %1$s</string>
<string name="uploading">Uploading…</string> <string name="uploading">Uploading…</string>
<string name="upload_error_title">Upload Error</string>
<string name="upload_error_voice_message_failed">Failed to upload voice message</string>
<string name="upload_error_voice_message_unexpected_state">Unexpected upload state</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 is not supported for voice messages yet</string>
<string name="upload_error_voice_message_exception">Voice upload failed: %1$s</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string>
<string name="reply_here">"reply here.. "</string> <string name="reply_here">"reply here.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string>
+18 -3
View File
@@ -74,6 +74,13 @@ kotlin {
// https://developer.android.com/kotlin/multiplatform/migrate // https://developer.android.com/kotlin/multiplatform/migrate
val xcfName = "quartz-kmpKit" val xcfName = "quartz-kmpKit"
iosX64 {
binaries.framework {
baseName = xcfName
}
}
iosArm64 { iosArm64 {
binaries.framework { binaries.framework {
baseName = xcfName baseName = xcfName
@@ -208,6 +215,10 @@ kotlin {
} }
} }
val iosX64Main by getting {
dependsOn(iosMain.get()) // iosX64Main depends on iosMain
}
val iosArm64Main by getting { val iosArm64Main by getting {
dependsOn(iosMain.get()) // iosArm64Main depends on iosMain 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 { val iosArm64Test by getting {
dependsOn(iosTest.get()) // iosArm64Main depends on iosMain dependsOn(iosTest.get()) // iosArm64Test depends on iosTest
} }
val iosSimulatorArm64Test by getting { 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" connection = "https://github.com/vitorpamplona/amethyst/.git"
} }
} }
} }
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.quartz.experimental.trustedAssertions.list 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.experimental.trustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArray