Merge
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
+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.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 ->
|
||||||
|
|||||||
-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.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
|
||||||
|
|||||||
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
-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.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,
|
||||||
|
|||||||
+62
-11
@@ -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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.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)
|
||||||
|
|||||||
+6
-9
@@ -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,
|
||||||
|
|||||||
-2
@@ -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
|
||||||
|
|||||||
-1
@@ -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(
|
||||||
|
|||||||
-1
@@ -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(
|
||||||
|
|||||||
-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.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
|
||||||
|
|||||||
+145
-12
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+184
-3
@@ -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
|
||||||
|
|||||||
+4
-12
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user