mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (48 commits) Use accountViewModel.viewModelScope instead of rememberCoroutineScope() to allow download/share to finish even when controls auto-close. Add content parameter to allow sharing of video from video player New Crowdin translations by GitHub Action - use a valid hex key - check the value of the tags Fixed NullPointerException when filter contain tags Fix assertEquals order fix Jackson deserialization for empty Filters and add regression test New Crowdin translations by GitHub Action Consume File.delete() return values Extract duplicated "https://" literal into a private const val HTTPS_PREFIX New Crowdin translations by GitHub Action New Crowdin translations by GitHub Action New Crowdin translations by GitHub Action Load and save payment targets refactor(commons): migrate from Moko to Compose Multiplatform Resources feat(broadcast): Enhance retry, multi-broadcast UI, and post tracking (#1682) wip: Add BroadcastTracker to AccountViewModel (#1682) feat(broadcast): Add transparent event broadcasting feedback (#1682) Enabled the reactions expand control for zapraisers even when there are no zaps New Crowdin translations by GitHub Action refactor to reduce complexity encodePcmToAac ... # Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt
This commit is contained in:
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -126,6 +127,7 @@ private object PrefKeys {
|
||||
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
|
||||
}
|
||||
|
||||
object LocalPreferences {
|
||||
@@ -357,6 +359,7 @@ object LocalPreferences {
|
||||
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
|
||||
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
|
||||
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
|
||||
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||
@@ -486,6 +489,7 @@ object LocalPreferences {
|
||||
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
|
||||
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
|
||||
val latestTrustProviderList = parseEventOrNull<TrustProviderListEvent>(PrefKeys.LATEST_TRUST_PROVIDER_LIST)
|
||||
val latestPaymentTargets = parseEventOrNull<PaymentTargetsEvent>(PrefKeys.LATEST_PAYMENT_TARGETS)
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
@@ -533,6 +537,7 @@ object LocalPreferences {
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
backupNipA3PaymentTargets = latestPaymentTargets,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryption
|
||||
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
|
||||
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
|
||||
import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState
|
||||
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
|
||||
@@ -149,6 +150,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -353,6 +355,8 @@ class Account(
|
||||
|
||||
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
||||
|
||||
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
|
||||
|
||||
val feedDecryptionCaches =
|
||||
FeedDecryptionCaches(
|
||||
peopleListCache = peopleListDecryptionCache,
|
||||
@@ -511,6 +515,38 @@ class Account(
|
||||
onPrivate = ::broadcastPrivately,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a reaction event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
* Returns null if note has already been reacted to or note has no event.
|
||||
*/
|
||||
suspend fun createReactionEvent(
|
||||
note: Note,
|
||||
reaction: String,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!signer.isWriteable()) return null
|
||||
if (note.hasReacted(userProfile(), reaction)) return null
|
||||
|
||||
val noteEvent = note.event ?: return null
|
||||
|
||||
// For NIP-17 private groups, we don't support tracked mode (too complex)
|
||||
if (noteEvent is NIP17Group) return null
|
||||
|
||||
val relayHint = note.relays.firstOrNull()?.url
|
||||
val event = ReactionAction.reactTo(noteEvent, reaction, signer, relayHint)
|
||||
val relays = computeRelayListToBroadcast(event)
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a reaction event into local cache.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeReactionEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun createZapRequestFor(
|
||||
event: Event,
|
||||
pollOption: Int?,
|
||||
@@ -632,6 +668,35 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a boost event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createBoostEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? =
|
||||
RepostAction.repost(note, signer)?.let { event ->
|
||||
event to computeMyReactionToNote(note, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a boost event and updates the local cache.
|
||||
* Used after tracked broadcasting completes.
|
||||
*/
|
||||
fun sendBoostEvent(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
client.send(event, relays)
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local cache with a boost event.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeBoostEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
fun computeMyReactionToNote(
|
||||
note: Note,
|
||||
reaction: Event,
|
||||
@@ -1178,6 +1243,36 @@ class Account(
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a post event without sending it.
|
||||
* Returns the event, target relays, and extra events to broadcast.
|
||||
* For use with tracked broadcasting.
|
||||
*/
|
||||
suspend fun <T : Event> createPostEvent(
|
||||
template: EventTemplate<T>,
|
||||
extraNotesToBroadcast: List<Event> = emptyList(),
|
||||
): Triple<T, Set<NormalizedRelayUrl>, List<Event>> {
|
||||
val event = signer.sign(template)
|
||||
|
||||
// Use event-based relay computation (not note-based, since note is empty)
|
||||
val relayList = computeRelayListToBroadcast(event)
|
||||
|
||||
return Triple(event, relayList, extraNotesToBroadcast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a post event into local cache and sends extra events.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumePostEvent(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
extraNotesToBroadcast: List<Event>,
|
||||
) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
extraNotesToBroadcast.forEach { client.send(it, relays) }
|
||||
}
|
||||
|
||||
suspend fun createAndSendDraftIgnoreErrors(
|
||||
draftTag: String,
|
||||
template: EventTemplate<out Event>,
|
||||
@@ -1573,6 +1668,46 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bookmark event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createAddBookmarkEvent(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!isWriteable() || note.isDraft()) return null
|
||||
|
||||
val event = bookmarkState.addBookmark(note, isPrivate)
|
||||
val relays = outboxRelays.flow.value
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a remove bookmark event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createRemoveBookmarkEvent(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!isWriteable() || note.isDraft()) return null
|
||||
|
||||
val event = bookmarkState.removeBookmark(note, isPrivate) ?: return null
|
||||
val relays = outboxRelays.flow.value
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a bookmark event into local cache.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeBookmarkEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun createAuthEvent(
|
||||
relay: NormalizedRelayUrl,
|
||||
challenge: String,
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
@@ -145,6 +146,7 @@ class AccountSettings(
|
||||
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
|
||||
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
|
||||
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
|
||||
) : EphemeralChatRepository,
|
||||
PublicChatListRepository {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
|
||||
@@ -342,6 +344,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
|
||||
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
|
||||
backupNipA3PaymentTargets = newNIPA3PaymentTargets
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
|
||||
@@ -48,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
|
||||
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
@@ -226,7 +228,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
|
||||
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
|
||||
|
||||
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, Pair<Note?, suspend (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
val paymentTracker = NwcPaymentTracker()
|
||||
|
||||
val relayHints = HintIndexer()
|
||||
|
||||
@@ -309,7 +311,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
|
||||
|
||||
fun getOrCreateUser(key: HexKey): User {
|
||||
override fun getOrCreateUser(key: HexKey): User {
|
||||
require(isValidHex(key = key)) { "$key is not a valid hex" }
|
||||
|
||||
return users.getOrCreate(key) {
|
||||
@@ -788,6 +790,51 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PaymentTargetsEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
val isVerified =
|
||||
if (version.event == null && (wasVerified || justVerify(event))) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
true
|
||||
} else {
|
||||
wasVerified
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return wasVerified
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isVerified || justVerify(event)) {
|
||||
if (event.createdAt > (note.createdAt() ?: 0L)) {
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
refreshNewNoteObservers(note)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: WikiNoteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2019,7 +2066,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
paymentTracker.registerRequest(event.id, zappedNote, onResponse)
|
||||
|
||||
refreshNewNoteObservers(note)
|
||||
|
||||
@@ -2035,9 +2082,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val requestId = event.requestId()
|
||||
val pair = awaitingPaymentRequests[requestId] ?: return false
|
||||
val pending = paymentTracker.onResponseReceived(requestId) ?: return false
|
||||
|
||||
val (zappedNote, responseCallback) = pair
|
||||
val zappedNote = pending.zappedNote
|
||||
val responseCallback = pending.onResponse
|
||||
|
||||
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
|
||||
|
||||
@@ -2966,6 +3014,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is VoiceEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceReplyEvent -> consume(event, relay, wasVerified)
|
||||
is WikiNoteEvent -> consume(event, relay, wasVerified)
|
||||
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}")
|
||||
false
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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.model.nipA3PaymentTargets
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NipA3PaymentTargetsState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
val nipA3PaymentTargetsNote = cache.getOrCreateAddressableNote(getNipA3PaymentTargetsState())
|
||||
|
||||
fun getNipA3PaymentTargetsFlow(): StateFlow<NoteState> = nipA3PaymentTargetsNote.flow().metadata.stateFlow
|
||||
|
||||
fun getNipA3PaymentTargetsState() = PaymentTargetsEvent.createAddress(signer.pubKey)
|
||||
|
||||
init {
|
||||
settings.backupNipA3PaymentTargets?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved nipA3 Payment targets ${it.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "nipA3 Payment targets Collector Start")
|
||||
getNipA3PaymentTargetsFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating nipA3 Payment targets for ${signer.pubKey}")
|
||||
(it.note.event as? PaymentTargetsEvent)?.let { paymentTargetsEvent ->
|
||||
settings.updateNIPA3PaymentTargets(paymentTargetsEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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.service.broadcast
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Result of a relay's response to an event publish.
|
||||
*/
|
||||
@Immutable
|
||||
sealed class RelayResult {
|
||||
/** Relay accepted the event (OK message with success=true) */
|
||||
data object Success : RelayResult()
|
||||
|
||||
/** Relay rejected the event (OK message with success=false) */
|
||||
data class Error(
|
||||
val code: String,
|
||||
val message: String?,
|
||||
) : RelayResult()
|
||||
|
||||
/** Relay did not respond within timeout */
|
||||
data object Timeout : RelayResult()
|
||||
|
||||
/** Waiting for relay response */
|
||||
data object Pending : RelayResult()
|
||||
|
||||
/** Retry in progress for this relay */
|
||||
data object Retrying : RelayResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall status of a broadcast operation.
|
||||
*/
|
||||
enum class BroadcastStatus {
|
||||
/** Currently waiting for relay responses */
|
||||
IN_PROGRESS,
|
||||
|
||||
/** All relays accepted the event */
|
||||
SUCCESS,
|
||||
|
||||
/** Some relays accepted, some failed */
|
||||
PARTIAL,
|
||||
|
||||
/** No relays accepted the event */
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a single event broadcast to multiple relays.
|
||||
*/
|
||||
@Immutable
|
||||
data class BroadcastEvent(
|
||||
val id: String,
|
||||
val eventId: HexKey,
|
||||
val eventName: String,
|
||||
val kind: Int,
|
||||
val targetRelays: List<NormalizedRelayUrl>,
|
||||
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
|
||||
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
/** Number of relays that accepted the event */
|
||||
val successCount: Int
|
||||
get() = results.count { it.value is RelayResult.Success }
|
||||
|
||||
/** Number of relays that rejected or timed out */
|
||||
val failureCount: Int
|
||||
get() = results.count { it.value is RelayResult.Error || it.value is RelayResult.Timeout }
|
||||
|
||||
/** Number of relays still pending response */
|
||||
val pendingCount: Int
|
||||
get() = targetRelays.size - results.size
|
||||
|
||||
/** Total number of target relays */
|
||||
val totalRelays: Int
|
||||
get() = targetRelays.size
|
||||
|
||||
/** Progress as a fraction (0.0 to 1.0) */
|
||||
val progress: Float
|
||||
get() = if (totalRelays == 0) 0f else results.size.toFloat() / totalRelays
|
||||
|
||||
/** Whether all relays have responded */
|
||||
val isComplete: Boolean
|
||||
get() = results.size >= targetRelays.size
|
||||
|
||||
/** List of relays that failed and are not currently retrying */
|
||||
val failedRelays: List<NormalizedRelayUrl>
|
||||
get() =
|
||||
results
|
||||
.filter {
|
||||
(it.value is RelayResult.Error || it.value is RelayResult.Timeout) &&
|
||||
it.value !is RelayResult.Retrying
|
||||
}.keys
|
||||
.toList()
|
||||
|
||||
/** List of relays currently being retried */
|
||||
val retryingRelays: List<NormalizedRelayUrl>
|
||||
get() = results.filter { it.value is RelayResult.Retrying }.keys.toList()
|
||||
|
||||
/** Creates a copy with an updated relay result */
|
||||
fun withResult(
|
||||
relay: NormalizedRelayUrl,
|
||||
result: RelayResult,
|
||||
): BroadcastEvent {
|
||||
val newResults = results + (relay to result)
|
||||
val newStatus =
|
||||
when {
|
||||
newResults.size < targetRelays.size -> BroadcastStatus.IN_PROGRESS
|
||||
newResults.all { it.value is RelayResult.Success } -> BroadcastStatus.SUCCESS
|
||||
newResults.none { it.value is RelayResult.Success } -> BroadcastStatus.FAILED
|
||||
else -> BroadcastStatus.PARTIAL
|
||||
}
|
||||
return copy(results = newResults, status = newStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a broadcast operation, returned after all relays respond or timeout.
|
||||
*/
|
||||
@Immutable
|
||||
data class BroadcastResult(
|
||||
val broadcast: BroadcastEvent,
|
||||
val isSuccess: Boolean,
|
||||
) {
|
||||
val successCount: Int get() = broadcast.successCount
|
||||
val totalRelays: Int get() = broadcast.totalRelays
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* 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.service.broadcast
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Tracks event broadcasts to relays with live progress updates.
|
||||
*
|
||||
* Provides:
|
||||
* - Real-time progress as relays respond
|
||||
* - Detailed per-relay success/error information
|
||||
* - Retry functionality for failed relays
|
||||
*/
|
||||
class BroadcastTracker {
|
||||
companion object {
|
||||
private const val TAG = "BroadcastTracker"
|
||||
private const val TIMEOUT_SECONDS = 15L
|
||||
const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent
|
||||
}
|
||||
|
||||
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList())
|
||||
val activeBroadcasts: StateFlow<List<BroadcastEvent>> = _activeBroadcasts.asStateFlow()
|
||||
|
||||
private val _completedBroadcast = MutableSharedFlow<BroadcastEvent>(extraBufferCapacity = 10)
|
||||
val completedBroadcast: SharedFlow<BroadcastEvent> = _completedBroadcast.asSharedFlow()
|
||||
|
||||
// Event cache for retries - maps tracking ID to original Event
|
||||
private val eventCache = mutableMapOf<String, Event>()
|
||||
|
||||
/**
|
||||
* Tracks an event broadcast to relays with live progress updates.
|
||||
*
|
||||
* @param event The Nostr event to broadcast
|
||||
* @param eventName Human-readable name (e.g., "Boost", "Reaction")
|
||||
* @param relays Target relays to send to
|
||||
* @param client The Nostr client for sending
|
||||
* @return BroadcastResult with final status
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
suspend fun trackBroadcast(
|
||||
event: Event,
|
||||
eventName: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
client: INostrClient,
|
||||
): BroadcastResult {
|
||||
val trackingId = UUID.randomUUID().toString()
|
||||
|
||||
val broadcast =
|
||||
BroadcastEvent(
|
||||
id = trackingId,
|
||||
eventId = event.id,
|
||||
eventName = eventName,
|
||||
kind = event.kind,
|
||||
targetRelays = relays.toList(),
|
||||
)
|
||||
|
||||
// Add to active broadcasts and cache event for retries
|
||||
_activeBroadcasts.update { it + broadcast }
|
||||
eventCache[trackingId] = event
|
||||
|
||||
Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays")
|
||||
|
||||
val resultChannel = Channel<RelayResponse>(UNLIMITED)
|
||||
|
||||
val subscription =
|
||||
object : IRelayClientListener {
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
if (relay.url in relays) {
|
||||
resultChannel.trySend(
|
||||
RelayResponse(
|
||||
relay = relay.url,
|
||||
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
|
||||
),
|
||||
)
|
||||
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
if (relay.url in relays) {
|
||||
resultChannel.trySend(
|
||||
RelayResponse(
|
||||
relay = relay.url,
|
||||
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
|
||||
),
|
||||
)
|
||||
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
|
||||
when (msg) {
|
||||
is OkMessage -> {
|
||||
if (msg.eventId == event.id) {
|
||||
val result =
|
||||
if (msg.success) {
|
||||
RelayResult.Success
|
||||
} else {
|
||||
val (code, message) = parseOkError(msg.message)
|
||||
RelayResult.Error(code, message)
|
||||
}
|
||||
resultChannel.trySend(RelayResponse(relay.url, result))
|
||||
Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subscription)
|
||||
|
||||
val finalBroadcast =
|
||||
coroutineScope {
|
||||
val resultCollector =
|
||||
async {
|
||||
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
var currentBroadcast = broadcast
|
||||
|
||||
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
|
||||
while (receivedRelays.size < relays.size) {
|
||||
val response = resultChannel.receive()
|
||||
|
||||
// Skip if already received (don't override success)
|
||||
if (response.relay in receivedRelays) continue
|
||||
|
||||
receivedRelays.add(response.relay)
|
||||
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
|
||||
|
||||
// Update active broadcasts with new progress
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { if (it.id == trackingId) currentBroadcast else it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark remaining relays as timeout
|
||||
relays.filter { it !in receivedRelays }.forEach { relay ->
|
||||
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
|
||||
}
|
||||
|
||||
currentBroadcast
|
||||
}
|
||||
|
||||
// Send after setting up listener
|
||||
client.send(event, relays)
|
||||
|
||||
resultCollector.await()
|
||||
}
|
||||
|
||||
client.unsubscribe(subscription)
|
||||
resultChannel.close()
|
||||
|
||||
// Remove from active, emit to completed
|
||||
_activeBroadcasts.update { list -> list.filter { it.id != trackingId } }
|
||||
_completedBroadcast.emit(finalBroadcast)
|
||||
|
||||
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
||||
|
||||
return BroadcastResult(
|
||||
broadcast = finalBroadcast,
|
||||
isSuccess = finalBroadcast.successCount > 0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks relays as Retrying in an existing broadcast.
|
||||
* Call this before starting the retry to show immediate feedback.
|
||||
*/
|
||||
fun markRelaysRetrying(
|
||||
broadcastId: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { broadcast ->
|
||||
if (broadcast.id == broadcastId) {
|
||||
var updated = broadcast
|
||||
relays.forEach { relay ->
|
||||
updated = updated.withResult(relay, RelayResult.Retrying)
|
||||
}
|
||||
updated.copy(status = BroadcastStatus.IN_PROGRESS)
|
||||
} else {
|
||||
broadcast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries sending an event to failed relays using cached event.
|
||||
* Updates the existing broadcast in-place with retry results.
|
||||
*
|
||||
* @param broadcast The broadcast to retry (must be in activeBroadcasts or recently completed)
|
||||
* @param client The Nostr client
|
||||
* @param specificRelay Optional specific relay to retry (null = all failed)
|
||||
* @return Updated BroadcastEvent or null if event not in cache
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
suspend fun retry(
|
||||
broadcast: BroadcastEvent,
|
||||
client: INostrClient,
|
||||
specificRelay: NormalizedRelayUrl? = null,
|
||||
): BroadcastEvent? {
|
||||
val event = eventCache[broadcast.id] ?: return null
|
||||
|
||||
val relaysToRetry =
|
||||
if (specificRelay != null) {
|
||||
setOf(specificRelay)
|
||||
} else {
|
||||
broadcast.failedRelays.toSet()
|
||||
}
|
||||
|
||||
if (relaysToRetry.isEmpty()) {
|
||||
return broadcast
|
||||
}
|
||||
|
||||
// Mark relays as retrying for immediate feedback
|
||||
markRelaysRetrying(broadcast.id, relaysToRetry)
|
||||
|
||||
// If broadcast not in active list, re-add it
|
||||
if (_activeBroadcasts.value.none { it.id == broadcast.id }) {
|
||||
_activeBroadcasts.update { list ->
|
||||
var updated = broadcast
|
||||
relaysToRetry.forEach { relay ->
|
||||
updated = updated.withResult(relay, RelayResult.Retrying)
|
||||
}
|
||||
list + updated.copy(status = BroadcastStatus.IN_PROGRESS)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup result collection
|
||||
val resultChannel = Channel<RelayResponse>(UNLIMITED)
|
||||
|
||||
val subscription =
|
||||
object : IRelayClientListener {
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
if (relay.url in relaysToRetry) {
|
||||
resultChannel.trySend(
|
||||
RelayResponse(
|
||||
relay = relay.url,
|
||||
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
|
||||
),
|
||||
)
|
||||
Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
if (relay.url in relaysToRetry) {
|
||||
resultChannel.trySend(
|
||||
RelayResponse(
|
||||
relay = relay.url,
|
||||
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
|
||||
),
|
||||
)
|
||||
Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
|
||||
when (msg) {
|
||||
is OkMessage -> {
|
||||
if (msg.eventId == event.id) {
|
||||
val result =
|
||||
if (msg.success) {
|
||||
RelayResult.Success
|
||||
} else {
|
||||
val (code, message) = parseOkError(msg.message)
|
||||
RelayResult.Error(code, message)
|
||||
}
|
||||
resultChannel.trySend(RelayResponse(relay.url, result))
|
||||
Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subscription)
|
||||
|
||||
val finalBroadcast =
|
||||
coroutineScope {
|
||||
val resultCollector =
|
||||
async {
|
||||
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
var currentBroadcast = _activeBroadcasts.value.find { it.id == broadcast.id } ?: broadcast
|
||||
|
||||
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
|
||||
while (receivedRelays.size < relaysToRetry.size) {
|
||||
val response = resultChannel.receive()
|
||||
|
||||
if (response.relay !in relaysToRetry) continue
|
||||
if (response.relay in receivedRelays) continue
|
||||
|
||||
receivedRelays.add(response.relay)
|
||||
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
|
||||
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { if (it.id == broadcast.id) currentBroadcast else it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark remaining as timeout
|
||||
relaysToRetry.filter { it !in receivedRelays }.forEach { relay ->
|
||||
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
|
||||
}
|
||||
|
||||
// Recalculate status
|
||||
val newStatus =
|
||||
when {
|
||||
currentBroadcast.results.values.any { it is RelayResult.Pending || it is RelayResult.Retrying } ->
|
||||
BroadcastStatus.IN_PROGRESS
|
||||
currentBroadcast.results.all { it.value is RelayResult.Success } ->
|
||||
BroadcastStatus.SUCCESS
|
||||
currentBroadcast.results.none { it.value is RelayResult.Success } ->
|
||||
BroadcastStatus.FAILED
|
||||
else -> BroadcastStatus.PARTIAL
|
||||
}
|
||||
currentBroadcast.copy(status = newStatus)
|
||||
}
|
||||
|
||||
client.send(event, relaysToRetry)
|
||||
|
||||
resultCollector.await()
|
||||
}
|
||||
|
||||
client.unsubscribe(subscription)
|
||||
resultChannel.close()
|
||||
|
||||
// Update in active broadcasts
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { if (it.id == broadcast.id) finalBroadcast else it }
|
||||
}
|
||||
|
||||
// Emit to completed if all done
|
||||
if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) {
|
||||
_completedBroadcast.emit(finalBroadcast)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
||||
|
||||
return finalBroadcast
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets details for a specific broadcast by tracking ID.
|
||||
*/
|
||||
fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId }
|
||||
|
||||
/**
|
||||
* Gets the cached event for a broadcast (for retries).
|
||||
*/
|
||||
fun getCachedEvent(trackingId: String): Event? = eventCache[trackingId]
|
||||
|
||||
/**
|
||||
* Removes a broadcast from cache (call after COMPLETED_DISPLAY_DURATION_MS expires).
|
||||
*/
|
||||
fun expireBroadcast(trackingId: String) {
|
||||
eventCache.remove(trackingId)
|
||||
Log.d(TAG, "Expired broadcast $trackingId from cache")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all active broadcasts and cache (e.g., on logout).
|
||||
*/
|
||||
fun clear() {
|
||||
_activeBroadcasts.update { emptyList() }
|
||||
eventCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses NIP-20 OK error message into code and description.
|
||||
* Format: "prefix: message" or just "message"
|
||||
*/
|
||||
private fun parseOkError(message: String): Pair<String, String?> {
|
||||
val parts = message.split(":", limit = 2)
|
||||
return if (parts.size == 2) {
|
||||
parts[0].trim().uppercase() to parts[1].trim()
|
||||
} else {
|
||||
"ERROR" to message.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class RelayResponse(
|
||||
val relay: NormalizedRelayUrl,
|
||||
val result: RelayResult,
|
||||
)
|
||||
}
|
||||
+4
-3
@@ -25,12 +25,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareImageAction
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size110dp
|
||||
@@ -71,11 +72,11 @@ fun RenderControlButtons(
|
||||
}
|
||||
|
||||
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
|
||||
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle)
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
|
||||
}
|
||||
} else {
|
||||
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle ->
|
||||
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle)
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEByKey
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler
|
||||
|
||||
+3
-1
@@ -20,15 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.AccountFollowsLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets.AccountPaymentTargetsEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -61,6 +62,7 @@ class AccountFilterAssembler(
|
||||
AccountDraftsEoseManager(client, ::allKeys),
|
||||
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
|
||||
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),
|
||||
AccountPaymentTargetsEoseManager(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -20,12 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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.service.relayClient.reqCommand.account.paymentTargets
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountPaymentTargetsEoseManager(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<AccountQueryState>,
|
||||
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
|
||||
override fun user(key: AccountQueryState) = key.account.userProfile()
|
||||
|
||||
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
|
||||
|
||||
override fun updateFilter(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
if (key.account.isWriteable()) {
|
||||
relayFlow(key).value.flatMap {
|
||||
listOf(
|
||||
filterPaymentTargetsFromKey(it, user(key).pubkeyHex, since?.get(it)?.time),
|
||||
).flatten()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: AccountQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
relayFlow(key).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.service.relayClient.reqCommand.account.paymentTargets
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
val paymentTargetsKinds =
|
||||
listOf(
|
||||
PaymentTargetsEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterPaymentTargetsFromKey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey == null || pubkey.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = paymentTargetsKinds,
|
||||
authors = listOf(pubkey),
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixCha
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
+1
-1
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.service.relays.MutableTime
|
||||
|
||||
+1
-1
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
+1
-1
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler
|
||||
|
||||
+1
-1
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@SuppressLint("StateFlowValueCalledInComposition")
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
|
||||
+1
-1
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.toHexSet
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
|
||||
+1
-1
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.toHexSet
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
|
||||
+1
-1
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+2
-2
@@ -21,10 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.searchCommand
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.searchCommand
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.searchCommand
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
|
||||
@@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
const val MAX_VOICE_RECORD_SECONDS = 600
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun RecordAudioBox(
|
||||
modifier: Modifier,
|
||||
onRecordTaken: (RecordingResult) -> Unit,
|
||||
maxDurationSeconds: Int? = null,
|
||||
content: @Composable (Boolean, Int) -> Unit,
|
||||
) {
|
||||
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
|
||||
@@ -107,6 +110,10 @@ fun RecordAudioBox(
|
||||
while (isActive) {
|
||||
delay(1000)
|
||||
elapsedSeconds++
|
||||
if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) {
|
||||
stopRecording()
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset elapsed time when not recording
|
||||
|
||||
+5
-1
@@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
|
||||
fun RecordVoiceButton(
|
||||
onVoiceTaken: (RecordingResult) -> Unit,
|
||||
maxDurationSeconds: Int? = null,
|
||||
) {
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||
|
||||
@@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
|
||||
elapsedSeconds = 0
|
||||
onVoiceTaken(recording)
|
||||
},
|
||||
maxDurationSeconds = maxDurationSeconds,
|
||||
) { recordingState, elapsed ->
|
||||
// Update parent state after composition completes
|
||||
SideEffect {
|
||||
|
||||
+7
-3
@@ -61,6 +61,7 @@ class VoiceAnonymizationController(
|
||||
preset: VoicePreset,
|
||||
originalFile: File?,
|
||||
) {
|
||||
Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}")
|
||||
if (processingPreset != null || preset == selectedPreset) return
|
||||
|
||||
if (preset == VoicePreset.NONE) {
|
||||
@@ -76,9 +77,9 @@ class VoiceAnonymizationController(
|
||||
val file = originalFile ?: return
|
||||
|
||||
processingJob?.cancel()
|
||||
processingPreset = preset
|
||||
processingJob =
|
||||
scope.launch {
|
||||
processingPreset = preset
|
||||
try {
|
||||
val anonymizer = VoiceAnonymizer()
|
||||
val result = anonymizer.anonymize(file, preset)
|
||||
@@ -108,8 +109,11 @@ class VoiceAnonymizationController(
|
||||
distortedFiles.values.forEach { result ->
|
||||
try {
|
||||
if (result.file.exists()) {
|
||||
result.file.delete()
|
||||
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
|
||||
if (result.file.delete()) {
|
||||
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
|
||||
} else {
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
|
||||
|
||||
+1
-2
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -63,7 +62,7 @@ fun VoiceAnonymizationSection(
|
||||
Text(
|
||||
text = stringRes(R.string.voice_anonymize_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
+312
-163
@@ -41,12 +41,26 @@ import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Result of voice anonymization processing.
|
||||
*
|
||||
* @property file The output audio file (AAC in MP4 container)
|
||||
* @property waveform Amplitude data for waveform visualization (one value per second)
|
||||
* @property duration Audio duration in seconds
|
||||
*/
|
||||
data class AnonymizedResult(
|
||||
val file: File,
|
||||
val waveform: List<Float>,
|
||||
val duration: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Processes audio files to alter voice characteristics for privacy.
|
||||
*
|
||||
* Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with
|
||||
* rate transposition to shift pitch while preserving duration. Note that in TarsosDSP,
|
||||
* pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch.
|
||||
*/
|
||||
class VoiceAnonymizer {
|
||||
companion object {
|
||||
private const val TAG = "VoiceAnonymizer"
|
||||
@@ -54,6 +68,20 @@ class VoiceAnonymizer {
|
||||
private const val BIT_RATE = 128000
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies voice anonymization to an audio file.
|
||||
*
|
||||
* The process involves three stages:
|
||||
* 1. Decode input audio to PCM (0-30% progress)
|
||||
* 2. Apply pitch shifting with TarsosDSP (30-70% progress)
|
||||
* 3. Encode processed audio to AAC (70-100% progress)
|
||||
*
|
||||
* @param inputFile Source audio file (supports formats decodable by MediaCodec)
|
||||
* @param preset Voice transformation preset (NONE is not allowed)
|
||||
* @param onProgress Callback invoked with progress value from 0.0 to 1.0
|
||||
* @return [Result.success] with [AnonymizedResult] containing the output file,
|
||||
* waveform data, and duration; or [Result.failure] with the exception
|
||||
*/
|
||||
suspend fun anonymize(
|
||||
inputFile: File,
|
||||
preset: VoicePreset,
|
||||
@@ -98,7 +126,8 @@ class VoiceAnonymizer {
|
||||
): File {
|
||||
val baseName = inputFile.nameWithoutExtension
|
||||
val presetSuffix = preset.name.lowercase()
|
||||
return File(inputFile.parentFile, "${baseName}_$presetSuffix.mp4")
|
||||
val parentDir = inputFile.parentFile ?: inputFile.absoluteFile.parentFile
|
||||
return File(parentDir, "${baseName}_$presetSuffix.mp4")
|
||||
}
|
||||
|
||||
private data class DecodedAudio(
|
||||
@@ -107,6 +136,116 @@ class VoiceAnonymizer {
|
||||
val duration: Int,
|
||||
)
|
||||
|
||||
private data class AudioTrackInfo(
|
||||
val trackIndex: Int,
|
||||
val format: MediaFormat,
|
||||
val mime: String,
|
||||
val sampleRate: Int,
|
||||
val durationUs: Long,
|
||||
)
|
||||
|
||||
private fun findAudioTrack(extractor: MediaExtractor): AudioTrackInfo {
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val trackFormat = extractor.getTrackFormat(i)
|
||||
val mime = trackFormat.getString(MediaFormat.KEY_MIME)
|
||||
if (mime?.startsWith("audio/") == true) {
|
||||
return AudioTrackInfo(
|
||||
trackIndex = i,
|
||||
format = trackFormat,
|
||||
mime = mime,
|
||||
sampleRate = trackFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE),
|
||||
durationUs = trackFormat.getLong(MediaFormat.KEY_DURATION),
|
||||
)
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("No audio track found in file")
|
||||
}
|
||||
|
||||
private class DecoderInputFeeder(
|
||||
private val decoder: MediaCodec,
|
||||
private val extractor: MediaExtractor,
|
||||
private val durationUs: Long,
|
||||
private val onProgress: (Float) -> Unit,
|
||||
) {
|
||||
var isDone = false
|
||||
private set
|
||||
|
||||
fun feedInput() {
|
||||
if (isDone) return
|
||||
|
||||
val inputBufferIndex = decoder.dequeueInputBuffer(10000)
|
||||
if (inputBufferIndex < 0) return
|
||||
|
||||
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
||||
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
||||
|
||||
if (sampleSize < 0) {
|
||||
queueEndOfStream(inputBufferIndex)
|
||||
} else {
|
||||
queueSampleData(inputBufferIndex, sampleSize)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueEndOfStream(inputBufferIndex: Int) {
|
||||
decoder.queueInputBuffer(
|
||||
inputBufferIndex,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
isDone = true
|
||||
}
|
||||
|
||||
private fun queueSampleData(
|
||||
inputBufferIndex: Int,
|
||||
sampleSize: Int,
|
||||
) {
|
||||
val presentationTimeUs = extractor.sampleTime
|
||||
decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
|
||||
extractor.advance()
|
||||
reportProgress(presentationTimeUs)
|
||||
}
|
||||
|
||||
private fun reportProgress(presentationTimeUs: Long) {
|
||||
if (durationUs > 0) {
|
||||
onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DecoderOutputDrainer(
|
||||
private val decoder: MediaCodec,
|
||||
private val pcmSamples: MutableList<Float>,
|
||||
) {
|
||||
private val bufferInfo = MediaCodec.BufferInfo()
|
||||
var isDone = false
|
||||
private set
|
||||
|
||||
fun drainOutput() {
|
||||
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
|
||||
if (outputBufferIndex < 0) return
|
||||
|
||||
extractPcmSamples(outputBufferIndex)
|
||||
decoder.releaseOutputBuffer(outputBufferIndex, false)
|
||||
checkForEndOfStream()
|
||||
}
|
||||
|
||||
private fun extractPcmSamples(outputBufferIndex: Int) {
|
||||
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
||||
val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer()
|
||||
while (shortBuffer.hasRemaining()) {
|
||||
pcmSamples.add(shortBuffer.get() / 32768f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForEndOfStream() {
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
isDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeAudioToPcm(
|
||||
inputFile: File,
|
||||
onProgress: (Float) -> Unit,
|
||||
@@ -116,92 +255,43 @@ class VoiceAnonymizer {
|
||||
|
||||
try {
|
||||
extractor.setDataSource(inputFile.absolutePath)
|
||||
val trackInfo = findAudioTrack(extractor)
|
||||
extractor.selectTrack(trackInfo.trackIndex)
|
||||
|
||||
var audioTrackIndex = -1
|
||||
var format: MediaFormat? = null
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val trackFormat = extractor.getTrackFormat(i)
|
||||
val mime = trackFormat.getString(MediaFormat.KEY_MIME)
|
||||
if (mime?.startsWith("audio/") == true) {
|
||||
audioTrackIndex = i
|
||||
format = trackFormat
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTrackIndex == -1 || format == null) {
|
||||
throw IllegalStateException("No audio track found in file")
|
||||
}
|
||||
|
||||
extractor.selectTrack(audioTrackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
|
||||
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
val durationUs = format.getLong(MediaFormat.KEY_DURATION)
|
||||
val duration = (durationUs / 1_000_000).toInt()
|
||||
|
||||
decoder = MediaCodec.createDecoderByType(mime)
|
||||
decoder.configure(format, null, null, 0)
|
||||
decoder.start()
|
||||
|
||||
val pcmSamples = mutableListOf<Float>()
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var outputDone = false
|
||||
|
||||
while (!outputDone && currentCoroutineContext().isActive) {
|
||||
if (!inputDone) {
|
||||
val inputBufferIndex = decoder.dequeueInputBuffer(10000)
|
||||
if (inputBufferIndex >= 0) {
|
||||
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
||||
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
||||
if (sampleSize < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
inputBufferIndex,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
val presentationTimeUs = extractor.sampleTime
|
||||
decoder.queueInputBuffer(
|
||||
inputBufferIndex,
|
||||
0,
|
||||
sampleSize,
|
||||
presentationTimeUs,
|
||||
0,
|
||||
)
|
||||
extractor.advance()
|
||||
if (durationUs > 0) {
|
||||
onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
decoder =
|
||||
MediaCodec.createDecoderByType(trackInfo.mime).apply {
|
||||
configure(trackInfo.format, null, null, 0)
|
||||
start()
|
||||
}
|
||||
|
||||
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
|
||||
if (outputBufferIndex >= 0) {
|
||||
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
||||
val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer()
|
||||
while (shortBuffer.hasRemaining()) {
|
||||
pcmSamples.add(shortBuffer.get() / 32768f)
|
||||
}
|
||||
decoder.releaseOutputBuffer(outputBufferIndex, false)
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
outputDone = true
|
||||
}
|
||||
}
|
||||
val estimatedSamples = (trackInfo.sampleRate.toLong() * trackInfo.durationUs / 1_000_000).toInt()
|
||||
val pcmSamples = ArrayList<Float>(estimatedSamples)
|
||||
|
||||
val inputFeeder = DecoderInputFeeder(decoder, extractor, trackInfo.durationUs, onProgress)
|
||||
val outputDrainer = DecoderOutputDrainer(decoder, pcmSamples)
|
||||
|
||||
while (!outputDrainer.isDone && currentCoroutineContext().isActive) {
|
||||
inputFeeder.feedInput()
|
||||
outputDrainer.drainOutput()
|
||||
}
|
||||
|
||||
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
|
||||
val duration = (trackInfo.durationUs / 1_000_000).toInt()
|
||||
return DecodedAudio(pcmSamples.toFloatArray(), trackInfo.sampleRate, duration)
|
||||
} finally {
|
||||
decoder?.stop()
|
||||
decoder?.release()
|
||||
decoder?.safeStopAndRelease()
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaCodec.safeStopAndRelease() {
|
||||
try {
|
||||
stop()
|
||||
} catch (_: IllegalStateException) {
|
||||
// Decoder was never started
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
private fun processPcmWithTarsos(
|
||||
pcmData: FloatArray,
|
||||
preset: VoicePreset,
|
||||
@@ -218,8 +308,8 @@ class VoiceAnonymizer {
|
||||
}
|
||||
else -> baseFactor
|
||||
}
|
||||
val processedSamples = mutableListOf<Float>()
|
||||
val totalSamples = pcmData.size
|
||||
val processedSamples = ArrayList<Float>(totalSamples)
|
||||
|
||||
val wsola =
|
||||
WaveformSimilarityBasedOverlapAdd(
|
||||
@@ -252,7 +342,9 @@ class VoiceAnonymizer {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun processingFinished() {}
|
||||
override fun processingFinished() {
|
||||
// No-op: no cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
@@ -276,7 +368,9 @@ class VoiceAnonymizer {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun processingFinished() {}
|
||||
override fun processingFinished() {
|
||||
// No-op: no cleanup needed
|
||||
}
|
||||
}
|
||||
dispatcher.addAudioProcessor(progressProcessor)
|
||||
|
||||
@@ -308,106 +402,159 @@ class VoiceAnonymizer {
|
||||
return waveform
|
||||
}
|
||||
|
||||
private class EncoderInputFeeder(
|
||||
private val encoder: MediaCodec,
|
||||
private val pcmData: FloatArray,
|
||||
private val sampleRate: Int,
|
||||
private val onProgress: (Float) -> Unit,
|
||||
) {
|
||||
private var inputOffset = 0
|
||||
var isDone = false
|
||||
private set
|
||||
|
||||
fun feedInput() {
|
||||
if (isDone) return
|
||||
|
||||
val inputBufferIndex = encoder.dequeueInputBuffer(10000)
|
||||
if (inputBufferIndex < 0) return
|
||||
|
||||
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
|
||||
inputBuffer.clear()
|
||||
|
||||
val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset)
|
||||
if (samplesToWrite <= 0) {
|
||||
queueEndOfStream(inputBufferIndex)
|
||||
} else {
|
||||
queueSampleData(inputBufferIndex, inputBuffer, samplesToWrite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueEndOfStream(inputBufferIndex: Int) {
|
||||
encoder.queueInputBuffer(
|
||||
inputBufferIndex,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
isDone = true
|
||||
}
|
||||
|
||||
private fun queueSampleData(
|
||||
inputBufferIndex: Int,
|
||||
inputBuffer: java.nio.ByteBuffer,
|
||||
samplesToWrite: Int,
|
||||
) {
|
||||
writePcmSamplesToBuffer(inputBuffer, samplesToWrite)
|
||||
val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate
|
||||
encoder.queueInputBuffer(inputBufferIndex, 0, inputBuffer.position(), presentationTimeUs, 0)
|
||||
inputOffset += samplesToWrite
|
||||
onProgress(inputOffset.toFloat() / pcmData.size)
|
||||
}
|
||||
|
||||
private fun writePcmSamplesToBuffer(
|
||||
inputBuffer: java.nio.ByteBuffer,
|
||||
samplesToWrite: Int,
|
||||
) {
|
||||
for (i in 0 until samplesToWrite) {
|
||||
val sample =
|
||||
(pcmData[inputOffset + i] * 32767)
|
||||
.toInt()
|
||||
.coerceIn(-32768, 32767)
|
||||
.toShort()
|
||||
inputBuffer.putShort(sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EncoderOutputDrainer(
|
||||
private val encoder: MediaCodec,
|
||||
private val muxer: MediaMuxer,
|
||||
) {
|
||||
private val bufferInfo = MediaCodec.BufferInfo()
|
||||
private var audioTrackIndex = -1
|
||||
var isMuxerStarted = false
|
||||
private set
|
||||
var isDone = false
|
||||
private set
|
||||
|
||||
fun drainOutput() {
|
||||
val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000)
|
||||
|
||||
when {
|
||||
outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> startMuxer()
|
||||
outputBufferIndex >= 0 -> processOutputBuffer(outputBufferIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMuxer() {
|
||||
audioTrackIndex = muxer.addTrack(encoder.outputFormat)
|
||||
muxer.start()
|
||||
isMuxerStarted = true
|
||||
}
|
||||
|
||||
private fun processOutputBuffer(outputBufferIndex: Int) {
|
||||
val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
|
||||
writeToMuxerIfReady(outputBuffer)
|
||||
encoder.releaseOutputBuffer(outputBufferIndex, false)
|
||||
checkForEndOfStream()
|
||||
}
|
||||
|
||||
private fun writeToMuxerIfReady(outputBuffer: java.nio.ByteBuffer) {
|
||||
if (isMuxerStarted && bufferInfo.size > 0) {
|
||||
outputBuffer.position(bufferInfo.offset)
|
||||
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
||||
muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForEndOfStream() {
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
isDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAacFormat(sampleRate: Int): MediaFormat =
|
||||
MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, CHANNELS).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
|
||||
}
|
||||
|
||||
private fun encodePcmToAac(
|
||||
pcmData: FloatArray,
|
||||
sampleRate: Int,
|
||||
outputFile: File,
|
||||
onProgress: (Float) -> Unit,
|
||||
) {
|
||||
val format =
|
||||
MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, CHANNELS)
|
||||
format.setInteger(
|
||||
MediaFormat.KEY_AAC_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AACObjectLC,
|
||||
)
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
|
||||
|
||||
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
var muxerStarted = false
|
||||
|
||||
try {
|
||||
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
encoder.configure(createAacFormat(sampleRate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
encoder.start()
|
||||
|
||||
var audioTrackIndex = -1
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputOffset = 0
|
||||
var inputDone = false
|
||||
var outputDone = false
|
||||
val totalSamples = pcmData.size
|
||||
val inputFeeder = EncoderInputFeeder(encoder, pcmData, sampleRate, onProgress)
|
||||
val outputDrainer = EncoderOutputDrainer(encoder, muxer)
|
||||
|
||||
while (!outputDone) {
|
||||
if (!inputDone) {
|
||||
val inputBufferIndex = encoder.dequeueInputBuffer(10000)
|
||||
if (inputBufferIndex >= 0) {
|
||||
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
|
||||
inputBuffer.clear()
|
||||
|
||||
val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset)
|
||||
if (samplesToWrite <= 0) {
|
||||
encoder.queueInputBuffer(
|
||||
inputBufferIndex,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
for (i in 0 until samplesToWrite) {
|
||||
val sample =
|
||||
(pcmData[inputOffset + i] * 32767)
|
||||
.toInt()
|
||||
.coerceIn(-32768, 32767)
|
||||
.toShort()
|
||||
inputBuffer.putShort(sample)
|
||||
}
|
||||
val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate
|
||||
encoder.queueInputBuffer(
|
||||
inputBufferIndex,
|
||||
0,
|
||||
inputBuffer.position(),
|
||||
presentationTimeUs,
|
||||
0,
|
||||
)
|
||||
inputOffset += samplesToWrite
|
||||
onProgress(inputOffset.toFloat() / totalSamples)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000)
|
||||
when {
|
||||
outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
audioTrackIndex = muxer.addTrack(encoder.outputFormat)
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
|
||||
outputBufferIndex >= 0 -> {
|
||||
val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
|
||||
if (muxerStarted && bufferInfo.size > 0) {
|
||||
outputBuffer.position(bufferInfo.offset)
|
||||
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
||||
muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
|
||||
}
|
||||
encoder.releaseOutputBuffer(outputBufferIndex, false)
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
outputDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
while (!outputDrainer.isDone) {
|
||||
inputFeeder.feedInput()
|
||||
outputDrainer.drainOutput()
|
||||
}
|
||||
muxerStarted = outputDrainer.isMuxerStarted
|
||||
} finally {
|
||||
encoder.stop()
|
||||
encoder.release()
|
||||
if (muxerStarted) {
|
||||
muxer.stop()
|
||||
}
|
||||
muxer.release()
|
||||
encoder.safeStopAndRelease()
|
||||
muxer.safeStopAndRelease(muxerStarted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaMuxer.safeStopAndRelease(wasStarted: Boolean) {
|
||||
if (wasStarted) {
|
||||
stop()
|
||||
}
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
private class FloatArrayAudioInputStream(
|
||||
@@ -446,5 +593,7 @@ private class FloatArrayAudioInputStream(
|
||||
return actualSkip.toLong() * 2
|
||||
}
|
||||
|
||||
override fun close() {}
|
||||
override fun close() {
|
||||
// No-op: no cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
+144
-58
@@ -35,8 +35,10 @@ 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.Mic
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -67,6 +69,8 @@ fun VoiceMessagePreview(
|
||||
voiceMetadata: AudioMeta,
|
||||
localFile: File? = null,
|
||||
onRemove: () -> Unit,
|
||||
onReRecord: ((RecordingResult) -> Unit)? = null,
|
||||
isUploading: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -101,77 +105,159 @@ fun VoiceMessagePreview(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
).padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// Play/Pause Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
handlePlayPauseClick(
|
||||
mediaPlayer = mediaPlayer,
|
||||
isPlaying = isPlaying,
|
||||
progress = progress,
|
||||
onProgressReset = { progress = 0f },
|
||||
onPlayingChanged = { isPlaying = it },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(48.dp),
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
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 ->
|
||||
handleWaveformScrub(
|
||||
newProgress = newProgress,
|
||||
// Play/Pause Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
handlePlayPauseClick(
|
||||
mediaPlayer = mediaPlayer,
|
||||
onProgressChanged = { progress = it },
|
||||
isPlaying = isPlaying,
|
||||
progress = progress,
|
||||
onProgressReset = { progress = 0f },
|
||||
onPlayingChanged = { isPlaying = it },
|
||||
)
|
||||
},
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
// 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 ->
|
||||
handleWaveformScrub(
|
||||
newProgress = newProgress,
|
||||
mediaPlayer = mediaPlayer,
|
||||
onProgressChanged = { progress = it },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
if (onReRecord != null) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
ReRecordButton(
|
||||
isUploading = isUploading,
|
||||
isPlaying = isPlaying,
|
||||
onRecordTaken = onReRecord,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReRecordButton(
|
||||
isUploading: Boolean,
|
||||
isPlaying: Boolean,
|
||||
onRecordTaken: (RecordingResult) -> Unit,
|
||||
) {
|
||||
if (isUploading || isPlaying) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = stringRes(id = R.string.record_a_message),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(id = R.string.re_record),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
RecordAudioBox(
|
||||
modifier = Modifier,
|
||||
onRecordTaken = onRecordTaken,
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
) { isRecording, elapsedSeconds ->
|
||||
val contentColor =
|
||||
if (isRecording) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val icon =
|
||||
if (isRecording) {
|
||||
Icons.Default.Stop
|
||||
} else {
|
||||
Icons.Default.Mic
|
||||
}
|
||||
val label =
|
||||
if (isRecording) {
|
||||
formatSecondsToTime(elapsedSeconds)
|
||||
} else {
|
||||
stringRes(id = R.string.re_record)
|
||||
}
|
||||
val iconDescription =
|
||||
if (isRecording) {
|
||||
stringRes(id = R.string.recording_indicator_description)
|
||||
} else {
|
||||
stringRes(id = R.string.record_a_message)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconDescription,
|
||||
tint = contentColor,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageMediaPlayer(
|
||||
voiceMetadata: AudioMeta,
|
||||
|
||||
@@ -29,5 +29,5 @@ enum class VoicePreset(
|
||||
NONE(1.0, R.string.voice_preset_none),
|
||||
DEEP(1.4, R.string.voice_preset_deep),
|
||||
HIGH(0.75, R.string.voice_preset_high),
|
||||
NEUTRAL(0.9, R.string.voice_preset_neutral),
|
||||
NEUTRAL(1.1, R.string.voice_preset_neutral),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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.broadcast
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
|
||||
/**
|
||||
* Banner showing active broadcast progress.
|
||||
* Displayed above bottom navigation when events are being sent to relays.
|
||||
*/
|
||||
@Composable
|
||||
fun BroadcastBanner(
|
||||
broadcasts: List<BroadcastEvent>,
|
||||
onTap: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = broadcasts.isNotEmpty(),
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 2.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.animateContentSize(),
|
||||
) {
|
||||
if (broadcasts.size == 1) {
|
||||
SingleBroadcastContent(broadcasts.first())
|
||||
} else {
|
||||
MultipleBroadcastsContent(broadcasts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sync,
|
||||
contentDescription = "Broadcasting",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Broadcasting ${broadcast.eventName} (kind ${broadcast.kind})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = "[${broadcast.results.size}/${broadcast.totalRelays}]",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = broadcast.progress,
|
||||
animationSpec = tween(300),
|
||||
label = "progress",
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
|
||||
val totalRelays = broadcasts.sumOf { it.totalRelays }
|
||||
val completedResponses = broadcasts.sumOf { it.results.size }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sync,
|
||||
contentDescription = "Broadcasting",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Broadcasting ${broadcasts.size} events...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "[$completedResponses/$totalRelays]",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
val progress = if (totalRelays > 0) completedResponses.toFloat() / totalRelays else 0f
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
animationSpec = tween(300),
|
||||
label = "progress",
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 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.broadcast
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
private const val MAX_EXPANDED_SECTIONS = 2
|
||||
|
||||
/**
|
||||
* Modal bottom sheet showing detailed relay results for broadcasts.
|
||||
* Shows up to 2 expanded sections, with a summary for additional broadcasts.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BroadcastDetailsSheet(
|
||||
broadcast: BroadcastEvent,
|
||||
onDismiss: () -> Unit,
|
||||
onRetryRelay: (NormalizedRelayUrl) -> Unit,
|
||||
onRetryAllFailed: () -> Unit,
|
||||
sheetState: SheetState =
|
||||
rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
),
|
||||
) {
|
||||
MultiBroadcastDetailsSheet(
|
||||
broadcasts = listOf(broadcast),
|
||||
onDismiss = onDismiss,
|
||||
onRetryRelay = { _, relay -> onRetryRelay(relay) },
|
||||
onRetryAllFailed = { onRetryAllFailed() },
|
||||
sheetState = sheetState,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal bottom sheet showing detailed relay results for multiple broadcasts.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MultiBroadcastDetailsSheet(
|
||||
broadcasts: List<BroadcastEvent>,
|
||||
onDismiss: () -> Unit,
|
||||
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit,
|
||||
onRetryAllFailed: (BroadcastEvent) -> Unit,
|
||||
sheetState: SheetState =
|
||||
rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
),
|
||||
) {
|
||||
// Synced rotation animation for all pending/retrying icons
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
|
||||
val rotationAngle by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "rotation",
|
||||
)
|
||||
|
||||
// Track which sections are expanded (by broadcast ID)
|
||||
var expandedSections by remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
// Sort: in-progress first, then by start time
|
||||
val sortedBroadcasts =
|
||||
broadcasts.sortedWith(
|
||||
compareBy<BroadcastEvent> { it.status != BroadcastStatus.IN_PROGRESS }
|
||||
.thenByDescending { it.startedAt },
|
||||
)
|
||||
|
||||
// First 2 get shown expanded by default (unless completed)
|
||||
val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS)
|
||||
val overflowBroadcasts = sortedBroadcasts.drop(MAX_EXPANDED_SECTIONS)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = if (broadcasts.size == 1) "Broadcast Results" else "Broadcasts (${broadcasts.size})",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Visible broadcast sections
|
||||
visibleBroadcasts.forEachIndexed { index, broadcast ->
|
||||
val isExpanded =
|
||||
broadcast.id in expandedSections ||
|
||||
(broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections)
|
||||
|
||||
// Auto-expand in-progress, auto-collapse completed (unless manually expanded)
|
||||
val showExpanded =
|
||||
if (broadcast.status == BroadcastStatus.IN_PROGRESS) {
|
||||
true
|
||||
} else {
|
||||
broadcast.id in expandedSections
|
||||
}
|
||||
|
||||
BroadcastSection(
|
||||
broadcast = broadcast,
|
||||
isExpanded = showExpanded,
|
||||
onToggleExpand = {
|
||||
expandedSections =
|
||||
if (broadcast.id in expandedSections) {
|
||||
expandedSections - broadcast.id
|
||||
} else {
|
||||
expandedSections + broadcast.id
|
||||
}
|
||||
},
|
||||
onRetryRelay = { relay -> onRetryRelay(broadcast, relay) },
|
||||
onRetryAllFailed = { onRetryAllFailed(broadcast) },
|
||||
rotationAngle = rotationAngle,
|
||||
)
|
||||
|
||||
if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow summary
|
||||
if (overflowBroadcasts.isNotEmpty()) {
|
||||
OverflowSummary(overflowBroadcasts)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Dismiss button
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BroadcastSection(
|
||||
broadcast: BroadcastEvent,
|
||||
isExpanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onRetryRelay: (NormalizedRelayUrl) -> Unit,
|
||||
onRetryAllFailed: () -> Unit,
|
||||
rotationAngle: Float,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Section header (clickable to expand/collapse)
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggleExpand() },
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) {
|
||||
StatusIcon(broadcast.status, rotationAngle)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = broadcast.eventName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "kind ${broadcast.kind}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${broadcast.successCount}/${broadcast.totalRelays}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = statusColor(broadcast.status),
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expandable relay list
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column {
|
||||
broadcast.targetRelays.forEach { relay ->
|
||||
val result = broadcast.results[relay] ?: RelayResult.Pending
|
||||
RelayResultRow(
|
||||
relay = relay,
|
||||
result = result,
|
||||
onRetry = { onRetryRelay(relay) },
|
||||
rotationAngle = rotationAngle,
|
||||
)
|
||||
}
|
||||
|
||||
// Retry all button
|
||||
if (broadcast.failedRelays.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onRetryAllFailed,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Retry Failed (${broadcast.failedRelays.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverflowSummary(broadcasts: List<BroadcastEvent>) {
|
||||
val totalSuccess = broadcasts.sumOf { it.successCount }
|
||||
val totalRelays = broadcasts.sumOf { it.totalRelays }
|
||||
val inProgressCount = broadcasts.count { it.status == BroadcastStatus.IN_PROGRESS }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.HourglassEmpty,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "+${broadcasts.size} more broadcasts",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
buildString {
|
||||
append("$totalSuccess/$totalRelays relays")
|
||||
if (inProgressCount > 0) {
|
||||
append(" ($inProgressCount in progress)")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun statusColor(status: BroadcastStatus): Color =
|
||||
when (status) {
|
||||
BroadcastStatus.SUCCESS -> Color(0xFF22C55E)
|
||||
BroadcastStatus.PARTIAL -> Color(0xFFF59E0B)
|
||||
BroadcastStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusIcon(
|
||||
status: BroadcastStatus,
|
||||
rotationAngle: Float = 0f,
|
||||
) {
|
||||
val (icon, tint, shouldRotate) =
|
||||
when (status) {
|
||||
BroadcastStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, Color(0xFF22C55E), false)
|
||||
BroadcastStatus.PARTIAL -> Triple(Icons.Default.Error, Color(0xFFF59E0B), false)
|
||||
BroadcastStatus.FAILED -> Triple(Icons.Default.Error, MaterialTheme.colorScheme.error, false)
|
||||
BroadcastStatus.IN_PROGRESS -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = status.name,
|
||||
tint = tint,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.then(
|
||||
if (shouldRotate) {
|
||||
Modifier.graphicsLayer { rotationZ = rotationAngle }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayResultRow(
|
||||
relay: NormalizedRelayUrl,
|
||||
result: RelayResult,
|
||||
onRetry: () -> Unit,
|
||||
rotationAngle: Float = 0f,
|
||||
) {
|
||||
val successColor = Color(0xFF22C55E)
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val warningColor = Color(0xFFF59E0B)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Status icon
|
||||
val (icon, tint, shouldRotate) =
|
||||
when (result) {
|
||||
is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false)
|
||||
is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, false)
|
||||
is RelayResult.Timeout -> Triple(Icons.Default.HourglassEmpty, warningColor, false)
|
||||
is RelayResult.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||
is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.then(
|
||||
if (shouldRotate) {
|
||||
Modifier.graphicsLayer { rotationZ = rotationAngle }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// Relay URL
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = relay.displayUrl(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Error message if present
|
||||
when (result) {
|
||||
is RelayResult.Error -> {
|
||||
Text(
|
||||
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = errorColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
is RelayResult.Timeout -> {
|
||||
Text(
|
||||
text = "Timeout",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = warningColor,
|
||||
)
|
||||
}
|
||||
is RelayResult.Retrying -> {
|
||||
Text(
|
||||
text = "Retrying...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry button for failed relays (not when already retrying)
|
||||
if ((result is RelayResult.Error || result is RelayResult.Timeout) && result !is RelayResult.Retrying) {
|
||||
IconButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Retry",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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.broadcast
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarData
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.SnackbarVisuals
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* Custom snackbar visuals for broadcast results.
|
||||
*/
|
||||
data class BroadcastSnackbarVisuals(
|
||||
val broadcast: BroadcastEvent,
|
||||
override val actionLabel: String? = "View",
|
||||
override val duration: SnackbarDuration = SnackbarDuration.Short,
|
||||
override val withDismissAction: Boolean = true,
|
||||
) : SnackbarVisuals {
|
||||
override val message: String
|
||||
get() =
|
||||
when (broadcast.status) {
|
||||
BroadcastStatus.SUCCESS -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
|
||||
BroadcastStatus.PARTIAL -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
|
||||
BroadcastStatus.FAILED -> "${broadcast.eventName} failed - 0/${broadcast.totalRelays} relays"
|
||||
BroadcastStatus.IN_PROGRESS -> "Broadcasting ${broadcast.eventName}..."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snackbar host that listens to completed broadcasts and shows result notifications.
|
||||
*/
|
||||
@Composable
|
||||
fun BroadcastSnackbarHost(
|
||||
completedBroadcast: SharedFlow<BroadcastEvent>,
|
||||
onViewDetails: (BroadcastEvent) -> Unit,
|
||||
onRetry: (BroadcastEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(completedBroadcast) {
|
||||
completedBroadcast.collect { broadcast ->
|
||||
val visuals = BroadcastSnackbarVisuals(broadcast)
|
||||
val result = snackbarHostState.showSnackbar(visuals)
|
||||
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> {
|
||||
if (broadcast.status == BroadcastStatus.FAILED) {
|
||||
onRetry(broadcast)
|
||||
} else {
|
||||
onViewDetails(broadcast)
|
||||
}
|
||||
}
|
||||
SnackbarResult.Dismissed -> { /* No action */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = modifier.padding(bottom = 8.dp),
|
||||
) { snackbarData ->
|
||||
BroadcastSnackbarContent(snackbarData)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BroadcastSnackbarContent(snackbarData: SnackbarData) {
|
||||
val visuals = snackbarData.visuals
|
||||
val broadcastVisuals = visuals as? BroadcastSnackbarVisuals
|
||||
|
||||
val containerColor =
|
||||
when (broadcastVisuals?.broadcast?.status) {
|
||||
BroadcastStatus.FAILED -> Color(0xFF7F1D1D) // Dark red
|
||||
BroadcastStatus.PARTIAL -> Color(0xFF78350F) // Dark amber
|
||||
else -> Color(0xFF1E3A5F) // Dark blue (default)
|
||||
}
|
||||
|
||||
val actionLabel =
|
||||
when (broadcastVisuals?.broadcast?.status) {
|
||||
BroadcastStatus.FAILED -> "Retry"
|
||||
else -> "View"
|
||||
}
|
||||
|
||||
Snackbar(
|
||||
action = {
|
||||
snackbarData.visuals.actionLabel?.let {
|
||||
TextButton(onClick = { snackbarData.performAction() }) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissAction =
|
||||
if (visuals.withDismissAction) {
|
||||
{
|
||||
TextButton(onClick = { snackbarData.dismiss() }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
containerColor = containerColor,
|
||||
) {
|
||||
Text(visuals.message)
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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.broadcast
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Displays broadcast progress UI components:
|
||||
* - BroadcastBanner: Shows active broadcasts with progress
|
||||
* - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s)
|
||||
* - BroadcastDetailsSheet: Shows detailed relay status on tap
|
||||
*
|
||||
* Only shown when FeatureSetType.COMPLETE is enabled.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
|
||||
// Only show in COMPLETE UI mode
|
||||
if (!accountViewModel.settings.isCompleteUIMode()) return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
|
||||
|
||||
// State for completed broadcast (with auto-dismiss)
|
||||
var completedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
|
||||
|
||||
// State for details sheet
|
||||
var selectedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
|
||||
|
||||
// Collect completed broadcasts and set auto-dismiss timer
|
||||
LaunchedEffect(Unit) {
|
||||
accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast ->
|
||||
completedBroadcast = broadcast
|
||||
|
||||
// Auto-dismiss after LONG duration
|
||||
delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS)
|
||||
|
||||
// Only dismiss if still showing same broadcast
|
||||
if (completedBroadcast?.id == broadcast.id) {
|
||||
completedBroadcast = null
|
||||
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Banner for active broadcasts (above bottom navigation)
|
||||
BroadcastBanner(
|
||||
broadcasts = activeBroadcasts,
|
||||
onTap = {
|
||||
activeBroadcasts.firstOrNull()?.let { selectedBroadcast = it }
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 56.dp),
|
||||
)
|
||||
|
||||
// Completed broadcast indicator (when no active broadcasts)
|
||||
if (activeBroadcasts.isEmpty()) {
|
||||
CompletedBroadcastIndicator(
|
||||
broadcast = completedBroadcast,
|
||||
onTap = { broadcast ->
|
||||
selectedBroadcast = broadcast
|
||||
},
|
||||
onRetry = { b ->
|
||||
scope.launch {
|
||||
accountViewModel.broadcastTracker.retry(
|
||||
broadcast = b,
|
||||
client = accountViewModel.account.client,
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = { broadcast ->
|
||||
completedBroadcast = null
|
||||
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Details sheet - show all broadcasts when opened
|
||||
if (selectedBroadcast != null) {
|
||||
// Combine active broadcasts with the selected/completed one
|
||||
val allBroadcasts =
|
||||
(activeBroadcasts + listOfNotNull(completedBroadcast))
|
||||
.distinctBy { it.id }
|
||||
|
||||
MultiBroadcastDetailsSheet(
|
||||
broadcasts = allBroadcasts,
|
||||
onDismiss = { selectedBroadcast = null },
|
||||
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
|
||||
scope.launch {
|
||||
accountViewModel.broadcastTracker.retry(
|
||||
broadcast = b,
|
||||
client = accountViewModel.account.client,
|
||||
specificRelay = relay,
|
||||
)
|
||||
}
|
||||
},
|
||||
onRetryAllFailed = { b: BroadcastEvent ->
|
||||
scope.launch {
|
||||
accountViewModel.broadcastTracker.retry(
|
||||
broadcast = b,
|
||||
client = accountViewModel.account.client,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact indicator for completed broadcasts.
|
||||
* Styled consistently with BroadcastBanner.
|
||||
* Tappable to show details sheet, with retry button for failures.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompletedBroadcastIndicator(
|
||||
broadcast: BroadcastEvent?,
|
||||
onTap: (BroadcastEvent) -> Unit,
|
||||
onRetry: (BroadcastEvent) -> Unit,
|
||||
onDismiss: (BroadcastEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Status colors (for icon tint only)
|
||||
val successColor = Color(0xFF22C55E)
|
||||
val warningColor = Color(0xFFF59E0B)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = broadcast != null,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
broadcast?.let { b ->
|
||||
val (statusIcon, iconTint) =
|
||||
when (b.status) {
|
||||
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
|
||||
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
|
||||
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
|
||||
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
// Same styling as BroadcastBanner
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 2.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap(b) },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Small status icon (like BroadcastBanner)
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = b.status.name,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "${b.eventName} sent",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "[${b.successCount}/${b.totalRelays}]",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = iconTint,
|
||||
)
|
||||
}
|
||||
|
||||
if (b.failedRelays.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Tap to view details",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry button for failures
|
||||
if (b.failedRelays.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = { onRetry(b) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Retry failed",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss X
|
||||
Text(
|
||||
text = "×",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { onDismiss(b) }
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,37 @@ import java.io.IOException
|
||||
|
||||
object ShareHelper {
|
||||
private const val TAG = "ShareHelper"
|
||||
private const val DEFAULT_EXTENSION = "jpg"
|
||||
private const val DEFAULT_IMAGE_EXTENSION = "jpg"
|
||||
private const val DEFAULT_VIDEO_EXTENSION = "mp4"
|
||||
private const val SHARED_FILE_PREFIX = "shared_media"
|
||||
|
||||
// Media type magic numbers
|
||||
data class SharableFile(
|
||||
val uri: Uri,
|
||||
val extension: String,
|
||||
val file: File,
|
||||
)
|
||||
|
||||
// Image type magic numbers
|
||||
private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte())
|
||||
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte())
|
||||
private val WEBP_HEADER_START = "RIFF".toByteArray()
|
||||
private val WEBP_HEADER_END = "WEBP".toByteArray()
|
||||
private val GIF_MAGIC = "GIF8".toByteArray()
|
||||
|
||||
// Video type magic numbers
|
||||
// Note: WebM and MKV share the same EBML header (Matroska container)
|
||||
private val WEBM_MAGIC = byteArrayOf(0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte())
|
||||
private val AVI_HEADER_START = "RIFF".toByteArray()
|
||||
private val AVI_HEADER_END = "AVI ".toByteArray()
|
||||
private val MOV_FTYP = "ftyp".toByteArray()
|
||||
private val MOV_MOOV = "moov".toByteArray()
|
||||
private val MOV_MDAT = "mdat".toByteArray()
|
||||
private val MOV_FREE = "free".toByteArray()
|
||||
private val MP4_BRAND_ISOM = "isom".toByteArray()
|
||||
private val MP4_BRAND_MP41 = "mp41".toByteArray()
|
||||
private val MP4_BRAND_MP42 = "mp42".toByteArray()
|
||||
private val MOV_BRAND_QT = "qt ".toByteArray()
|
||||
|
||||
suspend fun getSharableUriFromUrl(
|
||||
context: Context,
|
||||
imageUrl: String,
|
||||
@@ -64,39 +85,85 @@ object ShareHelper {
|
||||
} ?: throw IOException("Unable to open snapshot for: $imageUrl")
|
||||
}
|
||||
|
||||
private fun getImageExtension(file: File): String =
|
||||
try {
|
||||
private fun getImageExtension(file: File): String = getMediaExtension(file, isVideo = false)
|
||||
|
||||
private fun getVideoExtension(file: File): String = getMediaExtension(file, isVideo = true)
|
||||
|
||||
private fun getMediaExtension(
|
||||
file: File,
|
||||
isVideo: Boolean,
|
||||
): String {
|
||||
val defaultExtension = if (isVideo) DEFAULT_VIDEO_EXTENSION else DEFAULT_IMAGE_EXTENSION
|
||||
return try {
|
||||
FileInputStream(file).use { inputStream ->
|
||||
val header = ByteArray(12)
|
||||
val header = ByteArray(16)
|
||||
val bytesRead = inputStream.read(header)
|
||||
|
||||
if (bytesRead < 4) {
|
||||
// If we couldn't read at least 4 bytes, default to jpg
|
||||
return DEFAULT_EXTENSION
|
||||
return defaultExtension
|
||||
}
|
||||
|
||||
when {
|
||||
// JPEG: Check first 2 bytes
|
||||
matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg"
|
||||
if (isVideo) {
|
||||
when {
|
||||
// Video formats
|
||||
// WebM/MKV: Check first 4 bytes (EBML header)
|
||||
// Both use Matroska container; default to webm as it's more common on web
|
||||
matchesMagicNumbers(header, 0, WEBM_MAGIC) -> "webm"
|
||||
|
||||
// PNG: Check first 4 bytes
|
||||
matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png"
|
||||
// AVI: Check "RIFF" (bytes 0-3) and "AVI " (bytes 8-11)
|
||||
matchesMagicNumbers(header, 0, AVI_HEADER_START) &&
|
||||
bytesRead >= 12 &&
|
||||
matchesMagicNumbers(header, 8, AVI_HEADER_END) -> "avi"
|
||||
|
||||
// GIF: Check first 4 bytes for "GIF8"
|
||||
matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif"
|
||||
// MP4/MOV: Check for ftyp box (bytes 4-7 should be "ftyp")
|
||||
bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header)
|
||||
|
||||
// WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11)
|
||||
matchesMagicNumbers(header, 0, WEBP_HEADER_START) &&
|
||||
bytesRead >= 12 &&
|
||||
matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp"
|
||||
// MP4/MOV alternative: moov, mdat, or free at offset 4
|
||||
bytesRead >= 8 && (
|
||||
matchesMagicNumbers(header, 4, MOV_MOOV) ||
|
||||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
|
||||
matchesMagicNumbers(header, 4, MOV_FREE)
|
||||
) -> "mp4"
|
||||
|
||||
else -> DEFAULT_EXTENSION
|
||||
else -> defaultExtension
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
// Image formats
|
||||
// JPEG: Check first 2 bytes
|
||||
matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg"
|
||||
|
||||
// PNG: Check first 4 bytes
|
||||
matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png"
|
||||
|
||||
// GIF: Check first 4 bytes for "GIF8"
|
||||
matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif"
|
||||
|
||||
// WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11)
|
||||
matchesMagicNumbers(header, 0, WEBP_HEADER_START) &&
|
||||
bytesRead >= 12 &&
|
||||
matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp"
|
||||
|
||||
else -> defaultExtension
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e)
|
||||
DEFAULT_EXTENSION
|
||||
Log.w(TAG, "Could not determine media type for ${file.name}, defaulting to $defaultExtension", e)
|
||||
defaultExtension
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectMp4OrMov(header: ByteArray): String {
|
||||
// Check brand at bytes 8-11 to differentiate MP4 from MOV
|
||||
return when {
|
||||
matchesMagicNumbers(header, 8, MP4_BRAND_ISOM) -> "mp4"
|
||||
matchesMagicNumbers(header, 8, MP4_BRAND_MP41) -> "mp4"
|
||||
matchesMagicNumbers(header, 8, MP4_BRAND_MP42) -> "mp4"
|
||||
matchesMagicNumbers(header, 8, MOV_BRAND_QT) -> "mov"
|
||||
else -> "mp4" // Default to mp4 for unknown ftyp brands
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesMagicNumbers(
|
||||
data: ByteArray,
|
||||
@@ -132,4 +199,58 @@ object ShareHelper {
|
||||
|
||||
return sharableFile
|
||||
}
|
||||
|
||||
suspend fun getSharableUriForLocalVideo(
|
||||
context: Context,
|
||||
localFile: File,
|
||||
): Pair<Uri, String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val fileExtension = getVideoExtension(localFile)
|
||||
val sharableFile = prepareSharableFile(context, localFile, fileExtension)
|
||||
Pair(
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile),
|
||||
fileExtension,
|
||||
)
|
||||
}
|
||||
|
||||
fun createTempVideoFile(context: Context): File {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
return File(context.cacheDir, "${SHARED_FILE_PREFIX}_video_$timestamp.tmp")
|
||||
}
|
||||
|
||||
fun prepareTempVideoForSharing(
|
||||
context: Context,
|
||||
tempFile: File,
|
||||
): SharableFile {
|
||||
val extension = getVideoExtension(tempFile)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension")
|
||||
|
||||
try {
|
||||
moveTempFileForSharing(tempFile, sharableFile)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to rename temp video file for sharing", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
return SharableFile(
|
||||
uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile),
|
||||
extension = extension,
|
||||
file = sharableFile,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun moveTempFileForSharing(
|
||||
tempFile: File,
|
||||
sharableFile: File,
|
||||
rename: (File, File) -> Boolean = { source, dest -> source.renameTo(dest) },
|
||||
) {
|
||||
val renamed = rename(tempFile, sharableFile)
|
||||
if (!renamed) {
|
||||
tempFile.copyTo(sharableFile, overwrite = true)
|
||||
if (!tempFile.delete()) {
|
||||
Log.w(TAG, "Failed to delete temp file ${tempFile.path} after copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -234,7 +234,7 @@ private fun DialogContent(
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
||||
}
|
||||
|
||||
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
|
||||
|
||||
+218
-20
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -52,7 +53,6 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
@@ -108,12 +109,25 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
// Delay before cleaning up shared video temp files.
|
||||
// Allows time for receiving app to copy the file after user confirms share.
|
||||
private const val SHARED_VIDEO_CLEANUP_DELAY_MS = 120_000L
|
||||
|
||||
@Composable
|
||||
fun ZoomableContentView(
|
||||
content: BaseMediaContent,
|
||||
@@ -232,8 +246,8 @@ fun LocalImageView(
|
||||
}
|
||||
|
||||
val ratio = remember(content) { content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.localFile.toString()) }
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
if (it) {
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { imageVisible ->
|
||||
if (imageVisible) {
|
||||
SubcomposeAsyncImage(
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
@@ -538,11 +552,7 @@ fun ShowHash(content: MediaUrlContent) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WaitAndDisplay(
|
||||
content:
|
||||
@Composable()
|
||||
(AnimatedVisibilityScope.() -> Unit),
|
||||
) {
|
||||
fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
|
||||
val visible = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -670,14 +680,14 @@ fun DisplayBlurHash(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareImageAction(
|
||||
fun ShareMediaAction(
|
||||
accountViewModel: AccountViewModel,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
content: BaseMediaContent,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (content is MediaUrlContent) {
|
||||
ShareImageAction(
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.url,
|
||||
@@ -690,7 +700,7 @@ fun ShareImageAction(
|
||||
content = content,
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
ShareImageAction(
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.localFile?.toUri().toString(),
|
||||
@@ -707,7 +717,7 @@ fun ShareImageAction(
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun ShareImageAction(
|
||||
fun ShareMediaAction(
|
||||
accountViewModel: AccountViewModel,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
videoUri: String?,
|
||||
@@ -719,11 +729,14 @@ fun ShareImageAction(
|
||||
onDismiss: () -> Unit,
|
||||
content: BaseMediaContent? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scope = accountViewModel.viewModelScope
|
||||
|
||||
// Track if video is downloading - hoisted here to block menu dismiss during download
|
||||
val isDownloadingVideo = remember { mutableStateOf(false) }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() },
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
@@ -765,19 +778,72 @@ fun ShareImageAction(
|
||||
}
|
||||
|
||||
content?.let {
|
||||
if (content is MediaUrlImage) {
|
||||
val context = LocalContext.current
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
val context = LocalContext.current
|
||||
|
||||
when (content) {
|
||||
is MediaUrlImage -> {
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.share_image)) },
|
||||
onClick = {
|
||||
scope.launch { shareImageFile(context, videoUri, mimeType) }
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MediaUrlVideo -> {
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringRes(R.string.share_video))
|
||||
if (isDownloadingVideo.value) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isDownloadingVideo.value,
|
||||
onClick = {
|
||||
isDownloadingVideo.value = true
|
||||
scope.launch {
|
||||
shareVideoFile(
|
||||
context = context,
|
||||
videoUrl = videoUri,
|
||||
mimeType = mimeType,
|
||||
okHttpClient = { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
|
||||
},
|
||||
onComplete = {
|
||||
isDownloadingVideo.value = false
|
||||
onDismiss()
|
||||
},
|
||||
onError = {
|
||||
isDownloadingVideo.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MediaLocalVideo -> {
|
||||
content.localFile?.let { localFile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.share_image)) },
|
||||
text = { Text(stringRes(R.string.share_video)) },
|
||||
onClick = {
|
||||
scope.launch { shareImageFile(context, videoUri, mimeType) }
|
||||
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> { /* No share option for other types */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -810,6 +876,138 @@ private suspend fun shareImageFile(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private suspend fun shareVideoFile(
|
||||
context: Context,
|
||||
videoUrl: String,
|
||||
mimeType: String?,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onComplete: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
val tempFile = ShareHelper.createTempVideoFile(context)
|
||||
var sharedFile: File? = null
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Download video using streaming
|
||||
val client = okHttpClient(videoUrl)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.get()
|
||||
.url(videoUrl)
|
||||
.build()
|
||||
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
check(response.isSuccessful) { "Download failed: ${response.code}" }
|
||||
val responseBody = response.body
|
||||
|
||||
// Stream the response to the temp file
|
||||
tempFile.outputStream().use { outputStream ->
|
||||
val bytesCopied = responseBody.source().readAll(outputStream.sink())
|
||||
if (bytesCopied == 0L) {
|
||||
throw IOException("Download failed: empty response body")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the temp file for sharing (determines extension and creates sharable URI)
|
||||
val (uri, extension, sharableFile) = ShareHelper.prepareTempVideoForSharing(context, tempFile)
|
||||
sharedFile = sharableFile
|
||||
|
||||
// Determine mime type
|
||||
val determinedMimeType = mimeType ?: "video/$extension"
|
||||
|
||||
// Create share intent
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = determinedMimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
context.startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule cleanup to allow the receiving app time to copy the file.
|
||||
// GlobalScope is intentional: cleanup must survive after share UI is dismissed.
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(SHARED_VIDEO_CLEANUP_DELAY_MS)
|
||||
sharedFile?.let { file ->
|
||||
if (!file.delete()) {
|
||||
Log.w("ZoomableContentView", "Failed to delete shared file: ${file.path}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onComplete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("ZoomableContentView", "Failed to share video: $videoUrl", e)
|
||||
|
||||
// Clean up temp file on error
|
||||
if (!tempFile.delete()) {
|
||||
Log.w("ZoomableContentView", "Failed to delete temp file: ${tempFile.path}")
|
||||
}
|
||||
sharedFile?.let { file ->
|
||||
if (!file.delete()) {
|
||||
Log.w("ZoomableContentView", "Failed to delete shared file: ${file.path}")
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.unable_to_share_video),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shareLocalVideoFile(
|
||||
context: Context,
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Get sharable URI for the local file
|
||||
val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile)
|
||||
|
||||
// Determine mime type
|
||||
val determinedMimeType = mimeType ?: "video/$extension"
|
||||
|
||||
// Create share intent
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = determinedMimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
context.startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("ZoomableContentView", "Failed to share local video: ${localFile.path}", e)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.unable_to_share_video),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card
|
||||
|
||||
val DefaultFeedOrder: Comparator<Note> =
|
||||
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
@@ -310,6 +311,7 @@ fun AppNavigation(
|
||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||
DisplayNotifyMessages(accountViewModel, nav)
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
|
||||
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
@@ -414,7 +415,9 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(
|
||||
) {
|
||||
val hasReactions by observeNoteReferences(baseNote, accountViewModel)
|
||||
|
||||
if (hasReactions) {
|
||||
val hasZapraiser = (baseNote.event?.zapraiserAmount() ?: 0) > 0
|
||||
|
||||
if (hasReactions || hasZapraiser) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -635,6 +638,7 @@ fun ReplyViaVoiceReaction(
|
||||
)
|
||||
}
|
||||
},
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
) { isRecording, elapsedSeconds ->
|
||||
if (voiceRecordingState != null) {
|
||||
SideEffect {
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import android.R.attr.label
|
||||
import android.R.attr.maxLines
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
|
||||
+125
-7
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
@@ -59,6 +60,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.service.cashu.CashuToken
|
||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -76,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
@@ -166,6 +167,7 @@ class AccountViewModel(
|
||||
var firstRoute: Route? = null
|
||||
|
||||
val toastManager = ToastManager()
|
||||
val broadcastTracker = BroadcastTracker()
|
||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||
|
||||
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
|
||||
@@ -313,7 +315,25 @@ class AccountViewModel(
|
||||
if (currentReactions.isNotEmpty()) {
|
||||
account.delete(currentReactions)
|
||||
} else {
|
||||
account.reactTo(note, reaction)
|
||||
if (settings.isCompleteUIMode()) {
|
||||
// Tracked broadcasting with progress feedback
|
||||
account.createReactionEvent(note, reaction)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Reaction",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
// Only consume event if at least one relay succeeded
|
||||
if (result.isSuccess) {
|
||||
account.consumeReactionEvent(event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget (original behavior)
|
||||
account.reactTo(note, reaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,7 +727,29 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun boost(note: Note) = launchSigner { account.boost(note) }
|
||||
fun boost(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
// Tracked broadcasting with progress feedback
|
||||
launchSigner {
|
||||
account.createBoostEvent(note)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Boost",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
// Only consume event if at least one relay succeeded
|
||||
if (result.isSuccess) {
|
||||
account.consumeBoostEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget (original behavior)
|
||||
launchSigner { account.boost(note) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) }
|
||||
|
||||
@@ -729,13 +771,89 @@ class AccountViewModel(
|
||||
|
||||
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
|
||||
|
||||
fun addPrivateBookmark(note: Note) = launchSigner { account.addBookmark(note, true) }
|
||||
fun addPrivateBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createAddBookmarkEvent(note, true)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.addBookmark(note, true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addPublicBookmark(note: Note) = launchSigner { account.addBookmark(note, false) }
|
||||
fun addPublicBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createAddBookmarkEvent(note, false)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.addBookmark(note, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removePrivateBookmark(note: Note) = launchSigner { account.removeBookmark(note, true) }
|
||||
fun removePrivateBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Remove Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.removeBookmark(note, true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removePublicBookmark(note: Note) = launchSigner { account.removeBookmark(note, false) }
|
||||
fun removePublicBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Remove Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.removeBookmark(note, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun broadcast(note: Note) = launchSigner { account.broadcast(note) }
|
||||
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
|
||||
|
||||
+1
-1
@@ -21,8 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// This allows multiple screen to be listening to tags, even the same tag
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// This allows multiple screen to be listening to tags, even the same tag
|
||||
|
||||
+1
-1
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
|
||||
@Composable
|
||||
fun CommunityFilterAssemblerSubscription(
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// This allows multiple screen to be listening to tags, even the same tag
|
||||
|
||||
+1
-1
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasourc
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.filterHomePostsByAuthors
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
|
||||
+4
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -374,6 +375,8 @@ private fun NewPostScreenBody(
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = displayMetadata,
|
||||
localFile = postViewModel.activeFile,
|
||||
onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) },
|
||||
isUploading = postViewModel.isUploadingVoice,
|
||||
onRemove = { postViewModel.removeVoiceMessage() },
|
||||
)
|
||||
|
||||
@@ -497,6 +500,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
onVoiceTaken = { recording ->
|
||||
postViewModel.selectVoiceRecording(recording)
|
||||
},
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
)
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
|
||||
+38
-1
@@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
@@ -544,12 +545,48 @@ open class ShortNotePostViewModel :
|
||||
val version = draftTag.current
|
||||
cancel()
|
||||
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
if (accountViewModel.settings.isCompleteUIMode()) {
|
||||
// Tracked broadcasting with progress feedback (non-blocking)
|
||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
||||
val eventName = getEventName(event)
|
||||
|
||||
// Launch broadcast in background - don't wait for completion
|
||||
accountViewModel.viewModelScope.launch {
|
||||
val result =
|
||||
accountViewModel.broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = eventName,
|
||||
relays = relays,
|
||||
client = accountViewModel.account.client,
|
||||
)
|
||||
|
||||
// Only consume event if at least one relay succeeded
|
||||
if (result.isSuccess) {
|
||||
accountViewModel.account.consumePostEvent(event, relays, extras)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget (original behavior)
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
}
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEventName(event: Event): String =
|
||||
when (event) {
|
||||
is TextNoteEvent -> {
|
||||
val quotes = event.taggedQuotes()
|
||||
if (quotes.isNotEmpty()) "Quote" else "Post"
|
||||
}
|
||||
is PollNoteEvent -> "Poll"
|
||||
is VoiceEvent -> "Voice"
|
||||
is VoiceReplyEvent -> "Voice Reply"
|
||||
else -> "Post"
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
|
||||
+2
-94
@@ -21,46 +21,32 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
@@ -103,9 +89,6 @@ fun VoiceReplyScreen(
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
ReRecordButton(viewModel)
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
@@ -161,6 +144,8 @@ private fun VoiceReplyScreenBody(
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = displayMetadata,
|
||||
localFile = viewModel.activeFile,
|
||||
onReRecord = { recording -> viewModel.selectRecording(recording) },
|
||||
isUploading = viewModel.isUploading,
|
||||
onRemove = {
|
||||
viewModel.cancel()
|
||||
nav.popBack()
|
||||
@@ -192,80 +177,3 @@ private fun VoiceReplyScreenBody(
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReRecordButton(viewModel: VoiceReplyViewModel) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(vertical = 16.dp, horizontal = Size10dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (viewModel.isUploading) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = stringRes(id = R.string.record_a_message),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(id = R.string.re_record),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
RecordAudioBox(
|
||||
modifier = Modifier,
|
||||
onRecordTaken = { recording ->
|
||||
viewModel.selectRecording(recording)
|
||||
},
|
||||
) { isRecording, elapsedSeconds ->
|
||||
val contentColor =
|
||||
if (isRecording) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
}
|
||||
val icon =
|
||||
if (isRecording) {
|
||||
Icons.Default.Stop
|
||||
} else {
|
||||
Icons.Default.Mic
|
||||
}
|
||||
val label =
|
||||
if (isRecording) {
|
||||
formatSecondsToTime(elapsedSeconds)
|
||||
} else {
|
||||
stringRes(id = R.string.re_record)
|
||||
}
|
||||
val iconDescription =
|
||||
if (isRecording) {
|
||||
stringRes(id = R.string.recording_indicator_description)
|
||||
} else {
|
||||
stringRes(id = R.string.record_a_message)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconDescription,
|
||||
tint = contentColor,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+2
@@ -26,6 +26,8 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
|
||||
+1
-26
@@ -21,23 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Immutable
|
||||
interface Card {
|
||||
fun createdAt(): Long
|
||||
|
||||
fun id(): String
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class BadgeCard(
|
||||
@@ -113,19 +104,3 @@ class MessageSetCard(
|
||||
|
||||
override fun id() = note.idHex
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class CardFeedState {
|
||||
@Immutable object Loading : CardFeedState()
|
||||
|
||||
@Stable
|
||||
class Loaded(
|
||||
val feed: MutableStateFlow<LoadedFeedState<Card>>,
|
||||
) : CardFeedState()
|
||||
|
||||
@Immutable object Empty : CardFeedState()
|
||||
|
||||
@Immutable class FeedError(
|
||||
val errorMessage: String,
|
||||
) : CardFeedState()
|
||||
}
|
||||
|
||||
+2
@@ -43,6 +43,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// This allows multiple screen to be listening to tags, even the same tag
|
||||
|
||||
+1
-1
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
|
||||
@Composable
|
||||
fun UserProfileFilterAssemblerSubscription(
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
+7
-5
@@ -128,6 +128,8 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
private const val HTTPS_PREFIX = "https://"
|
||||
|
||||
@Composable
|
||||
fun RelayInformationScreen(
|
||||
relayUrl: String,
|
||||
@@ -434,7 +436,7 @@ fun FeesCard(
|
||||
}
|
||||
payUrl?.let {
|
||||
val uri = LocalUriHandler.current
|
||||
ClickableInfoRow(Icons.Default.Payment, stringRes(R.string.payments_url), it.removePrefix("https://")) {
|
||||
ClickableInfoRow(Icons.Default.Payment, stringRes(R.string.payments_url), it.removePrefix(HTTPS_PREFIX)) {
|
||||
runCatching {
|
||||
uri.openUri(it)
|
||||
}
|
||||
@@ -583,8 +585,8 @@ fun SoftwareCard(relayInfo: Nip11RelayInformation) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
val uri = LocalUriHandler.current
|
||||
relayInfo.software?.let {
|
||||
if (it.contains("https://")) {
|
||||
ClickableInfoRow(Icons.Default.Code, stringRes(R.string.software), it.removePrefix("git+https://").removePrefix("https://")) {
|
||||
if (it.contains(HTTPS_PREFIX)) {
|
||||
ClickableInfoRow(Icons.Default.Code, stringRes(R.string.software), it.removePrefix("git+https://").removePrefix(HTTPS_PREFIX)) {
|
||||
runCatching {
|
||||
uri.openUri(it.removePrefix("git+"))
|
||||
}
|
||||
@@ -809,7 +811,7 @@ fun PoliciesCard(relay: Nip11RelayInformation) {
|
||||
val pp = relay.privacy_policy
|
||||
|
||||
if (pp != null) {
|
||||
ClickableInfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), pp.removePrefix("https://")) {
|
||||
ClickableInfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), pp.removePrefix(HTTPS_PREFIX)) {
|
||||
runCatching {
|
||||
uri.openUri(pp)
|
||||
}
|
||||
@@ -820,7 +822,7 @@ fun PoliciesCard(relay: Nip11RelayInformation) {
|
||||
|
||||
val ts = relay.terms_of_service
|
||||
if (ts != null) {
|
||||
ClickableInfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), ts.removePrefix("https://")) {
|
||||
ClickableInfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), ts.removePrefix(HTTPS_PREFIX)) {
|
||||
runCatching {
|
||||
uri.openUri(ts)
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
+1
-1
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"></resources>
|
||||
@@ -1197,6 +1197,9 @@
|
||||
<string name="group_relay_explanation">Relé, ke kterému se všichni uživatelé tohoto chatu připojují</string>
|
||||
<string name="share_image">Sdílet obrázek…</string>
|
||||
<string name="unable_to_share_image">Nelze sdílet obrázek, zkuste to prosím později…</string>
|
||||
<string name="share_video">Sdílet video…</string>
|
||||
<string name="unable_to_share_video">Video nelze sdílet, zkuste to prosím později…</string>
|
||||
<string name="downloading_video_for_sharing">Stahování videa…</string>
|
||||
<string name="search_by_hashtag">Vyhledávání hashtag: #%1$s</string>
|
||||
<string name="dont_translate_from">Nepřekládat z</string>
|
||||
<string name="dont_translate_from_description">Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit.</string>
|
||||
|
||||
@@ -177,6 +177,7 @@ erie gespeichert</string>
|
||||
<string name="voice_preset_none">Keine</string>
|
||||
<string name="voice_preset_deep">Tief</string>
|
||||
<string name="voice_preset_high">Hoch</string>
|
||||
<string name="voice_preset_neutral">Neutral</string>
|
||||
<string name="voice_anonymize_title">Anonymisieren</string>
|
||||
<string name="voice_anonymize_description">Verändert die Tonhöhe deiner Stimme. Hinweis: einfache Tonhöhenänderungen können von entschlossenen Zuhörern möglicherweise rückgängig gemacht werden.</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>
|
||||
@@ -1201,6 +1202,9 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="group_relay_explanation">Das Relais, mit dem sich alle Benutzer dieses Chats verbinden</string>
|
||||
<string name="share_image">Bild teilen…</string>
|
||||
<string name="unable_to_share_image">Bild kann nicht geteilt werden, bitte versuchen Sie es später erneut…</string>
|
||||
<string name="share_video">Video teilen…</string>
|
||||
<string name="unable_to_share_video">Video kann nicht geteilt werden, bitte versuchen Sie es später erneut…</string>
|
||||
<string name="downloading_video_for_sharing">Video wird heruntergeladen…</string>
|
||||
<string name="search_by_hashtag">Suche Hashtag: #%1$s</string>
|
||||
<string name="dont_translate_from">Nicht übersetzen von</string>
|
||||
<string name="dont_translate_from_description">Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen.</string>
|
||||
|
||||
@@ -135,6 +135,8 @@
|
||||
<string name="add_a_user">Ajouter un utilisateur</string>
|
||||
<string name="add_a_relay">Ajouter un relais</string>
|
||||
<string name="profile_name">Nom</string>
|
||||
<string name="profile_name_with_explainer">Nom (pour @tagging)</string>
|
||||
<string name="my_name">Mon nom @tag</string>
|
||||
<string name="display_name">Nom visible du public</string>
|
||||
<string name="my_display_name">Mon nom visible du public</string>
|
||||
<string name="my_awesome_name">Ostrich McGénial</string>
|
||||
@@ -170,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">Erreur lors de l\'envoi</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n\'est pas encore supporté pour les messages vocaux</string>
|
||||
<string name="upload_error_voice_message_exception">Échec de l\'envoi de la voix : %1$s</string>
|
||||
<string name="voice_preset_none">Aucun</string>
|
||||
<string name="voice_preset_deep">Profond</string>
|
||||
<string name="voice_preset_high">Haute</string>
|
||||
<string name="voice_preset_neutral">Neutre</string>
|
||||
<string name="voice_anonymize_title">Anonymisation</string>
|
||||
<string name="voice_anonymize_description">Modifie votre niveau de voix. Remarque : les changements de hauteur de base peuvent potentiellement être inversés par des auditeurs déterminés.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
|
||||
<string name="reply_here">"répondre ici"</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr</string>
|
||||
@@ -364,6 +372,7 @@
|
||||
<string name="manual_zaps">Division manuelle de zaps</string>
|
||||
<string name="bookmarks">Favoris</string>
|
||||
<string name="bookmarks_title">Signets par défaut</string>
|
||||
<string name="bookmarks_explainer">Vos favoris par défaut que de nombreux clients prennent en charge</string>
|
||||
<string name="drafts">Brouillons</string>
|
||||
<string name="private_bookmarks">Favoris Privés</string>
|
||||
<string name="public_bookmarks">Favoris Publics</string>
|
||||
@@ -371,6 +380,40 @@
|
||||
<string name="add_to_public_bookmarks">Ajouter aux Favoris Publics</string>
|
||||
<string name="remove_from_private_bookmarks">Supprimer des Favoris Privés</string>
|
||||
<string name="remove_from_public_bookmarks">Supprimer des Favoris Publics</string>
|
||||
<string name="bookmark_lists">Listes de favoris</string>
|
||||
<string name="bookmark_list_icon_label">Icône pour la liste de favoris</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nouvelle liste de favoris</string>
|
||||
<string name="bookmark_list_edit_sub_title">Métadonnées de la liste de favoris</string>
|
||||
<string name="bookmark_list_clone_btn_label">Dupliquer la liste de favoris</string>
|
||||
<string name="bookmark_list_broadcast_btn_label">Diffuser la liste de favoris</string>
|
||||
<string name="bookmark_list_delete_btn_label">Supprimer la liste de favoris</string>
|
||||
<string name="bookmark_list_posts_btn_label">Voir les publications</string>
|
||||
<string name="bookmark_list_articles_btn_label">Voir les articles</string>
|
||||
<string name="bookmark_list_links_btn_label">Voir les liens</string>
|
||||
<string name="bookmark_list_hashtags_btn_label">Voir les hashtags</string>
|
||||
<string name="bookmark_list_feed_empty_msg">Vous n\'avez pas encore de liste de favoris. Appuyez sur le bouton ci-dessous pour en créer une.</string>
|
||||
<string name="private_posts_label">Publications privées</string>
|
||||
<string name="private_posts_count">Publications privées (%1$s)</string>
|
||||
<string name="public_posts_label">Publications publiques</string>
|
||||
<string name="public_posts_count">Publications publiques (%1$s)</string>
|
||||
<string name="private_articles_label">Articles privés</string>
|
||||
<string name="private_articles_count">Articles privés (%1$s)</string>
|
||||
<string name="public_articles_label">Articles publics</string>
|
||||
<string name="public_articles_count">Articles publics (%1$s)</string>
|
||||
<string name="manage_bookmark_label">Gérer %1$s dans les listes de favoris</string>
|
||||
<string name="post_bookmark_management_title">Ajouter cette publication aux favoris</string>
|
||||
<string name="article_bookmark_management_title">Ajouter cet article aux favoris</string>
|
||||
<string name="public_bookmark_presence_indicator">est un favori public ici</string>
|
||||
<string name="private_bookmark_presence_indicator">est un favori privé ici</string>
|
||||
<string name="bookmark_absence_indicator">n\'est pas un favori ici</string>
|
||||
<string name="bookmark_remove_action_desc">Supprimer le favori de la liste</string>
|
||||
<string name="bookmark_add_action_desc">Ajouter un favori à la liste</string>
|
||||
<string name="public_bookmark_add_action_label">Ajouter comme favori public</string>
|
||||
<string name="private_bookmark_add_action_label">Ajouter comme favori privé</string>
|
||||
<string name="bookmark_remove_action_label">Supprimer de la liste de favoris</string>
|
||||
<string name="bookmark_list_explainer">Les métadonnées des listes de favoris peuvent être vues par n\'importe qui sur Nostr. Seuls vos membres privés sont chiffrés.</string>
|
||||
<string name="move_bookmark_to_public_label">Déplacer en public</string>
|
||||
<string name="move_bookmark_to_private_label">Déplacer en privé</string>
|
||||
<string name="wallet_connect_service">Service Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Autorise un Nostr Secret à payer des zaps sans quitter l\'application. Gardez le secret en toute sécurité et utilisez un relais privé si possible</string>
|
||||
<string name="wallet_connect_service_pubkey">Clé publique Wallet Connect</string>
|
||||
@@ -604,12 +647,23 @@
|
||||
<string name="contact">Contact</string>
|
||||
<string name="supports">NIPs supportés</string>
|
||||
<string name="admission_fees">Frais d\'admission</string>
|
||||
<string name="publication">Publication</string>
|
||||
<string name="payment_link">Paiements %1$s</string>
|
||||
<string name="payments_url">URL de payments</string>
|
||||
<string name="target_audience">Audience cible</string>
|
||||
<string name="policies_and_links">Politiques & liens</string>
|
||||
<string name="fees_and_payments">Frais & paiements</string>
|
||||
<string name="limitations">Limites</string>
|
||||
<string name="countries">Pays</string>
|
||||
<string name="languages">Langages</string>
|
||||
<string name="tags">Étiquettes</string>
|
||||
<string name="topics">Sujets</string>
|
||||
<string name="all_countries">Tous les pays</string>
|
||||
<string name="all_languages">Toutes les langues</string>
|
||||
<string name="posting_policy">Politique de publication</string>
|
||||
<string name="privacy_policy">Politique de confidentialité</string>
|
||||
<string name="terms_and_conditions">Termes & Conditions</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Erreurs et Notifications de ce Relais</string>
|
||||
<string name="message_length">Longueur du message</string>
|
||||
<string name="subscriptions">Abonnements</string>
|
||||
@@ -618,9 +672,19 @@
|
||||
<string name="minimum_prefix">Préfixe minimum</string>
|
||||
<string name="maximum_event_tags">Tags d\'événement maximum</string>
|
||||
<string name="content_length">Longueur du contenu</string>
|
||||
<string name="max_content_length">Longueur maximale du contenu</string>
|
||||
<string name="discards_older_than">Rejeter plus anciens que</string>
|
||||
<string name="accepts_up_to">Accepte jusqu\'à</string>
|
||||
<string name="time_in_the_future">%1$s dans le futur</string>
|
||||
<string name="time_in_the_past">Il y a %1$s</string>
|
||||
<string name="content_size">Taille du contenu</string>
|
||||
<string name="connectivity">Connectivité</string>
|
||||
<string name="access_control">Contrôle d\'accès</string>
|
||||
<string name="minimum_pow">PoW minimum</string>
|
||||
<string name="auth">Authentification</string>
|
||||
<string name="auth_required">Authentification requise</string>
|
||||
<string name="payment">Paiement</string>
|
||||
<string name="payment_required">Paiement requis</string>
|
||||
<string name="cashu">Jeton Cashu</string>
|
||||
<string name="cashu_redeem">Échanger</string>
|
||||
<string name="cashu_redeem_to_zap">Envoyer vers le portefeuille Zap</string>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">अनपेक्षित आरोहण स्थिति</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
|
||||
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string>
|
||||
<string name="voice_preset_none">कुछ भी नहीं</string>
|
||||
<string name="voice_preset_deep">गहन</string>
|
||||
<string name="voice_preset_high">उच्च</string>
|
||||
<string name="voice_preset_neutral">मध्यम</string>
|
||||
<string name="voice_anonymize_title">अनामीकरण</string>
|
||||
<string name="voice_anonymize_description">आपके स्वर का परिवर्तन करता है। ध्यातव्य। आधारभूत स्वर परिवर्तन को पूर्ववत किया जा सकता है दृढ निश्चित श्रोताओं द्वारा।</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
|
||||
<string name="reply_here">"उत्तर यहाँ दें.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">नोस्ट्र में बाँटने के लिए टीका विभेदक की अनुकृति करता है टाँकाफलक में</string>
|
||||
@@ -1232,4 +1238,5 @@
|
||||
<string name="follow_pack_broadcast">पोटली प्रसारण</string>
|
||||
<string name="follow_set_delete">सूची मिटाएँ</string>
|
||||
<string name="follow_pack_delete">पोटली मिटाएँ</string>
|
||||
<string name="kinds">प्रकार</string>
|
||||
</resources>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">Váratlan feltöltési állapot</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">A NIP-95 még nem támogatja a hangüzeneteket</string>
|
||||
<string name="upload_error_voice_message_exception">Nem sikerült feltölteni a hangüzenetet: %1$s</string>
|
||||
<string name="voice_preset_none">Semmi</string>
|
||||
<string name="voice_preset_deep">Mély</string>
|
||||
<string name="voice_preset_high">Magas</string>
|
||||
<string name="voice_preset_neutral">Semleges</string>
|
||||
<string name="voice_anonymize_title">Névtelenítés</string>
|
||||
<string name="voice_anonymize_description">Módosítja az Ön hangmagasságát. Megjegyzés: az alapvető hangmagasság-változásokat a figyelmes hallgatók visszafordíthatják.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel</string>
|
||||
<string name="reply_here">"Válasz írása… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">A bejegyzés azonosítóját a vágólapra másolja a Nostr-ban való megosztáshoz</string>
|
||||
@@ -697,7 +703,7 @@
|
||||
<string name="content_size">Tartalom mérete</string>
|
||||
<string name="connectivity">Kapcsolat</string>
|
||||
<string name="access_control">Hozzáférés-vezérlés</string>
|
||||
<string name="minimum_pow">Spam-szűrés erőssége</string>
|
||||
<string name="minimum_pow">Kéretlen tartalmak szűrésének erőssége</string>
|
||||
<string name="auth">Hitelesítés</string>
|
||||
<string name="auth_required">Hitelesítés szükséges</string>
|
||||
<string name="payment">Fizetés</string>
|
||||
@@ -1196,6 +1202,9 @@
|
||||
<string name="group_relay_explanation">Az átjátszó, amelyhez a csevegés összes felhasználója csatlakozik</string>
|
||||
<string name="share_image">Kép megosztása…</string>
|
||||
<string name="unable_to_share_image">Nem lehetett megosztani a képet, próbálja meg újra később…</string>
|
||||
<string name="share_video">Videó megosztása…</string>
|
||||
<string name="unable_to_share_video">Nem sikerült megosztani a videót, próbálja meg újra később…</string>
|
||||
<string name="downloading_video_for_sharing">Videó letöltése…</string>
|
||||
<string name="search_by_hashtag">Hashtag keresése: #%1$s</string>
|
||||
<string name="dont_translate_from">Innentől NE fordítsa le</string>
|
||||
<string name="dont_translate_from_description">Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet.</string>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">Nieoczekiwany status przesyłania</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 nie jest jeszcze dostępny dla wiadomości głosowych</string>
|
||||
<string name="upload_error_voice_message_exception">Nie udało się przesłać głosu: %1$s</string>
|
||||
<string name="voice_preset_none">Brak</string>
|
||||
<string name="voice_preset_deep">Niska</string>
|
||||
<string name="voice_preset_high">Wysoka</string>
|
||||
<string name="voice_preset_neutral">Neutralna</string>
|
||||
<string name="voice_anonymize_title">Anonimowy</string>
|
||||
<string name="voice_anonymize_description">Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy</string>
|
||||
<string name="reply_here">"odpowiedz tutaj.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr</string>
|
||||
@@ -493,7 +499,7 @@
|
||||
<string name="no">Nie</string>
|
||||
<string name="follow_list_selection">Lista obserwowanych</string>
|
||||
<string name="follow_list_kind3follows">Obserwowane</string>
|
||||
<string name="follow_list_kind3follows_users_only">Obserwujące</string>
|
||||
<string name="follow_list_kind3follows_users_only">Obserwowane kontakty</string>
|
||||
<string name="follow_list_kind3_follows_users_only">Domyślna lista obserwowanych</string>
|
||||
<string name="follow_list_kind3follows_proxy">Obserwuje przez proxy</string>
|
||||
<string name="follow_list_aroundme">W pobliżu</string>
|
||||
@@ -715,7 +721,7 @@
|
||||
<string name="copied_token_to_clipboard">Skopiowano token do schowka</string>
|
||||
<string name="live_stream_live_tag">NA ŻYWO</string>
|
||||
<string name="live_stream_offline_tag">OFFLINE</string>
|
||||
<string name="live_stream_ended_tag">Zakończony</string>
|
||||
<string name="live_stream_ended_tag">Zakończono</string>
|
||||
<string name="live_stream_planned_tag">ZAPLANOWANE</string>
|
||||
<string name="live_stream_is_offline">Transmisja wyłączona</string>
|
||||
<string name="live_stream_has_ended">Transmisja na żywo zakończona</string>
|
||||
@@ -727,7 +733,7 @@
|
||||
<string name="discover_reads">Popularne</string>
|
||||
<string name="discover_content_v2">Wybrane przez algorytm</string>
|
||||
<string name="discover_marketplace">Market</string>
|
||||
<string name="discover_live_v2">Transmisja na żywo</string>
|
||||
<string name="discover_live_v2">Na żywo</string>
|
||||
<string name="discover_community_v2">Społeczności</string>
|
||||
<string name="discover_chat">Czaty</string>
|
||||
<string name="community_approved_posts">Zatwierdzone posty</string>
|
||||
@@ -1193,6 +1199,9 @@
|
||||
<string name="group_relay_explanation">Transmiter, z którym łączą się wszyscy użytkownicy tego czatu</string>
|
||||
<string name="share_image">Udostępnij zdjęcie…</string>
|
||||
<string name="unable_to_share_image">Nie można udostępnić obrazu, spróbuj ponownie później…</string>
|
||||
<string name="share_video">Udostępnij film…</string>
|
||||
<string name="unable_to_share_video">Nie można udostępnić filmu, spróbuj ponownie później…</string>
|
||||
<string name="downloading_video_for_sharing">Pobieranie filmu…</string>
|
||||
<string name="search_by_hashtag">Szukaj tagu: #%1$s</string>
|
||||
<string name="dont_translate_from">Nie tłumacz z</string>
|
||||
<string name="dont_translate_from_description">Języki wyświetlane tutaj nie będą tłumaczone. Wybierz język, aby usunąć go z listy języków nietłumaczonych.</string>
|
||||
|
||||
@@ -1197,6 +1197,9 @@
|
||||
<string name="group_relay_explanation">O relé a qual todos os usuários deste chat se conectam</string>
|
||||
<string name="share_image">Compartilhar imagem…</string>
|
||||
<string name="unable_to_share_image">Não é possível compartilhar a imagem, por favor tente novamente mais tarde…</string>
|
||||
<string name="share_video">Compartilhar vídeo…</string>
|
||||
<string name="unable_to_share_video">Não foi possível compartilhar o vídeo, tente novamente mais tarde…</string>
|
||||
<string name="downloading_video_for_sharing">Baixando vídeo…</string>
|
||||
<string name="search_by_hashtag">Pesquisar hashtag: #%1$s</string>
|
||||
<string name="dont_translate_from">Não Traduzir de</string>
|
||||
<string name="dont_translate_from_description">Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente.</string>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<string name="could_not_decrypt_the_message">Dešifriranje sporočila ni uspelo</string>
|
||||
<string name="group_picture">Slika skupine</string>
|
||||
<string name="explicit_content">Eksplicitna vsebina</string>
|
||||
<string name="relay_notice">Obvestilo releja</string>
|
||||
<string name="duplicated_post">Podvojena objava</string>
|
||||
<string name="spam">Nezaželena vsebina</string>
|
||||
<string name="spam_description">Iz tega releja izvira številna nezaželena vsebina</string>
|
||||
<string name="impersonation">Oponašanje</string>
|
||||
@@ -132,6 +134,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="relay_address">Naslov releja</string>
|
||||
<string name="posts">Pošlji</string>
|
||||
<string name="bytes">Bajti</string>
|
||||
<string name="error">Napaka</string>
|
||||
<string name="errors">Napake</string>
|
||||
<string name="errors_description">Število napak pri povezovanju v tej seji</string>
|
||||
<string name="home_feed">Domače vsebine</string>
|
||||
@@ -143,6 +146,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="add_a_user">Dodaj Uporabnika</string>
|
||||
<string name="add_a_relay">Dodaj rele</string>
|
||||
<string name="profile_name">Ime</string>
|
||||
<string name="profile_name_with_explainer">Ime (za @označbe)</string>
|
||||
<string name="my_name">Moje ime @oznake</string>
|
||||
<string name="display_name">Prikazano ime</string>
|
||||
<string name="my_display_name">Moje prikazno ime</string>
|
||||
<string name="my_awesome_name">Janez Slovenc</string>
|
||||
@@ -169,7 +174,21 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="record_a_message">Posnemi sporočilo</string>
|
||||
<string name="record_a_message_title">Posnemi sporočilo</string>
|
||||
<string name="record_a_message_description">Klikni in drži za snemanje sporočila</string>
|
||||
<string name="re_record">Ponovno posnemi</string>
|
||||
<string name="recording_indicator_description">Snemam</string>
|
||||
<string name="recording_indicator_with_time">Snemam %1$s</string>
|
||||
<string name="uploading">Nalaganje …</string>
|
||||
<string name="upload_error_title">Naloži napako</string>
|
||||
<string name="upload_error_voice_message_failed">Neuspešno nalaganje zvočnega sporočila</string>
|
||||
<string name="upload_error_voice_message_unexpected_state">Nepričakovan status nalaganja</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 še ne podpira zvočnih posnetkov</string>
|
||||
<string name="upload_error_voice_message_exception">Neuspešno nalaganje zvočnega posnetka: %1$s</string>
|
||||
<string name="voice_preset_none">Nič</string>
|
||||
<string name="voice_preset_deep">Globok</string>
|
||||
<string name="voice_preset_high">Visok</string>
|
||||
<string name="voice_preset_neutral">Nevtralen</string>
|
||||
<string name="voice_anonymize_title">Anonimiziraj</string>
|
||||
<string name="voice_anonymize_description">Prilagodi višino tona svojega glasu: Opomba: osnovne spremembe višine tona lahko poslušalci potencialno razveljavijo.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev</string>
|
||||
<string name="reply_here">"odgovori tukaj.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopira ID zapiska v odložišče za deljenje v Nostr</string>
|
||||
@@ -363,6 +382,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="block_only">Blokiraj</string>
|
||||
<string name="manual_zaps">Ročno razdeli zape</string>
|
||||
<string name="bookmarks">Zaznamki</string>
|
||||
<string name="bookmarks_title">Privzeti zaznamki</string>
|
||||
<string name="bookmarks_explainer">Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev.</string>
|
||||
<string name="drafts">Osnutki</string>
|
||||
<string name="private_bookmarks">Privatni zaznamki</string>
|
||||
<string name="public_bookmarks">Javni zaznamki</string>
|
||||
@@ -646,28 +667,64 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="read_from_relay_description">Količina bajtov, ki je bila prejeta od tega releja, vključno s filtri in dogodki</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Prišlo je do napake pri pridobivanju informacij o releju iz %1$s</string>
|
||||
<string name="owner">Lastnik</string>
|
||||
<string name="self">Servisni ključ</string>
|
||||
<string name="running_software">V teku %1$s</string>
|
||||
<string name="running_software_version">V teku %1$s (%2$s)</string>
|
||||
<string name="version">Različica</string>
|
||||
<string name="software">Program</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="supports">Podprti NIP-i</string>
|
||||
<string name="supported_grasps">Podprti upravljalniki vsebine</string>
|
||||
<string name="admission_fees">Vstopnina</string>
|
||||
<string name="admission">Vstopnina</string>
|
||||
<string name="subscription">Naročnina</string>
|
||||
<string name="publication">Publikacija</string>
|
||||
<string name="payment_link">Plačila %1$s</string>
|
||||
<string name="payments_url">URL za plačila</string>
|
||||
<string name="target_audience">Ciljna skupina</string>
|
||||
<string name="policies_and_links">Pravilnik & Povezave</string>
|
||||
<string name="fees_and_payments">Provizije & Plačila</string>
|
||||
<string name="limitations">Omejitve</string>
|
||||
<string name="countries">Države</string>
|
||||
<string name="languages">Jeziki</string>
|
||||
<string name="tags">Oznake</string>
|
||||
<string name="topics">Teme</string>
|
||||
<string name="all_countries">Vse države</string>
|
||||
<string name="all_languages">Vsi jeziki</string>
|
||||
<string name="posting_policy">Pravila objavljanja</string>
|
||||
<string name="privacy_policy">Izjava o zasebnosti</string>
|
||||
<string name="terms_and_conditions">Določila & Pogoji</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Napake in obvestila s tega releja</string>
|
||||
<string name="message_length">Dolžina sporočila</string>
|
||||
<string name="subscriptions">Naročnine</string>
|
||||
<string name="filters">Filtri</string>
|
||||
<string name="subscription_id_length">Dolžina id naročnine</string>
|
||||
<string name="minimum_prefix">Minimalna predpona</string>
|
||||
<string name="maximum_event_tags">Maksimum oznak v dogodku</string>
|
||||
<string name="maximum_event_tags">Največ oznak v dogodku</string>
|
||||
<string name="content_length">Dolžina vsebine</string>
|
||||
<string name="max_content_length">Največja dolžina vsebine</string>
|
||||
<string name="discards_older_than">Zavrži starejše od</string>
|
||||
<string name="accepts_up_to">Sprejme do</string>
|
||||
<string name="time_in_the_future">%1$s v prihodnost</string>
|
||||
<string name="time_in_the_past">Pred %1$s</string>
|
||||
<string name="amount_in_zeros">%1$s ničel</string>
|
||||
<string name="amount_in_bits">%1$s bitov</string>
|
||||
<string name="event_retention">Zadržanje dogodka</string>
|
||||
<string name="content_size">Velikost vsebine</string>
|
||||
<string name="connectivity">Povezljivost</string>
|
||||
<string name="access_control">Nadzor dostopa</string>
|
||||
<string name="minimum_pow">Minimalen PoW</string>
|
||||
<string name="auth">Auth</string>
|
||||
<string name="auth_required">Potrebna avtentikacija</string>
|
||||
<string name="payment">Plačilo</string>
|
||||
<string name="payment_required">Potrebno je plačilo</string>
|
||||
<string name="max_message_length">Največja dolžina sporočila</string>
|
||||
<string name="max_subs">Največ naročnin</string>
|
||||
<string name="max_filters_per_sub">Največ filtrov na naročnino</string>
|
||||
<string name="max_limit_events_returning">Največja omejitev (vrnjenih dogodkov)</string>
|
||||
<string name="default_limit">Privzeta omejitev (vrnjenih dogodkov)</string>
|
||||
<string name="max_subid_length">Največja dolžina SubID</string>
|
||||
<string name="cashu">Cashu žeton</string>
|
||||
<string name="cashu_redeem">Unovči</string>
|
||||
<string name="cashu_redeem_to_zap">Pošlji v Zap denarnico</string>
|
||||
@@ -1193,4 +1250,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="follow_pack_broadcast">Oddajaj paket</string>
|
||||
<string name="follow_set_delete">Izbriši seznam</string>
|
||||
<string name="follow_pack_delete">Izbriši paket</string>
|
||||
<string name="kinds">Tipi</string>
|
||||
</resources>
|
||||
|
||||
@@ -1196,6 +1196,9 @@
|
||||
<string name="group_relay_explanation">Reläet som alla användare av den här chatten ansluter till</string>
|
||||
<string name="share_image">Dela bild…</string>
|
||||
<string name="unable_to_share_image">Kunde inte dela bilden, försök igen senare…</string>
|
||||
<string name="share_video">Dela video…</string>
|
||||
<string name="unable_to_share_video">Det gick inte att dela videon, försök igen senare…</string>
|
||||
<string name="downloading_video_for_sharing">Laddar ner videon…</string>
|
||||
<string name="search_by_hashtag">Sök hashtag: #%1$s</string>
|
||||
<string name="dont_translate_from">Översätt inte från</string>
|
||||
<string name="dont_translate_from_description">Språk som visas här kommer inte att översättas. Välj ett språk för att ta bort det och få det översatt igen.</string>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">未知的上传状态</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">语音消息尚不支持 NIP-95</string>
|
||||
<string name="upload_error_voice_message_exception">语音上传失败:%1$s</string>
|
||||
<string name="voice_preset_none">无</string>
|
||||
<string name="voice_preset_deep">深沉</string>
|
||||
<string name="voice_preset_high">高音</string>
|
||||
<string name="voice_preset_neutral">中性</string>
|
||||
<string name="voice_anonymize_title">匿名化</string>
|
||||
<string name="voice_anonymize_description">更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
|
||||
<string name="reply_here">"🔏在此回复… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string>
|
||||
@@ -1196,6 +1202,9 @@
|
||||
<string name="group_relay_explanation">此聊天所有用户都连接到的中继</string>
|
||||
<string name="share_image">分享图片…</string>
|
||||
<string name="unable_to_share_image">无法分享图片,请稍后重试…</string>
|
||||
<string name="share_video">分享视频…</string>
|
||||
<string name="unable_to_share_video">无法分享视频,请稍后重试…</string>
|
||||
<string name="downloading_video_for_sharing">正下载视频…</string>
|
||||
<string name="search_by_hashtag">搜索话题标签:#%1$s</string>
|
||||
<string name="dont_translate_from">不要翻译</string>
|
||||
<string name="dont_translate_from_description">此处显示的语言不会被翻译,请选择一种目标语言重新翻译并去除这个提示。</string>
|
||||
@@ -1232,4 +1241,5 @@
|
||||
<string name="follow_pack_broadcast">广播包</string>
|
||||
<string name="follow_set_delete">删除列表</string>
|
||||
<string name="follow_pack_delete">删除包</string>
|
||||
<string name="kinds">类型</string>
|
||||
</resources>
|
||||
|
||||
@@ -1426,6 +1426,9 @@
|
||||
<string name="group_relay_explanation">The relay that all users of this chat connect to</string>
|
||||
<string name="share_image">Share image…</string>
|
||||
<string name="unable_to_share_image">Unable to share image, please try again later…</string>
|
||||
<string name="share_video">Share video…</string>
|
||||
<string name="unable_to_share_video">Unable to share video, please try again later…</string>
|
||||
<string name="downloading_video_for_sharing">Downloading video…</string>
|
||||
|
||||
<string name="search_by_hashtag">Search hashtag: #%1$s</string>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user