Merge branch 'vitorpamplona:main' into kmp-completeness
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) {
|
||||
@@ -783,6 +785,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?,
|
||||
@@ -2014,7 +2061,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
paymentTracker.registerRequest(event.id, zappedNote, onResponse)
|
||||
|
||||
refreshNewNoteObservers(note)
|
||||
|
||||
@@ -2030,9 +2077,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) }
|
||||
|
||||
@@ -2959,6 +3007,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,
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta
|
||||
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 +71,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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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)) {
|
||||
|
||||
+203
-11
@@ -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
|
||||
@@ -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,
|
||||
@@ -670,14 +684,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 +704,7 @@ fun ShareImageAction(
|
||||
content = content,
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
ShareImageAction(
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.localFile?.toUri().toString(),
|
||||
@@ -707,7 +721,7 @@ fun ShareImageAction(
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun ShareImageAction(
|
||||
fun ShareMediaAction(
|
||||
accountViewModel: AccountViewModel,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
videoUri: String?,
|
||||
@@ -721,9 +735,12 @@ fun ShareImageAction(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 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 +782,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 +880,128 @@ 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?.delete()
|
||||
}
|
||||
|
||||
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
|
||||
tempFile.delete()
|
||||
sharedFile?.delete()
|
||||
|
||||
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
|
||||
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
|
||||
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
|
||||
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
|
||||
@@ -77,7 +79,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
|
||||
@@ -168,6 +169,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)
|
||||
@@ -315,7 +317,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,7 +729,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) }
|
||||
|
||||
@@ -731,13 +773,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,8 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
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
|
||||
@@ -30,14 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
|
||||
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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -721,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>
|
||||
@@ -733,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class ShareHelperTest {
|
||||
@Test
|
||||
fun moveTempFileForSharing_renameFails_copiesAndDeletesSource() {
|
||||
val tempDir = Files.createTempDirectory("sharehelpertest").toFile()
|
||||
val tempFile = File(tempDir, "video.tmp")
|
||||
val content = "test-content"
|
||||
tempFile.writeText(content)
|
||||
val targetFile = File(tempDir, "shared.mp4")
|
||||
|
||||
ShareHelper.moveTempFileForSharing(
|
||||
tempFile,
|
||||
targetFile,
|
||||
rename = { _, _ -> false },
|
||||
)
|
||||
|
||||
assertTrue(targetFile.exists())
|
||||
assertEquals(content, targetFile.readText())
|
||||
assertFalse(tempFile.exists())
|
||||
|
||||
targetFile.delete()
|
||||
tempDir.delete()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ plugins {
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.mokoResources)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -81,9 +80,8 @@ kotlin {
|
||||
// Immutable collections
|
||||
api(libs.kotlinx.collections.immutable)
|
||||
|
||||
// Moko Resources for KMP string resources
|
||||
api(libs.moko.resources)
|
||||
api(libs.moko.resources.compose)
|
||||
// Compose Multiplatform Resources
|
||||
implementation(compose.components.resources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +139,8 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
multiplatformResources {
|
||||
resourcesPackage.set("com.vitorpamplona.amethyst.commons")
|
||||
resourcesClassName.set("SharedRes")
|
||||
compose.resources {
|
||||
publicResClass = true
|
||||
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
|
||||
generateResClass = always
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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.commons.icons
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
|
||||
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun VectorPreview() {
|
||||
Image(Bookmark, null)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun FilledPreview() {
|
||||
Image(BookmarkFilled, null)
|
||||
}
|
||||
|
||||
private var bookmark: ImageVector? = null
|
||||
private var bookmarkFilled: ImageVector? = null
|
||||
|
||||
val Bookmark: ImageVector
|
||||
get() =
|
||||
bookmark ?: ImageVector
|
||||
.Builder(
|
||||
name = "Bookmark",
|
||||
defaultWidth = 24.0.dp,
|
||||
defaultHeight = 24.0.dp,
|
||||
viewportWidth = 24.0f,
|
||||
viewportHeight = 24.0f,
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF000000)),
|
||||
stroke = null,
|
||||
strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt,
|
||||
strokeLineJoin = Miter,
|
||||
strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero,
|
||||
) {
|
||||
// Material bookmark outline
|
||||
moveTo(17.0f, 3.0f)
|
||||
horizontalLineTo(7.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(16.0f)
|
||||
lineToRelative(7.0f, -3.0f)
|
||||
lineToRelative(7.0f, 3.0f)
|
||||
verticalLineTo(5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
moveTo(17.0f, 18.0f)
|
||||
lineToRelative(-5.0f, -2.18f)
|
||||
lineTo(7.0f, 18.0f)
|
||||
verticalLineTo(5.0f)
|
||||
horizontalLineToRelative(10.0f)
|
||||
verticalLineToRelative(13.0f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
.also { bookmark = it }
|
||||
|
||||
val BookmarkFilled: ImageVector
|
||||
get() =
|
||||
bookmarkFilled ?: ImageVector
|
||||
.Builder(
|
||||
name = "BookmarkFilled",
|
||||
defaultWidth = 24.0.dp,
|
||||
defaultHeight = 24.0.dp,
|
||||
viewportWidth = 24.0f,
|
||||
viewportHeight = 24.0f,
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF000000)),
|
||||
stroke = null,
|
||||
strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt,
|
||||
strokeLineJoin = Miter,
|
||||
strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero,
|
||||
) {
|
||||
// Material bookmark filled
|
||||
moveTo(17.0f, 3.0f)
|
||||
horizontalLineTo(7.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(16.0f)
|
||||
lineToRelative(7.0f, -3.0f)
|
||||
lineToRelative(7.0f, 3.0f)
|
||||
verticalLineTo(5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
.also { bookmarkFilled = it }
|
||||
Vendored
+22
@@ -112,5 +112,27 @@ interface ICacheProvider {
|
||||
*/
|
||||
fun hasBeenDeleted(event: Any): Boolean
|
||||
|
||||
/**
|
||||
* Finds users whose name, displayName, nip05, or lud16 starts with the given prefix.
|
||||
* Used by search functionality to find users by name.
|
||||
*
|
||||
* @param prefix The search prefix to match against user names
|
||||
* @param limit Maximum number of results to return
|
||||
* @return List of Users matching the prefix
|
||||
*/
|
||||
fun findUsersStartingWith(
|
||||
prefix: String,
|
||||
limit: Int = 50,
|
||||
): List<Any> = emptyList()
|
||||
|
||||
/**
|
||||
* Gets or creates a User by public key hex.
|
||||
* Used when processing events that reference users.
|
||||
*
|
||||
* @param pubkey The user's public key in hex format
|
||||
* @return The User (existing or newly created)
|
||||
*/
|
||||
fun getOrCreateUser(pubkey: HexKey): Any?
|
||||
|
||||
fun justConsumeMyOwnEvent(event: Event): Boolean
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user