This commit is contained in:
Vitor Pamplona
2026-04-20 09:38:12 -04:00
150 changed files with 8491 additions and 448 deletions
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
@@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
@@ -151,8 +154,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
@@ -187,6 +193,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -195,6 +202,12 @@ import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
@@ -224,6 +237,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet
import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
@@ -231,6 +245,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -243,8 +258,12 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
@OptIn(DelicateCoroutinesApi::class)
@Stable
@@ -323,6 +342,10 @@ class Account(
val hashtagListDecryptionCache = HashtagListDecryptionCache(signer)
val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings)
val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer)
val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings)
val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope)
val geohashListDecryptionCache = GeohashListDecryptionCache(signer)
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
@@ -418,6 +441,8 @@ class Account(
caches = feedDecryptionCaches,
signer = signer,
scope = scope,
favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator,
favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow,
).flow
// App-ready Feeds
@@ -451,6 +476,9 @@ class Account(
val liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList)
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -1013,6 +1041,12 @@ class Account(
suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag))
suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm))
suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm))
fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm)
suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash))
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
@@ -1068,6 +1102,136 @@ class Account(
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
}
suspend fun sendBadgeDefinition(
badgeId: String,
name: String?,
imageUrl: String?,
imageDim: DimensionTag?,
description: String?,
thumbs: List<ThumbTag> = emptyList(),
) {
if (!isWriteable()) return
val template =
BadgeDefinitionEvent.build(
badgeId = badgeId,
name = name,
imageUrl = imageUrl,
imageDimensions = imageDim,
description = description,
thumbs = thumbs,
)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, outboxRelays.flow.value)
}
suspend fun deleteBadgeDefinition(event: BadgeDefinitionEvent) {
if (!isWriteable()) return
if (event.pubKey != signer.pubKey) return
val template = DeletionEvent.build(listOf(event))
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
}
suspend fun sendBadgeAward(
definition: BadgeDefinitionEvent,
awardees: List<PTag>,
) {
if (!isWriteable()) return
if (awardees.isEmpty()) return
val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null)
val template = BadgeAwardEvent.build(aTag, awardees)
val signedEvent = signer.sign(template)
val relays =
outboxRelays.flow.value +
awardees
.flatMap { cache.getOrCreateUser(it.pubKey).inboxRelays() ?: emptyList() }
.toSet()
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, relays)
}
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
val newEvent = newNote?.event as? ProfileBadgesEvent
if (newEvent != null) return newEvent.acceptedBadges()
val oldNote = cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))
val oldEvent = oldNote?.event as? AcceptedBadgeSetEvent
return oldEvent?.acceptedBadges() ?: emptyList()
}
/**
* Serializes read-modify-write of the accepted-badges replaceable event so two
* rapid toggles can't race each other into losing updates.
*/
private val profileBadgesMutex = Mutex()
/**
* Returns a createdAt strictly greater than whatever ProfileBadgesEvent (or
* the legacy AcceptedBadgeSetEvent) currently sits in cache. Needed because
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
* strictly greater, and TimeUtils.now() has only second resolution.
*/
private fun nextProfileBadgesCreatedAt(): Long {
val latest =
maxOf(
(cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L,
(cache.getAddressableNoteIfExists(AcceptedBadgeSetEvent.createAddress(signer.pubKey))?.event?.createdAt) ?: 0L,
)
return maxOf(TimeUtils.now(), latest + 1)
}
suspend fun addAcceptedBadge(
award: BadgeAwardEvent,
definition: BadgeDefinitionEvent,
) {
if (!isWriteable()) return
val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null)
val eTag = ETag(award.id)
val signedEvent =
profileBadgesMutex.withLock {
val current = loadCurrentAcceptedBadges()
if (current.any { it.badgeAward.eventId == award.id }) return
val updated = current + AcceptedBadge(aTag, eTag)
val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt())
val signed = signer.sign(template)
cache.justConsumeMyOwnEvent(signed)
signed
}
client.publish(signedEvent, outboxRelays.flow.value)
}
suspend fun removeAcceptedBadge(award: BadgeAwardEvent) {
if (!isWriteable()) return
val signedEvent =
profileBadgesMutex.withLock {
val current = loadCurrentAcceptedBadges()
val updated = current.filterNot { it.badgeAward.eventId == award.id }
if (updated.size == current.size) return
val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt())
val signed = signer.sign(template)
cache.justConsumeMyOwnEvent(signed)
signed
}
client.publish(signedEvent, outboxRelays.flow.value)
}
fun sendMyPublicAndPrivateOutbox(event: Event?) {
if (event == null) return
cache.justConsumeMyOwnEvent(event)
@@ -1119,6 +1283,7 @@ class Account(
headerInfo.mimeType?.let { mimeType(it) }
headerInfo.dim?.let { dimension(it) }
headerInfo.blurHash?.let { blurhash(it.blurhash) }
headerInfo.thumbHash?.let { nip95thumbhash(it.thumbhash) }
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
@@ -1203,16 +1368,17 @@ class Account(
val iMetas =
urlHeaderInfo.map {
PictureMeta(
it.key,
it.value.mimeType,
it.value.blurHash?.blurhash,
it.value.dim,
caption,
it.value.hash,
it.value.size,
null,
emptyList(),
emptyList(),
url = it.key,
mimeType = it.value.mimeType,
blurhash = it.value.blurHash?.blurhash,
dimension = it.value.dim,
alt = caption,
hash = it.value.hash,
size = it.value.size,
service = null,
fallback = emptyList(),
annotations = emptyList(),
thumbhash = it.value.thumbHash?.thumbhash,
)
}
@@ -1255,13 +1421,14 @@ class Account(
quotes(findNostrUris(it))
}
pictureIMeta(
url,
headerInfo.mimeType,
headerInfo.blurHash?.blurhash,
headerInfo.dim,
headerInfo.hash,
headerInfo.size,
alt,
url = url,
mimeType = headerInfo.mimeType,
blurhash = headerInfo.blurHash?.blurhash,
dimension = headerInfo.dim,
hash = headerInfo.hash,
size = headerInfo.size,
alt = alt,
thumbhash = headerInfo.thumbHash?.thumbhash,
)
// add zap splits
// add zap raiser
@@ -1279,6 +1446,7 @@ class Account(
dimension = headerInfo.dim,
blurhash = headerInfo.blurHash?.blurhash,
alt = alt,
thumbhash = headerInfo.thumbHash?.thumbhash,
)
if (headerInfo.dim.height > headerInfo.dim.width) {
@@ -1298,6 +1466,7 @@ class Account(
headerInfo.mimeType?.let { mimeType(it) }
headerInfo.dim?.let { dimension(it) }
headerInfo.blurHash?.let { blurhash(it.blurhash) }
headerInfo.thumbHash?.let { thumbhash(it.thumbhash) }
originalHash?.let { originalHash(it) }
magnetUri?.let { magnet(it) }
@@ -2148,6 +2317,7 @@ class Account(
dim: DimensionTag?,
hash: String?,
mimeType: String?,
thumbhash: String? = null,
) {
val template =
ProfileGalleryEntryEvent.build(url) {
@@ -2156,6 +2326,7 @@ class Account(
mimeType?.let { mimeType(it) }
dim?.let { dimension(it) }
blurhash?.let { blurhash(it) }
thumbhash?.let { galleryThumbhash(it) }
}
val event = signer.sign(template)
@@ -2367,7 +2538,7 @@ class Account(
suspend fun requestDVMContentDiscovery(
dvmPublicKey: User,
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
) {
val relays = nip65RelayList.inboxFlow.value.toSet()
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
@@ -2377,7 +2548,7 @@ class Account(
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
cache.justConsumeMyOwnEvent(request)
onReady(request)
onReady(request, relayList.toSet())
delay(100)
client.publish(request, relayList)
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
@@ -125,6 +126,9 @@ sealed class TopFilter(
@Serializable
object Chess : TopFilter(" Chess ")
@Serializable
object Mine : TopFilter(" Mine ")
@Serializable
class PeopleList(
val address: Address,
@@ -154,6 +158,13 @@ sealed class TopFilter(
class Relay(
val url: String,
) : TopFilter("Relay/$url")
@Serializable
class FavoriteAlgoFeed(
val address: Address,
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
}
@Stable
@@ -174,6 +185,7 @@ class AccountSettings(
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
@@ -195,6 +207,7 @@ class AccountSettings(
var backupChannelList: ChannelListEvent? = null,
var backupCommunityList: CommunityListEvent? = null,
var backupHashtagList: HashtagListEvent? = null,
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
@@ -503,6 +516,17 @@ class AccountSettings(
}
}
fun changeDefaultBadgesFollowList(name: FeedDefinition) {
changeDefaultBadgesFollowList(name.code)
}
fun changeDefaultBadgesFollowList(name: TopFilter) {
if (defaultBadgesFollowList.value != name) {
defaultBadgesFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// language services
// ---
@@ -718,6 +742,16 @@ class AccountSettings(
}
}
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
backupFavoriteAlgoFeedsList = newFavoriteDvmList
saveAccountSettings()
}
}
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
@@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
@@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
@@ -0,0 +1,211 @@
/*
* 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.algoFeeds
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private const val RESPONSE_TIMEOUT_MS = 20_000L
/**
* Immutable snapshot of a favorite algo feed's current request/response state.
*
* - [requestId] is the id of the most recently published kind-5300 request.
* - [responseRelays] is the relay set the kind-5300 was sent to the same set on
* which the DVM will publish its 6300/7000 responses, so the home subscription
* manager must listen there (not on the user's own outbox).
* - [ids] and [addresses] are the note references returned by the latest kind-6300 response.
* - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, ).
* - [errorMessage] captures any client-side failure while publishing the request.
*/
data class FavoriteAlgoFeedsSnapshot(
val requestId: HexKey? = null,
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
val ids: Set<HexKey> = emptySet(),
val addresses: Set<String> = emptySet(),
val latestStatus: NIP90StatusEvent? = null,
val errorMessage: String? = null,
)
/**
* Manages the NIP-90 content-discovery RPC cycle for each favorite algo feed the user
* pins to the top-nav.
*
* The orchestrator is lazy: it starts a request/response cycle the first time any
* consumer calls [observe] for a given DVM address, and keeps emitting updated
* snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the
* kind-5300 request (e.g. pull-to-refresh).
*
* This class does not own the relay subscriptions that fetch DVM responses and
* matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the
* user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes
* what the relays deliver into `LocalCache`.
*/
class FavoriteAlgoFeedsOrchestrator(
val account: Account,
val scope: CoroutineScope,
) {
private val flows = mutableMapOf<Address, MutableStateFlow<FavoriteAlgoFeedsSnapshot>>()
private val jobs = mutableMapOf<Address, Job>()
private val mutex = Mutex()
fun observe(feedAddress: Address): StateFlow<FavoriteAlgoFeedsSnapshot> {
flows[feedAddress]?.let { return it.asStateFlow() }
val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot())
flows[feedAddress] = seed
scope.launch { startFor(feedAddress, seed) }
return seed.asStateFlow()
}
fun refresh(feedAddress: Address) {
val seed = flows[feedAddress] ?: return
scope.launch {
mutex.withLock {
jobs.remove(feedAddress)?.cancel()
}
startFor(feedAddress, seed)
}
}
fun stop(feedAddress: Address) {
scope.launch {
mutex.withLock {
jobs.remove(feedAddress)?.cancel()
flows.remove(feedAddress)
}
}
}
private suspend fun startFor(
feedAddress: Address,
seed: MutableStateFlow<FavoriteAlgoFeedsSnapshot>,
) {
val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return
val job =
scope.launch(Dispatchers.IO) {
try {
account.requestDVMContentDiscovery(user) { request, relays ->
seed.update {
it.copy(
requestId = request.id,
responseRelays = relays,
ids = emptySet(),
addresses = emptySet(),
latestStatus = null,
errorMessage = null,
)
}
}
val requestId = seed.value.requestId ?: return@launch
launch {
account.cache
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
Filter(
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
tags = mapOf("e" to listOf(requestId)),
limit = 1,
),
).collectLatest { response ->
if (response == null) return@collectLatest
val (eventIds, addresses) = splitInnerTags(response.innerTags())
seed.update {
it.copy(
ids = eventIds,
addresses = addresses,
)
}
}
}
launch {
account.cache
.observeLatestEvent<NIP90StatusEvent>(
Filter(
kinds = listOf(NIP90StatusEvent.KIND),
tags = mapOf("e" to listOf(requestId)),
limit = 1,
),
).collectLatest { status ->
seed.update { it.copy(latestStatus = status) }
}
}
// If nothing arrives within RESPONSE_TIMEOUT_MS (neither a 6300
// response nor any 7000 status), surface an error so the banner
// can show Retry instead of spinning forever.
launch {
delay(RESPONSE_TIMEOUT_MS)
val current = seed.value
val stillWaiting =
current.requestId == requestId &&
current.ids.isEmpty() &&
current.addresses.isEmpty() &&
current.latestStatus == null &&
current.errorMessage == null
if (stillWaiting) {
seed.update { it.copy(errorMessage = "timeout") }
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e)
seed.update { it.copy(errorMessage = e.message ?: "Unknown error") }
}
}
mutex.withLock { jobs[feedAddress] = job }
}
private fun splitInnerTags(innerTags: List<HexKey>): Pair<Set<HexKey>, Set<String>> {
val ids = mutableSetOf<HexKey>()
val addresses = mutableSetOf<String>()
innerTags.forEach { value ->
if (value.contains(':')) {
addresses.add(value)
} else if (value.length == 64) {
ids.add(value)
}
}
return ids to addresses
}
}
@@ -0,0 +1,36 @@
/*
* 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.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet
class FavoriteAlgoFeedsListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteAlgoFeedsListEvent>(signer)
fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet()
suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet()
}
@@ -0,0 +1,128 @@
/*
* 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.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class FavoriteAlgoFeedsListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: FavoriteAlgoFeedsListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress())
fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey)
fun getFavoriteAlgoFeedsListFlow(): StateFlow<NoteState> = favoriteAlgoFeedsListNote.flow().metadata.stateFlow
fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent
suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set<Address> {
val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList
return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<Address>> =
getFavoriteAlgoFeedsListFlow()
.transformLatest { noteState ->
emit(favoriteAlgoFeedsListWithBackup(noteState.note))
}.onStart {
emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowNotes: StateFlow<List<AddressableNote>> =
flow
.map { addresses ->
addresses.map { cache.getOrCreateAddressableNote(it) }
}.onStart {
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent {
val list = getFavoriteAlgoFeedsList()
return if (list == null) {
FavoriteAlgoFeedsListEvent.create(dvm, false, signer)
} else {
FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer)
}
}
suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? {
val list = getFavoriteAlgoFeedsList() ?: return null
return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer)
}
init {
settings.backupFavoriteAlgoFeedsList?.let { event ->
Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start")
getFavoriteAlgoFeedsListFlow().collect {
Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" }
(it.note.event as? FavoriteAlgoFeedsListEvent)?.let {
settings.updateFavoriteAlgoFeedsListTo(it)
}
}
}
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
@@ -30,11 +31,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavoriteAlgoFeedsFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -62,6 +66,8 @@ class FeedTopNavFilterState(
val caches: FeedDecryptionCaches,
val signer: NostrSigner,
val scope: CoroutineScope,
val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator,
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
) {
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
when (listName) {
@@ -89,6 +95,10 @@ class FeedTopNavFilterState(
ChessFeedFlow(followsRelays, proxyRelays)
}
TopFilter.Mine -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
}
is TopFilter.Community -> {
NoteFeedFlow(
LocalCache
@@ -142,6 +152,24 @@ class FeedTopNavFilterState(
is TopFilter.Relay -> {
RelayFeedFlow(listName.url.normalizeRelayUrl())
}
is TopFilter.FavoriteAlgoFeed -> {
FavoriteAlgoFeedFlow(
feedAddress = listName.address,
orchestrator = favoriteAlgoFeedsOrchestrator,
outboxRelays = followsRelays,
proxyRelays = proxyRelays,
)
}
TopFilter.AllFavoriteAlgoFeeds -> {
AllFavoriteAlgoFeedsFlow(
favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses,
orchestrator = favoriteAlgoFeedsOrchestrator,
outboxRelays = followsRelays,
proxyRelays = proxyRelays,
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -0,0 +1,115 @@
/*
* 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.topNavFeeds.favoriteAlgoFeeds
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
/**
* Feed flow that merges snapshots from every currently-favorited DVM into a
* single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the
* favorite set changes.
*/
class AllFavoriteAlgoFeedsFlow(
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
val orchestrator: FavoriteAlgoFeedsOrchestrator,
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
private fun resolveContentRelays(
outbox: Set<NormalizedRelayUrl>,
proxy: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
private fun merge(
snapshots: List<FavoriteAlgoFeedsSnapshot>,
contentRelays: Set<NormalizedRelayUrl>,
): AllFavoriteAlgoFeedsTopNavFilter {
val ids = mutableSetOf<String>()
val addresses = mutableSetOf<String>()
val listen = mutableSetOf<NormalizedRelayUrl>()
val requestIds = mutableSetOf<String>()
snapshots.forEach { snap ->
ids += snap.ids
addresses += snap.addresses
listen += snap.responseRelays
snap.requestId?.let { requestIds += it }
}
return AllFavoriteAlgoFeedsTopNavFilter(
acceptedIds = ids,
acceptedAddresses = addresses,
contentRelays = contentRelays,
listenRelays = listen,
requestIds = requestIds,
)
}
private fun emptyFilter(contentRelays: Set<NormalizedRelayUrl>): AllFavoriteAlgoFeedsTopNavFilter =
AllFavoriteAlgoFeedsTopNavFilter(
acceptedIds = emptySet(),
acceptedAddresses = emptySet(),
contentRelays = contentRelays,
listenRelays = emptySet(),
requestIds = emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
override fun flow(): Flow<IFeedTopNavFilter> =
favoriteAlgoFeedAddresses.flatMapLatest { addresses ->
if (addresses.isEmpty()) {
combine(outboxRelays, proxyRelays) { outbox, proxy ->
emptyFilter(resolveContentRelays(outbox, proxy))
}
} else {
val snapshotFlows: List<Flow<FavoriteAlgoFeedsSnapshot>> = addresses.map { orchestrator.observe(it) }
combine(snapshotFlows) { it.toList() }
.let { merged ->
combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy ->
merge(snaps, resolveContentRelays(outbox, proxy))
}
}
}
}
override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter {
val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value)
val addresses = favoriteAlgoFeedAddresses.value
return if (addresses.isEmpty()) {
emptyFilter(contentRelays)
} else {
merge(addresses.map { orchestrator.observe(it).value }, contentRelays)
}
}
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -0,0 +1,67 @@
/*
* 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.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Top-nav filter that unions the latest kind-6300 responses from every currently
* favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check
* against a snapshot), but the accepted set is the union across N DVMs and the
* request-id list carries one entry per DVM for the relay-listen subscription.
*/
@Immutable
class AllFavoriteAlgoFeedsTopNavFilter(
val acceptedIds: Set<HexKey>,
val acceptedAddresses: Set<String>,
val contentRelays: Set<NormalizedRelayUrl>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestIds: Set<HexKey>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean =
noteEvent.id in acceptedIds ||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
FavoriteAlgoFeedTopNavPerRelayFilterSet(
contentFetches =
contentRelays.associateWith {
FavoriteAlgoFeedTopNavPerRelayFilter(
ids = acceptedIds,
addresses = acceptedAddresses,
)
},
listenRelays = listenRelays,
requestIds = requestIds,
)
}
@@ -0,0 +1,70 @@
/*
* 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.topNavFeeds.favoriteAlgoFeeds
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
class FavoriteAlgoFeedFlow(
val feedAddress: Address,
val orchestrator: FavoriteAlgoFeedsOrchestrator,
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
private fun resolveRelays(
outbox: Set<NormalizedRelayUrl>,
proxy: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
private fun buildFilter(
snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot,
contentRelays: Set<NormalizedRelayUrl>,
) = FavoriteAlgoFeedTopNavFilter(
feedAddress = feedAddress,
acceptedIds = snapshot.ids,
acceptedAddresses = snapshot.addresses,
contentRelays = contentRelays,
listenRelays = snapshot.responseRelays,
requestId = snapshot.requestId,
)
override fun flow(): Flow<IFeedTopNavFilter> =
combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy ->
buildFilter(snap, resolveRelays(outbox, proxy))
}
override fun startValue(): FavoriteAlgoFeedTopNavFilter =
buildFilter(
snapshot = orchestrator.observe(feedAddress).value,
contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -0,0 +1,70 @@
/*
* 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.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Top-nav filter backed by the latest kind-6300 response from a favorite algo feed.
*
* The filter is a pure immutable membership check: [match] accepts a note only if the
* DVM's latest response included it. When a new response arrives, a new instance is
* emitted through [FavoriteAlgoFeedFlow] and replaces the active filter.
*/
@Immutable
class FavoriteAlgoFeedTopNavFilter(
val feedAddress: Address,
val acceptedIds: Set<HexKey>,
val acceptedAddresses: Set<String>,
val contentRelays: Set<NormalizedRelayUrl>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestId: HexKey?,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean =
noteEvent.id in acceptedIds ||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
FavoriteAlgoFeedTopNavPerRelayFilterSet(
contentFetches =
contentRelays.associateWith {
FavoriteAlgoFeedTopNavPerRelayFilter(
ids = acceptedIds,
addresses = acceptedAddresses,
)
},
listenRelays = listenRelays,
requestIds = setOfNotNull(requestId),
)
}
@@ -0,0 +1,31 @@
/*
* 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.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Immutable
class FavoriteAlgoFeedTopNavPerRelayFilter(
val ids: Set<HexKey>,
val addresses: Set<String>,
) : IFeedTopNavPerRelayFilter
@@ -0,0 +1,42 @@
/*
* 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.topNavFeeds.favoriteAlgoFeeds
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Two relay sets, two distinct subscriptions:
*
* - [contentFetches] for each user-configured content relay, the ids/addresses
* we want to pull (the actual notes the DVM curated).
* - [listenRelays] the union of DVM publish relays across all active DVMs
* (where they will deliver future kind 6300 / 7000 events for their requests).
* - [requestIds] the set of currently-active kind-5300 request ids to listen
* for. A single feed carries one; the merged "All favorite algo feeds"
* filter carries one per favorite algo feed.
*/
class FavoriteAlgoFeedTopNavPerRelayFilterSet(
val contentFetches: Map<NormalizedRelayUrl, FavoriteAlgoFeedTopNavPerRelayFilter>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestIds: Set<HexKey>,
) : IFeedTopNavPerRelayFilterSet
@@ -83,10 +83,12 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(ThumbHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope))
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(ThumbHashFetcher.TKeyer)
add(ProfilePictureFetcher.BKeyer)
add(OkHttpFactory(callFactory))
}.build(),
@@ -0,0 +1,85 @@
/*
* 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.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder
data class ThumbhashWrapper(
val thumbhash: String,
)
@Stable
class ThumbHashFetcher(
private val options: Options,
private val data: ThumbhashWrapper,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val hash = data.thumbhash
val platformImage = ThumbHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
return ImageFetchResult(
image = platformImage.toAndroidBitmap().asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<ThumbhashWrapper> {
override fun create(
data: ThumbhashWrapper,
options: Options,
imageLoader: ImageLoader,
): Fetcher = ThumbHashFetcher(options, data)
}
object TKeyer : Keyer<ThumbhashWrapper> {
override fun key(
data: ThumbhashWrapper,
options: Options,
): String = data.thumbhash
}
}
/**
* Pick the best Coil model for a media placeholder.
*
* Prefers [ThumbhashWrapper] when a thumbhash is available (better quality, preserves aspect ratio
* and alpha) and falls back to [BlurhashWrapper] when only a blurhash is present. Returns null
* when neither is available, so callers can skip the placeholder request entirely.
*/
fun placeholderModel(
thumbhash: String?,
blurhash: String?,
): Any? =
when {
!thumbhash.isNullOrEmpty() -> ThumbhashWrapper(thumbhash)
!blurhash.isNullOrEmpty() -> BlurhashWrapper(blurhash)
else -> null
}
@@ -68,6 +68,7 @@ fun VideoView(
onDialog: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
thumbhash: String? = null,
) {
val borderModifier =
if (roundedCorner) {
@@ -78,7 +79,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash)
}
@Composable
@@ -99,6 +100,7 @@ fun VideoView(
alwaysShowVideo: Boolean = false,
showControls: Boolean = true,
accountViewModel: AccountViewModel,
thumbhash: String? = null,
) {
val automaticallyStartPlayback =
remember {
@@ -107,7 +109,7 @@ fun VideoView(
)
}
if (blurhash == null) {
if (blurhash == null && thumbhash == null) {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier =
@@ -154,12 +156,13 @@ fun VideoView(
}
Box(modifier, contentAlignment = Alignment.Center) {
// Always displays Blurharh to avoid size flickering
// Always displays a placeholder (thumbhash preferred, blurhash fallback) to avoid size flickering
DisplayBlurHash(
blurhash,
null,
contentScale,
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
thumbhash = thumbhash,
)
if (!automaticallyStartPlayback.value) {
@@ -71,6 +71,8 @@ class UrlPreview {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else if (mimeType.type == "application" && mimeType.subtype == "pdf") {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
}
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -97,6 +99,8 @@ class RelaySubscriptionsCoordinator(
val shorts = ShortsFilterAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -114,6 +118,8 @@ class RelaySubscriptionsCoordinator(
shorts,
longs,
articles,
badges,
profileBadges,
channelFinder,
eventFinder,
userFinder,
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -49,6 +50,7 @@ val RepliesAndReactionsToAddressesKinds1 =
ZapPollEvent.KIND,
CommentEvent.KIND,
AttestationEvent.KIND,
BadgeAwardEvent.KIND,
)
val PostsAndChatMessagesToAddresses =
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads
import android.media.MediaDataSource
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
@@ -36,6 +37,7 @@ class FileHeader(
val size: Int,
val dim: DimensionTag?,
val blurHash: BlurhashWrapper?,
val thumbHash: ThumbhashWrapper? = null,
) {
class UnableToDownload(
val fileUrl: String,
@@ -71,9 +73,9 @@ class FileHeader(
val hash = sha256(data).toHexKey()
val size = data.size
val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed)
val preview = PreviewMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed)
Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
Result.success(FileHeader(mimeType, hash, size, preview.dim, preview.blurhash, preview.thumbhash))
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload") { "Couldn't convert image in to File Header: ${e.message}" }
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -45,12 +46,15 @@ data class MediaUploadResult(
val ipfs: String? = null,
// blurhash value for previews
val blurHash: BlurhashWrapper? = null,
// thumbhash value for previews (preferred over blurhash in the UI)
val thumbHash: ThumbhashWrapper? = null,
) {
fun mergeLocalMetadata(localMetadata: Pair<BlurhashWrapper?, DimensionTag?>?): MediaUploadResult =
localMetadata?.let { (blur, dim) ->
fun mergeLocalMetadata(localMetadata: PreviewHashes?): MediaUploadResult =
localMetadata?.let { hashes ->
copy(
dimension = dim ?: dimension,
blurHash = blur ?: blurHash,
dimension = hashes.dim ?: dimension,
blurHash = hashes.blurhash ?: blurHash,
thumbHash = hashes.thumbhash ?: thumbHash,
)
} ?: this
}
@@ -26,11 +26,28 @@ import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
object BlurhashMetadataCalculator {
/**
* Result of precomputing placeholder metadata during an upload. The bitmap or video thumbnail is
* decoded exactly once and both hashes are computed from the same pixels to keep the hot upload
* path cheap.
*/
data class PreviewHashes(
val blurhash: BlurhashWrapper? = null,
val thumbhash: ThumbhashWrapper? = null,
val dim: DimensionTag? = null,
) {
companion object {
val EMPTY = PreviewHashes()
}
}
object PreviewMetadataCalculator {
private fun isImage(mimeType: String?) = mimeType?.startsWith("image/", ignoreCase = true) == true
private fun isVideo(mimeType: String?) = mimeType?.startsWith("video/", ignoreCase = true) == true
@@ -42,19 +59,11 @@ object BlurhashMetadataCalculator {
inPreferredConfig = Bitmap.Config.ARGB_8888
}
private fun processImage(
bitmap: Bitmap?,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> {
val (blur, dim) = processBitmap(bitmap)
return blur to (dim ?: dimPrecomputed)
}
fun computeFromBytes(
data: ByteArray,
mimeType: String?,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> =
): PreviewHashes =
when {
isImage(mimeType) -> {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions())
@@ -72,7 +81,7 @@ object BlurhashMetadataCalculator {
}
else -> {
null to dimPrecomputed
PreviewHashes(dim = dimPrecomputed)
}
}
@@ -81,7 +90,7 @@ object BlurhashMetadataCalculator {
uri: Uri,
mimeType: String?,
dimPrecomputed: DimensionTag? = null,
): Pair<BlurhashWrapper?, DimensionTag?>? {
): PreviewHashes? {
if (!shouldAttempt(mimeType)) return null
return try {
@@ -90,7 +99,7 @@ object BlurhashMetadataCalculator {
context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions())
processImage(bitmap, dimPrecomputed)
} ?: (null to dimPrecomputed)
} ?: PreviewHashes(dim = dimPrecomputed)
}
isVideo(mimeType) -> {
@@ -108,33 +117,44 @@ object BlurhashMetadataCalculator {
}
}
} catch (e: Exception) {
Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e)
Log.w("PreviewMetadataCalc", "Failed to compute metadata from uri", e)
null
}
}
private fun processBitmap(bitmap: Bitmap?): Pair<BlurhashWrapper?, DimensionTag?> =
private fun processImage(
bitmap: Bitmap?,
dimPrecomputed: DimensionTag?,
): PreviewHashes {
val hashes = processBitmap(bitmap)
return hashes.copy(dim = hashes.dim ?: dimPrecomputed)
}
private fun processBitmap(bitmap: Bitmap?): PreviewHashes =
if (bitmap != null) {
try {
val blurhash = BlurhashWrapper(bitmap.toBlurhash())
blurhash to DimensionTag(bitmap.width, bitmap.height)
val blurhash = runCatching { BlurhashWrapper(bitmap.toBlurhash()) }.getOrNull()
val thumbhash = runCatching { ThumbhashWrapper(bitmap.toThumbhash()) }.getOrNull()
PreviewHashes(
blurhash = blurhash,
thumbhash = thumbhash,
dim = DimensionTag(bitmap.width, bitmap.height),
)
} finally {
bitmap.recycle()
}
} else {
null to null
PreviewHashes.EMPTY
}
private fun processRetriever(
retriever: MediaMetadataRetriever,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> {
): PreviewHashes {
val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) }
return if (dim?.hasSize() == true) {
blurhash to dim
} else {
blurhash to null
}
val thumb = retriever.getThumbnail()
val hashes = processBitmap(thumb)
val finalDim = if (dim?.hasSize() == true) dim else hashes.dim
return hashes.copy(dim = finalDim)
}
}
@@ -28,8 +28,8 @@ import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -95,7 +95,7 @@ class BlossomUploader {
hashBytes.toHexKey() to totalBytes
}
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType)
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
@@ -30,8 +30,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
@@ -107,7 +107,7 @@ class Nip96Uploader {
val myContentType = contentType ?: contentResolver.getType(uri)
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType)
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
@@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -233,6 +234,8 @@ open class EditPostViewModel : ViewModel() {
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -112,6 +113,15 @@ fun RenderLoaded(
accountViewModel = accountViewModel,
)
}
} else if (state.previewInfo.mimeType.startsWith("application/pdf")) {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
content = MediaUrlPdf(url, uri = callbackUri, mimeType = state.previewInfo.mimeType),
roundedCorner = true,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
} else {
UrlPreviewCard(url, state.previewInfo)
}
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
@@ -480,6 +481,9 @@ private fun RenderWordWithoutPreview(
// Don't preview Videos
is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText)
// Don't preview PDFs
is PdfSegment -> ClickableUrl(word.segmentText, word.segmentText)
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
@@ -529,6 +533,7 @@ private fun RenderWordWithPreview(
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
@@ -207,14 +207,16 @@ fun ContentWarningNoteWithBigReasonPreview() {
@Composable
fun BlurhashBackdrop(
blurhash: String,
blurhash: String?,
description: String?,
thumbhash: String? = null,
) {
DisplayBlurHash(
blurhash = blurhash,
description = description,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
thumbhash = thumbhash,
)
}
@@ -222,12 +224,13 @@ fun BlurhashBackdrop(
fun BlurhashGridBackdrop(media: List<MediaUrlImage>) {
AutoNonlazyGrid(media.size) { idx ->
val item = media[idx]
if (item.blurhash != null) {
if (item.blurhash != null || item.thumbhash != null) {
DisplayBlurHash(
blurhash = item.blurhash,
description = item.description,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
thumbhash = item.thumbhash,
)
}
}
@@ -535,6 +535,7 @@ private fun RenderImageOrVideo(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
fullResolution = true,
)
}
@@ -593,6 +594,7 @@ private fun RenderImageOrVideo(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
fullResolution = true,
)
}
@@ -77,6 +77,8 @@ import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.request.ImageRequest
import coil3.size.Size
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -86,13 +88,15 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard
import com.vitorpamplona.amethyst.ui.components.pdf.PdfViewerDialog
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
@@ -157,7 +161,7 @@ fun ZoomableContentView(
preloadUrls = listOf(content.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier =
@@ -179,7 +183,7 @@ fun ZoomableContentView(
preloadUrls = emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
@@ -198,6 +202,7 @@ fun ZoomableContentView(
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
thumbhash = content.thumbhash,
)
}
}
@@ -237,9 +242,26 @@ fun ZoomableContentView(
}
}
}
is MediaUrlPdf -> {
Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) {
PdfPreviewCard(
content = content,
accountViewModel = accountViewModel,
onOpen = { dialogOpen = true },
)
}
}
}
if (dialogOpen) {
if (content is MediaUrlPdf) {
PdfViewerDialog(
content = content,
accountViewModel = accountViewModel,
onDismiss = { dialogOpen = false },
)
} else {
ZoomableImageDialog(
imageUrl = content,
allImages = images,
@@ -251,6 +273,7 @@ fun ZoomableContentView(
)
}
}
}
@Composable
fun TwoSecondController(
@@ -271,6 +294,7 @@ fun LocalImageView(
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
fullResolution: Boolean = false,
) {
if (content.localFileExists()) {
val showImage =
@@ -281,10 +305,23 @@ fun LocalImageView(
}
val ratio = remember(content) { content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.localFile.toString()) }
val context = LocalContext.current
val imageModel =
if (fullResolution) {
remember(content.localFile, context) {
ImageRequest
.Builder(context)
.data(content.localFile)
.size(Size.ORIGINAL)
.build()
}
} else {
content.localFile
}
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { imageVisible ->
if (imageVisible) {
SubcomposeAsyncImage(
model = content.localFile,
model = imageModel,
contentDescription = content.description,
contentScale = contentScale,
modifier = mainImageModifier,
@@ -293,13 +330,14 @@ fun LocalImageView(
when (state) {
is AsyncImagePainter.State.Loading,
-> {
if (content.blurhash != null) {
if (content.blurhash != null || content.thumbhash != null) {
if (ratio != null) {
DisplayBlurHash(
content.blurhash,
content.description,
contentScale,
loadedImageModifier.aspectRatio(ratio),
thumbhash = content.thumbhash,
)
} else {
DisplayBlurHash(
@@ -307,6 +345,7 @@ fun LocalImageView(
content.description,
contentScale,
loadedImageModifier,
thumbhash = content.thumbhash,
)
}
} else {
@@ -352,7 +391,7 @@ fun LocalImageView(
}
}
} else {
if (content.blurhash != null && ratio != null) {
if ((content.blurhash != null || content.thumbhash != null) && ratio != null) {
DisplayBlurHash(
content.blurhash,
content.description,
@@ -360,6 +399,7 @@ fun LocalImageView(
loadedImageModifier
.aspectRatio(ratio)
.clickable { showImage.value = true },
thumbhash = content.thumbhash,
)
IconButton(
modifier = Modifier.size(Size75dp),
@@ -386,6 +426,7 @@ fun UrlImageView(
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
fullResolution: Boolean = false,
) {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
@@ -396,10 +437,24 @@ fun UrlImageView(
)
}
val context = LocalContext.current
val imageModel =
if (fullResolution) {
remember(content.url, context) {
ImageRequest
.Builder(context)
.data(content.url)
.size(Size.ORIGINAL)
.build()
}
} else {
content.url
}
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = content.url,
model = imageModel,
contentDescription = content.description,
contentScale = contentScale,
modifier = mainImageModifier,
@@ -408,7 +463,7 @@ fun UrlImageView(
when (state) {
is AsyncImagePainter.State.Loading,
-> {
if (content.blurhash != null) {
if (content.blurhash != null || content.thumbhash != null) {
if (ratio != null) {
val modifier =
if (contentScale == ContentScale.Crop) {
@@ -422,6 +477,7 @@ fun UrlImageView(
content.description,
ContentScale.Crop,
modifier,
thumbhash = content.thumbhash,
)
} else {
DisplayBlurHash(
@@ -429,6 +485,7 @@ fun UrlImageView(
content.description,
ContentScale.Crop,
loadedImageModifier,
thumbhash = content.thumbhash,
)
}
} else {
@@ -463,7 +520,7 @@ fun UrlImageView(
}
}
} else {
if (content.blurhash != null && ratio != null) {
if ((content.blurhash != null || content.thumbhash != null) && ratio != null) {
val modifier =
if (contentScale == ContentScale.Crop) {
loadedImageModifier.clickable { showImage.value = true }
@@ -476,6 +533,7 @@ fun UrlImageView(
content.description,
contentScale,
modifier,
thumbhash = content.thumbhash,
)
IconButton(
modifier = Modifier.size(Size75dp),
@@ -734,11 +792,14 @@ fun DisplayBlurHash(
description: String?,
contentScale: ContentScale,
modifier: Modifier,
thumbhash: String? = null,
) {
if (blurhash == null) return
val model =
com.vitorpamplona.amethyst.service.images
.placeholderModel(thumbhash, blurhash) ?: return
AsyncImage(
model = BlurhashWrapper(blurhash),
model = model,
contentDescription = description,
contentScale = contentScale,
modifier = modifier,
@@ -0,0 +1,75 @@
/*
* 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.pdf
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException
object PdfFetcher {
/**
* Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is
* responsible for closing the returned snapshot; while it's open the cache entry cannot be
* evicted, so the underlying file stays valid for `PdfRenderer`.
*
* Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU
* eviction and disk budget as images.
*/
suspend fun fetchSnapshot(
url: String,
okHttpClient: (String) -> OkHttpClient,
): DiskCache.Snapshot {
val diskCache = Amethyst.instance.diskCache
diskCache.openSnapshot(url)?.let { return it }
return withContext(Dispatchers.IO) {
val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url")
try {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
throw IOException("PDF download failed: ${response.code}")
}
diskCache.fileSystem.write(editor.data) {
val bytes = writeAll(response.body.source())
if (bytes == 0L) throw IOException("PDF download failed: empty response body")
}
}
editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url")
} catch (t: Throwable) {
runCatching { editor.abort() }
throw t
}
}
}
}
@@ -0,0 +1,300 @@
/*
* 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.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages.
private const val THUMBNAIL_MAX_DIM_PX = 1600
data class PdfPreview(
val thumbnail: Bitmap,
val pageCount: Int,
val aspectRatio: Float,
)
private sealed class PdfLoadState {
data object Loading : PdfLoadState()
data class Ready(
val preview: PdfPreview,
) : PdfLoadState()
data object Failed : PdfLoadState()
}
@Composable
fun PdfPreviewCard(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
) {
val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) }
if (showPdf.value) {
LoadedPdfPreviewCard(content, accountViewModel, onOpen)
} else {
PlaceholderPdfCard(content) { showPdf.value = true }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LoadedPdfPreviewCard(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
) {
val sharePopupExpanded = remember { mutableStateOf(false) }
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val targetWidthPx =
remember(density, configuration) {
val screenPx =
with(density) {
configuration.screenWidthDp.dp
.toPx()
.toInt()
}
screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1)
}
@Suppress("ProduceStateDoesNotAssignValue")
val state by produceState<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) {
value =
try {
PdfFetcher
.fetchSnapshot(content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}.use { snapshot ->
withContext(Dispatchers.IO) {
renderFirstPage(snapshot.data.toFile(), targetWidthPx)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
PdfLoadState.Failed
}
}
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
val filename = remember(content.url) { extractFilename(content.url) }
when (val current = state) {
is PdfLoadState.Loading -> {
PdfSkeletonCard(filename)
}
is PdfLoadState.Failed -> {
ClickableUrl(urlText = content.url, url = content.url)
}
is PdfLoadState.Ready -> {
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = onOpen,
onLongClick = { sharePopupExpanded.value = true },
),
) {
Image(
bitmap = current.preview.thumbnail.asImageBitmap(),
contentDescription = content.description ?: filename,
contentScale = ContentScale.FillWidth,
filterQuality = FilterQuality.High,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
)
FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount))
Spacer(modifier = DoubleVertSpacer)
}
}
}
}
@Composable
private fun PlaceholderPdfCard(
content: MediaUrlPdf,
onLoad: () -> Unit,
) {
val filename = remember(content.url) { extractFilename(content.url) }
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.fillMaxWidth()
.combinedClickable(onClick = onLoad, onLongClick = onLoad),
) {
FilenameRow(filename = filename, subtitle = "Tap to load PDF")
Spacer(modifier = DoubleVertSpacer)
}
}
@Composable
private fun PdfSkeletonCard(filename: String) {
Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) {
FilenameRow(filename = filename, subtitle = "Loading…")
Spacer(modifier = DoubleVertSpacer)
}
}
@Composable
private fun FilenameRow(
filename: String,
subtitle: String,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Outlined.PictureAsPdf,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
private fun renderFirstPage(
file: java.io.File,
targetWidthPx: Int,
): PdfLoadState =
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
PdfRenderer(pfd).use { renderer ->
val pageCount = renderer.pageCount
if (pageCount <= 0) return@use PdfLoadState.Failed
renderer.openPage(0).use { page ->
val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx)
// PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output.
val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
PdfLoadState.Ready(
PdfPreview(
thumbnail = bitmap,
pageCount = pageCount,
aspectRatio = page.width.toFloat() / page.height.toFloat(),
),
)
}
}
}
/**
* Returns the bitmap dimensions to render a PDF page at, scaled so the longest side equals
* [targetDim] while preserving aspect ratio. Always scales, never returns native size: a PDF
* page's native width/height are in PostScript points (1/72"), which is far below any useful
* display resolution. Since PDFs are vector, rendering at a larger target is essentially free
* and avoids a 72-DPI-blurry bitmap.
*/
internal fun cappedRenderSize(
pageWidth: Int,
pageHeight: Int,
targetDim: Int,
): Pair<Int, Int> {
if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1
val longest = maxOf(pageWidth, pageHeight)
val scale = targetDim.toFloat() / longest
val w = (pageWidth * scale).toInt().coerceAtLeast(1)
val h = (pageHeight * scale).toInt().coerceAtLeast(1)
return w to h
}
internal fun extractFilename(url: String): String {
val afterQuery = url.substringBefore('?').substringBefore('#')
val name = afterQuery.substringAfterLast('/', afterQuery)
return if (name.isBlank()) url else name
}
internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
@@ -0,0 +1,415 @@
/*
* 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.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
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.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.toggleScale
import net.engawapg.lib.zoomable.zoomable
// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when
// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes
// of RAM (PdfRenderer requires ARGB_8888). 3072 gives ~26 MB per A4-shaped page.
private const val VIEWER_MAX_DIM_PX = 3072
// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in
// past HI_RES_ZOOM_THRESHOLD we re-render the current page at
// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within
// common GPU texture limits (so drawing stays hardware-accelerated) and caps
// memory at ~48 MB for an A4-shaped page. Above 4096 most mid-range GPUs fall
// back to software rendering, which is what caused the pan/zoom jitter.
private const val HI_RES_MAX_DIM_PX = 4096
private const val HI_RES_ZOOM_THRESHOLD = 1.5f
private const val HI_RES_DEBOUNCE_MS = 200L
// Zoom level the viewer animates to when the user double-taps. Matches the
// threshold region where we swap in the hi-res bitmap.
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
// How many recently-rendered pages to keep around. Pager already pre-composes the
// current page plus one neighbor; this just speeds up small back/forward swipes.
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
private const val PAGE_CACHE_SIZE = 3
private class PageBitmapCache(
private val maxSize: Int,
) {
private val cache =
object : java.util.LinkedHashMap<Int, Bitmap>(maxSize, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<Int, Bitmap>): Boolean = size > maxSize
}
@Synchronized fun get(key: Int): Bitmap? = cache[key]
@Synchronized fun put(
key: Int,
value: Bitmap,
) {
cache[key] = value
}
}
private class PdfDocumentHandle(
val snapshot: DiskCache.Snapshot,
val pfd: ParcelFileDescriptor,
val renderer: PdfRenderer,
) {
// Snapshot eagerly. Compose's saveable PagerState reads pageCount during
// teardown, which can run *after* close() — so we can't query the renderer
// lazily without tripping IllegalStateException("Document already closed").
val pageCount: Int = renderer.pageCount
val mutex: Mutex = Mutex()
@Volatile var closed: Boolean = false
private set
fun close() {
closed = true
runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) }
runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) }
runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) }
}
}
@Composable
fun PdfViewerDialog(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
PdfViewerContent(
content = content,
accountViewModel = accountViewModel,
onDismiss = onDismiss,
)
}
}
}
@Composable
private fun PdfViewerContent(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
value =
try {
withContext(Dispatchers.IO) {
val snapshot =
PdfFetcher.fetchSnapshot(content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}
try {
val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
val renderer = PdfRenderer(pfd)
PdfDocumentHandle(snapshot, pfd, renderer)
} catch (t: Throwable) {
runCatching { snapshot.close() }
throw t
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e)
null
}
}
// Capture the handle as a local val so the onDispose lambda closes *this* handle,
// not whatever the delegated property reads at dispose time. Without this, the
// DisposableEffect keyed on handleState runs its onDispose when handleState
// transitions from null -> handle, and `handleState?.close()` reads the new handle
// and closes it right after it was created.
val handleForDispose = handleState
DisposableEffect(handleForDispose) {
onDispose {
handleForDispose?.close()
}
}
val sharePopupExpanded = remember { mutableStateOf(false) }
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
val handle = handleState
if (handle == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
} else if (handle.pageCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Unable to open PDF",
color = Color.White,
)
}
} else {
val pagerState = rememberPagerState { handle.pageCount }
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { pageIndex ->
PdfPageView(
handle = handle,
pageIndex = pageIndex,
cache = pageCache,
)
}
Row(
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.statusBarsPadding()
.systemBarsPadding()
.padding(horizontal = Size15dp, vertical = Size10dp),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
color = Color.White,
modifier =
Modifier
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
.padding(horizontal = Size10dp, vertical = Size5dp),
)
Spacer(modifier = Modifier.weight(1f))
OutlinedButton(
onClick = { sharePopupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
}
}
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun PdfPageView(
handle: PdfDocumentHandle,
pageIndex: Int,
cache: PageBitmapCache,
) {
val cached = cache.get(pageIndex)
@Suppress("ProduceStateDoesNotAssignValue")
val baseBitmap by produceState<Bitmap?>(initialValue = cached, key1 = handle, key2 = pageIndex) {
if (value != null) return@produceState
val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX)
rendered?.let { cache.put(pageIndex, it) }
value = rendered
}
val zoomState = rememberZoomState()
// Re-render the page at a higher resolution once the user zooms in and settles,
// so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base
// bitmap. Released when zoom drops back under threshold or the page leaves view.
var hiResBitmap by remember(handle, pageIndex) { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(handle, pageIndex, baseBitmap) {
if (baseBitmap == null) return@LaunchedEffect
snapshotFlow { zoomState.scale }
.debounce(HI_RES_DEBOUNCE_MS)
.distinctUntilChanged()
.collectLatest { scale ->
if (scale < HI_RES_ZOOM_THRESHOLD) {
hiResBitmap = null
} else {
val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX)
// Skip if the hi-res render wouldn't beat what we already have.
val base = baseBitmap ?: return@collectLatest
val baseLongest = maxOf(base.width, base.height)
if (target <= baseLongest) {
hiResBitmap = null
} else {
hiResBitmap = renderPageCatching(handle, pageIndex, target)
}
}
}
}
val current = hiResBitmap ?: baseBitmap
// Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new
// one and push Compose into thinking the texture changed.
val imageBitmap = remember(current) { current?.asImageBitmap() }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (imageBitmap != null) {
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = ContentScale.Fit,
// Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every
// frame during pan/zoom, which is the main source of jitter when the
// source bitmap is several megapixels. Bilinear on a 3072-4096 px source
// looks effectively identical on-screen.
filterQuality = FilterQuality.Medium,
modifier =
Modifier
.fillMaxSize()
.zoomable(
zoomState = zoomState,
onDoubleTap = { position ->
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
},
),
)
} else {
CircularProgressIndicator(color = Color.White)
}
}
}
private suspend fun renderPageCatching(
handle: PdfDocumentHandle,
pageIndex: Int,
maxDim: Int,
): Bitmap? =
try {
handle.mutex.withLock {
if (handle.closed) {
null
} else {
withContext(Dispatchers.IO) {
handle.renderer.openPage(pageIndex).use { page ->
val (width, height) = cappedRenderSize(page.width, page.height, maxDim)
// PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently
// rejected and produces blank output.
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bmp.eraseColor(android.graphics.Color.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bmp
}
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e)
null
}
@@ -58,6 +58,7 @@ object ScrollStateKeys {
const val POLLS_SCREEN = "PollsFeed"
const val POLLS_OPEN = "PollsOpenFeed"
const val POLLS_CLOSED = "PollsClosedFeed"
const val BADGES_SCREEN = "BadgesFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed"
@@ -64,6 +64,9 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
@@ -95,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
@@ -214,6 +218,9 @@ fun BuildNavigation(
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
@@ -277,6 +284,7 @@ fun BuildNavigation(
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
@@ -59,6 +59,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material.icons.outlined.Settings
@@ -601,6 +602,14 @@ fun ListContent(
route = Route.Polls,
)
NavigationRow(
title = R.string.badges,
icon = Icons.Outlined.MilitaryTech,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Badges,
)
NavigationRow(
title = R.string.discover_marketplace,
icon = Icons.Outlined.Storefront,
@@ -45,6 +45,22 @@ sealed class Route {
@Serializable object Polls : Route()
@Serializable object Badges : Route()
@Serializable object ProfileBadges : Route()
@Serializable data class AwardBadge(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Pictures : Route()
@Serializable object Products : Route()
@@ -171,6 +187,8 @@ sealed class Route {
@Serializable object EditMediaServers : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route()
@Serializable object UpdateReactionType : Route()
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewList
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Person
@@ -67,6 +68,7 @@ import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
import com.vitorpamplona.amethyst.ui.screen.HashtagName
@@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalPermissionsApi::class)
@@ -150,23 +154,40 @@ fun FeedFilterSpinner(
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Size20Modifier)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Bound the Column so long filter names (e.g. DVM titles) get truncated
// instead of wrapping to multiple lines and shoving the expand icon out.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f, fill = false),
) {
val filter = selected?.code
if (filter is TopFilter.Geohash) {
LoadCityName(
geohashStr = filter.tag,
onLoading = {
Row {
Text(filter.tag)
Text(
text = filter.tag,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
}
},
) { cityName ->
Text(cityName)
Text(
text = cityName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
Text(currentText)
Text(
text = currentText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (filter is TopFilter.AroundMe) {
@@ -178,6 +199,8 @@ fun FeedFilterSpinner(
text = stringRes(R.string.lack_location_permissions),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
@@ -193,6 +216,8 @@ fun FeedFilterSpinner(
text = "(${myLocation.geoHash})",
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
@@ -203,6 +228,8 @@ fun FeedFilterSpinner(
text = "($cityName)",
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -212,6 +239,8 @@ fun FeedFilterSpinner(
text = stringRes(R.string.lack_location_permissions),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -220,6 +249,8 @@ fun FeedFilterSpinner(
text = stringRes(R.string.loading_location),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -329,6 +360,20 @@ fun RenderOption(
color = MaterialTheme.colorScheme.onSurface,
)
}
is FavoriteAlgoFeedName -> {
val noteState by observeNote(option.note, accountViewModel)
val name =
(noteState.note.event as? AppDefinitionEvent)
?.appMetaData()
?.name
?.takeIf { it.isNotBlank() } ?: option.note.dTag()
Text(
text = name,
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -346,6 +391,7 @@ private enum class FeedGroup(
COMMUNITIES(R.string.feed_group_communities),
LOCATIONS(R.string.feed_group_locations),
LISTS(R.string.feed_group_lists),
DVMS(R.string.feed_group_dvms),
RELAYS(R.string.feed_group_relays),
}
@@ -373,10 +419,15 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
FeedGroup.LOCATIONS
}
is FavoriteAlgoFeedName -> {
FeedGroup.DVMS
}
is ResourceName -> {
when (entry.item.code) {
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
is TopFilter.Global -> FeedGroup.RELAYS
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
else -> FeedGroup.FEEDS
}
}
@@ -541,12 +592,21 @@ private fun FeedIcon(
Icons.AutoMirrored.Outlined.ViewList
}
is TopFilter.FavoriteAlgoFeed -> {
Icons.Outlined.AutoAwesome
}
is TopFilter.AllFavoriteAlgoFeeds -> {
Icons.Outlined.AutoAwesome
}
else -> {
when (item.name) {
is GeoHashName -> Icons.Outlined.LocationOn
is RelayName -> Icons.Outlined.Storage
is CommunityName -> Icons.Outlined.Groups
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome
else -> Icons.Outlined.Person
}
}
@@ -46,11 +46,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.types.AcceptBadgeControls
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.BadgeCard
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -128,6 +130,10 @@ fun BadgeCompose(
note.replyTo?.firstOrNull()?.let {
BadgeDisplay(baseNote = it, accountViewModel)
}
(note.event as? BadgeAwardEvent)?.let { award ->
AcceptBadgeControls(award, accountViewModel)
}
}
}
}
@@ -396,10 +396,6 @@ fun AcceptableNote(
}
}
is BadgeDefinitionEvent -> {
BadgeDisplay(baseNote = baseNote, accountViewModel)
}
else -> {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, nav) { showPopup ->
CheckNewAndRenderNote(
@@ -454,10 +450,6 @@ fun AcceptableNote(
}
}
is BadgeDefinitionEvent -> {
BadgeDisplay(baseNote, accountViewModel)
}
else -> {
LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
CheckNewAndRenderNote(
@@ -938,6 +930,10 @@ private fun RenderNoteRow(
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
}
is BadgeDefinitionEvent -> {
BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
}
is LnZapEvent -> {
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
}
@@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -543,6 +544,8 @@ open class CommentPostViewModel :
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
@@ -20,15 +20,28 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -36,109 +49,82 @@ 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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
private val BadgeCardShape = RoundedCornerShape(12.dp)
private val BadgeThumbSize = 72.dp
@Composable
fun BadgeDisplay(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
val badgeData by observeNoteEvent<BadgeDefinitionEvent>(baseNote, accountViewModel)
val definition = badgeData ?: return
badgeData?.let {
RenderBadge(
it.image(),
it.name(),
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.onBackground,
it.description(),
)
}
}
val isMine = definition.pubKey == accountViewModel.userProfile().pubkeyHex
@Preview
@Composable
private fun RenderBadgePreview() {
val background = MaterialTheme.colorScheme.background
ThemeComparisonRow {
RenderBadge(
image = "http://test.com",
name = "Name",
backgroundForRow = background,
textColor = Color.LightGray,
description = "This badge is awarded to the dedicated individuals who actively contributed by writing events to the relay during the crucial testing phase leading up to the first beta release of Grain.",
)
BadgeCard(
imageUrl = definition.thumb()?.ifBlank { null } ?: definition.image(),
name = definition.name(),
description = definition.description(),
onClick =
nav?.let {
{
routeFor(baseNote, accountViewModel.account)?.let { route -> nav.nav(route) }
}
}
@Composable
private fun RenderBadge(
image: String?,
name: String?,
backgroundForRow: Color,
textColor: Color,
description: String?,
},
) {
Row(
modifier = Modifier.padding(vertical = 10.dp),
) {
Column {
image?.let {
AsyncImage(
model = it,
contentDescription =
stringRes(
R.string.badge_award_image_for,
name ?: "",
if (isMine && nav != null) {
BadgeActionRow {
FilledTonalButton(
onClick = {
nav.nav(
Route.AwardBadge(
kind = definition.kind,
pubKeyHex = definition.pubKey,
dTag = definition.dTag(),
),
modifier = Modifier.fillMaxWidth().background(backgroundForRow),
contentScale = ContentScale.FillWidth,
)
},
) {
Icon(
imageVector = Icons.Outlined.MilitaryTech,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(6.dp))
Text(stringRes(R.string.award_badge))
}
name?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
color = textColor,
)
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp),
color = textColor,
)
}
}
}
@@ -155,28 +141,239 @@ fun RenderBadgeAward(
if (note.replyTo.isNullOrEmpty()) return
val noteEvent = note.event as? BadgeAwardEvent ?: return
val definitionNote = note.replyTo?.firstOrNull()
val definition by
if (definitionNote != null) {
observeNoteEvent<BadgeDefinitionEvent>(definitionNote, accountViewModel)
} else {
remember { mutableStateOf<BadgeDefinitionEvent?>(null) }
}
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
Text(text = stringRes(R.string.award_granted_to))
LaunchedEffect(note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } }
LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardeeIds()) { awardees = it } }
BadgeCard(
imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image(),
name = definition?.name() ?: stringRes(R.string.award_granted_to),
description = definition?.description(),
onClick = {
routeFor(note, accountViewModel.account)?.let { nav.nav(it) }
},
) {
if (awardees.isNotEmpty()) {
BadgeAwardeesRow(awardees, accountViewModel, nav)
}
AcceptBadgeControls(noteEvent, accountViewModel)
}
}
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
awardees.take(100).forEach { user ->
@Composable
private fun BadgeCard(
imageUrl: String?,
name: String?,
description: String?,
onClick: (() -> Unit)? = null,
actions: @Composable () -> Unit = {},
) {
val baseModifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)
OutlinedCard(
modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier,
shape = BadgeCardShape,
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
BadgeThumbnail(imageUrl, name)
Spacer(modifier = Modifier.size(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
actions()
}
}
}
@Composable
private fun BadgeThumbnail(
imageUrl: String?,
name: String?,
) {
val description =
if (name != null) {
stringRes(R.string.badge_award_image_for, name)
} else {
stringRes(R.string.badge_award_image)
}
Box(
modifier = Modifier.size(BadgeThumbSize).clip(RoundedCornerShape(10.dp)),
) {
if (imageUrl.isNullOrBlank()) {
RobohashAsyncImage(
robot = "badgenotfound",
contentDescription = description,
modifier = Modifier.size(BadgeThumbSize),
loadRobohash = true,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = description,
modifier = Modifier.size(BadgeThumbSize),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
private fun BadgeActionRow(content: @Composable () -> Unit) {
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
content()
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BadgeAwardeesRow(
awardees: List<User>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Spacer(modifier = Modifier.height(14.dp))
Text(
text = stringRes(R.string.badge_awardees_label, awardees.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
awardees.take(24).forEach { user ->
UserPicture(
user = user,
size = Size35dp,
size = Size30dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (awardees.size > 100) {
Text(stringRes(R.string.badge_and_n_others, awardees.size - 100), maxLines = 1)
if (awardees.size > 24) {
Text(
text = stringRes(R.string.badge_and_n_others, awardees.size - 24),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 2.dp),
)
}
}
}
note.replyTo?.firstOrNull()?.let {
BadgeDisplay(baseNote = it, accountViewModel)
@Composable
fun AcceptBadgeControls(
award: BadgeAwardEvent,
accountViewModel: AccountViewModel,
) {
val myPubkey = accountViewModel.userProfile().pubkeyHex
val amAwardee = remember(award, myPubkey) { award.awardeeIds().contains(myPubkey) }
if (!amAwardee) return
val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey))
val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey))
val newState by newNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val oldState by oldNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val isAccepted =
remember(newState, oldState, award.id) {
val newEvent = newState.note.event as? ProfileBadgesEvent
val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent
val awardIds =
newEvent?.badgeAwardEvents()?.map { it.eventId }
?: oldEvent?.badgeAwardEvents()?.map { it.eventId }
?: emptyList()
awardIds.contains(award.id)
}
BadgeActionRow {
if (isAccepted) {
OutlinedButton(
onClick = {
accountViewModel.launchSigner {
accountViewModel.account.removeAcceptedBadge(award)
}
},
) {
Text(stringRes(R.string.unaccept_badge))
}
} else {
TextButton(
onClick = {
accountViewModel.launchSigner {
accountViewModel.account.removeAcceptedBadge(award)
}
},
) {
Text(stringRes(R.string.reject_badge))
}
Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton(
onClick = {
accountViewModel.launchSigner {
val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner
val defNote = LocalCache.getAddressableNoteIfExists(defAddr)
val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner
accountViewModel.account.addAcceptedBadge(award, defEvent)
}
},
) {
Text(stringRes(R.string.accept_badge))
}
}
}
}
@Preview
@Composable
private fun RenderBadgePreview() {
ThemeComparisonRow {
BadgeCard(
imageUrl = null,
name = "Relay Beta Tester",
description = "Awarded to the dedicated individuals who actively contributed by writing events to the relay during the beta phase.",
)
}
}
@@ -70,6 +70,7 @@ fun RenderClassifieds(
dim = it.dimension,
uri = note.toNostrUri(),
mimeType = it.mimeType,
thumbhash = it.thumbhash,
)
}
val title = noteEvent.title()
@@ -49,6 +49,7 @@ fun FileHeaderDisplay(
val content by
remember(note) {
val blurHash = event.blurhash()
val thumbHash = event.thumbhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
@@ -66,6 +67,7 @@ fun FileHeaderDisplay(
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
@@ -77,6 +79,7 @@ fun FileHeaderDisplay(
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
},
)
@@ -76,6 +76,7 @@ private fun ObserverAndRenderNIP95(
val uri = header.toNostrUri()
val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) }
val blurHash = eventHeader.blurhash()
val thumbHash = eventHeader.thumbhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.alt() ?: eventHeader.content
val mimeType = eventHeader.mimeType()
@@ -90,6 +91,7 @@ private fun ObserverAndRenderNIP95(
blurhash = blurHash,
isVerified = true,
uri = uri,
thumbhash = thumbHash,
)
} else {
MediaLocalVideo(
@@ -100,6 +102,8 @@ private fun ObserverAndRenderNIP95(
isVerified = true,
uri = uri,
authorName = header.author?.toBestDisplayName(),
blurhash = blurHash,
thumbhash = thumbHash,
)
}
@@ -85,6 +85,7 @@ fun PictureDisplay(
dim = it.dimension,
uri = uri,
mimeType = it.mimeType,
thumbhash = it.thumbhash,
)
}.toImmutableList(),
)
@@ -116,7 +117,7 @@ fun PictureDisplay(
preloadUrls = listOf(first.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } },
backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } },
) {
ZoomableContentView(
content = first,
@@ -103,6 +103,7 @@ fun VideoDisplay(
dim = imeta.dimension,
uri = uri,
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
} else {
MediaUrlVideo(
@@ -115,6 +116,7 @@ fun VideoDisplay(
artworkUri = imeta.image.firstOrNull(),
mimeType = imeta.mimeType,
blurhash = imeta.blurhash,
thumbhash = imeta.thumbhash,
)
},
)
@@ -71,6 +71,7 @@ fun JustVideoDisplay(
dim = imeta.dimension,
uri = note.toNostrUri(),
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
} else {
MediaUrlVideo(
@@ -82,6 +83,7 @@ fun JustVideoDisplay(
uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(),
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
},
)
@@ -95,7 +97,7 @@ fun JustVideoDisplay(
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
backdrop = (imeta.thumbhash ?: imeta.blurhash)?.let { { BlurhashBackdrop(imeta.blurhash, content.description, imeta.thumbhash) } },
) {
ZoomableContentView(
content = content,
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -96,6 +97,18 @@ class TopNavFilterState(
name = ResourceName(R.string.follow_list_chess),
)
val mineFollow =
FeedDefinition(
code = TopFilter.Mine,
name = ResourceName(R.string.follow_list_mine),
)
val allFavoriteAlgoFeedsFollow =
FeedDefinition(
code = TopFilter.AllFavoriteAlgoFeeds,
name = ResourceName(R.string.follow_list_all_favorite_dvms),
)
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
fun mergePeopleLists(
@@ -140,6 +153,7 @@ class TopNavFilterState(
geotagList: Set<String>,
communityList: List<AddressableNote>,
relayList: Set<NormalizedRelayUrl>,
favoriteAlgoFeedsList: List<AddressableNote>,
): List<FeedDefinition> {
val hashtags =
hashtagList.map {
@@ -173,7 +187,26 @@ class TopNavFilterState(
)
}
return (communities + hashtags + geotags + relays).sortedBy { it.name.name() }
// Favorites can only be added through FavoriteAlgoFeedToggle, which itself checks
// that the AppDefinitionEvent advertises kind 5300. Don't re-check here: on
// cold start the AppDefinitionEvent may not be in cache yet, and dropping
// the entry means the persisted TopFilter.FavoriteAlgoFeed can't find its chip
// in the spinner (user sees "Select an option" while the banner fires the
// RPC — the bug we had before this change).
val favoriteAlgoFeeds =
favoriteAlgoFeedsList.map { feedNote ->
FeedDefinition(
TopFilter.FavoriteAlgoFeed(feedNote.address),
FavoriteAlgoFeedName(feedNote),
)
}
// Only show the "All favorite algo feeds" meta-chip when there is at least one
// real favorite to merge; otherwise the chip opens to an empty feed.
val allFavorites =
if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList()
return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() }
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -183,6 +216,7 @@ class TopNavFilterState(
account.geohashList.flow,
account.communityList.flowNotes,
account.relayFeedsList.flow,
account.favoriteAlgoFeedsList.flowNotes,
::mergeInterests,
).onStart {
emit(
@@ -191,6 +225,7 @@ class TopNavFilterState(
account.geohashList.flow.value,
account.communityList.flowNotes.value,
account.relayFeedsList.flow.value,
account.favoriteAlgoFeedsList.flowNotes.value,
),
)
}
@@ -211,6 +246,18 @@ class TopNavFilterState(
)
}
private val _badgeRoutes =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
emit(
listOf(
listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow),
peopleLists,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -233,6 +280,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, defaultLists)
val badgeRoutes =
_badgeRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -298,6 +350,15 @@ class CommunityName(
override fun name() = "/n/${(note.dTag())}"
}
@Stable
class FavoriteAlgoFeedName(
val note: AddressableNote,
) : Name() {
override fun name(): String =
(note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() }
?: note.dTag()
}
@Immutable
class FeedDefinition(
val code: TopFilter,
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
@@ -81,6 +82,8 @@ class AccountFeedContentStates(
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
@@ -125,6 +128,8 @@ class AccountFeedContentStates(
openPollsFeed.updateFeedWith(newNotes)
closedPollsFeed.updateFeedWith(newNotes)
badgesFeed.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
@@ -163,6 +168,8 @@ class AccountFeedContentStates(
openPollsFeed.deleteFromFeed(newNotes)
closedPollsFeed.deleteFromFeed(newNotes)
badgesFeed.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
@@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -1106,6 +1107,12 @@ class AccountViewModel(
fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) }
fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) }
fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) }
fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm)
fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) }
fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) }
@@ -1820,8 +1827,8 @@ class AccountViewModel(
onReady: (event: Note) -> Unit,
) {
launchSigner {
account.requestDVMContentDiscovery(dvmPublicKey) {
onReady(LocalCache.getOrCreateNote(it.id))
account.requestDVMContentDiscovery(dvmPublicKey) { request, _ ->
onReady(LocalCache.getOrCreateNote(request.id))
}
}
}
@@ -0,0 +1,113 @@
/*
* 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.screen.loggedIn.badges
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
@Composable
fun BadgesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
BadgesScreen(
feedContentState = accountViewModel.feedStates.badgesFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun BadgesScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForBadgesScreen(feedContentState, accountViewModel)
BadgesFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
BadgesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Badges, accountViewModel) { route ->
if (route == Route.Badges) {
feedContentState.sendToTop()
} else {
nav.newStack(route)
}
}
},
floatingButton = {
NewBadgeButton(accountViewModel)
},
accountViewModel = accountViewModel,
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "BadgesFeed",
)
}
}
}
}
}
@Composable
fun WatchAccountForBadgesScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveBadgesFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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.screen.loggedIn.badges
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun BadgesTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultBadgesFollowList
.collectAsStateWithLifecycle()
BadgesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultBadgesFollowList,
)
}
}
@Composable
private fun BadgesTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.badgeRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,69 @@
/*
* 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.screen.loggedIn.badges
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post.NewBadgeModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewBadgeButton(accountViewModel: AccountViewModel) {
var showDialog by remember { mutableStateOf(false) }
val postViewModel: NewBadgeModel = viewModel()
if (showDialog) {
NewBadgeDialog(
onClose = { showDialog = false },
postViewModel = postViewModel,
accountViewModel = accountViewModel,
)
}
FloatingActionButton(
onClick = { showDialog = true },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
painter = painterRes(R.drawable.ic_compose, 5),
contentDescription = stringRes(id = R.string.new_badge),
modifier = Size26Modifier,
tint = Color.White,
)
}
}
@@ -0,0 +1,265 @@
/*
* 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.screen.loggedIn.badges.award
import androidx.activity.compose.BackHandler
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
fun AwardBadgeScreen(
kind: Int,
pubKeyHex: HexKey,
dTag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: AwardBadgeViewModel = viewModel()
LaunchedEffect(accountViewModel, kind, pubKeyHex, dTag) {
vm.init(accountViewModel, kind, pubKeyHex, dTag)
}
var searchInput by remember { mutableStateOf("") }
val selectedUsers = remember { mutableStateListOf<User>() }
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
DisposableEffect(Unit) {
onDispose { userSuggestions.reset() }
}
BackHandler {
nav.popBack()
}
Scaffold(
topBar = {
SavingTopBar(
titleRes = R.string.award_badge,
isActive = { vm.definition != null && selectedUsers.isNotEmpty() },
onCancel = { nav.popBack() },
onPost = {
val toAward = selectedUsers.toList()
accountViewModel.launchSigner {
vm.sendPost(toAward)
nav.popBack()
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
BadgeSummary(vm)
HorizontalDivider()
if (selectedUsers.isNotEmpty()) {
selectedUsers.toList().forEachIndexed { index, user ->
SelectedUserRow(
user = user,
accountViewModel = accountViewModel,
nav = nav,
onClear = { selectedUsers.remove(user) },
)
if (index < selectedUsers.lastIndex) HorizontalDivider()
}
HorizontalDivider()
}
OutlinedTextField(
value = searchInput,
onValueChange = { newValue ->
searchInput = newValue
if (newValue.length > 2) {
userSuggestions.processCurrentWord(newValue)
} else {
userSuggestions.reset()
}
},
label = { Text(stringRes(R.string.award_badge_search_label)) },
placeholder = { Text(stringRes(R.string.award_badge_search_placeholder)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
)
Spacer(modifier = Modifier.height(4.dp))
if (searchInput.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) {
selectedUsers.add(user)
}
searchInput = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
}
@Composable
private fun BadgeSummary(vm: AwardBadgeViewModel) {
val def = vm.definition
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
if (def == null) {
Text(
text = stringRes(R.string.award_badge_loading),
style = MaterialTheme.typography.bodyMedium,
)
} else {
Text(
text = def.name() ?: def.dTag(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
def.description()?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun SelectedUserRow(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
onClear: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(
userHex = user.pubkeyHex,
size = 40.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
UserSecondaryLine(user)
}
TextButton(onClick = onClear) {
Text(stringRes(R.string.award_badge_remove_recipient))
}
}
}
@Composable
private fun UserSecondaryLine(user: User) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
val text =
when (val state = nip05StateMetadata) {
is Nip05State.Exists -> {
val name = state.nip05.name
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
}
else -> {
user.pubkeyDisplayHex()
}
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -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.ui.screen.loggedIn.badges.award
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@Stable
class AwardBadgeViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var definition by mutableStateOf<BadgeDefinitionEvent?>(null)
fun init(
accountVM: AccountViewModel,
kind: Int,
pubKeyHex: HexKey,
dTag: String,
) {
this.accountViewModel = accountVM
this.account = accountVM.account
val ev =
LocalCache.getAddressableNoteIfExists(Address(kind, pubKeyHex, dTag))?.event as? BadgeDefinitionEvent
definition = ev
}
suspend fun sendPost(awardees: List<User>) {
val def = definition ?: return
if (awardees.isEmpty()) return
val pTags = awardees.map { PTag(it.pubkeyHex) }
account.sendBadgeAward(def, pTags)
}
}
@@ -0,0 +1,93 @@
/*
* 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.screen.loggedIn.badges.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
class BadgesFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-badges-" + followList().code
override fun limit() = 100
fun followList(): TopFilter = account.settings.defaultBadgesFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
private fun myPubkey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> {
val notes =
if (followList() == TopFilter.Mine) {
val me = myPubkey()
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
}
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
}
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = myPubkey()
return newItems.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
}
}
val params = buildFilterParams(account)
return newItems.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
}
}
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveBadgesFollowLists.value,
account.hiddenUsers.flow.value,
)
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,50 @@
/*
* 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.screen.loggedIn.badges.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class BadgesQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class BadgesFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<BadgesQueryState>() {
val group =
listOf(
BadgesSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* 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.screen.loggedIn.badges.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun BadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
BadgesFilterAssemblerSubscription(
accountViewModel.dataSources().badges,
accountViewModel,
)
}
@Composable
fun BadgesFilterAssemblerSubscription(
dataSource: BadgesFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
BadgesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,103 @@
/*
* 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.screen.loggedIn.badges.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
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.flow.sample
import kotlinx.coroutines.launch
class BadgesSubAssembler(
client: INostrClient,
allKeys: () -> Set<BadgesQueryState>,
) : PerUserAndFollowListEoseManager<BadgesQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: BadgesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val listName = key.listName()
val defaultSince = key.feedStates.badgesFeed.lastNoteCreatedAtIfFilled()
return if (listName == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
filterBadgesMine(key.account.userProfile().pubkeyHex, outbox, since)
} else {
makeBadgesFilter(key.followsPerRelay(), since, defaultSince)
}
}
override fun user(key: BadgesQueryState) = key.account.userProfile()
override fun list(key: BadgesQueryState) = key.listName()
fun BadgesQueryState.listNameFlow() = account.settings.defaultBadgesFollowList
fun BadgesQueryState.listName() = listNameFlow().value
fun BadgesQueryState.followsPerRelayFlow() = account.liveBadgesFollowListsPerRelay
fun BadgesQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: BadgesQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.badgesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).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,149 @@
/*
* 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.screen.loggedIn.badges.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.utils.TimeUtils
private const val BADGE_FEED_LIMIT = 100
fun makeBadgesFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet -> filterBadgesByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterBadgesByAuthors(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince)
else -> emptyList()
}
fun filterBadgesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(BadgeDefinitionEvent.KIND),
authors = authors,
limit = BADGE_FEED_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
private fun filterBadgesByAuthorsOnRelay(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
if (authors.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(BadgeDefinitionEvent.KIND),
authors = authors.sorted(),
limit = BADGE_FEED_LIMIT,
since = since,
),
),
)
}
private fun filterBadgesByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val sinceValue = since?.get(it.key)?.time ?: defaultSince
val authors = it.value.authors
if (authors == null || authors.isEmpty()) {
emptyList()
} else {
filterBadgesByAuthorsOnRelay(it.key, authors, sinceValue)
}
}
}
private fun filterBadgesByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set.flatMap {
filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince)
}
}
private fun filterBadgesByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set.flatMap {
filterBadgesByAuthorsOnRelay(it.key, it.value.authors, since?.get(it.key)?.time ?: defaultSince)
}
}
private fun filterBadgesGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val sinceValue = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(BadgeDefinitionEvent.KIND),
limit = BADGE_FEED_LIMIT,
since = sinceValue,
),
)
}
}
@@ -0,0 +1,369 @@
/*
* 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.screen.loggedIn.badges.post
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
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.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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewBadgeDialog(
onClose: () -> Unit,
postViewModel: NewBadgeModel,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(account) {
postViewModel.init(account)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelect(
onImageUri = { uris ->
wantsToPickImage = false
postViewModel.setPickedMedia(
if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(),
)
},
)
}
Dialog(
onDismissRequest = onClose,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
CreatingTopBar(
titleRes = R.string.new_badge,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.cancelModel()
onClose()
},
onPost = {
postViewModel.upload(
context,
onSuccess = onClose,
onError = accountViewModel.toastManager::toast,
)
},
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 10.dp, vertical = 10.dp),
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
) {
BadgeImagePicker(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
onPickImage = { wantsToPickImage = true },
)
Spacer(modifier = Modifier.height(12.dp))
BadgeFormFields(postViewModel, accountViewModel)
}
}
}
}
}
}
@Composable
private fun BadgeImagePicker(
postViewModel: NewBadgeModel,
accountViewModel: AccountViewModel,
onPickImage: () -> Unit,
) {
if (postViewModel.hasPickedImage()) {
postViewModel.multiOrchestrator?.let {
// Tap the preview to swap to a different image.
Box(modifier = Modifier.clickable(onClick = onPickImage)) {
ShowImageUploadGallery(
list = it,
onDelete = { postViewModel.setPickedMedia(persistentListOf()) },
accountViewModel = accountViewModel,
)
}
}
} else {
UploadPlaceholder(onClick = onPickImage)
}
}
@Composable
private fun UploadPlaceholder(onClick: () -> Unit) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(12.dp),
).clickable(onClick = onClick)
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringRes(R.string.badge_upload_image_cta),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.badge_upload_image_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun BadgeFormFields(
postViewModel: NewBadgeModel,
accountViewModel: AccountViewModel,
) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState()
val fileServerOptions =
remember(fileServers) {
fileServers
.map { TitleExplainer(it.name, it.baseUrl) }
.toImmutableList()
}
OutlinedTextField(
value = postViewModel.name,
onValueChange = { postViewModel.name = it },
label = { Text(stringRes(R.string.badge_name_label)) },
placeholder = {
Text(
text = stringRes(R.string.badge_name_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = postViewModel.description,
onValueChange = { postViewModel.description = it },
label = { Text(stringRes(R.string.badge_description_label)) },
placeholder = {
Text(
text = stringRes(R.string.badge_description_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
modifier =
Modifier
.fillMaxWidth()
.height(120.dp),
minLines = 2,
maxLines = 6,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
Spacer(modifier = Modifier.height(12.dp))
SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner(
label = "",
placeholder =
fileServers
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
?.name
?: fileServers.firstOrNull()?.name
?: DEFAULT_MEDIA_SERVERS[0].name,
options = fileServerOptions,
onSelect = { postViewModel.selectedServer = fileServers[it] },
)
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (postViewModel.mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = postViewModel.mediaQualitySlider.toFloat(),
onValueChange = { postViewModel.mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
SettingSwitchItem(
title = R.string.strip_metadata_label,
description = R.string.strip_metadata_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = postViewModel.stripMetadata,
onCheckedChange = { postViewModel.stripMetadata = it },
)
}
@@ -0,0 +1,209 @@
/*
* 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.screen.loggedIn.badges.post
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Drives the "new badge" creation flow: uploads the user-picked image (at the
* chosen server / compression), then publishes a NIP-58 BadgeDefinitionEvent
* (kind 30009) with an auto-generated UUID d-tag, the uploaded URL as both
* `image` and `thumb`, and the user-provided name / description.
*
* Intentionally does NOT publish the event unless the image upload succeeds
* otherwise we'd announce a badge that references a URL that doesn't exist.
*/
@Stable
class NewBadgeModel : ViewModel() {
var account: Account? = null
var isUploading by mutableStateOf(false)
var selectedServer by mutableStateOf<ServerName?>(null)
var name by mutableStateOf("")
var description by mutableStateOf("")
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
// 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
var stripMetadata by mutableStateOf(true)
var onceUploaded: () -> Unit = {}
fun init(account: Account) {
if (this.account == account) return
this.account = account
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
}
fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
) {
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
this.name = ""
this.description = ""
}
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun hasPickedImage(): Boolean = multiOrchestrator != null
fun canPost(): Boolean =
!isUploading &&
multiOrchestrator != null &&
selectedServer != null &&
name.isNotBlank()
fun upload(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) = try {
uploadUnsafe(context, onSuccess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
}
private fun uploadUnsafe(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
val myAccount = account ?: return@launch
val serverToUse = selectedServer ?: return@launch
val orch = multiOrchestrator ?: return@launch
isUploading = true
val results =
orch.upload(
alt = name,
contentWarningReason = null,
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
server = serverToUse,
account = myAccount,
context = context,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (!results.allGood) {
val messages =
results.errors
.map { stringRes(context, it.errorResource, *it.params) }
.distinct()
.joinToString(".\n")
onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
isUploading = false
return@launch
}
val uploaded =
results.successful.firstNotNullOfOrNull {
it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
}
if (uploaded == null) {
onError(
stringRes(context, R.string.failed_to_upload_media_no_details),
"Upload succeeded but no image URL was returned by the server.",
)
isUploading = false
return@launch
}
val imageUrl = uploaded.url
val dimensions = uploaded.fileHeader.dim
myAccount.sendBadgeDefinition(
badgeId = UUID.randomUUID().toString(),
name = name.trim(),
imageUrl = imageUrl,
imageDim = dimensions,
description = description.trim().ifBlank { null },
// NIP-58 thumb is optional; we emit it pointing at the same
// uploaded asset so clients that only honor the thumb tag also
// see the badge image.
thumbs = listOf(ThumbTag(imageUrl, dimensions)),
)
myAccount.settings.changeDefaultFileServer(serverToUse)
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
onSuccess()
onceUploaded()
cancelModel()
}
}
fun cancelModel() {
multiOrchestrator = null
isUploading = false
name = ""
description = ""
selectedServer = defaultServer()
}
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
fun onceUploaded(action: () -> Unit) {
this.onceUploaded = action
}
}
@@ -0,0 +1,320 @@
/*
* 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.screen.loggedIn.badges.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ProfileBadgesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val myPubkey = accountViewModel.userProfile().pubkeyHex
// Pull every kind 8 award tagging me from the user's notification relays so
// historic awards land in cache while this screen is open.
ProfileBadgesFilterAssemblerSubscription(accountViewModel)
val newNote = accountViewModel.getOrCreateAddressableNote(ProfileBadgesEvent.createAddress(myPubkey))
val oldNote = accountViewModel.getOrCreateAddressableNote(AcceptedBadgeSetEvent.createAddress(myPubkey))
val newState by newNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val oldState by oldNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val acceptedAwardIds =
remember(newState, oldState) {
val newEvent = newState.note.event as? ProfileBadgesEvent
val oldEvent = oldState.note.event as? AcceptedBadgeSetEvent
(newEvent?.badgeAwardEvents()?.map { it.eventId } ?: oldEvent?.badgeAwardEvents()?.map { it.eventId } ?: emptyList())
.toSet()
}
// Tick whenever LocalCache emits a bundle that contains an award addressed
// to me, so the snapshot below recomputes and the new award appears.
var bundleTick by remember { mutableIntStateOf(0) }
LaunchedEffect(myPubkey) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { bundle ->
val touchesMe =
bundle.any { note ->
val ev = note.event
ev is BadgeAwardEvent && ev.awardeeIds().contains(myPubkey)
}
if (touchesMe) bundleTick++
}
}
}
// "loaded" flips once when either profile-badges event arrives in cache, so
// we re-sort exactly once after the initial load. Toggling the Switch below
// doesn't change it, so the list stays in place while the user edits.
val acceptedDataLoaded =
newState.note.event != null || oldState.note.event != null
val receivedAwards =
remember(myPubkey, bundleTick, acceptedDataLoaded) {
// acceptedAwardIds is captured from the enclosing scope at this
// recomputation point; it is NOT part of the remember key, so
// subsequent toggles don't re-run this sort.
val initialAccepted = acceptedAwardIds
LocalCache.notes
.filterIntoSet { _, it ->
val event = it.event
event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey)
}.mapNotNull { it.event as? BadgeAwardEvent }
.sortedWith(
// Accepted badges on top (at load time), then most recent first.
compareByDescending<BadgeAwardEvent> { initialAccepted.contains(it.id) }
.thenByDescending { it.createdAt },
)
}
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.profile_badges_title), nav::popBack)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Text(
text = stringRes(R.string.profile_badges_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
)
HorizontalDivider()
if (receivedAwards.isEmpty()) {
Text(
text = stringRes(R.string.profile_badges_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(20.dp),
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
items = receivedAwards,
key = { it.id },
) { award ->
AwardRow(
award = award,
isAccepted = acceptedAwardIds.contains(award.id),
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun AwardRow(
award: BadgeAwardEvent,
isAccepted: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val defAddr = award.awardDefinition().firstOrNull()
if (defAddr == null) {
StaticAwardRow(
definition = null,
defNote = null,
isAccepted = isAccepted,
accountViewModel = accountViewModel,
award = award,
nav = nav,
)
return
}
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
if (defNote == null) {
StaticAwardRow(
definition = null,
defNote = null,
isAccepted = isAccepted,
accountViewModel = accountViewModel,
award = award,
nav = nav,
)
} else {
// Ask relays for the definition if we don't have it yet, then watch.
EventFinderFilterAssemblerSubscription(defNote, accountViewModel)
val definition by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
StaticAwardRow(
definition = definition,
defNote = defNote,
isAccepted = isAccepted,
accountViewModel = accountViewModel,
award = award,
nav = nav,
)
}
}
}
@Composable
private fun StaticAwardRow(
definition: BadgeDefinitionEvent?,
defNote: com.vitorpamplona.amethyst.model.AddressableNote?,
isAccepted: Boolean,
accountViewModel: AccountViewModel,
award: BadgeAwardEvent,
nav: INav,
) {
val rowModifier =
if (defNote != null) {
Modifier
.fillMaxWidth()
.clickable {
routeFor(defNote, accountViewModel.account)?.let { nav.nav(it) }
}.padding(horizontal = 20.dp, vertical = 12.dp)
} else {
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp)
}
Row(
modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically,
) {
BadgeThumb(definition)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = definition?.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
definition?.description()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.size(12.dp))
Switch(
checked = isAccepted,
enabled = definition != null,
onCheckedChange = { checked ->
accountViewModel.launchSigner {
if (checked) {
val defEvent = definition ?: return@launchSigner
accountViewModel.account.addAcceptedBadge(award, defEvent)
} else {
accountViewModel.account.removeAcceptedBadge(award)
}
}
},
)
}
}
@Composable
private fun BadgeThumb(definition: BadgeDefinitionEvent?) {
val imageUrl = definition?.thumb()?.ifBlank { null } ?: definition?.image()?.ifBlank { null }
val thumbModifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp))
if (imageUrl.isNullOrBlank()) {
RobohashAsyncImage(
robot = definition?.id ?: "badgenotfound",
contentDescription = null,
modifier = thumbModifier,
loadRobohash = true,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = thumbModifier,
contentScale = ContentScale.Crop,
)
}
}
@@ -0,0 +1,53 @@
/*
* 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.screen.loggedIn.badges.profile.datasource
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
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
/**
* Pulls every badge award (kind 8) tagging this pubkey, from the user's
* notification relays. Used by the "Profile badges" management screen to
* back-fill the full history of awards (the always-on notifications
* subscription is bounded by `since`, so older awards may not be in
* cache yet).
*/
fun filterReceivedBadgeAwards(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (pubkey.isEmpty() || relays.isEmpty()) return emptyList()
val pTags = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to pTags),
limit = 500,
),
)
}
}
@@ -0,0 +1,46 @@
/*
* 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.screen.loggedIn.badges.profile.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class ProfileBadgesQueryState(
val account: Account,
)
@Stable
class ProfileBadgesFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ProfileBadgesQueryState>() {
val group =
listOf(
ProfileBadgesSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,36 @@
/*
* 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.screen.loggedIn.badges.profile.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ProfileBadgesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
val state =
remember(accountViewModel.account) {
ProfileBadgesQueryState(accountViewModel.account)
}
KeyDataSourceSubscription(state, accountViewModel.dataSources().profileBadges)
}
@@ -0,0 +1,42 @@
/*
* 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.screen.loggedIn.badges.profile.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ProfileBadgesSubAssembler(
client: INostrClient,
allKeys: () -> Set<ProfileBadgesQueryState>,
) : PerUserEoseManager<ProfileBadgesQueryState>(client, allKeys) {
override fun user(key: ProfileBadgesQueryState) = key.account.userProfile()
override fun updateFilter(
key: ProfileBadgesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
filterReceivedBadgeAwards(
pubkey = user(key).pubkeyHex,
relays = key.account.notificationRelays.flow.value,
)
}
@@ -79,6 +79,7 @@ fun RenderEncryptedFile(
encryptionAlgo = algo,
encryptionKey = key,
encryptionNonce = nonce,
thumbhash = noteEvent.thumbhash(),
)
} else {
EncryptedMediaUrlVideo(
@@ -93,6 +94,7 @@ fun RenderEncryptedFile(
encryptionAlgo = algo,
encryptionKey = key,
encryptionNonce = nonce,
thumbhash = noteEvent.thumbhash(),
)
},
)
@@ -139,6 +139,7 @@ private fun RenderMip04Content(
encryptionAlgo = meta.version,
encryptionKey = exporterSecret,
encryptionNonce = meta.nonceBytes,
thumbhash = meta.thumbhash,
)
} else {
EncryptedMediaUrlVideo(
@@ -153,6 +154,7 @@ private fun RenderMip04Content(
encryptionAlgo = meta.version,
encryptionKey = exporterSecret,
encryptionNonce = meta.nonceBytes,
thumbhash = meta.thumbhash,
)
},
)
@@ -43,6 +43,7 @@ class MarmotFileSender(
nonce = upload.nonce,
dimensions = upload.dimensions,
blurhash = upload.blurhash,
thumbhash = upload.thumbhash,
)
accountViewModel.sendMarmotGroupMediaMessage(
@@ -43,6 +43,7 @@ class Mip04UploadResult(
val dimensions: String?,
val blurhash: String?,
val caption: String?,
val thumbhash: String? = null,
)
/**
@@ -102,6 +103,7 @@ class MarmotFileUploader(
dimensions = serverResult.fileHeader.dim?.toString(),
blurhash = serverResult.fileHeader.blurHash?.blurhash,
caption = viewState.caption.ifEmpty { null },
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
),
)
} else {
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import okhttp3.OkHttpClient
import java.util.Locale
@@ -60,6 +61,7 @@ class IMetaAttachments {
it.mimeType?.let { mimeType(it) }
it.dim?.let { dims(it) }
it.blurHash?.let { blurhash(it.blurhash) }
it.thumbHash?.let { thumbhash(it.thumbhash) }
}.build()
}
@@ -101,6 +103,7 @@ class IMetaAttachments {
result.fileHeader.mimeType?.let { mimeType(it) }
result.fileHeader.dim?.let { dims(it) }
result.fileHeader.blurHash?.let { blurhash(it.blurhash) }
result.fileHeader.thumbHash?.let { thumbhash(it.thumbhash) }
result.magnet?.let { magnet(it) }
result.uploadedHash?.let { originalHash(it) }
@@ -626,6 +626,9 @@ fun ShowImageUploadGallery(
encryptionAlgo = data.cipher.name(),
encryptionKey = data.cipher.keyBytes,
encryptionNonce = data.cipher.nonce,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
)
} else {
EncryptedMediaUrlVideo(
@@ -641,6 +644,9 @@ fun ShowImageUploadGallery(
encryptionAlgo = data.cipher.name(),
encryptionKey = data.cipher.keyBytes,
encryptionNonce = data.cipher.nonce,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
)
}
} else {
@@ -655,6 +661,9 @@ fun ShowImageUploadGallery(
dim = data.result.fileHeader.dim,
uri = null,
mimeType = data.result.mimeTypeBeforeEncryption,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
)
} else {
MediaUrlVideo(
@@ -667,6 +676,9 @@ fun ShowImageUploadGallery(
dim = data.result.fileHeader.dim,
uri = null,
mimeType = data.result.mimeTypeBeforeEncryption,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
)
}
},
@@ -64,6 +64,7 @@ class ChatFileSender(
size = result.fileHeader.size,
dimension = result.fileHeader.dim,
blurhash = result.fileHeader.blurHash?.blurhash,
thumbhash = result.fileHeader.thumbHash?.thumbhash,
) {
if (!caption.isNullOrEmpty()) {
alt(caption)
@@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -544,6 +545,8 @@ class LongFormPostViewModel :
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
@@ -21,11 +21,9 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -40,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
@@ -51,12 +49,12 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteAlgoFeedToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.nip05
@@ -118,25 +116,12 @@ fun RenderContentDVMThumb(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(modifier = StdVertSpacer)
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = RowColSpacing5dp,
) {
LikeReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.onSurface,
if (baseNote is AddressableNote) {
FavoriteAlgoFeedToggle(
appDefinitionNote = baseNote,
accountViewModel = accountViewModel,
nav,
)
}
Spacer(modifier = StdHorzSpacer)
ZapReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav = nav,
)
},
onDescription = {
card.description?.let {
@@ -168,23 +153,17 @@ fun RenderContentDVMThumb(
color = MaterialTheme.colorScheme.primary
amount = card.amount + " Sats"
}
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Absolute.Right,
) {
Text(
textAlign = TextAlign.End,
text = " $amount ",
color = color,
maxLines = 3,
maxLines = 1,
modifier =
Modifier
.weight(1f, fill = false)
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
fontSize = 12.sp,
)
}
}
Spacer(modifier = StdHorzSpacer)
card.personalized?.let {
var color = Color.DarkGray
@@ -196,24 +175,35 @@ fun RenderContentDVMThumb(
color = MaterialTheme.colorScheme.nip05
name = "Generic"
}
Spacer(modifier = StdVertSpacer)
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Absolute.Right,
) {
Text(
textAlign = TextAlign.End,
text = " $name ",
color = color,
maxLines = 3,
maxLines = 1,
modifier =
Modifier
.padding(start = 4.dp)
.weight(1f, fill = false)
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
fontSize = 12.sp,
)
}
Spacer(modifier = Modifier.weight(1f))
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = RowColSpacing5dp,
) {
LikeReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav,
)
Spacer(modifier = StdHorzSpacer)
ZapReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav = nav,
)
}
},
)
@@ -429,14 +429,18 @@ open class NewProductViewModel :
) {
productImages = productImages +
ProductImageMeta(
it.result.url,
it.result.fileHeader.mimeType,
url = it.result.url,
mimeType = it.result.fileHeader.mimeType,
blurhash =
it.result.fileHeader.blurHash
?.blurhash,
it.result.fileHeader.dim,
alt,
it.result.fileHeader.hash,
it.result.fileHeader.size,
dimension = it.result.fileHeader.dim,
alt = alt,
hash = it.result.fileHeader.hash,
size = it.result.fileHeader.size,
thumbhash =
it.result.fileHeader.thumbHash
?.thumbhash,
)
} else {
iMetaDescription.add(it.result, alt, contentWarningReason)
@@ -354,10 +354,28 @@ fun FeedDVM(
Spacer(modifier = DoubleVertSpacer)
Text(currentStatus, textAlign = TextAlign.Center)
if (status.code == "payment-required") {
DvmPaymentActions(
latestStatus = latestStatus,
accountViewModel = accountViewModel,
nav = nav,
onStatusUpdate = { currentStatus = it },
)
}
}
@Composable
fun DvmPaymentActions(
latestStatus: NIP90StatusEvent,
accountViewModel: AccountViewModel,
nav: INav,
onStatusUpdate: (String) -> Unit,
) {
val status = latestStatus.status() ?: return
if (status.code != "payment-required") return
val amountTag = latestStatus.firstAmount()
val amount = amountTag?.amount
val invoice = amountTag?.lnInvoice
val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment)
@@ -371,10 +389,10 @@ fun FeedDVM(
bolt11 = invoice,
zappedNote = null,
onSent = {
currentStatus = nwcPaymentRequest
onStatusUpdate(nwcPaymentRequest)
},
onResponse = { response ->
currentStatus =
onStatusUpdate(
if (response is PayInvoiceErrorResponse) {
stringRes(
context,
@@ -384,7 +402,8 @@ fun FeedDVM(
)
} else {
thankYou
}
},
)
},
)
} else {
@@ -392,10 +411,10 @@ fun FeedDVM(
invoice,
context,
onPaid = {
currentStatus = thankYou
onStatusUpdate(thankYou)
},
onError = {
currentStatus = it
onStatusUpdate(it)
},
)
}
@@ -426,12 +445,6 @@ fun FeedDVM(
}
}
}
} else if (status.code == "processing") {
currentStatus = status.description
} else if (status.code == "error") {
currentStatus = status.description
}
}
}
@Composable
@@ -21,19 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@Composable
fun DvmTopBar(
@@ -41,7 +47,7 @@ fun DvmTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
TopBarExtensibleWithBackButton(
MyExtensibleTopAppBar(
title = {
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
if (appDefinitionNote != null) {
@@ -82,6 +88,27 @@ fun DvmTopBar(
}
}
},
popBack = nav::popBack,
navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } },
actions = {
// The route passes the event's hex id, so LoadNote returns a plain Note,
// not the AddressableNote the toggle needs. Derive the AddressableNote
// from the loaded AppDefinitionEvent's address() once the event exists.
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
if (appDefinitionNote != null) {
val addressableNote by
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
(note.event as? AppDefinitionEvent)?.let {
LocalCache.getOrCreateAddressableNote(it.address())
}
}
addressableNote?.let { target ->
FavoriteAlgoFeedToggle(
appDefinitionNote = target,
accountViewModel = accountViewModel,
)
}
}
}
},
)
}
@@ -0,0 +1,104 @@
/*
* 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.screen.loggedIn.dvms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
/**
* Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM.
*
* Uses [ClickableBox] (no [androidx.compose.material3.IconButton] padding) so it
* fits in a `LeftPictureLayout` title row alongside other compact reactions
* without inflating the row to 48dp.
*
* Hidden until the underlying [AppDefinitionEvent] loads and we can confirm the
* DVM advertises kind 5300 favouriting any other DVM type would only stall on
* a 6300 reply that never comes.
*/
@Composable
fun FavoriteAlgoFeedToggle(
appDefinitionNote: AddressableNote,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
iconSizeModifier: Modifier = Size20Modifier,
) {
val supportsContentDiscovery by
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
(note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true
}
if (!supportsContentDiscovery) return
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow
.collectAsStateWithLifecycle()
val isFavorite = favorites.contains(appDefinitionNote.address)
ClickableBox(
modifier = modifier,
onClick = {
if (isFavorite) {
accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address)
} else {
accountViewModel.followFavoriteAlgoFeed(
AddressBookmark(
address = appDefinitionNote.address,
relayHint = appDefinitionNote.relayHintUrl(),
),
)
}
},
) {
if (isFavorite) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
imageVector = Icons.Outlined.StarBorder,
contentDescription = stringRes(R.string.add_dvm_to_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -0,0 +1,226 @@
/*
* 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.screen.loggedIn.dvms.favorites
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FavoriteAlgoFeedsListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringRes(R.string.favorite_dvms_title),
popBack = nav::popBack,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 16.dp,
top = padding.calculateTopPadding(),
end = 16.dp,
bottom = padding.calculateBottomPadding(),
).consumeWindowInsets(padding),
) {
Text(
text = stringRes(R.string.favorite_dvms_explainer),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
FavoriteAlgoFeedList(accountViewModel, nav)
}
}
}
@Composable
private fun FavoriteAlgoFeedList(
accountViewModel: AccountViewModel,
nav: INav,
) {
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes
.collectAsStateWithLifecycle()
if (favorites.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.favorite_dvms_empty),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
return
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = FeedPadding,
) {
items(
items = favorites,
key = { it.address.toValue() },
) { feedNote ->
FavoriteAlgoFeedRow(
feedNote = feedNote,
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) },
onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FavoriteAlgoFeedRow(
feedNote: AddressableNote,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
val card = observeAppDefinition(feedNote, accountViewModel)
Row(
modifier =
Modifier
.fillMaxWidth()
.combinedClickable(onClick = onOpen)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
card.cover?.let { cover ->
Box(contentAlignment = BottomStart) {
MyAsyncImage(
imageUrl = cover,
contentDescription = card.name,
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = SimpleImage35Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = {
feedNote.author?.let { author ->
BannerImage(author, SimpleImage35Modifier, accountViewModel)
}
},
onError = {
feedNote.author?.let { author ->
BannerImage(author, SimpleImage35Modifier, accountViewModel)
}
},
)
}
} ?: run {
feedNote.author?.let { author ->
BannerImage(author, SimpleImage35Modifier, accountViewModel)
}
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = card.name.ifBlank { feedNote.dTag() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge,
)
card.description?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = StdVertSpacer)
Text(
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.grayText,
style = MaterialTheme.typography.bodyMedium,
)
}
}
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
)
}
}
}
@@ -0,0 +1,264 @@
/*
* 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.screen.loggedIn.home
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@Composable
fun HomeAlgoFeedStatusBanner(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val topFilter by accountViewModel.account.settings.defaultHomeFollowList
.collectAsStateWithLifecycle()
when (val filter = topFilter) {
is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier)
is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier)
else -> Unit
}
}
@Composable
private fun SingleAlgoFeedBanner(
favFeed: TopFilter.FavoriteAlgoFeed,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator
.observe(favFeed.address)
.collectAsStateWithLifecycle()
// Hide the banner when the feed is already populated.
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
val feedAddressValue = favFeed.address.toValue()
LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote ->
val resolvedName by
observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note ->
(note.event as? AppDefinitionEvent)
?.appMetaData()
?.name
?.takeIf { it.isNotBlank() }
?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag()
?: ""
}
BannerCard(modifier) {
val status = snapshot.latestStatus?.status()
when {
snapshot.errorMessage != null -> {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_error),
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
}
status?.code == "payment-required" -> {
BannerMessageRow(
message =
status.description.ifBlank {
stringRes(R.string.dvm_home_status_payment_required)
},
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
var statusOverride by remember { mutableStateOf<String?>(null) }
val msg = statusOverride
if (msg != null) {
Text(text = msg, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = StdVertSpacer)
}
snapshot.latestStatus?.let {
DvmPaymentActions(
latestStatus = it,
accountViewModel = accountViewModel,
nav = nav,
onStatusUpdate = { statusOverride = it },
)
}
}
status?.code == "error" -> {
BannerMessageRow(
message =
status.description.ifBlank {
stringRes(R.string.dvm_home_status_error)
},
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
}
status?.code == "processing" -> {
BannerMessageRow(
message =
status.description.ifBlank {
stringRes(R.string.dvm_home_status_processing)
},
showSpinner = true,
)
}
else -> {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_requesting, resolvedName),
showSpinner = true,
)
}
}
}
}
}
@Composable
private fun AllFavoriteAlgoFeedsBanner(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow
.collectAsStateWithLifecycle()
if (addresses.isEmpty()) return
// Observe each DVM's snapshot so we can decide whether to hide the banner
// based on the aggregate state. Hide it as soon as any DVM has produced a
// feed; only error out when every one of them has errored.
val snapshots: List<FavoriteAlgoFeedsSnapshot> =
addresses.map { address ->
val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator
.observe(address)
.collectAsStateWithLifecycle()
snap
}
val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() }
if (anyResponded) return
val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" }
BannerCard(modifier) {
if (allErrored) {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_error),
showSpinner = false,
)
Spacer(modifier = StdVertSpacer)
RetryButton {
addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
}
} else {
BannerMessageRow(
message = stringRes(R.string.dvm_home_status_requesting_all),
showSpinner = true,
)
}
}
}
@Composable
private fun BannerCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
// Sits above the feed instead of in the topBar Column, so adding/removing
// the banner doesn't shift the tabs/filter up and down. tonalElevation
// gives it a faint surface tint so it reads as a floating overlay.
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 4.dp,
) {
Column(modifier = Modifier.padding(12.dp)) { content() }
}
}
@Composable
private fun BannerMessageRow(
message: String,
showSpinner: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
if (showSpinner) {
LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp)
Spacer(modifier = StdHorzSpacer)
}
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun RetryButton(onClick: () -> Unit) {
OutlinedButton(onClick = onClick) {
Text(stringRes(R.string.dvm_home_retry))
}
}
@@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
@@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -209,9 +213,15 @@ private fun HomePages(
HomeScreenFloatingButton(accountViewModel, nav)
},
accountViewModel = accountViewModel,
) { paddingValues ->
// Wrap pager + banner in a Box so the banner can float over the feed
// (anchored top-center) instead of living in the topBar Column where
// it would push the tabs down every time it appears or disappears.
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
) {
HorizontalPager(
contentPadding = it,
contentPadding = PaddingValues(0.dp),
state = pagerState,
userScrollEnabled = true,
modifier =
@@ -229,6 +239,13 @@ private fun HomePages(
nav = nav,
)
}
HomeAlgoFeedStatusBanner(
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
@@ -281,7 +298,23 @@ fun HomeFeeds(
accountViewModel: AccountViewModel,
nav: INav,
) {
RefresheableBox(feedState, enablePullRefresh) {
val activeFilter by accountViewModel.account.settings.defaultHomeFollowList
.collectAsStateWithLifecycle()
val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow
.collectAsStateWithLifecycle()
val onRefresh: () -> Unit = {
feedState.invalidateData()
// Swiping down on Home should also re-issue the kind-5300 request(s) so the
// DVM(s) produce fresh feeds, not just re-render whatever's cached.
when (val filter = activeFilter) {
is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address)
is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
else -> Unit
}
}
RefresheableHomeBox(onRefresh, enablePullRefresh) {
SaveableFeedContentState(feedState, scrollStateKey) { listState ->
RenderFeedContentState(
feedContentState = feedState,
@@ -290,12 +323,28 @@ fun HomeFeeds(
nav = nav,
routeForLastRead = routeForLastRead,
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
onEmpty = { HomeFeedEmpty(feedState::invalidateData) },
onEmpty = { HomeFeedEmpty(onRefresh) },
)
}
}
}
@Composable
private fun RefresheableHomeBox(
onRefresh: () -> Unit,
enablePullRefresh: Boolean,
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
) {
if (enablePullRefresh) {
RefresheableBox(onRefresh = onRefresh, content = content)
} else {
androidx.compose.foundation.layout.Box(
Modifier.fillMaxSize(),
content = content,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FeedLoaded(
@@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
@@ -1124,6 +1125,8 @@ open class ShortNotePostViewModel :
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
@@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds
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
@@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager(
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
}
@@ -0,0 +1,117 @@
/*
* 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.screen.loggedIn.home.datasource.nip90AlgoFeeds
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
/**
* Builds relay REQ filters for a favorite-DVM home feed.
*
* Two distinct subscription kinds with two distinct relay sets:
*
* - **Content fetch** for each of the user's outbox/proxy relays, request the
* note IDs and addressable references the DVM curated. Notes typically live on
* the user's normal relays, so this is where we fetch them.
*
* - **Response listen** for each relay the DVM advertised (where it received
* the kind-5300 request and will publish its 6300/7000 reply), subscribe to
* future kind 6300 / 7000 events tagged with the request id. The DVM almost
* never publishes responses on the user's outbox, so listening anywhere else
* would silently miss them.
*/
fun filterHomePostsByAlgoFeedIds(
set: FavoriteAlgoFeedTopNavPerRelayFilterSet,
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
@Suppress("UNUSED_PARAMETER") defaultSince: Long?,
): List<RelayBasedFilter> {
val out = mutableListOf<RelayBasedFilter>()
set.contentFetches.forEach { (relay, filter) ->
out += contentFetchFilters(relay, filter)
}
if (set.requestIds.isNotEmpty()) {
val requestIds = set.requestIds.toList()
set.listenRelays.forEach { relay ->
out += responseListenFilter(relay, requestIds)
}
}
return out
}
private fun contentFetchFilters(
relay: NormalizedRelayUrl,
filter: FavoriteAlgoFeedTopNavPerRelayFilter,
): List<RelayBasedFilter> {
val out = mutableListOf<RelayBasedFilter>()
if (filter.ids.isNotEmpty()) {
out +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
ids = filter.ids.toList(),
limit = filter.ids.size,
),
)
}
if (filter.addresses.isNotEmpty()) {
out +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = mapOf("a" to filter.addresses.toList()),
limit = filter.addresses.size,
),
)
}
return out
}
private fun responseListenFilter(
relay: NormalizedRelayUrl,
requestIds: List<HexKey>,
) = RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds =
listOf(
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
),
tags = mapOf("e" to requestIds),
limit = 10 * requestIds.size,
),
)
@@ -107,6 +107,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
import com.vitorpamplona.quartz.utils.Hex
@@ -486,6 +487,8 @@ class NewPublicMessageViewModel :
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
@@ -115,6 +115,7 @@ private fun PictureCardImage(
dim = it.dimension,
uri = uri,
mimeType = it.mimeType,
thumbhash = it.thumbhash,
)
}.toImmutableList(),
)
@@ -131,7 +132,7 @@ private fun PictureCardImage(
preloadUrls = listOf(single.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } },
backdrop = (single.thumbhash ?: single.blurhash)?.let { { BlurhashBackdrop(single.blurhash, single.description, single.thumbhash) } },
) {
ZoomableContentView(
content = single,
@@ -86,6 +86,7 @@ fun GalleryThumbnail(
dim = noteEvent.dimensions(),
uri = null,
mimeType = noteEvent.mimeType(),
thumbhash = noteEvent.thumbhash(),
)
} else {
MediaUrlImage(
@@ -97,6 +98,7 @@ fun GalleryThumbnail(
dim = noteEvent.dimensions(),
uri = null,
mimeType = noteEvent.mimeType(),
thumbhash = noteEvent.thumbhash(),
)
}
}
@@ -111,6 +113,7 @@ fun GalleryThumbnail(
dim = imeta.dimension,
uri = null,
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
}
} else if (noteEvent is VideoEvent) {
@@ -124,6 +127,7 @@ fun GalleryThumbnail(
dim = imeta.dimension,
uri = null,
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
}
} else {
@@ -210,12 +214,13 @@ fun UrlImageView(
when (state) {
is AsyncImagePainter.State.Loading,
-> {
if (content.blurhash != null) {
if (content.blurhash != null || content.thumbhash != null) {
DisplayBlurHash(
content.blurhash,
content.description,
ContentScale.Crop,
defaultModifier,
thumbhash = content.thumbhash,
)
} else {
Box(defaultModifier, contentAlignment = Alignment.Center) {
@@ -243,12 +248,13 @@ fun UrlImageView(
}
}
} else {
if (content.blurhash != null) {
if (content.blurhash != null || content.thumbhash != null) {
DisplayBlurHash(
content.blurhash,
content.description,
ContentScale.Crop,
defaultModifier.clickable { showImage.value = true },
thumbhash = content.thumbhash,
)
Icon(
imageVector = Icons.Default.PlayCircleOutline,
@@ -20,17 +20,44 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges
import androidx.compose.foundation.background
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
@@ -43,12 +70,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@@ -60,8 +86,12 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
private val ProfileBadgeSize = 44.dp
private val ProfileBadgeShape = RoundedCornerShape(8.dp)
private const val VISIBLE_BADGE_LIMIT = 8
@Composable
fun DisplayBadges(
@@ -75,91 +105,242 @@ fun DisplayBadges(
val oldNote = accountViewModel.getOrCreateAddressableNote(oldDesign)
val newNote = accountViewModel.getOrCreateAddressableNote(newDesign)
WatchAndRenderBadgeList(oldNote, newNote, accountViewModel, nav)
WatchAndRenderBadgeList(baseUser, oldNote, newNote, accountViewModel, nav)
}
@Composable
private fun WatchAndRenderBadgeList(
baseUser: User,
oldNote: AddressableNote,
newNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(oldNote, accountViewModel)
EventFinderFilterAssemblerSubscription(newNote, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(oldNote, newNote) {
combine(
oldNote.flow().metadata.stateFlow,
newNote.flow().metadata.stateFlow,
) { oldNote, newNote ->
val oldProfileBadgeEvent = oldNote.note.event as? AcceptedBadgeSetEvent
val newProfileBadgeEvent = newNote.note.event as? ProfileBadgesEvent
) { oldNoteState, newNoteState ->
val oldEvent = oldNoteState.note.event as? AcceptedBadgeSetEvent
val newEvent = newNoteState.note.event as? ProfileBadgesEvent
newProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
?: oldProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
newEvent?.badgeAwardEvents()?.toImmutableList()
?: oldEvent?.badgeAwardEvents()?.toImmutableList()
?: persistentListOf()
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
val badgeList by flow.collectAsStateWithLifecycle(persistentListOf())
badgeList?.let { list -> RenderBadgeList(list, accountViewModel, nav) }
if (badgeList.isEmpty()) return
val isMe = baseUser.pubkeyHex == accountViewModel.userProfile().pubkeyHex
RenderProfileBadgeStrip(badgeList, isMe, accountViewModel, nav)
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderBadgeList(
@Composable
private fun RenderProfileBadgeStrip(
list: ImmutableList<ETag>,
isMe: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
FlowRow(
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp),
var showAllSheet by rememberSaveable { mutableStateOf(false) }
val visible = remember(list) { list.take(VISIBLE_BADGE_LIMIT) }
val overflow = (list.size - VISIBLE_BADGE_LIMIT).coerceAtLeast(0)
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) }
Text(
text = stringRes(R.string.profile_badges_header, list.size),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (isMe) {
IconButton(
onClick = { nav.nav(Route.ProfileBadges) },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringRes(R.string.profile_badges_title),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
visible.forEach { eTag ->
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
BadgeThumb(defNote, accountViewModel, nav)
}
}
if (overflow > 0) {
OverflowChip(overflow) { showAllSheet = true }
}
}
}
if (showAllSheet) {
AllBadgesSheet(
awards = list,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = { showAllSheet = false },
)
}
}
@Composable
private fun LoadAndRenderBadge(
badgeAwardEvent: ETag,
private fun OverflowChip(
count: Int,
onClick: () -> Unit,
) {
Box(
modifier =
Modifier
.size(ProfileBadgeSize)
.clip(ProfileBadgeShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$count",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AllBadgesSheet(
awards: ImmutableList<ETag>,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
val baseNote =
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Text(
text = stringRes(R.string.profile_badges_header, awards.size),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(items = awards, key = { it.eventId }) { eTag ->
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
BadgeSheetRow(
defNote = defNote,
accountViewModel = accountViewModel,
onClick = {
val route = routeFor(defNote, accountViewModel.account)
scope.launch {
sheetState.hide()
onDismiss()
route?.let { nav.nav(it) }
}
},
)
}
}
}
}
}
@Composable
private fun BadgeSheetRow(
defNote: Note,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val event by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
val definition = event ?: return
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RenderBadgeImage(
id = definition.id,
name = definition.name(),
image =
definition.thumb()?.ifBlank { null }
?: definition.image()?.ifBlank { null },
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = definition.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
definition.description()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun LoadDefinitionForAward(
eTag: ETag,
accountViewModel: AccountViewModel,
content: @Composable (Note) -> Unit,
) {
val awardNote =
produceState(
LocalCache.getNoteIfExists(badgeAwardEvent),
badgeAwardEvent,
LocalCache.getNoteIfExists(eTag),
eTag,
) {
val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent)
val newValue = LocalCache.checkGetOrCreateNote(eTag)
if (newValue != value) {
value = newValue
}
}
baseNote.value?.let {
ObserveAndRenderBadge(it, accountViewModel, nav)
}
}
@Composable
private fun ObserveAndRenderBadge(
it: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val badgeAwardState by observeNoteEvent<BadgeAwardEvent>(it, accountViewModel)
val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull()
if (badgeDefinitionId != null) {
LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote ->
badgeDefNote?.let {
BadgeThumb(it, accountViewModel, nav)
awardNote.value?.let { note ->
val awardEvent by observeNoteEvent<BadgeAwardEvent>(note, accountViewModel)
awardEvent?.awardDefinition()?.firstOrNull()?.let { defAddr ->
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
defNote?.let { content(it) }
}
}
}
@@ -173,7 +354,10 @@ fun BadgeThumb(
) {
Box(
modifier =
Size35Modifier.clickable(
Modifier
.size(ProfileBadgeSize)
.clip(ProfileBadgeShape)
.clickable(
onClick = {
nav.nav {
routeFor(baseNote, accountViewModel.account)
@@ -215,11 +399,13 @@ private fun RenderBadgeImage(
stringRes(id = R.string.badge_award_image)
}
val modifier = Modifier.size(ProfileBadgeSize).clip(ProfileBadgeShape)
if (image == null) {
RobohashAsyncImage(
robot = "badgenotfound",
contentDescription = description,
modifier = BadgePictureModifier,
modifier = modifier,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
} else {
@@ -227,7 +413,7 @@ private fun RenderBadgeImage(
robot = id,
model = image,
contentDescription = description,
modifier = BadgePictureModifier,
modifier = modifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.DeleteForever
@@ -36,6 +37,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Security
@@ -122,6 +124,20 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.EditMediaServers) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.profile_badges_title,
icon = Icons.Outlined.MilitaryTech,
tint = tint,
onClick = { nav.nav(Route.ProfileBadges) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.favorite_dvms_title,
icon = Icons.Outlined.AutoAwesome,
tint = tint,
onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.reactions,
icon = Icons.Outlined.FavoriteBorder,
@@ -121,6 +121,7 @@ private fun VideoCardImage(
dim = imeta.dimension,
uri = note.toNostrUri(),
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
} else {
MediaUrlVideo(
@@ -132,6 +133,7 @@ private fun VideoCardImage(
uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(),
mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
)
},
)
@@ -145,7 +147,7 @@ private fun VideoCardImage(
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
backdrop = (imeta.thumbhash ?: imeta.blurhash)?.let { { BlurhashBackdrop(imeta.blurhash, content.description, imeta.thumbhash) } },
) {
ZoomableContentView(
content = content,
@@ -922,6 +922,7 @@ private fun RenderClassifiedsReaderForThread(
dim = it.dimension,
uri = note.toNostrUri(),
mimeType = it.mimeType,
thumbhash = it.thumbhash,
)
}
@@ -104,6 +104,7 @@ private fun FileHeaderCardImage(
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
val blurHash = remember(note) { event.blurhash() }
val thumbHash = remember(note) { event.thumbhash() }
val dimensions = remember(note) { event.dimensions() }
val content by remember(note) {
@@ -122,6 +123,7 @@ private fun FileHeaderCardImage(
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
@@ -133,6 +135,7 @@ private fun FileHeaderCardImage(
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
},
)
@@ -146,7 +149,7 @@ private fun FileHeaderCardImage(
preloadUrls = if (isImage) listOf(fullUrl) else emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
backdrop = (thumbHash ?: blurHash)?.let { { BlurhashBackdrop(blurHash, content.description, thumbHash) } },
) {
ZoomableContentView(
content = content,
@@ -270,6 +270,7 @@
Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní
a proto zprávy časem mizí</string>
<string name="public_chat">Veřejný chat</string>
<string name="marmot_group">MLS skupina</string>
<string name="public_chat_title">Metadata veřejného chatu</string>
<string name="public_chat_explainer">Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli
se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat.
@@ -389,6 +390,7 @@
<string name="pictures">Obrázky</string>
<string name="shorts">Krátká videa</string>
<string name="longs">Videa</string>
<string name="articles">Články</string>
<string name="private_bookmarks">Soukromé záložky</string>
<string name="public_bookmarks">Veřejné záložky</string>
<string name="add_to_private_bookmarks">Přidat do soukromých záložek</string>
@@ -396,8 +398,12 @@
<string name="remove_from_private_bookmarks">Odebrat ze soukromých záložek</string>
<string name="remove_from_public_bookmarks">Odebrat z veřejných záložek</string>
<string name="pinned_notes">Připnuté poznámky</string>
<string name="pinned_notes_explainer">Vaše připnuté poznámky</string>
<string name="pin_to_profile">Připnout na profil</string>
<string name="unpin_from_profile">Odepnout z profilu</string>
<string name="deleted_items_banner_title">%1$d položek v tomto seznamu bylo smazáno autorem.</string>
<string name="deleted_items_banner_remove">Odebrat ze seznamu</string>
<string name="deleted_items_banner_dismiss">Zavřít</string>
<string name="bookmark_lists">Seznamy záložek</string>
<string name="bookmark_list_icon_label">Ikona seznamu záložek</string>
<string name="bookmark_list_creation_screen_title">Nový seznam záložek</string>
@@ -712,6 +718,8 @@
<string name="call_failed_accept">Nepodařilo se přijmout hovor</string>
<string name="call_failed_session">Nepodařilo se vytvořit relaci hovoru</string>
<string name="call_settings">Nastavení hovorů</string>
<string name="call_settings_enable_calls">Povolit hlasové a video hovory</string>
<string name="call_settings_enable_calls_description">Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány.</string>
<string name="call_settings_video_quality">Kvalita videa</string>
<string name="call_settings_max_bitrate">Maximální datový tok videa</string>
<string name="call_settings_turn_servers">TURN / STUN servery</string>
@@ -937,6 +945,7 @@
<string name="geohash_exclusive_explainer">Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí.</string>
<string name="hashtag_exclusive">Hashtag-exkluzivní příspěvek</string>
<string name="hashtag_exclusive_explainer">Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí.</string>
<string name="long_form_reading_minutes">%1$d min čtení</string>
<string name="loading_location">Načítání umístění</string>
<string name="lack_location_permissions">Žádná lokace oprávnění</string>
<string name="add_sensitive_content_explainer">Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující</string>
@@ -945,6 +954,7 @@
<string name="new_feature_nip17_might_not_be_available_description">Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-17 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-17 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta.</string>
<string name="new_feature_nip17_activate">Aktivovat</string>
<string name="messages_create_public_chat">Veřejné</string>
<string name="messages_create_group">Skupina</string>
<string name="messages_create_public_private_chat_description">Nová veřejná nebo soukromá skupina</string>
<string name="messages_relay_based">Relé</string>
<string name="messages_new_message">Soukromé</string>
@@ -1338,6 +1348,8 @@
<string name="private_inbox_section">DM schránka relé</string>
<string name="private_inbox_section_explainer_profile">Uživatel přijímá soukromé zprávy na těchto přenašečích</string>
<string name="private_inbox_section_explainer">Vložte mezi 13 relé, která budou sloužit jako vaše soukromá schránka. Ostatní použijí tato relé k posílání DM zpráv vám. DM schránka relé by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené)</string>
<string name="keypackage_section">KeyPackage relays</string>
<string name="keypackage_section_explainer">Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 13 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení.</string>
<string name="private_outbox_section">Soukromá relé</string>
<string name="private_outbox_section_explainer">Vložte mezi 13 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele.</string>
<string name="kind_3_section">Obecná relé</string>
@@ -1475,7 +1487,20 @@
<string name="feed_group_locations">Lokace</string>
<string name="feed_group_communities">Komunity</string>
<string name="feed_group_lists">Seznamy</string>
<string name="feed_group_dvms">Algoritmy zdrojů</string>
<string name="follow_list_all_favorite_dvms">Všechny oblíbené algoritmy zdrojů</string>
<string name="feed_group_relays">Relé</string>
<string name="add_dvm_to_favorites">Přidat algoritmus zdroje k oblíbeným</string>
<string name="remove_dvm_from_favorites">Odebrat z oblíbených</string>
<string name="favorite_dvms_title">Oblíbené algoritmy zdrojů</string>
<string name="favorite_dvms_explainer">Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších.</string>
<string name="favorite_dvms_empty">Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou.</string>
<string name="dvm_home_status_requesting">Žádost %1$s o zdroj…</string>
<string name="dvm_home_status_requesting_all">Žádost oblíbených algoritmů o zdroje…</string>
<string name="dvm_home_status_processing">Zpracování zdroje…</string>
<string name="dvm_home_status_payment_required">Tento algoritmus zdroje vyžaduje platbu</string>
<string name="dvm_home_status_error">Algoritmus zdroje vrátil chybu</string>
<string name="dvm_home_retry">Zkusit znovu</string>
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
<string name="private_message">Soukromá zpráva</string>
<string name="public_message">Veřejná zpráva</string>
@@ -1819,6 +1844,41 @@
<string name="playback_actions_dialog_title">Přehrávání</string>
<string name="video_quality_auto">Auto</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS nahrávání</string>
<string name="share_hls_video_drawer_description">Publikujte multi-rozlišení HLS na váš media server</string>
<string name="hls_pick_video_primary">Vyberte video</string>
<string name="hls_pick_video_helper">Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení.</string>
<string name="hls_change_video">Změnit</string>
<string name="hls_title_label">Titulek</string>
<string name="hls_title_placeholder">Zadejte titulek videa</string>
<string name="hls_description_label">Popis</string>
<string name="hls_description_placeholder">O čem je toto video?</string>
<string name="hls_content_warning_reason_placeholder">Důvod (nepovinné)</string>
<string name="hls_codec_label">Kodek</string>
<string name="hls_codec_h265">H.265 (lepší komprese)</string>
<string name="hls_codec_fallback_notice">H.265 není na tomto zařízení dostupný — používá se H.264.</string>
<string name="hls_renditions_label">Rozlišení</string>
<string name="hls_renditions_source_format">Zdrojové rozlišení: %1$d×%2$d</string>
<string name="hls_renditions_produce_format">Bude vytvořeno: %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s přeskočeno — nad zdrojem)</string>
<string name="hls_rendition_above_source">nad zdrojem — bude přeskočeno</string>
<string name="hls_publish_button">Publikovat HD video</string>
<string name="hls_publishing_header_format">Publikování „%1$s“…</string>
<string name="hls_state_transcoding_format">Překódování %1$s</string>
<string name="hls_state_uploading_idle">Nahrát</string>
<string name="hls_state_uploading_format">Nahrávání %1$d z %2$d</string>
<string name="hls_state_uploading_with_label_format">Nahrávání %1$s (%2$d z %3$d)</string>
<string name="hls_state_uploaded_format">Nahráno %1$d z %2$d</string>
<string name="hls_state_publishing">Publikování události…</string>
<string name="hls_state_success_title">Video publikováno</string>
<string name="hls_state_success_body">Vaše HD video je publikováno na Nostr.</string>
<string name="hls_state_failure_title">Něco se pokazilo</string>
<string name="hls_view_note">Zobrazit poznámku</string>
<string name="hls_done">Hotovo</string>
<string name="hls_try_again">Zkusit znovu</string>
<string name="hls_draft_note_after_upload">Vytvořit poznámku po nahrání</string>
<string name="hls_draft_note_after_upload_explainer">Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním.</string>
<string name="hls_draft_note_button">Vytvořit poznámku</string>
<string name="pack_actions_dialog_title">Akce balíčku</string>
<string name="list_actions_dialog_title">Akce seznamu</string>
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
@@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich</string>
Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig
und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string>
<string name="public_chat">Öffentlicher Chat</string>
<string name="marmot_group">MLS-Gruppe</string>
<string name="public_chat_title">Öffentliche Chat Metadaten</string>
<string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder
kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen.
@@ -395,6 +396,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="pictures">Bilder</string>
<string name="shorts">Kurzvideos</string>
<string name="longs">Videos</string>
<string name="articles">Artikel</string>
<string name="private_bookmarks">Private Lesezeichen</string>
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
@@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich</string>
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
<string name="remove_from_public_bookmarks">Aus den öffentlichen Lesezeichen entfernen</string>
<string name="pinned_notes">Angeheftete Notizen</string>
<string name="pinned_notes_explainer">Deine angepinnten Notizen</string>
<string name="pin_to_profile">An Profil anheften</string>
<string name="unpin_from_profile">Von Profil lösen</string>
<string name="deleted_items_banner_title">%1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht.</string>
<string name="deleted_items_banner_remove">Aus Liste entfernen</string>
<string name="deleted_items_banner_dismiss">Verwerfen</string>
<string name="bookmark_lists">Lesezeichenlisten</string>
<string name="bookmark_list_icon_label">Symbol für Lesezeichenliste</string>
<string name="bookmark_list_creation_screen_title">Neue Lesezeichenliste</string>
@@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="call_failed_accept">Anruf konnte nicht angenommen werden</string>
<string name="call_failed_session">Anrufsitzung konnte nicht erstellt werden</string>
<string name="call_settings">Anrufeinstellungen</string>
<string name="call_settings_enable_calls">Sprach- und Videoanrufe aktivieren</string>
<string name="call_settings_enable_calls_description">Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert.</string>
<string name="call_settings_video_quality">Videoqualität</string>
<string name="call_settings_max_bitrate">Maximale Video-Bitrate</string>
<string name="call_settings_turn_servers">TURN- / STUN-Server</string>
@@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="geohash_exclusive_explainer">Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen.</string>
<string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string>
<string name="hashtag_exclusive_explainer">Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen.</string>
<string name="long_form_reading_minutes">%1$d Min. Lesezeit</string>
<string name="loading_location">Standort wird geladen</string>
<string name="lack_location_permissions">Keine Standortberechtigungen</string>
<string name="add_sensitive_content_explainer">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten</string>
@@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="new_feature_nip17_might_not_be_available_description">Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet.</string>
<string name="new_feature_nip17_activate">Aktivieren</string>
<string name="messages_create_public_chat">Öffentlich</string>
<string name="messages_create_group">Gruppe</string>
<string name="messages_create_public_private_chat_description">Neue öffentliche oder private Gruppe</string>
<string name="messages_relay_based">Relais</string>
<string name="messages_new_message">Privat</string>
@@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="private_inbox_section">DM-Posteingangsrelais</string>
<string name="private_inbox_section_explainer_profile">Der Benutzer empfängt Direktnachrichten auf diesen Relays</string>
<string name="private_inbox_section_explainer">Fügen Sie 13 Relais ein, die als Ihr privater Posteingang dienen sollen. Andere werden diese Relais verwenden, um Ihnen DMs zu senden. DM-Posteingangsrelais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt)</string>
<string name="keypackage_section">KeyPackage-Relays</string>
<string name="keypackage_section_explainer">Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 13 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben.</string>
<string name="private_outbox_section">Private Relais</string>
<string name="private_outbox_section_explainer">Fügen Sie zwischen 13 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen.</string>
<string name="kind_3_section">Allgemeine Relais</string>
@@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich</string>
<string name="feed_group_locations">Standorte</string>
<string name="feed_group_communities">Gemeinschaften</string>
<string name="feed_group_lists">Listen</string>
<string name="feed_group_dvms">Feed-Algorithmen</string>
<string name="follow_list_all_favorite_dvms">Alle bevorzugten Feed-Algorithmen</string>
<string name="feed_group_relays">Relais</string>
<string name="add_dvm_to_favorites">Feed-Algorithmus zu Favoriten hinzufügen</string>
<string name="remove_dvm_from_favorites">Aus Favoriten entfernen</string>
<string name="favorite_dvms_title">Bevorzugte Feed-Algorithmen</string>
<string name="favorite_dvms_explainer">Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen.</string>
<string name="favorite_dvms_empty">Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen.</string>
<string name="dvm_home_status_requesting">Frage %1$s nach einem Feed…</string>
<string name="dvm_home_status_requesting_all">Frage deine bevorzugten Feed-Algorithmen nach Feeds…</string>
<string name="dvm_home_status_processing">Feed wird verarbeitet…</string>
<string name="dvm_home_status_payment_required">Dieser Feed-Algorithmus erfordert eine Zahlung</string>
<string name="dvm_home_status_error">Der Feed-Algorithmus hat einen Fehler zurückgegeben</string>
<string name="dvm_home_retry">Wiederholen</string>
<string name="temporary_account">Beim Sperren des Geräts abmelden</string>
<string name="private_message">Private Nachricht</string>
<string name="public_message">Öffentliche Nachricht</string>
@@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich</string>
<string name="playback_actions_dialog_title">Wiedergabe</string>
<string name="video_quality_auto">Auto</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS-Upload</string>
<string name="share_hls_video_drawer_description">Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver</string>
<string name="hls_pick_video_primary">Video auswählen</string>
<string name="hls_pick_video_helper">Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten.</string>
<string name="hls_change_video">Ändern</string>
<string name="hls_title_label">Titel</string>
<string name="hls_title_placeholder">Gib deinem Video einen Titel</string>
<string name="hls_description_label">Beschreibung</string>
<string name="hls_description_placeholder">Worum geht es in diesem Video?</string>
<string name="hls_content_warning_reason_placeholder">Grund (optional)</string>
<string name="hls_codec_h265">H.265 (bessere Komprimierung)</string>
<string name="hls_codec_fallback_notice">H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264.</string>
<string name="hls_renditions_label">Versionen</string>
<string name="hls_renditions_source_format">Quellauflösung: %1$d×%2$d</string>
<string name="hls_renditions_produce_format">Wird erstellt: %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s übersprungen — über Quelle)</string>
<string name="hls_rendition_above_source">über Quelle — wird übersprungen</string>
<string name="hls_publish_button">HD-Video veröffentlichen</string>
<string name="hls_publishing_header_format">Veröffentliche „%1$s“…</string>
<string name="hls_state_transcoding_format">Transkodiere %1$s</string>
<string name="hls_state_uploading_idle">Hochladen</string>
<string name="hls_state_uploading_format">Lade %1$d von %2$d hoch</string>
<string name="hls_state_uploading_with_label_format">Lade %1$s hoch (%2$d von %3$d)</string>
<string name="hls_state_uploaded_format">%1$d von %2$d hochgeladen</string>
<string name="hls_state_publishing">Veröffentliche Ereignis…</string>
<string name="hls_state_success_title">Video veröffentlicht</string>
<string name="hls_state_success_body">Dein HD-Video ist auf Nostr live.</string>
<string name="hls_state_failure_title">Etwas ist schiefgelaufen</string>
<string name="hls_view_note">Notiz anzeigen</string>
<string name="hls_done">Fertig</string>
<string name="hls_try_again">Erneut versuchen</string>
<string name="hls_draft_note_after_upload">Notiz nach Upload entwerfen</string>
<string name="hls_draft_note_after_upload_explainer">Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst.</string>
<string name="hls_draft_note_button">Notiz entwerfen</string>
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
<string name="list_actions_dialog_title">Listenaktionen</string>
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
@@ -1350,6 +1350,7 @@
<string name="private_inbox_section">सीधा संदेश आगतपेटिका पुनःप्रसारक</string>
<string name="private_inbox_section_explainer_profile">प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है</string>
<string name="private_inbox_section_explainer">आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क)</string>
<string name="keypackages">कुंचिकापोटलियाँ</string>
<string name="keypackage_section">कुंचिकापोटली पुनःप्रसारक</string>
<string name="keypackage_section_explainer">पुनःप्रसारक जहाँ आपके एमएलएस॰ कुंचिकापोटलियाँ प्रकाशित होंगे (मिप॰००)। अन्य प्रयोक्ता इन कुंचिकापोटलियों को प्राप्त करेंगे आपको आमन्त्रित करने के लिए मार्मोट॰ समूह चर्चाओं में। १ - ३ पुनःप्रसारकों को जोडें जो आप से कुंचिकापोटली घटनाएँ स्वीकारते हों तथा सार्वजनिक पठन की अनुमती देते हों।</string>
<string name="private_outbox_section">निजी पुनःप्रसारक</string>
@@ -1851,6 +1852,25 @@
<string name="hls_renditions_source_format">स्रोत सुलझाव : %1$dघ्न%2$d</string>
<string name="hls_renditions_produce_format">उत्पाद्य : %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s त्यक्त - स्रोत ऊपर)</string>
<string name="hls_rendition_bitrate_kbps_format">%1$d सहस्रांकप्रतिसेकण्ड</string>
<string name="hls_rendition_above_source">स्रोत ऊपर - त्यक्तव्य</string>
<string name="hls_publish_button">उच्छनिरूपण दृश्याभिलेख प्रकाशित करें</string>
<string name="hls_publishing_header_format">प्रकाशन चालू \"%1$s\"…</string>
<string name="hls_state_transcoding_format">संकुचनान्तरण चालू %1$s</string>
<string name="hls_state_uploading_idle">आरोहण</string>
<string name="hls_state_uploading_format">आरोहण चालू %2$d में से %1$d</string>
<string name="hls_state_uploading_with_label_format">आरोहण चालू %1$s (%3$d में से %2$d)</string>
<string name="hls_state_uploaded_format">आरोहण कृत %2$d में से %1$d</string>
<string name="hls_state_publishing">घटना प्रकाशन चालू…</string>
<string name="hls_state_success_title">दृश्याभिलेख प्रकाशित</string>
<string name="hls_state_success_body">आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है।</string>
<string name="hls_state_failure_title">कुछ गडबड हुआ</string>
<string name="hls_view_note">टीका देखें</string>
<string name="hls_done">हो गया</string>
<string name="hls_try_again">पुनः प्रयास करें</string>
<string name="hls_draft_note_after_upload">टीका सम्पादन आरोहण पश्चात</string>
<string name="hls_draft_note_after_upload_explainer">टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व।</string>
<string name="hls_draft_note_button">टीका सम्पादन</string>
<string name="pack_actions_dialog_title">पोटली कार्य</string>
<string name="list_actions_dialog_title">सूची कार्य</string>
<string name="bookmark_item_actions_dialog_title">स्मर्त्तव्यचिह्न कार्य</string>
@@ -1350,6 +1350,9 @@
<string name="private_inbox_section">Bejövő közvetlen üzenet-átjátszók</string>
<string name="private_inbox_section_explainer_profile">A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket</string>
<string name="private_inbox_section_explainer">Adjon hozzá 13 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős)</string>
<string name="keypackages">Kulcscsomagok</string>
<string name="keypackage_section">Kulcscsomag-átjátszók</string>
<string name="keypackage_section_explainer">Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1-3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását.</string>
<string name="private_outbox_section">Privát saját átjátszók</string>
<string name="private_outbox_section_explainer">Adjon hozzá 13 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt.</string>
<string name="kind_3_section">Általános átjátszók</string>
@@ -1346,6 +1346,7 @@
<string name="private_inbox_section">Odbiorcze transmitery DM</string>
<string name="private_inbox_section_explainer_profile">Użytkownik otrzymuje DM na tych transmiterach</string>
<string name="private_inbox_section_explainer">Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny)</string>
<string name="keypackages">Pakiety kluczy</string>
<string name="keypackage_section">Transmitery pakietu kluczy</string>
<string name="keypackage_section_explainer">Transmitery, na których publikowane są Twoje pakiety kluczy MLS (MIP-00). Inni użytkownicy pobierają te pakiety kluczy, aby zaprosić Cię do czatów grupowych w aplikacji Marmot. Wprowadź od 1 do 3 transmiterów, które akceptują zdarzenia związane z pakietami kluczy od Ciebie i zezwalają na publiczny dostęp do tych danych.</string>
<string name="private_outbox_section">Transmitery Prywatne</string>

Some files were not shown because too many files have changed in this diff Show More