Merge
This commit is contained in:
@@ -249,7 +249,9 @@ object LocalPreferences {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
if (!File(prefsDir, it).delete()) {
|
||||
Log.w("LocalPreferences", "Failed to delete preference file: $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
@@ -602,7 +601,7 @@ class Account(
|
||||
url: String,
|
||||
method: String,
|
||||
body: ByteArray? = null,
|
||||
): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
|
||||
): HTTPAuthorizationEvent = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
|
||||
|
||||
suspend fun createBlossomUploadAuth(
|
||||
hash: HexKey,
|
||||
|
||||
@@ -43,7 +43,6 @@ import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
import kotlin.plus
|
||||
|
||||
interface UserDependencies
|
||||
|
||||
@@ -219,7 +218,7 @@ class User(
|
||||
|
||||
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
|
||||
|
||||
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
|
||||
fun reportsOrNull(): UserReportCache? = reports
|
||||
|
||||
|
||||
+5
-8
@@ -43,9 +43,6 @@ import com.vitorpamplona.quartz.nip51Lists.followList.personFirst
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.title
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
|
||||
import com.vitorpamplona.quartz.utils.flattenToSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -83,14 +80,14 @@ class FollowListsState(
|
||||
.map { existingPeopleListNotes() }
|
||||
.onStart { emit(existingPeopleListNotes()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val followListsEventIds =
|
||||
followListNotes
|
||||
.map { it.eventIdSet() }
|
||||
.onStart { emit(followListNotes.value.eventIdSet()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptySet())
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val latestLists: StateFlow<List<FollowListEvent>> =
|
||||
@@ -107,7 +104,7 @@ class FollowListsState(
|
||||
.map { it.mapToUserIdSet() }
|
||||
.onStart { emit(latestLists.value.mapToUserIdSet()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptySet())
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
fun FollowListEvent.toUI() =
|
||||
PeopleList(
|
||||
@@ -126,7 +123,7 @@ class FollowListsState(
|
||||
.map { it.toUI() }
|
||||
.onStart { emit(latestLists.value.toUI()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun List<PeopleList>.select(dTag: String) =
|
||||
this.firstOrNull {
|
||||
@@ -142,7 +139,7 @@ class FollowListsState(
|
||||
|
||||
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
|
||||
|
||||
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value)
|
||||
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.KIND) || deletesAnyEventIn(followListsEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.update
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
|
||||
|
||||
-1
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
+2
-2
@@ -41,10 +41,10 @@ class WssDataStreamCollector : WebSocketListener() {
|
||||
reason: String,
|
||||
) {
|
||||
super.onClosing(webSocket, code, reason)
|
||||
wssData.removeAll(wssData)
|
||||
wssData.clear()
|
||||
}
|
||||
|
||||
fun canStream(): Boolean = wssData.size > 0
|
||||
fun canStream(): Boolean = wssData.isNotEmpty()
|
||||
|
||||
fun getNextStream(): ByteString = wssData.pollFirst()
|
||||
}
|
||||
|
||||
-2
@@ -33,8 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
|
||||
class UserCardsSubAssembler(
|
||||
client: INostrClient,
|
||||
|
||||
+60
-9
@@ -23,10 +23,14 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
@@ -35,33 +39,78 @@ import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun RecordAudioBox(
|
||||
modifier: Modifier,
|
||||
onRecordTaken: (RecordingResult) -> Unit,
|
||||
content: @Composable (Boolean) -> Unit,
|
||||
content: @Composable (Boolean, Int) -> Unit,
|
||||
) {
|
||||
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
|
||||
val context = LocalContext.current
|
||||
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||
var wantsToRecord by remember { mutableStateOf(false) }
|
||||
|
||||
ClickAndHoldBoxComposable(
|
||||
modifier = modifier,
|
||||
onPress = {
|
||||
// Must be called at Composable scope, not in callback
|
||||
val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
if (!recordPermissionState.status.isGranted) {
|
||||
recordPermissionState.launchPermissionRequest()
|
||||
} else {
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
wantsToRecord = false
|
||||
mediaRecorder.value?.stop()
|
||||
mediaRecorder.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
if (mediaRecorder.value == null) {
|
||||
elapsedSeconds = 0
|
||||
mediaRecorder.value = VoiceMessageRecorder()
|
||||
mediaRecorder.value?.start(context, scope)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) {
|
||||
if (recordPermissionState.status.isGranted && wantsToRecord) {
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
// Track elapsed time while recording
|
||||
LaunchedEffect(mediaRecorder.value) {
|
||||
// Capture the current recorder state to avoid repeated reads of volatile state
|
||||
val currentRecorder = mediaRecorder.value
|
||||
if (currentRecorder != null) {
|
||||
// Loop while coroutine is active - LaunchedEffect will cancel when mediaRecorder.value changes
|
||||
while (isActive) {
|
||||
delay(1000)
|
||||
elapsedSeconds++
|
||||
}
|
||||
} else {
|
||||
// Reset elapsed time when not recording
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
ClickAndHoldBoxComposable(
|
||||
modifier = modifier,
|
||||
onPress = {
|
||||
wantsToRecord = true
|
||||
if (!recordPermissionState.status.isGranted) {
|
||||
recordPermissionState.launchPermissionRequest()
|
||||
} else {
|
||||
// Start immediately for responsive UX when permission already granted
|
||||
startRecording()
|
||||
}
|
||||
},
|
||||
onRelease = {
|
||||
wantsToRecord = false
|
||||
val result = mediaRecorder.value?.stop()
|
||||
mediaRecorder.value = null
|
||||
if (result != null) {
|
||||
onRecordTaken(result)
|
||||
} else {
|
||||
@@ -75,8 +124,10 @@ fun RecordAudioBox(
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
wantsToRecord = false
|
||||
mediaRecorder.value?.stop()
|
||||
mediaRecorder.value = null
|
||||
},
|
||||
content,
|
||||
content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
|
||||
)
|
||||
}
|
||||
|
||||
+106
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+231
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+259
@@ -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
|
||||
}
|
||||
+108
-17
@@ -24,11 +24,16 @@ import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import androidx.media3.common.MimeTypes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@@ -40,30 +45,44 @@ class RecordingResult(
|
||||
)
|
||||
|
||||
class VoiceMessageRecorder {
|
||||
@Volatile
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
private var startTime: Long = 0
|
||||
private var job: Job? = null
|
||||
|
||||
// Own scope to manage lifecycle independently from caller
|
||||
private var recorderScope: CoroutineScope? = null
|
||||
|
||||
@Volatile
|
||||
private var amplitudeSamplingJob: Job? = null
|
||||
private var amplitudes: MutableList<Float> = mutableListOf()
|
||||
|
||||
private fun createRecorder(context: Context): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
MediaRecorder(context.applicationContext)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
suspend fun start(
|
||||
@Synchronized
|
||||
fun start(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
parentScope: CoroutineScope,
|
||||
) {
|
||||
// Clean up any existing recording first
|
||||
cleanup()
|
||||
|
||||
val fileName = RandomInstance.randomChars(16) + ".mp4"
|
||||
val outputFile = File(context.cacheDir, "/voice/$fileName")
|
||||
val outputFile = File(context.cacheDir, "voice/$fileName")
|
||||
outputFile.parentFile?.mkdirs()
|
||||
this.outputFile = outputFile
|
||||
this.startTime = TimeUtils.now()
|
||||
this.amplitudes.clear()
|
||||
|
||||
// Create own scope with SupervisorJob so failures don't cascade
|
||||
val scopeJob = SupervisorJob(parentScope.coroutineContext[Job])
|
||||
recorderScope = CoroutineScope(Dispatchers.Main.immediate + scopeJob)
|
||||
|
||||
createRecorder(context).apply {
|
||||
setAudioEncodingBitRate(16 * 44100)
|
||||
setAudioSamplingRate(44100) // Set the desired audio sampling rate (e.g., 44.1 kHz)
|
||||
@@ -78,31 +97,103 @@ class VoiceMessageRecorder {
|
||||
recorder = this
|
||||
}
|
||||
|
||||
job?.cancel()
|
||||
job =
|
||||
scope.launch {
|
||||
while (recorder != null) {
|
||||
amplitudes.add(recorder?.maxAmplitude?.toFloat() ?: 0f)
|
||||
// Launch amplitude sampling in our own scope
|
||||
amplitudeSamplingJob =
|
||||
recorderScope?.launch {
|
||||
while (isActive) {
|
||||
val recorderRef = recorder ?: break
|
||||
try {
|
||||
val amplitude = recorderRef.maxAmplitude.toFloat()
|
||||
synchronized(amplitudes) {
|
||||
amplitudes.add(amplitude)
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
// MediaRecorder might be in invalid state, stop sampling
|
||||
Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e)
|
||||
break
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stop(): RecordingResult? {
|
||||
recorder?.stop()
|
||||
recorder?.reset()
|
||||
recorder = null
|
||||
@Synchronized
|
||||
fun stop(): RecordingResult? {
|
||||
if (recorder == null) {
|
||||
cleanup()
|
||||
return null
|
||||
}
|
||||
val currentTime = TimeUtils.now()
|
||||
val file = outputFile
|
||||
return if (currentTime - startTime >= 1 && file != null) {
|
||||
|
||||
// Capture amplitudes before cleanup
|
||||
val amplitudesCopy =
|
||||
synchronized(amplitudes) {
|
||||
amplitudes.toList()
|
||||
}
|
||||
val duration = (currentTime - startTime).toInt()
|
||||
|
||||
// Clean up recorder and scope
|
||||
cleanup()
|
||||
|
||||
return if (duration >= 1 && file != null) {
|
||||
RecordingResult(
|
||||
file,
|
||||
MimeTypes.AUDIO_AAC,
|
||||
amplitudes,
|
||||
(currentTime - startTime).toInt(),
|
||||
amplitudesCopy,
|
||||
duration,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all resources: stops recorder, cancels jobs, cancels scope.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun cleanup() {
|
||||
// Cancel amplitude sampling job
|
||||
amplitudeSamplingJob?.cancel()
|
||||
amplitudeSamplingJob = null
|
||||
|
||||
// Stop any remaining coroutines before touching the recorder
|
||||
recorderScope?.cancel()
|
||||
recorderScope = null
|
||||
|
||||
// Swap local reference so we always null out the volatile field
|
||||
val recorderToRelease = recorder
|
||||
recorder = null
|
||||
|
||||
recorderToRelease?.let { mediaRecorder ->
|
||||
try {
|
||||
mediaRecorder.stop()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder due to illegal state", e)
|
||||
} catch (e: RuntimeException) {
|
||||
// MediaRecorder.stop() can throw RuntimeException if the recording is too short
|
||||
// or if no valid audio data was captured. This is a known Android issue.
|
||||
Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder (recording may be too short or invalid)", e)
|
||||
} finally {
|
||||
try {
|
||||
mediaRecorder.reset()
|
||||
} catch (resetError: Exception) {
|
||||
Log.w("VoiceMessageRecorder", "Failed to reset MediaRecorder before release", resetError)
|
||||
}
|
||||
try {
|
||||
mediaRecorder.release()
|
||||
} catch (releaseError: Exception) {
|
||||
Log.w("VoiceMessageRecorder", "Failed to release MediaRecorder resources", releaseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset transient state so a fresh recording always starts cleanly
|
||||
outputFile = null
|
||||
startTime = 0
|
||||
synchronized(amplitudes) {
|
||||
amplitudes.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ fun ClickAndHoldBox(
|
||||
@Composable
|
||||
fun ClickAndHoldBoxComposable(
|
||||
modifier: Modifier = Modifier,
|
||||
onPress: @Composable () -> Unit,
|
||||
onPress: () -> Unit,
|
||||
onRelease: suspend () -> Unit,
|
||||
onCancel: suspend () -> Unit,
|
||||
content: @Composable (Boolean) -> Unit,
|
||||
@@ -123,15 +123,16 @@ fun ClickAndHoldBoxComposable(
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
|
||||
if (isPressed) {
|
||||
onPress()
|
||||
}
|
||||
|
||||
LaunchedEffect(interactionSource) {
|
||||
val pressInteractions = mutableListOf<PressInteraction.Press>()
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> pressInteractions.add(interaction)
|
||||
is PressInteraction.Press -> {
|
||||
if (pressInteractions.isEmpty()) {
|
||||
onPress()
|
||||
}
|
||||
pressInteractions.add(interaction)
|
||||
}
|
||||
is PressInteraction.Release -> {
|
||||
onRelease()
|
||||
pressInteractions.remove(interaction.press)
|
||||
|
||||
+6
-9
@@ -20,10 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.layouts.listItem
|
||||
|
||||
import android.view.Surface
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -41,7 +39,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -111,13 +108,13 @@ fun ChannelNamePreview() {
|
||||
|
||||
SlimListItem(
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Text("This is my author", Modifier.weight(1f))
|
||||
TimeAgo(TimeUtils.now())
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Text("This is a message from this person", Modifier.weight(1f))
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
NewItemsBubble()
|
||||
@@ -137,13 +134,13 @@ fun ChannelNamePreview() {
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Text("This is my author", Modifier.weight(1f))
|
||||
TimeAgo(TimeUtils.now())
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Text("This is a message from this person", Modifier.weight(1f))
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
NewItemsBubble()
|
||||
@@ -509,7 +506,7 @@ private class ListItemMeasurePolicy : MultiContentMeasurePolicy {
|
||||
}
|
||||
}
|
||||
|
||||
private fun IntrinsicMeasureScope.calculateWidth(
|
||||
private fun calculateWidth(
|
||||
leadingWidth: Int,
|
||||
trailingWidth: Int,
|
||||
headlineWidth: Int,
|
||||
@@ -646,7 +643,7 @@ private value class ListItemType private constructor(
|
||||
/** Three line list item */
|
||||
val ThreeLine = ListItemType(3)
|
||||
|
||||
internal operator fun invoke(
|
||||
operator fun invoke(
|
||||
hasOverline: Boolean,
|
||||
hasSupporting: Boolean,
|
||||
isSupportingMultiline: Boolean,
|
||||
|
||||
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.drawer
|
||||
|
||||
import android.R.attr.fontWeight
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -122,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.drawerSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes.followerCount
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.toRoute
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Community
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@@ -609,7 +609,7 @@ fun ReplyViaVoiceReaction(
|
||||
onRecordTaken = { audio ->
|
||||
accountViewModel.sendVoiceReply(baseNote, audio, context)
|
||||
},
|
||||
) {
|
||||
) { _, _ ->
|
||||
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,10 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
-1
@@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.collections.emptyList
|
||||
|
||||
@Stable
|
||||
class BookmarkGroupViewModel(
|
||||
|
||||
-1
@@ -37,7 +37,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import kotlin.toString
|
||||
|
||||
@Composable
|
||||
fun NewBookmarkGroupCreationDialog(
|
||||
|
||||
-1
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
+133
@@ -24,7 +24,10 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -35,32 +38,47 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -93,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
@@ -260,6 +279,8 @@ private fun NewPostScreenBody(
|
||||
}
|
||||
}
|
||||
|
||||
// Only show text input if no voice message is being posted
|
||||
if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = Size10dp),
|
||||
) {
|
||||
@@ -273,6 +294,7 @@ private fun NewPostScreenBody(
|
||||
postViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
Row(
|
||||
@@ -341,6 +363,58 @@ private fun NewPostScreenBody(
|
||||
}
|
||||
}
|
||||
|
||||
// Show preview for both uploaded messages (voiceMetadata) and pending recordings
|
||||
(postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata ->
|
||||
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||
val fileServersState =
|
||||
accountViewModel.account.serverLists.liveServerList
|
||||
.collectAsState()
|
||||
val fileServers = fileServersState.value
|
||||
|
||||
val fileServerOptions =
|
||||
remember(fileServers) {
|
||||
fileServers
|
||||
.map {
|
||||
if (it.type == ServerType.NIP95) {
|
||||
TitleExplainer(it.name, nip95description)
|
||||
} else {
|
||||
TitleExplainer(it.name, it.baseUrl)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
// Display voice preview or uploading progress
|
||||
postViewModel.voiceOrchestrator?.let { orchestrator ->
|
||||
VoiceUploadingProgress(orchestrator)
|
||||
} ?: run {
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = metadata,
|
||||
localFile = postViewModel.voiceLocalFile,
|
||||
onRemove = { postViewModel.removeVoiceMessage() },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) }
|
||||
?.name
|
||||
?: fileServers[0].name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { postViewModel.voiceSelectedServer = fileServers[it] },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsInvoice) {
|
||||
postViewModel.lnAddress()?.let { lud16 ->
|
||||
InvoiceRequest(
|
||||
@@ -440,6 +514,12 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
},
|
||||
)
|
||||
|
||||
RecordVoiceButton(
|
||||
onVoiceTaken = { recording ->
|
||||
postViewModel.selectVoiceRecording(recording)
|
||||
},
|
||||
)
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringRes(R.string.poll_hashtag)
|
||||
@@ -504,3 +584,56 @@ private fun AddPollButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) {
|
||||
val progressValue = orchestrator.progress.collectAsState().value
|
||||
val progressStatusValue = orchestrator.progressState.collectAsState().value
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(55.dp),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||
) {
|
||||
val animatedProgress =
|
||||
animateFloatAsState(
|
||||
targetValue = progressValue.toFloat(),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
).value
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier =
|
||||
Size55Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
strokeWidth = 5.dp,
|
||||
)
|
||||
|
||||
val txt =
|
||||
when (progressStatusValue) {
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error)
|
||||
}
|
||||
|
||||
Text(
|
||||
txt,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+184
-3
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
@@ -116,6 +120,9 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -177,6 +184,14 @@ open class ShortNotePostViewModel :
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<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
|
||||
var canUsePoll by mutableStateOf(false)
|
||||
var wantsPoll by mutableStateOf(false)
|
||||
@@ -220,7 +235,7 @@ open class ShortNotePostViewModel :
|
||||
|
||||
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
|
||||
|
||||
fun user(): User? = account.userProfile()
|
||||
fun user(): User = account.userProfile()
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
@@ -466,6 +481,24 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
// Upload voice message first if it hasn't been uploaded yet
|
||||
if (voiceRecording != null && voiceMetadata == null) {
|
||||
val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer
|
||||
uploadVoiceMessageSync(
|
||||
serverToUse,
|
||||
{ _, _ -> }, // Error handling is done by checking voiceMetadata below
|
||||
)
|
||||
// Abort if upload failed - don't post without voice data
|
||||
if (voiceMetadata == null) {
|
||||
Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post")
|
||||
return
|
||||
}
|
||||
// Update default server if voice message was successfully uploaded
|
||||
if (voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) {
|
||||
account.settings.changeDefaultFileServer(voiceSelectedServer!!)
|
||||
}
|
||||
}
|
||||
|
||||
val template = createTemplate() ?: return
|
||||
val extraNotesToBroadcast = mutableListOf<Event>()
|
||||
|
||||
@@ -504,6 +537,24 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
// Check if this is a voice message
|
||||
voiceMetadata?.let { audioMeta ->
|
||||
// Only create voice reply if original note is also a VoiceEvent
|
||||
val originalVoiceHint = originalNote?.toEventHint<VoiceEvent>()
|
||||
return if (originalVoiceHint != null) {
|
||||
// Create voice reply event
|
||||
VoiceReplyEvent.build(
|
||||
voiceMessage = audioMeta,
|
||||
replyingTo = originalVoiceHint,
|
||||
)
|
||||
} else {
|
||||
// Create root voice event (no reply or original is not a voice message)
|
||||
VoiceEvent.build(
|
||||
voiceMessage = audioMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message.text,
|
||||
@@ -715,6 +766,13 @@ open class ShortNotePostViewModel :
|
||||
|
||||
multiOrchestrator = null
|
||||
isUploadingImage = false
|
||||
deleteVoiceLocalFile()
|
||||
voiceRecording = null
|
||||
voiceLocalFile = null
|
||||
isUploadingVoice = false
|
||||
voiceMetadata = null
|
||||
voiceSelectedServer = null
|
||||
voiceOrchestrator = null
|
||||
pTags = null
|
||||
|
||||
wantsPoll = false
|
||||
@@ -833,9 +891,16 @@ open class ShortNotePostViewModel :
|
||||
|
||||
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
fun canPost(): Boolean {
|
||||
// Voice messages can be posted without text (with either uploaded or pending recording)
|
||||
if (voiceMetadata != null || voiceRecording != null) {
|
||||
return !isUploadingVoice && !isUploadingImage
|
||||
}
|
||||
|
||||
// Regular text/media posts require text
|
||||
return message.text.isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!isUploadingVoice &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapRaiser || zapRaiserAmount.value != null) &&
|
||||
(
|
||||
@@ -847,6 +912,7 @@ open class ShortNotePostViewModel :
|
||||
)
|
||||
) &&
|
||||
multiOrchestrator == null
|
||||
}
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
@@ -856,6 +922,121 @@ open class ShortNotePostViewModel :
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
}
|
||||
|
||||
fun selectVoiceRecording(recording: RecordingResult) {
|
||||
// Delete any existing temp file before replacing
|
||||
deleteVoiceLocalFile()
|
||||
voiceRecording = recording
|
||||
voiceLocalFile = recording.file
|
||||
}
|
||||
|
||||
fun getVoicePreviewMetadata(): AudioMeta? =
|
||||
voiceRecording?.let { recording ->
|
||||
AudioMeta(
|
||||
url = "", // Empty URL for preview (local file will be used)
|
||||
mimeType = recording.mimeType,
|
||||
duration = recording.duration,
|
||||
waveform = recording.amplitudes,
|
||||
)
|
||||
}
|
||||
|
||||
fun removeVoiceMessage() {
|
||||
deleteVoiceLocalFile()
|
||||
voiceRecording = null
|
||||
voiceLocalFile = null
|
||||
voiceMetadata = null
|
||||
voiceSelectedServer = null
|
||||
isUploadingVoice = false
|
||||
voiceOrchestrator = null
|
||||
}
|
||||
|
||||
private fun deleteVoiceLocalFile() {
|
||||
voiceLocalFile?.let { file ->
|
||||
try {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadVoiceMessageSync(
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
) {
|
||||
val recording = voiceRecording ?: return
|
||||
val appContext = Amethyst.instance.appContext
|
||||
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
||||
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
|
||||
val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed)
|
||||
val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state)
|
||||
val uploadVoiceExceptionMessage: (String) -> String = { detail ->
|
||||
stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
|
||||
}
|
||||
|
||||
isUploadingVoice = true
|
||||
|
||||
try {
|
||||
val uri = android.net.Uri.fromFile(recording.file)
|
||||
val orchestrator = UploadOrchestrator()
|
||||
voiceOrchestrator = orchestrator
|
||||
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
uri = uri,
|
||||
mimeType = recording.mimeType,
|
||||
alt = null,
|
||||
contentWarningReason = null,
|
||||
compressionQuality = CompressorQuality.UNCOMPRESSED,
|
||||
server = server,
|
||||
account = account,
|
||||
context = appContext,
|
||||
useH265 = false,
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is UploadingState.Finished -> {
|
||||
when (val orchestratorResult = result.result) {
|
||||
is UploadOrchestrator.OrchestratorResult.ServerResult -> {
|
||||
voiceMetadata =
|
||||
AudioMeta(
|
||||
url = orchestratorResult.url,
|
||||
mimeType = recording.mimeType,
|
||||
hash = orchestratorResult.fileHeader.hash,
|
||||
duration = recording.duration,
|
||||
waveform = recording.amplitudes,
|
||||
)
|
||||
// Delete the local file after successful upload
|
||||
deleteVoiceLocalFile()
|
||||
voiceLocalFile = null
|
||||
voiceRecording = null
|
||||
}
|
||||
is UploadOrchestrator.OrchestratorResult.NIP95Result -> {
|
||||
// For NIP95, we need to create the event and get the nevent URL
|
||||
// This is handled differently - skip for now
|
||||
onError(uploadErrorTitle, uploadVoiceNip95NotSupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
is UploadingState.Error -> {
|
||||
onError(uploadErrorTitle, uploadVoiceFailed)
|
||||
voiceRecording = null
|
||||
}
|
||||
else -> {
|
||||
onError(uploadErrorTitle, uploadVoiceUnexpected)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName))
|
||||
voiceRecording = null
|
||||
} finally {
|
||||
isUploadingVoice = false
|
||||
voiceOrchestrator = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||
if (location == null) {
|
||||
location = locationManager().geohashStateFlow
|
||||
|
||||
+4
-12
@@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -73,6 +72,8 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
|
||||
|
||||
private const val IDENTITY_ICON_CACHE_KEY = 0
|
||||
|
||||
@Composable
|
||||
fun DrawAdditionalInfo(
|
||||
baseUser: User,
|
||||
@@ -192,7 +193,7 @@ fun DrawAdditionalInfo(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
tint = Color.Unspecified,
|
||||
painter = painterRes(resourceId = getIdentityClaimIcon(identity), getIdentityClaimIconReference(identity)),
|
||||
painter = painterRes(resourceId = getIdentityClaimIcon(identity), IDENTITY_ICON_CACHE_KEY),
|
||||
contentDescription = stringRes(getIdentityClaimDescription(identity)),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
@@ -247,14 +248,5 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int =
|
||||
is TelegramIdentity -> R.string.telegram
|
||||
is MastodonIdentity -> R.string.mastodon
|
||||
is GitHubIdentity -> R.string.github
|
||||
else -> R.drawable.github
|
||||
}
|
||||
|
||||
fun getIdentityClaimIconReference(identity: IdentityClaimTag): Int =
|
||||
when (identity) {
|
||||
is TwitterIdentity -> 0
|
||||
is TelegramIdentity -> 0
|
||||
is MastodonIdentity -> 0
|
||||
is GitHubIdentity -> 0
|
||||
else -> 0
|
||||
else -> R.string.github
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">Nezákonné jednání</string>
|
||||
<string name="other">Jiný</string>
|
||||
<string name="harassment">Obtěžování</string>
|
||||
<string name="violence">Násilí</string>
|
||||
<string name="unknown">Neznámý</string>
|
||||
<string name="relay_icon">Ikona předávání</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_title">Nahrajte zprávu</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="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="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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">Illegales Verhalten</string>
|
||||
<string name="other">Andere</string>
|
||||
<string name="harassment">Belästigung</string>
|
||||
<string name="violence">Gewalt</string>
|
||||
<string name="unknown">Unbekannt</string>
|
||||
<string name="relay_icon">Relay-Symbol</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_title">Eine Nachricht aufnehmen</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="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="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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">अवैध बरताव</string>
|
||||
<string name="other">अन्य</string>
|
||||
<string name="harassment">उत्पीडन</string>
|
||||
<string name="violence">हिंसा</string>
|
||||
<string name="unknown">अज्ञात</string>
|
||||
<string name="relay_icon">पनःप्रसारक चिह्न</string>
|
||||
<string name="unknown_author">अज्ञात लेखक</string>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">Antyspołeczne zachowanie</string>
|
||||
<string name="other">Inne</string>
|
||||
<string name="harassment">Nękanie</string>
|
||||
<string name="violence">Przemoc</string>
|
||||
<string name="unknown">Nieznani</string>
|
||||
<string name="relay_icon">Ikona transmitera</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="zaps">Zapy</string>
|
||||
<string name="view_count">Liczba wyświetleń</string>
|
||||
<string name="boost">Promuj</string>
|
||||
<string name="boosted">promowany</string>
|
||||
<string name="boost">Powtórz</string>
|
||||
<string name="boosted">powtórzony</string>
|
||||
<string name="edited">edytowano</string>
|
||||
<string name="edited_number">edytuj #%1$s</string>
|
||||
<string name="original">oryginalny</string>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">Comportamento ilegal</string>
|
||||
<string name="other">Outro</string>
|
||||
<string name="harassment">Assédio</string>
|
||||
<string name="violence">Violência</string>
|
||||
<string name="unknown">Desconhecido</string>
|
||||
<string name="relay_icon">Ícone do relay</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_title">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="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="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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">Nedovoljeno vedenje</string>
|
||||
<string name="other">Drugo</string>
|
||||
<string name="harassment">Nadlegovanje</string>
|
||||
<string name="violence">Nasilje</string>
|
||||
<string name="unknown">Nepoznano</string>
|
||||
<string name="relay_icon">Ikona releja</string>
|
||||
<string name="unknown_author">Avtor neznan</string>
|
||||
@@ -41,7 +42,7 @@
|
||||
<string name="report_malware">Zlonamerna programska vsebina</string>
|
||||
<string name="report_mod">Prijavi moderatorja</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.
|
||||
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.
|
||||
@@ -153,8 +154,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="banner_url">URL pasice</string>
|
||||
<string name="website_url">URL spletne strani</string>
|
||||
<string name="pronouns">Zaimki</string>
|
||||
<string name="ln_address">LN Naslov</string>
|
||||
<string name="ln_url_outdated">Zastarel LN (lightning) URL</string>
|
||||
<string name="ln_address">Lightning naslov (LUD-16)</string>
|
||||
<string name="ln_url_outdated">Zastarel lightning naslov (LUD-06)</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="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="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="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_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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="illegal_behavior">Olagligt beteende</string>
|
||||
<string name="other">Annat</string>
|
||||
<string name="harassment">Trakasseri</string>
|
||||
<string name="violence">Våld</string>
|
||||
<string name="unknown">Okänd</string>
|
||||
<string name="relay_icon">Relä ikon</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_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="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="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="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>
|
||||
|
||||
@@ -170,7 +170,14 @@
|
||||
<string name="record_a_message">Record a message</string>
|
||||
<string name="record_a_message_title">Record a message</string>
|
||||
<string name="record_a_message_description">Click and hold to record a message</string>
|
||||
<string name="recording_indicator_description">Recording</string>
|
||||
<string name="recording_indicator_with_time">Recording %1$s</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="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>
|
||||
|
||||
+17
-2
@@ -74,6 +74,13 @@ kotlin {
|
||||
// https://developer.android.com/kotlin/multiplatform/migrate
|
||||
val xcfName = "quartz-kmpKit"
|
||||
|
||||
|
||||
iosX64 {
|
||||
binaries.framework {
|
||||
baseName = xcfName
|
||||
}
|
||||
}
|
||||
|
||||
iosArm64 {
|
||||
binaries.framework {
|
||||
baseName = xcfName
|
||||
@@ -208,6 +215,10 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
val iosX64Main by getting {
|
||||
dependsOn(iosMain.get()) // iosX64Main depends on iosMain
|
||||
}
|
||||
|
||||
val iosArm64Main by getting {
|
||||
dependsOn(iosMain.get()) // iosArm64Main depends on iosMain
|
||||
}
|
||||
@@ -223,12 +234,16 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
val iosX64Test by getting {
|
||||
dependsOn(iosTest.get()) // iosX64Test depends on iosTest
|
||||
}
|
||||
|
||||
val iosArm64Test by getting {
|
||||
dependsOn(iosTest.get()) // iosArm64Main depends on iosMain
|
||||
dependsOn(iosTest.get()) // iosArm64Test depends on iosTest
|
||||
}
|
||||
|
||||
val iosSimulatorArm64Test by getting {
|
||||
dependsOn(iosTest.get()) // iosSimulatorArm64Main depends on iosMain
|
||||
dependsOn(iosTest.get()) // iosSimulatorArm64Test depends on iosTest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.trustedAssertions.list
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
|
||||
|
||||
Reference in New Issue
Block a user