Merge branch 'vitorpamplona:main' into kmp-completeness

This commit is contained in:
KotlinGeekDev
2026-01-28 00:09:19 +01:00
committed by GitHub
160 changed files with 10268 additions and 745 deletions
@@ -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
@@ -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)
}
}
}
}
}
@@ -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
}
@@ -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,
)
}
@@ -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)
}
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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() }
}
}
@@ -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,
),
),
)
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
@@ -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)
@@ -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,
)
@@ -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
}
}
@@ -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,
)
}
}
}
@@ -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)
}
}
@@ -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")
}
}
}
}
@@ -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)) {
@@ -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
@@ -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) }
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
@@ -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)
@@ -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,
)
}
}
}
}
@@ -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
@@ -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
@@ -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
@@ -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()
}
@@ -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
@@ -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
@@ -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(
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 &amp; liens</string>
<string name="fees_and_payments">Frais &amp; 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 &amp; 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>
+3
View File
@@ -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()
}
}
+6 -7
View File
@@ -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 }
@@ -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