diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 21b19ec83..613473752 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = topNavFilterFlow(settings.defaultArticlesFollowList) val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow + val liveBadgesFollowLists: StateFlow = 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 = 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, + ) { + 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 { + 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) -> Unit, ) { val relays = nip65RelayList.inboxFlow.value.toSet() val request = signer.sign(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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d091bf5c3..5397730e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), + val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 347df0b52..aed8edf69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt new file mode 100644 index 000000000..0496ad62d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt @@ -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 = emptySet(), + val ids: Set = emptySet(), + val addresses: Set = 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>() + private val jobs = mutableMapOf() + private val mutex = Mutex() + + fun observe(feedAddress: Address): StateFlow { + 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, + ) { + 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( + 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( + 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): Pair, Set> { + val ids = mutableSetOf() + val addresses = mutableSetOf() + innerTags.forEach { value -> + if (value.contains(':')) { + addresses.add(value) + } else if (value.length == 64) { + ids.add(value) + } + } + return ids to addresses + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt new file mode 100644 index 000000000..03b3f8536 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt @@ -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(signer) + + fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() + + suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt new file mode 100644 index 000000000..b39d78e80 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt @@ -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 = favoriteAlgoFeedsListNote.flow().metadata.stateFlow + + fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent + + suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList + return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + 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> = + 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index e9ae1dbc7..bc523eec8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -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>, ) { 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt new file mode 100644 index 000000000..f23930722 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt @@ -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>, + val orchestrator: FavoriteAlgoFeedsOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveContentRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun merge( + snapshots: List, + contentRelays: Set, + ): AllFavoriteAlgoFeedsTopNavFilter { + val ids = mutableSetOf() + val addresses = mutableSetOf() + val listen = mutableSetOf() + val requestIds = mutableSetOf() + 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): AllFavoriteAlgoFeedsTopNavFilter = + AllFavoriteAlgoFeedsTopNavFilter( + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = contentRelays, + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow(): Flow = + favoriteAlgoFeedAddresses.flatMapLatest { addresses -> + if (addresses.isEmpty()) { + combine(outboxRelays, proxyRelays) { outbox, proxy -> + emptyFilter(resolveContentRelays(outbox, proxy)) + } + } else { + val snapshotFlows: List> = 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) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt new file mode 100644 index 000000000..eba50b6bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -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, + val acceptedAddresses: Set, + val contentRelays: Set, + val listenRelays: Set, + val requestIds: Set, +) : 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 = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteAlgoFeedTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = requestIds, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt new file mode 100644 index 000000000..e17accd43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt @@ -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>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun buildFilter( + snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot, + contentRelays: Set, + ) = FavoriteAlgoFeedTopNavFilter( + feedAddress = feedAddress, + acceptedIds = snapshot.ids, + acceptedAddresses = snapshot.addresses, + contentRelays = contentRelays, + listenRelays = snapshot.responseRelays, + requestId = snapshot.requestId, + ) + + override fun flow(): Flow = + 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) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt new file mode 100644 index 000000000..6cd70135c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -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, + val acceptedAddresses: Set, + val contentRelays: Set, + val listenRelays: Set, + 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 = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteAlgoFeedTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = setOfNotNull(requestId), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt new file mode 100644 index 000000000..374f5ecf0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt @@ -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, + val addresses: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..a111c9361 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -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, + val listenRelays: Set, + val requestIds: Set, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 31b4e782f..f7b25bee4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt new file mode 100644 index 000000000..31c6cc137 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt @@ -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 { + override fun create( + data: ThumbhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = ThumbHashFetcher(options, data) + } + + object TKeyer : Keyer { + 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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index efb7cef5e..6d8cf5382 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 5deeb41ee..db4c9ac9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -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") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index b9b721b11..3d70480bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index dc2c08557..9015be8e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 79427a04b..93c28ad5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -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}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index 911a33da9..0853058a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -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?): 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt similarity index 71% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt index ef3b0aea1..5d8aed97c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt @@ -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 { - val (blur, dim) = processBitmap(bitmap) - return blur to (dim ?: dimPrecomputed) - } - fun computeFromBytes( data: ByteArray, mimeType: String?, dimPrecomputed: DimensionTag?, - ): Pair = + ): 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? { + ): 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 = + 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 { + ): 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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index c5ac44b9f..6dce74422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 2950a3fc0..b17e3ba8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index ec1d6db0a..a40e19f2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index b99fb85e9..371d21b58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index fe147eb19..133ecdab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 85e57f6d4..fb3de24b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -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) { 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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index c6331c9f0..e4bed1081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index a90440d19..e9d03260e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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,18 +242,36 @@ fun ZoomableContentView( } } } + + is MediaUrlPdf -> { + Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) { + PdfPreviewCard( + content = content, + accountViewModel = accountViewModel, + onOpen = { dialogOpen = true }, + ) + } + } } if (dialogOpen) { - ZoomableImageDialog( - imageUrl = content, - allImages = images, - sourceBounds = sourceBounds, - onDismiss = { - dialogOpen = false - }, - accountViewModel = accountViewModel, - ) + if (content is MediaUrlPdf) { + PdfViewerDialog( + content = content, + accountViewModel = accountViewModel, + onDismiss = { dialogOpen = false }, + ) + } else { + ZoomableImageDialog( + imageUrl = content, + allImages = images, + sourceBounds = sourceBounds, + onDismiss = { + dialogOpen = false + }, + accountViewModel = accountViewModel, + ) + } } } @@ -271,6 +294,7 @@ fun LocalImageView( controllerVisible: MutableState, 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, 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt new file mode 100644 index 000000000..4932b0a97 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt @@ -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 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt new file mode 100644 index 000000000..0712e95d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -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(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 { + 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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt new file mode 100644 index 000000000..57279441c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -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(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): 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(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(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(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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 77cbd110a..41de5c811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 510234327..70461364d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { BadgesScreen(accountViewModel, nav) } + composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } + composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } @@ -277,6 +284,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 9e94168f1..3e04757e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fdba69034..5e33e0053 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c3f7bba7c..9d01c8a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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): Map { + 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 338eb71b6..01a3c83e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 0e89d1199..a869d042b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 2e5b91c9a..2cc5c9f72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 4a72e2cce..509c82511 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -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(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.", - ) - } -} - -@Composable -private fun RenderBadge( - image: String?, - name: String?, - backgroundForRow: Color, - textColor: Color, - description: String?, -) { - Row( - modifier = Modifier.padding(vertical = 10.dp), + 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) } + } + }, ) { - Column { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringRes( - R.string.badge_award_image_for, - name ?: "", - ), - modifier = Modifier.fillMaxWidth().background(backgroundForRow), - contentScale = ContentScale.FillWidth, - ) - } - - 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, - ) + if (isMine && nav != null) { + BadgeActionRow { + FilledTonalButton( + onClick = { + nav.nav( + Route.AwardBadge( + kind = definition.kind, + pubKeyHex = definition.pubKey, + dTag = definition.dTag(), + ), + ) + }, + ) { + Icon( + imageVector = Icons.Outlined.MilitaryTech, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(6.dp)) + Text(stringRes(R.string.award_badge)) + } } } } @@ -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(definitionNote, accountViewModel) + } else { + remember { mutableStateOf(null) } + } + var awardees by remember { mutableStateOf>(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, + 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.", + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index e68aebcc5..dea8fee72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -70,6 +70,7 @@ fun RenderClassifieds( dim = it.dimension, uri = note.toNostrUri(), mimeType = it.mimeType, + thumbhash = it.thumbhash, ) } val title = noteEvent.title() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 9b6eedabb..4ad82bbff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -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, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 29cb0937a..b15e37e23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 6730e72c9..3015959a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 03ce5be92..1c2399218 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -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, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 63cb577eb..0abc16ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 50763b54d..f19d47bee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -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, communityList: List, relayList: Set, + favoriteAlgoFeedsList: List, ): List { 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index fc621ceb8..ac6a5e30a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d068d43b8..3096c8311 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt new file mode 100644 index 000000000..19cf4ed9f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt new file mode 100644 index 000000000..522a6d765 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt new file mode 100644 index 000000000..209be6fe9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt new file mode 100644 index 000000000..f600bfd52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -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() } + + 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt new file mode 100644 index 000000000..3a2b2d5b9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt @@ -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(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) { + val def = definition ?: return + if (awardees.isEmpty()) return + + val pTags = awardees.map { PTag(it.pubkeyHex) } + account.sendBadgeAward(def, pTags) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt new file mode 100644 index 000000000..049940997 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt @@ -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() { + 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 { + 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): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt new file mode 100644 index 000000000..74d95bc09 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt @@ -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() { + val group = + listOf( + BadgesSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt new file mode 100644 index 000000000..3d4b818ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt new file mode 100644 index 000000000..5971b8b40 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: BadgesQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt new file mode 100644 index 000000000..ba08ae632 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -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 = + 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, + since: SincePerRelayMap?, +): List { + 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, + since: Long? = null, +): List { + 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 { + 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 { + 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 { + 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 { + 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt new file mode 100644 index 000000000..6f3a40f90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt @@ -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 }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt new file mode 100644 index 000000000..1c33781fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt @@ -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(null) + var name by mutableStateOf("") + var description by mutableStateOf("") + + var multiOrchestrator by mutableStateOf(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, + ) { + this.account = account + this.multiOrchestrator = MultiOrchestrator(uris) + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + this.name = "" + this.description = "" + } + + fun setPickedMedia(uris: ImmutableList) { + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt new file mode 100644 index 000000000..516984277 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt @@ -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 { 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(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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt new file mode 100644 index 000000000..4212caab4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt @@ -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, +): List { + 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt new file mode 100644 index 000000000..02892b5a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt @@ -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() { + val group = + listOf( + ProfileBadgesSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt new file mode 100644 index 000000000..c0ecc05a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt new file mode 100644 index 000000000..b50260b1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt @@ -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, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: ProfileBadgesQueryState) = key.account.userProfile() + + override fun updateFilter( + key: ProfileBadgesQueryState, + since: SincePerRelayMap?, + ): List = + filterReceivedBadgeAwards( + pubkey = user(key).pubkeyHex, + relays = key.account.notificationRelays.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index 77095e393..9bee04cbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -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(), ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt index 79790ad5e..d4c5464fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt @@ -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, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt index b9f24db75..b16d14226 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt @@ -43,6 +43,7 @@ class MarmotFileSender( nonce = upload.nonce, dimensions = upload.dimensions, blurhash = upload.blurhash, + thumbhash = upload.thumbhash, ) accountViewModel.sendMarmotGroupMediaMessage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt index 6157b72e1..44dd46fda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index 279142002..b8ff848e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 716be8b86..7b4598158 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -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, ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 4337bffa8..b621b520f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 6e484ace4..ee865aea3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 6fa904415..329444e2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -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,22 +153,16 @@ 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, - modifier = - Modifier - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $amount ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) } Spacer(modifier = StdHorzSpacer) card.personalized?.let { @@ -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, - modifier = - Modifier - .padding(start = 4.dp) - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $name ", + color = color, + maxLines = 1, + modifier = + Modifier + .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, + ) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index c49d58960..608fdc5cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -429,14 +429,18 @@ open class NewProductViewModel : ) { productImages = productImages + ProductImageMeta( - it.result.url, - it.result.fileHeader.mimeType, - it.result.fileHeader.blurHash - ?.blurhash, - it.result.fileHeader.dim, - alt, - it.result.fileHeader.hash, - it.result.fileHeader.size, + url = it.result.url, + mimeType = it.result.fileHeader.mimeType, + blurhash = + it.result.fileHeader.blurHash + ?.blurhash, + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 0d81a4cc8..3f5dea38e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -354,82 +354,95 @@ fun FeedDVM( Spacer(modifier = DoubleVertSpacer) Text(currentStatus, textAlign = TextAlign.Center) - if (status.code == "payment-required") { - val amountTag = latestStatus.firstAmount() - val amount = amountTag?.amount + DvmPaymentActions( + latestStatus = latestStatus, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { currentStatus = it }, + ) + } +} - val invoice = amountTag?.lnInvoice +@Composable +fun DvmPaymentActions( + latestStatus: NIP90StatusEvent, + accountViewModel: AccountViewModel, + nav: INav, + onStatusUpdate: (String) -> Unit, +) { + val status = latestStatus.status() ?: return - val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) - val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + if (status.code != "payment-required") return - if (invoice != null) { - val context = LocalContext.current - Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - currentStatus = nwcPaymentRequest - }, - onResponse = { response -> - currentStatus = - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { - thankYou - } + val amountTag = latestStatus.firstAmount() + val amount = amountTag?.amount + val invoice = amountTag?.lnInvoice + + val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) + val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + + if (invoice != null) { + val context = LocalContext.current + Button(onClick = { + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou }, ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - currentStatus = thankYou - }, - onError = { - currentStatus = it - }, - ) - } - }) { - val amountInInvoice = - try { - LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (_: Exception) { - null - } - - if (amountInInvoice != null) { - Text(text = "Pay $amountInInvoice sats to the DVM") - } else { - Text(text = "Pay Invoice from the DVM") - } - } - } else if (amount != null) { - LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> - stateNote?.let { - ZapDVMButton( - baseNote = it, - amount = amount, - grayTint = MaterialTheme.colorScheme.onPrimary, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + }, + ) + } else { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } + }) { + val amountInInvoice = + try { + LnInvoiceUtil.getAmountInSats(invoice).toLong() + } catch (_: Exception) { + null + } + + if (amountInInvoice != null) { + Text(text = "Pay $amountInInvoice sats to the DVM") + } else { + Text(text = "Pay Invoice from the DVM") + } + } + } else if (amount != null) { + LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> + stateNote?.let { + ZapDVMButton( + baseNote = it, + amount = amount, + grayTint = MaterialTheme.colorScheme.onPrimary, + accountViewModel = accountViewModel, + nav = nav, + ) } - } else if (status.code == "processing") { - currentStatus = status.description - } else if (status.code == "error") { - currentStatus = status.description } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 928a2d9a8..78e65eed3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -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, + ) + } + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt new file mode 100644 index 000000000..3838bef64 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt new file mode 100644 index 000000000..8d71292bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt new file mode 100644 index 000000000..22d10c444 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -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(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 = + 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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 77adf8355..fb2ee8914 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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,24 +213,37 @@ private fun HomePages( HomeScreenFloatingButton(accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { - HorizontalPager( - contentPadding = it, - state = pagerState, - userScrollEnabled = true, - modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), - ) { page -> - HomeFeeds( - feedState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - scrollStateKey = tabs[page].scrollStateKey, - liveSection = tabs[page].liveSection, + ) { 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 = PaddingValues(0.dp), + state = pagerState, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), + ) { page -> + HomeFeeds( + feedState = tabs[page].feedState, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + liveSection = tabs[page].liveSection, + accountViewModel = accountViewModel, + 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 8bfb0e0af..a5bc0c568 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 39459be38..09488ead9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt new file mode 100644 index 000000000..029546417 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -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 { + val out = mutableListOf() + + 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 { + val out = mutableListOf() + + 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, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to requestIds), + limit = 10 * requestIds.size, + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index bf88579e0..fc197d120 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index d7aa97489..26322c6de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index fce93165f..66f06d75e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 9992202f9..f1f12a75d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -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, + isMe: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp), - ) { - list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) } + 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(), + ) { + 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, 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(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(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(note, accountViewModel) + awardEvent?.awardDefinition()?.firstOrNull()?.let { defAddr -> + LoadAddressableNote(defAddr, accountViewModel) { defNote -> + defNote?.let { content(it) } } } } @@ -173,13 +354,16 @@ fun BadgeThumb( ) { Box( modifier = - Size35Modifier.clickable( - onClick = { - nav.nav { - routeFor(baseNote, accountViewModel.account) - } - }, - ), + Modifier + .size(ProfileBadgeSize) + .clip(ProfileBadgeShape) + .clickable( + onClick = { + nav.nav { + routeFor(baseNote, accountViewModel.account) + } + }, + ), ) { WatchAndRenderBadgeImage(baseNote, accountViewModel) } @@ -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(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d0796c8e..f040d037e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 11385dd60..d4681cbdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 40e49b466..679295e5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -922,6 +922,7 @@ private fun RenderClassifiedsReaderForThread( dim = it.dimension, uri = note.toNostrUri(), mimeType = it.mimeType, + thumbhash = it.thumbhash, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index 2efc3243a..24f8d7947 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -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, diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6101c6c71..e445b58bc 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -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í Veřejný chat + MLS skupina Metadata veřejného chatu 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 @@ Obrázky Krátká videa Videa + Články Soukromé záložky Veřejné záložky Přidat do soukromých záložek @@ -396,8 +398,12 @@ Odebrat ze soukromých záložek Odebrat z veřejných záložek Připnuté poznámky + Vaše připnuté poznámky Připnout na profil Odepnout z profilu + %1$d položek v tomto seznamu bylo smazáno autorem. + Odebrat ze seznamu + Zavřít Seznamy záložek Ikona seznamu záložek Nový seznam záložek @@ -712,6 +718,8 @@ Nepodařilo se přijmout hovor Nepodařilo se vytvořit relaci hovoru Nastavení hovorů + Povolit hlasové a video hovory + Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány. Kvalita videa Maximální datový tok videa TURN / STUN servery @@ -937,6 +945,7 @@ Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. Hashtag-exkluzivní příspěvek Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí. + %1$d min čtení Načítání umístění Žádná lokace oprávnění 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í @@ -945,6 +954,7 @@ 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. Aktivovat Veřejné + Skupina Nová veřejná nebo soukromá skupina Relé Soukromé @@ -1338,6 +1348,8 @@ DM schránka relé Uživatel přijímá soukromé zprávy na těchto přenašečích Vložte mezi 1–3 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é) + KeyPackage relays + 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 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení. Soukromá relé Vložte mezi 1–3 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. Obecná relé @@ -1475,7 +1487,20 @@ Lokace Komunity Seznamy + Algoritmy zdrojů + Všechny oblíbené algoritmy zdrojů Relé + Přidat algoritmus zdroje k oblíbeným + Odebrat z oblíbených + Oblíbené algoritmy zdrojů + 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. + Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. + Žádost %1$s o zdroj… + Žádost oblíbených algoritmů o zdroje… + Zpracování zdroje… + Tento algoritmus zdroje vyžaduje platbu + Algoritmus zdroje vrátil chybu + Zkusit znovu Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1819,6 +1844,41 @@ Přehrávání Auto + HLS nahrávání + Publikujte multi-rozlišení HLS na váš media server + Vyberte video + 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í. + Změnit + Titulek + Zadejte titulek videa + Popis + O čem je toto video? + Důvod (nepovinné) + Kodek + H.265 (lepší komprese) + H.265 není na tomto zařízení dostupný — používá se H.264. + Rozlišení + Zdrojové rozlišení: %1$d×%2$d + Bude vytvořeno: %1$s + (%1$s přeskočeno — nad zdrojem) + nad zdrojem — bude přeskočeno + Publikovat HD video + Publikování „%1$s“… + Překódování %1$s + Nahrát + Nahrávání %1$d z %2$d + Nahrávání %1$s (%2$d z %3$d) + Nahráno %1$d z %2$d + Publikování události… + Video publikováno + Vaše HD video je publikováno na Nostr. + Něco se pokazilo + Zobrazit poznámku + Hotovo + Zkusit znovu + Vytvořit poznámku po nahrání + Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním. + Vytvořit poznámku Akce balíčku Akce seznamu Akce záložky diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 98878b100..78b9b9c61 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich 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 Öffentlicher Chat + MLS-Gruppe Öffentliche Chat Metadaten Ö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 Bilder Kurzvideos Videos + Artikel Private Lesezeichen Öffentliche Lesezeichen Zu den privaten Lesezeichen hinzufügen @@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich Aus den privaten Lesezeichen entfernen Aus den öffentlichen Lesezeichen entfernen Angeheftete Notizen + Deine angepinnten Notizen An Profil anheften Von Profil lösen + %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. + Aus Liste entfernen + Verwerfen Lesezeichenlisten Symbol für Lesezeichenliste Neue Lesezeichenliste @@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich Anruf konnte nicht angenommen werden Anrufsitzung konnte nicht erstellt werden Anrufeinstellungen + Sprach- und Videoanrufe aktivieren + Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. Videoqualität Maximale Video-Bitrate TURN- / STUN-Server @@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen. Hashtag-exklusive Beitrag Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen. + %1$d Min. Lesezeit Standort wird geladen Keine Standortberechtigungen 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 @@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich 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. Aktivieren Öffentlich + Gruppe Neue öffentliche oder private Gruppe Relais Privat @@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich DM-Posteingangsrelais Der Benutzer empfängt Direktnachrichten auf diesen Relays Fügen Sie 1–3 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) + KeyPackage-Relays + 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 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben. Private Relais Fügen Sie zwischen 1–3 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. Allgemeine Relais @@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich Standorte Gemeinschaften Listen + Feed-Algorithmen + Alle bevorzugten Feed-Algorithmen Relais + Feed-Algorithmus zu Favoriten hinzufügen + Aus Favoriten entfernen + Bevorzugte Feed-Algorithmen + Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. + Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. + Frage %1$s nach einem Feed… + Frage deine bevorzugten Feed-Algorithmen nach Feeds… + Feed wird verarbeitet… + Dieser Feed-Algorithmus erfordert eine Zahlung + Der Feed-Algorithmus hat einen Fehler zurückgegeben + Wiederholen Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich Wiedergabe Auto + HLS-Upload + Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver + Video auswählen + Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. + Ändern + Titel + Gib deinem Video einen Titel + Beschreibung + Worum geht es in diesem Video? + Grund (optional) + H.265 (bessere Komprimierung) + H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. + Versionen + Quellauflösung: %1$d×%2$d + Wird erstellt: %1$s + (%1$s übersprungen — über Quelle) + über Quelle — wird übersprungen + HD-Video veröffentlichen + Veröffentliche „%1$s“… + Transkodiere %1$s + Hochladen + Lade %1$d von %2$d hoch + Lade %1$s hoch (%2$d von %3$d) + %1$d von %2$d hochgeladen + Veröffentliche Ereignis… + Video veröffentlicht + Dein HD-Video ist auf Nostr live. + Etwas ist schiefgelaufen + Notiz anzeigen + Fertig + Erneut versuchen + Notiz nach Upload entwerfen + Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst. + Notiz entwerfen Paket-Aktionen Listenaktionen Lesezeichen-Aktionen diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 100418540..568400a75 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1350,6 +1350,7 @@ सीधा संदेश आगतपेटिका पुनःप्रसारक प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क) + कुंचिकापोटलियाँ कुंचिकापोटली पुनःप्रसारक पुनःप्रसारक जहाँ आपके एमएलएस॰ कुंचिकापोटलियाँ प्रकाशित होंगे (मिप॰००)। अन्य प्रयोक्ता इन कुंचिकापोटलियों को प्राप्त करेंगे आपको आमन्त्रित करने के लिए मार्मोट॰ समूह चर्चाओं में। १ - ३ पुनःप्रसारकों को जोडें जो आप से कुंचिकापोटली घटनाएँ स्वीकारते हों तथा सार्वजनिक पठन की अनुमती देते हों। निजी पुनःप्रसारक @@ -1851,6 +1852,25 @@ स्रोत सुलझाव : %1$dघ्न%2$d उत्पाद्य : %1$s (%1$s त्यक्त - स्रोत ऊपर) + %1$d सहस्रांकप्रतिसेकण्ड + स्रोत ऊपर - त्यक्तव्य + उच्छनिरूपण दृश्याभिलेख प्रकाशित करें + प्रकाशन चालू \"%1$s\"… + संकुचनान्तरण चालू %1$s + आरोहण + आरोहण चालू %2$d में से %1$d + आरोहण चालू %1$s (%3$d में से %2$d) + आरोहण कृत %2$d में से %1$d + घटना प्रकाशन चालू… + दृश्याभिलेख प्रकाशित + आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है। + कुछ गडबड हुआ + टीका देखें + हो गया + पुनः प्रयास करें + टीका सम्पादन आरोहण पश्चात + टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व। + टीका सम्पादन पोटली कार्य सूची कार्य स्मर्त्तव्यचिह्न कार्य diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index df8ce9048..510791544 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1350,6 +1350,9 @@ Bejövő közvetlen üzenet-átjátszók A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket Adjon hozzá 1–3 á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) + Kulcscsomagok + Kulcscsomag-átjátszók + 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. Privát saját átjátszók Adjon hozzá 1–3 á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. Általános átjátszók diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 9c980c163..473b78618 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1346,6 +1346,7 @@ Odbiorcze transmitery DM Użytkownik otrzymuje DM na tych transmiterach 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) + Pakiety kluczy Transmitery pakietu kluczy 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. Transmitery Prywatne diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 956ccfc2d..53ffc5eee 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -270,6 +270,7 @@ São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo Chat público + Grupo MLS Metadados do Chat Público Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. @@ -389,6 +390,7 @@ Imagens Curtas Vídeos + Artigos Itens Salvos Privados Itens Salvos Públicos Adicionar aos Itens Salvos Privados @@ -396,8 +398,12 @@ Remover dos Itens Salvos Privados Remover dos Itens Salvos Públicos Notas Fixadas + Suas notas fixadas Fixar no Perfil Desafixar do Perfil + %1$d item(ns) desta lista foram excluídos pelos seus autores. + Remover da lista + Dispensar Listas de favoritos Ícone da lista de favoritos Nova lista de favoritos @@ -712,6 +718,8 @@ Falha ao aceitar chamada Falha ao criar sessão de chamada Configurações de Chamada + Ativar chamadas de voz e vídeo + Quando desativado, os botões de chamada ficam ocultos nas telas de conversa e todas as chamadas recebidas são silenciosamente ignoradas. Qualidade do Vídeo Taxa de Bits Máxima de Vídeo Servidores TURN / STUN @@ -937,6 +945,7 @@ Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. Postagem exclusiva de Hashtag Somente seguidores da hashtag verão isso. Seus seguidores gerais não verão isso. + %1$d min de leitura Carregando localização Sem permissões para localização Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador @@ -945,6 +954,7 @@ Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-17 (GiftWrapped, Sealed Direct and Group Messages). NIP-17 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível. Ativar Público + Grupo Novo Grupo Público ou Privado Relé Privado @@ -1338,6 +1348,8 @@ Relés de Caixa de Entrada de DM O usuário recebe mensagens diretas (DMs) nesses relays Insira entre 1–3 relés para servir como sua caixa de entrada privada. Outros usarão esses relés para enviar DMs para você. Relés de Caixa de Entrada de DM devem aceitar qualquer mensagem de qualquer pessoa, mas permitir apenas você baixá-las. Boas opções são:\n - inbox.nostr.wine (pago)\n - you.nostr1.com (relés pessoais - pago) + Relays de KeyPackage + Relays onde seus MLS KeyPackages são publicados (MIP-00). Outros usuários os buscam para convidá-lo para chats em grupo Marmot. Insira de 1 a 3 relays que aceitem eventos KeyPackage seus e permitam leitura pública. Relés privados Insira entre 1–3 retransmissores para armazenar eventos que ninguém mais possa ver, como seus rascunhos e/ou configurações de aplicativo. Idealmente, esses relés são locais ou requerem autenticação antes de baixar o conteúdo de cada usuário. Relés Gerais @@ -1475,7 +1487,20 @@ Localizações Comunidades Listas + Algoritmos de feed + Todos os algoritmos de feed favoritos Relés + Adicionar algoritmo de feed aos favoritos + Remover dos favoritos + Algoritmos de feed favoritos + Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. + Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. + Solicitando feed a %1$s… + Solicitando feeds aos seus algoritmos favoritos… + Processando seu feed… + Este algoritmo de feed requer pagamento + O algoritmo de feed retornou um erro + Tentar novamente Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1819,6 +1844,40 @@ Reprodução Auto + Upload HLS + Publique HLS multi-resolução em seu servidor de mídia + Escolher um vídeo + Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. + Trocar + Título + Dê um título ao seu vídeo + Descrição + Sobre o que é este vídeo? + Motivo (opcional) + H.265 (melhor compressão) + H.265 não disponível neste dispositivo — usando H.264. + Versões + Resolução da origem: %1$d×%2$d + Será gerado: %1$s + (%1$s ignorado — acima da origem) + acima da origem — será ignorado + Publicar vídeo HD + Publicando “%1$s”… + Transcodificando %1$s + Enviar + Enviando %1$d de %2$d + Enviando %1$s (%2$d de %3$d) + Enviado %1$d de %2$d + Publicando evento… + Vídeo publicado + Seu vídeo HD está publicado no Nostr. + Algo deu errado + Ver nota + Concluído + Tentar novamente + Rascunho de nota após upload + Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar. + Rascunhar nota Ações do pacote Ações da lista Ações de favorito diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index c5d5e3161..575e988fd 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -281,6 +281,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne zato sporočila v klepetu sčasoma izginejo Javni pogovor + MLS skupina Metapodatki Javnega Klepeta Javni klepeti so vidni vsem na Nostru in vsakdo se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. @@ -402,6 +403,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Fotografije Kratki posnetki Videoposnetki + Članki Privatni zaznamki Javni zaznamki Dodaj v privatne zaznamke @@ -409,8 +411,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odstrani iz privatnih zaznamkov Odstrani iz javnih zaznamkov Pripeti zapiski + Vaši pripeti zapiski Pripni k profilu Odpni od profila + Odstrani iz seznama + Prekliči Seznam zaznamkov Ikona za seznam zaznamkov Nov seznam zaznamkov @@ -727,6 +732,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Sprejem klica ni uspel Vzpostavitev klicne seje ni uspela Klicne nastavitve + Vklop glasovnih in video klicev + Če funkcijo izklopite, bodo gumbi za klice skriti, vsi dohodni klici pa se bodo tiho zavrnili. Kakovost videa Najvišja bitna hitrost videa Strežniki TURN / STUN @@ -952,6 +959,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. Ekskluzivna objava ključnika Vidno bo le sledilcem tega ključnika. Tvoji splošni sledilci tega ne bojo videli. + %1$d min branja Nalaganje lokacije Brez dovoljenj za lokacijo Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo @@ -960,6 +968,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Za aktivacijo tega načina mora Amethyst poslati sporočilo NIP-17 (GiftWrapped, šifrirana neposredna in skupinska sporočila). NIP-17 je nov in večina Nostr odjemalcev ga še ni implementirala. Prepričajte se, da prejemnik uporablja združljiv Nostr odjemalec. Aktiviraj Javno + Skupina Nova javna ali zasebna skupina Rele Zasebno @@ -1355,6 +1364,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Releji predala zasebnih sporočil Uporabnik prejema ZS prek teh relejev Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Drugi bodo te releje uporabljali za pošiljanje zasebnih sporočil vam. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Dobre možnosti so:\n - inbox.nostr.wine (plačljiv)\n - auth.nostr1.com (brezplačen)\n - you.nostr1.com (osebni releji - plačljivi) + KeyPackage-i + KeyPackage Releji + Releji, kjer so objavljeni vaši MLS KeyPackage-i (MIP-00). Drugi uporabniki jih pridobijo, da vas lahko povabijo v skupinske klepete Marmot. Vstavite od 1 do 3 releje, ki sprejemajo vaše KeyPackage dogodke in omogočajo javno branje. Zasebni domači releji Vnesite 1–3 releje za shranjevanje dogodkov, ki jih nihče drug ne more videti, kot so vaši osnutki in/ali nastavitve aplikacije. Idealno je, da so ti releji bodisi lokalni ali zahtevajo preverjanje pristnosti pred prenosom vsebine posameznega uporabnika. Splošni releji @@ -1834,7 +1846,45 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Možnosti profila Možnosti predstavnosti Predvajaj + Samodejno + Naloži HLS + Objava HLS v več ločljivostih na medijski strežnik + Izberi video + Vaš video bo pripravljen v več ločljivostih, da bo predvajanje teklo gladko na vsaki povezavi. + Sprememba + Naslov + Dodajte naslov videa + Opis + Kaj je vsebina tega videa? + Razlog (neobvezno) + Kodek + H.265 (boljše stiskanje) + H.264 + Naprava ne podpira H.265 — izbran bo H.264. + Izvedbe + Resolucija vira: %1$d×%2$d + Ustvarilo bo: %1$s + (%1$s preskočen — zgornji vir) + %1$d kbps + Zgornji vir — bo preskočen + Objavite HD video + Objavljam “%1$s”… + Priprava videa: %1$s + Naložite + Nalagam %1$d of %2$d + Nalagam %1$s (%2$d of %3$d) + Naloženo %1$d of %2$d + Dogodek se objavlja… + Video je objavljen + Vaš HD-video je dostopen na Nostru. + Prišlo je do napake + Poglej zapisek + Končano + Poskusi znova + Osnutek zapiska po nalaganju + Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš. + Osnutek zapiska Možnosti paketa Možnosti seznama Možnosti zaznamkov diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5c425a64b..ed7eba1dc 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -270,6 +270,7 @@ De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade och därmed försvinner chattmeddelanden över tiden Publik Chat + MLS-grupp Metadata för offentlig chatt Offentliga chattar är synliga för alla på Nostr och alla kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. @@ -389,6 +390,7 @@ Bilder Kortfilmer Videor + Artiklar Privata Bokmärken Publika Bokmärken Lägg till i Privata Bokmärken @@ -396,8 +398,12 @@ Ta bort från Privata Bokmärken Ta bort från Publika Bokmärken Fästa anteckningar + Dina fastnålade anteckningar Fäst på profil Ta bort från profil + %1$d objekt i den här listan har raderats av sina författare. + Ta bort från lista + Avvisa Bokmärkeslistor Ikon för bokmärkeslista Ny bokmärkeslista @@ -711,6 +717,8 @@ Kunde inte ta emot samtal Kunde inte skapa samtalssession Samtalsinställningar + Aktivera röst- och videosamtal + När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. Videokvalitet Maximal videobitfrekvens TURN- / STUN-servrar @@ -936,6 +944,7 @@ Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. Hashtag-exklusivt inlägg Endast anhängare av hashtaggen kommer att se den. Dina generella följare kommer inte att se den. + %1$d min läsning Laddar position Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande @@ -944,6 +953,7 @@ För att aktivera denna funktion kräver det att Amethyst skickar ett NIP-17 meddelande (GiftWrapped, Förseglade Direkta och Gruppmeddelanden). NIP-17 är nytt och de flesta klienter har ännu inte implementerat det. Se till att mottagaren använder en kompatibel klient. Aktivera Publik + Grupp Ny offentlig eller privat grupp Relä Privat @@ -1337,6 +1347,8 @@ DM inkorgsreläer Användaren tar emot DM:s på dessa reläer Sätt in mellan 1–3 reläer som ska fungera som din privata inkorg. Andra kommer att använda dessa reläer för att skicka DM till dig. DM inkorgsreläer bör acceptera alla meddelanden från vem som helst, men endast tillåta dig att ladda ner dem. Bra alternativ är:\n - inbox.nostr.wine (betald)\n - you.nostr1.com (personliga reläer - betald) + KeyPackage-relays + Relays där dina MLS KeyPackages publiceras (MIP-00). Andra användare hämtar dessa KeyPackages för att bjuda in dig till Marmot-gruppchatter. Lägg till mellan 1–3 relays som accepterar KeyPackage-händelser från dig och tillåter offentlig läsning. Privata reläer Infoga mellan 1–3 reläer för att lagra händelser som ingen annan kan se, som dina Utkast och/eller appinställningar. Helst är dessa reläer antingen lokala eller kräver autentisering innan du laddar ner varje användares innehåll. Allmänna reläer @@ -1474,7 +1486,20 @@ Platser Gemenskaper Listor + Flödesalgoritmer + Alla favorit-flödesalgoritmer Reläer + Lägg till flödesalgoritm i favoriter + Ta bort från favoriter + Favorit-flödesalgoritmer + Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. + Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. + Frågar %1$s om ett flöde… + Frågar dina favorit-flödesalgoritmer om flöden… + Bearbetar ditt flöde… + Den här flödesalgoritmen kräver betalning + Flödesalgoritmen returnerade ett fel + Försök igen Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1818,6 +1843,40 @@ Uppspelning Auto + HLS-uppladdning + Publicera HLS i flera upplösningar till din mediaserver + Välj en video + Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. + Ändra + Titel + Ge din video en titel + Beskrivning + Vad handlar den här videon om? + Anledning (valfritt) + H.265 (bättre komprimering) + H.265 inte tillgängligt på den här enheten — växlar till H.264. + Versioner + Källupplösning: %1$d×%2$d + Kommer att skapa: %1$s + (%1$s hoppas över — över källan) + över källa — kommer att hoppas över + Publicera HD-video + Publicerar ”%1$s”… + Transkodar %1$s + Ladda upp + Laddar upp %1$d av %2$d + Laddar upp %1$s (%2$d av %3$d) + Uppladdade %1$d av %2$d + Publicerar händelse… + Video publicerad + Din HD-video är live på Nostr. + Något gick fel + Visa anteckning + Klar + Försök igen + Utkast till anteckning efter uppladdning + Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering. + Utkast till anteckning Paketåtgärder Liståtgärder Bokmärkesåtgärder diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 2c4e4d7a6..9f11a256a 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1350,6 +1350,7 @@ 私信收件箱中继 用户接收到这些中继的私信 设置 1 ~ 3 个中继作为您的私人收件箱。其他人将使用这些中继向您发送私信。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。示例:\n - inbox.nostr.wine(付费)\n - you.nostr1.com(个人专用中继 - 付费) + 密钥包 密钥包中继 发布你的 MLS 密钥包发布的中继(MIP-00)。其他用户获取这些密钥包来邀请你加入 Marmot 群聊。插入1~3个接受来自你的密钥包事件的中继并允许公开读取。 私人中继 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fcf878f13..b375737b9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -419,6 +419,40 @@ Polls Open Closed + Badges + Received + Mine + Awarded + Discover + New Badge + Edit Badge + Award Badge + Accept + Reject + Remove from profile + Badge ID (d-tag) + bravery, contributor-2025… + Name + Human-readable badge name + Description + Why is this badge awarded? + Image URL (1024×1024) + https://example.com/badge.png + Thumbnail URL (optional) + https://example.com/badge-thumb.png + Upload an image + Pick a square image to be the face of your badge. + Loading badge… + Search users + Name, npub, or NIP-05 + Remove + Untitled badge + Awarded to %1$d + You received a badge + Profile badges + Badges · %1$d + Choose which of the badges you\'ve received appear on your profile. + You haven\'t received any badges yet. Pictures Shorts Videos @@ -628,6 +662,7 @@ Around Me Global Chess + Mine Mute List Follow Lists @@ -1732,8 +1767,22 @@ Locations Communities Lists + Feed Algorithms + All favorite feed algorithms Relays + Add feed algorithm to favorites + Remove from favorites + Favorite Feed Algorithms + Feed algorithms you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favorite feed algorithms yet. Open Discover, tap one, and star it to add it here. + Asking %1$s for a feed… + Asking your favorite feed algorithms for feeds… + Processing your feed… + This feed algorithm requires payment + The feed algorithm returned an error + Retry + Log off on device lock Private Message Public Message diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt new file mode 100644 index 000000000..0e8594af8 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -0,0 +1,129 @@ +/* + * 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.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FavoriteAlgoFeedTopNavFilterTest { + private fun textNote(id: String) = TextNoteEvent(id = id, pubKey = "a".repeat(64), createdAt = 1, tags = emptyArray(), content = "", sig = "x".repeat(128)) + + private fun longFormNote( + pubkey: String, + dTag: String, + ) = LongTextNoteEvent( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = 1, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "x".repeat(128), + ) + + private val dvmAddress = Address(31990, "d".repeat(64), "content") + + @Test + fun matchesNoteWhoseIdIsInAcceptedSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = setOf("1".repeat(64)), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(textNote("1".repeat(64)))) + } + + @Test + fun rejectsNoteNotInAcceptedSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = setOf("1".repeat(64)), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertFalse(filter.match(textNote("2".repeat(64)))) + } + + @Test + fun matchesAddressableEventByAddressTag() { + val articleAuthor = "c".repeat(64) + val articleDTag = "my-post" + val articleAddress = "30023:$articleAuthor:$articleDTag" + + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = setOf(articleAddress), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(longFormNote(articleAuthor, articleDTag))) + } + + @Test + fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + // passing a LocalCache is only needed because the method demands it; + // FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it. + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds.isEmpty()) + } + + @Test + fun nonNullRequestIdProducesSingletonInFilterSet() { + val filter = + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = "9".repeat(64), + ) + + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds == setOf("9".repeat(64))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt new file mode 100644 index 000000000..5ae5d27a1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilterHomePostsByAlgoFeedIdsTest { + private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! + private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! + + @Test + fun emptyFilterSetProducesNoRequests() { + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + assertTrue(filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null).isEmpty()) + } + + @Test + fun contentFetchIssuedOnUserRelayWithIdsFilter() { + val ids = setOf("a".repeat(64), "b".repeat(64)) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val single = filters.single() + assertEquals(userRelay, single.relay) + assertEquals(ids.sorted(), single.filter.ids?.sorted()) + // Content fetch should not be restricted to a kind — the DVM curates freely. + assertEquals(null, single.filter.kinds) + } + + @Test + fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() { + val requestId = "9".repeat(64) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val listen = filters.single() + assertEquals(dvmRelay, listen.relay) + assertEquals( + listOf(NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND), + listen.filter.kinds, + ) + val eTag = listen.filter.tags?.get("e") + assertNotNull(eTag) + assertEquals(listOf(requestId), eTag) + } + + @Test + fun mergedRequestIdsAllRideOnOneListenFilterPerRelay() { + val req1 = "1".repeat(64) + val req2 = "2".repeat(64) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(req1, req2), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val eTag = + filters + .single() + .filter.tags + ?.get("e") + .orEmpty() + assertTrue(eTag.containsAll(listOf(req1, req2))) + assertEquals(2, eTag.size) + } + + @Test + fun contentAndListenSubscriptionsSplitAcrossTheirRespectiveRelays() { + val ids = setOf("a".repeat(64)) + val requestId = "9".repeat(64) + val set = + FavoriteAlgoFeedTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) + + assertEquals(2, filters.size) + assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null }) + assertTrue(filters.any { it.relay == dvmRelay && it.filter.kinds != null }) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt new file mode 100644 index 000000000..e7aaa4d56 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt @@ -0,0 +1,156 @@ +/* + * 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +@RunWith(AndroidJUnit4::class) +class ThumbHashBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + // Representative opaque landscape hash. Produced by encoding a smooth + // 32x24 warm gradient so the AC coefficients exercise the full L block. + private val warmLandscape = + run { + val w = 32 + val h = 24 + val pixels = + IntArray(w * h) { i -> + val x = i % w + val y = i / w + val r = (160 + (x * 3)) and 0xff + val g = (90 + (y * 4)) and 0xff + val b = (40 + ((x + y) * 2)) and 0xff + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + ThumbHashEncoder.encodeToBase64(pixels, w, h) + } + + // Representative alpha hash. Radial alpha vignette forces the alpha DCT + // block to carry real energy so decode cost matches production inputs. + private val alphaPortrait = + run { + val w = 24 + val h = 32 + val pixels = + IntArray(w * h) { i -> + val x = i % w + val y = i / w + val dx = x - w / 2 + val dy = y - h / 2 + val dist = kotlin.math.sqrt((dx * dx + dy * dy).toDouble()) + val maxDist = kotlin.math.sqrt((w * w / 4 + h * h / 4).toDouble()) + val alpha = (255 * (1.0 - (dist / maxDist).coerceIn(0.0, 1.0))).toInt() + (alpha shl 24) or (0x40 shl 16) or (0x80 shl 8) or 0xC0 + } + ThumbHashEncoder.encodeToBase64(pixels, w, h) + } + + private val warmBytes = Base64.decode(padded(warmLandscape)) + private val alphaBytes = Base64.decode(padded(alphaPortrait)) + + private fun padded(s: String): String { + val r = s.length % 4 + return if (r == 0) s else s + "=".repeat(4 - r) + } + + @Test + fun testAspectRatioFromBase64() { + // Warm up + ThumbHashDecoder.aspectRatio(warmLandscape) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.aspectRatio(warmLandscape) + } + } + + @Test + fun testAspectRatioFromBytes() { + ThumbHashDecoder.aspectRatio(warmBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.aspectRatio(warmBytes) + } + } + + @Test + fun testDecodeOpaqueBytes() { + // Warm up the cosine cache for this size. + ThumbHashDecoder.decode(warmBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decode(warmBytes) + } + } + + @Test + fun testDecodeWithAlphaBytes() { + ThumbHashDecoder.decode(alphaBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decode(alphaBytes) + } + } + + @Test + fun testDecodeOpaqueBase64() { + ThumbHashDecoder.decode(warmLandscape) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decode(warmLandscape) + } + } + + /** + * Measures decode cost when the cosine cache is cold on every call. + * This is the realistic "first time we see a new output size" cost; the + * cached case above represents steady-state feed scrolling. + */ + @Test + fun testDecodeOpaqueColdCache() { + ThumbHashDecoder.decode(warmBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.clearCache() + ThumbHashDecoder.decode(warmBytes) + } + } + + @Test + fun testDecodeKeepAspectRatio() { + ThumbHashDecoder.decodeKeepAspectRatio(warmLandscape, 32) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decodeKeepAspectRatio(warmLandscape, 32) + } + } +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt new file mode 100644 index 000000000..000a96e61 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.thumbhash + +import android.graphics.Bitmap +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage + +fun Bitmap.toThumbhash(): String = this.toPlatformImage().toThumbhash() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index d390a1834..e439378ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -29,6 +29,7 @@ abstract class BaseMediaContent( val description: String? = null, val dim: DimensionTag? = null, val blurhash: String? = null, + val thumbhash: String? = null, ) @Immutable @@ -40,7 +41,8 @@ abstract class MediaUrlContent( blurhash: String? = null, val uri: String? = null, val mimeType: String? = null, -) : BaseMediaContent(description, dim, blurhash) + thumbhash: String? = null, +) : BaseMediaContent(description, dim, blurhash, thumbhash) @Immutable open class MediaUrlImage( @@ -52,7 +54,8 @@ open class MediaUrlImage( uri: String? = null, val contentWarning: String? = null, mimeType: String? = null, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) + thumbhash: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash) class EncryptedMediaUrlImage( url: String, @@ -66,7 +69,20 @@ class EncryptedMediaUrlImage( val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) + thumbhash: String? = null, +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash) + +@Immutable +open class MediaUrlPdf( + url: String, + description: String? = null, + hash: String? = null, + blurhash: String? = null, + dim: DimensionTag? = null, + uri: String? = null, + mimeType: String? = null, + thumbhash: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash) @Immutable open class MediaUrlVideo( @@ -80,7 +96,8 @@ open class MediaUrlVideo( blurhash: String? = null, val contentWarning: String? = null, mimeType: String? = null, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) + thumbhash: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash) @Immutable class EncryptedMediaUrlVideo( @@ -97,7 +114,8 @@ class EncryptedMediaUrlVideo( val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) + thumbhash: String? = null, +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash) @Immutable abstract class MediaPreloadedContent( @@ -109,7 +127,8 @@ abstract class MediaPreloadedContent( blurhash: String? = null, val uri: String, val id: String? = null, -) : BaseMediaContent(description, dim, blurhash) { + thumbhash: String? = null, +) : BaseMediaContent(description, dim, blurhash, thumbhash) { fun localFileExists() = localFile != null && localFile.exists() } @@ -122,7 +141,8 @@ class MediaLocalImage( blurhash: String? = null, isVerified: Boolean? = null, uri: String, -) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri) + thumbhash: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) @Immutable class MediaLocalVideo( @@ -135,4 +155,5 @@ class MediaLocalVideo( uri: String, val artworkUri: String? = null, val authorName: String? = null, -) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri) + thumbhash: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bfc0b30cf..94f4119fe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -58,17 +59,21 @@ class RichTextParser { val isImage: Boolean val isVideo: Boolean + val isPdf: Boolean if (contentType != null) { isImage = contentType.startsWith("image/") isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") + isPdf = contentType.startsWith("application/pdf") } else if (fullUrl.startsWith("data:")) { isImage = fullUrl.startsWith("data:image/") isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/") + isPdf = fullUrl.startsWith("data:application/pdf") } else { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) } } return if (isImage) { @@ -81,6 +86,7 @@ class RichTextParser { contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), uri = callbackUri, mimeType = contentType, + thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), ) } else if (isVideo) { MediaUrlVideo( @@ -92,6 +98,18 @@ class RichTextParser { contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), uri = callbackUri, mimeType = contentType, + thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), + ) + } else if (isPdf) { + MediaUrlPdf( + url = fullUrl, + description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), + hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(), + blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(), + dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) }, + uri = callbackUri, + mimeType = contentType, + thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), ) } else { null @@ -158,6 +176,7 @@ class RichTextParser { val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys + val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys val emojiMap = CustomEmoji.createEmojiMap(tags.lists) @@ -165,7 +184,7 @@ class RichTextParser { val newContent = fixMissingSpaces(content, allUrls) - val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags) + val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags) val mediaForPagerWithBase64 = mediaForPager + @@ -197,6 +216,7 @@ class RichTextParser { content: String, images: Set, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -211,7 +231,7 @@ class RichTextParser { val segments = ArrayList(wordList.size) wordList.forEach { word -> - segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) + segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags)) } paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) @@ -262,6 +282,7 @@ class RichTextParser { word: String, images: Set, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -288,6 +309,14 @@ class RichTextParser { } } + if (pdfs.contains(word)) { + return if (urls.withoutScheme.contains(word)) { + PdfSegment("https://$word") + } else { + PdfSegment(word) + } + } + if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word) if (urls.withScheme.contains(word)) return LinkSegment(word) @@ -377,9 +406,11 @@ class RichTextParser { val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a") + val pdfExt = listOf("pdf") val imageExtensions = imageExt + imageExt.map { it.uppercase() } val videoExtensions = videoExt + videoExt.map { it.uppercase() } + val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() } val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)") val hashTagsPattern: Regex = @@ -421,6 +452,11 @@ class RichTextParser { return videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + fun isPdfUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } + } + fun isValidURL(url: String?): Boolean = try { if (url != null) { @@ -496,4 +532,6 @@ val mimeTypeMap: Map = "m4a" to "audio/mp4", "aac" to "audio/aac", "flac" to "audio/flac", + // Documents + "pdf" to "application/pdf", ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 79de431fd..c0aa426c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -62,6 +62,11 @@ class VideoSegment( segment: String, ) : Segment(segment) +@Immutable +class PdfSegment( + segment: String, +) : Segment(segment) + @Immutable class LinkSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt new file mode 100644 index 000000000..30bc6fd3b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.thumbhash + +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.round + +/** + * ThumbHash decoder. + * + * Port of the reference implementation by Evan Wallace + * (https://github.com/evanw/thumbhash, public domain), with performance + * optimisations for the decode hot path: + * + * - cosine tables for the inverse DCT are precomputed once per decode and + * cached across decodes keyed by `(size, componentCount)`; the reference + * JS impl recomputes them for every single output pixel. + * - AC coefficients are unpacked into fixed-size `DoubleArray`s, avoiding + * `ArrayList` boxing and the final array copy. + * - The LPQA → sRGB conversion uses an inline branch clamp instead of + * `min/max/round/coerceIn` chains. + */ +object ThumbHashDecoder { + // Cosine tables are small and decoded sizes repeat heavily in practice + // (every Coil request at a given target width shares the same table). + // Keep an unbounded map — there are at most a few dozen distinct + // (size, components) pairs across the entire app lifetime, each table is + // a few KB, so the memory ceiling is tiny. + private val cosineCache = HashMap() + private val cosineCacheLock = Any() + + /** + * Clear the cosine table cache. Tables are tiny but callers under memory + * pressure can release them; they will be recomputed on demand. + */ + fun clearCache() { + synchronized(cosineCacheLock) { cosineCache.clear() } + } + + private fun cosTable( + size: Int, + components: Int, + ): DoubleArray { + val key = (size.toLong() shl 32) or components.toLong() + synchronized(cosineCacheLock) { + cosineCache[key]?.let { return it } + } + val table = DoubleArray(size * components) + val piOverSize = PI / size + for (i in 0 until size) { + val phase = piOverSize * (i + 0.5) + val rowOffset = i * components + for (c in 0 until components) { + table[rowOffset + c] = cos(phase * c) + } + } + synchronized(cosineCacheLock) { + cosineCache.getOrPut(key) { table } + } + return table + } + + /** + * Returns width/height. Returns null if the hash is malformed. + */ + fun aspectRatio(hash: ByteArray): Float? { + if (hash.size < 5) return null + val header = hash[3].toInt() and 0xff + val hasAlpha = (hash[2].toInt() and 0x80) != 0 + val isLandscape = (hash[4].toInt() and 0x80) != 0 + val lx = if (isLandscape) (if (hasAlpha) 5 else 7) else (header and 7) + val ly = if (isLandscape) (header and 7) else (if (hasAlpha) 5 else 7) + if (lx == 0 || ly == 0) return null + return lx.toFloat() / ly.toFloat() + } + + /** + * Returns width/height. Returns null if the string is malformed. + */ + @OptIn(ExperimentalEncodingApi::class) + fun aspectRatio(base64Hash: String?): Float? { + if (base64Hash.isNullOrBlank()) return null + return try { + aspectRatio(Base64.decode(padBase64(base64Hash))) + } catch (_: IllegalArgumentException) { + null + } + } + + data class RGBAImage( + val width: Int, + val height: Int, + val pixels: IntArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RGBAImage) return false + return width == other.width && height == other.height && pixels.contentEquals(other.pixels) + } + + override fun hashCode(): Int { + var result = width + result = 31 * result + height + result = 31 * result + pixels.contentHashCode() + return result + } + } + + /** + * Decode a ThumbHash byte array into ARGB pixels. + * Returns null if the hash is malformed. + */ + fun decode(hash: ByteArray): RGBAImage? { + if (hash.size < 5) return null + + val b0 = hash[0].toInt() and 0xff + val b1 = hash[1].toInt() and 0xff + val b2 = hash[2].toInt() and 0xff + val b3 = hash[3].toInt() and 0xff + val b4 = hash[4].toInt() and 0xff + val header24 = b0 or (b1 shl 8) or (b2 shl 16) + val header16 = b3 or (b4 shl 8) + + val lDc = (header24 and 63) / 63.0 + val pDc = ((header24 shr 6) and 63) / 31.5 - 1.0 + val qDc = ((header24 shr 12) and 63) / 31.5 - 1.0 + val lScale = ((header24 shr 18) and 31) / 31.0 + val hasAlpha = (header24 shr 23) and 1 == 1 + val pScale = ((header16 shr 3) and 63) / 63.0 + val qScale = ((header16 shr 9) and 63) / 63.0 + val isLandscape = (header16 shr 15) and 1 == 1 + val lx = max(3, if (isLandscape) (if (hasAlpha) 5 else 7) else (header16 and 7)) + val ly = max(3, if (isLandscape) (header16 and 7) else (if (hasAlpha) 5 else 7)) + + val aDc: Double + val aScale: Double + if (hasAlpha) { + if (hash.size < 6) return null + aDc = (hash[5].toInt() and 15) / 15.0 + aScale = ((hash[5].toInt() shr 4) and 15) / 15.0 + } else { + aDc = 1.0 + aScale = 0.0 + } + + // Pre-size and unpack AC coefficients + val lAcCount = countAc(lx, ly) + val pqAcCount = countAc(3, 3) + val aAcCount = if (hasAlpha) countAc(5, 5) else 0 + val totalAc = lAcCount + pqAcCount * 2 + aAcCount + val acStart = if (hasAlpha) 6 else 5 + val acBytesAvailable = hash.size - acStart + // 2 coefficients per byte + if (acBytesAvailable * 2 < totalAc) return null + + val lAc = DoubleArray(lAcCount) + val pAc = DoubleArray(pqAcCount) + val qAc = DoubleArray(pqAcCount) + val aAc = if (hasAlpha) DoubleArray(aAcCount) else EMPTY_DOUBLE + + var acIndex = 0 + acIndex = readAcInto(hash, acStart, acIndex, lx, ly, lScale, lAc) + acIndex = readAcInto(hash, acStart, acIndex, 3, 3, pScale * 1.25, pAc) + acIndex = readAcInto(hash, acStart, acIndex, 3, 3, qScale * 1.25, qAc) + if (hasAlpha) readAcInto(hash, acStart, acIndex, 5, 5, aScale, aAc) + + // Output size + val ratio = lx.toDouble() / ly.toDouble() + val w = round(if (ratio > 1) 32.0 else 32.0 * ratio).toInt() + val h = round(if (ratio > 1) 32.0 / ratio else 32.0).toInt() + val pixels = IntArray(w * h) + + // Precomputed cosine tables (shared across decodes with matching size/components) + val cosXL = cosTable(w, lx) + val cosYL = cosTable(h, ly) + val cosXPQ = cosTable(w, 3) + val cosYPQ = cosTable(h, 3) + val cosXA: DoubleArray + val cosYA: DoubleArray + if (hasAlpha) { + cosXA = cosTable(w, 5) + cosYA = cosTable(h, 5) + } else { + cosXA = EMPTY_DOUBLE + cosYA = EMPTY_DOUBLE + } + + // Decode pixels using the inverse DCT + var pixelIdx = 0 + for (y in 0 until h) { + val cosYLBase = y * ly + val cosYPQBase = y * 3 + val cosYABase = y * 5 + for (x in 0 until w) { + val cosXLBase = x * lx + val cosXPQBase = x * 3 + val cosXABase = x * 5 + + var l = lDc + var p = pDc + var q = qDc + var a = aDc + + // L channel — triangular iteration over (cx, cy) + var j = 0 + var cy = 0 + while (cy < ly) { + val fyL2 = cosYL[cosYLBase + cy] * 2.0 + var cx = if (cy != 0) 0 else 1 + val cxLimit = cxLimitForL(lx, ly, cy) + while (cx < cxLimit) { + l += lAc[j] * cosXL[cosXLBase + cx] * fyL2 + j++ + cx++ + } + cy++ + } + + // P and Q share the same 3x3 triangular iteration + j = 0 + cy = 0 + while (cy < 3) { + val fyPQ2 = cosYPQ[cosYPQBase + cy] * 2.0 + var cx = if (cy != 0) 0 else 1 + val cxLimit = 3 - cy + while (cx < cxLimit) { + val f = cosXPQ[cosXPQBase + cx] * fyPQ2 + p += pAc[j] * f + q += qAc[j] * f + j++ + cx++ + } + cy++ + } + + // Alpha channel + if (hasAlpha) { + j = 0 + cy = 0 + while (cy < 5) { + val fyA2 = cosYA[cosYABase + cy] * 2.0 + var cx = if (cy != 0) 0 else 1 + val cxLimit = 5 - cy + while (cx < cxLimit) { + a += aAc[j] * cosXA[cosXABase + cx] * fyA2 + j++ + cx++ + } + cy++ + } + } + + // LPQA → sRGB with inline clamp + val bCh = l - 2.0 / 3.0 * p + val rCh = (3.0 * l - bCh + q) * 0.5 + val gCh = rCh - q + val rOut = clamp255(rCh) + val gOut = clamp255(gCh) + val bOut = clamp255(bCh) + val aOut = if (hasAlpha) clamp255(a) else 255 + pixels[pixelIdx++] = (aOut shl 24) or (rOut shl 16) or (gOut shl 8) or bOut + } + } + + return RGBAImage(w, h, pixels) + } + + /** + * Decode a base64-encoded ThumbHash string to ARGB pixels. + */ + @OptIn(ExperimentalEncodingApi::class) + fun decode(base64Hash: String?): RGBAImage? { + if (base64Hash.isNullOrBlank()) return null + return try { + decode(Base64.decode(padBase64(base64Hash))) + } catch (_: IllegalArgumentException) { + null + } + } + + /** + * Decode a ThumbHash string into a [PlatformImage] whose aspect ratio + * matches the original image. [targetWidth] is accepted for API symmetry + * with [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio] + * but the intrinsic decode output size is used because ThumbHash's own + * reconstruction is already aspect-correct at ~32px. + */ + @Suppress("UNUSED_PARAMETER") + fun decodeKeepAspectRatio( + hash: String?, + targetWidth: Int, + ): PlatformImage? { + val rgba = decode(hash) ?: return null + return PlatformImage.create(rgba.pixels, rgba.width, rgba.height) + } + + // --- internal helpers --- // + + private val EMPTY_DOUBLE = DoubleArray(0) + + /** + * Count the number of AC coefficients carried by a channel of size nx × ny, + * following the reference implementation's triangular traversal. + */ + private fun countAc( + nx: Int, + ny: Int, + ): Int { + var count = 0 + var cy = 0 + while (cy < ny) { + var cx = if (cy != 0) 0 else 1 + while (cx * ny < nx * (ny - cy)) { + count++ + cx++ + } + cy++ + } + return count + } + + /** + * Row limit for the L channel's triangular traversal. For nx == ny this + * collapses to `nx - cy`; keeping the explicit form avoids a mispredicted + * branch in the inner loop for non-square L blocks. + */ + private fun cxLimitForL( + lx: Int, + ly: Int, + cy: Int, + ): Int { + // cx * ly < lx * (ly - cy) ⇔ cx < (lx * (ly - cy)) / ly + // Use integer ceil emulation: smallest cx that fails the condition. + val numerator = lx * (ly - cy) + // Largest cx satisfying cx * ly < numerator: + // cx <= ceil(numerator / ly) - 1 when numerator is exact, + // otherwise cx <= floor(numerator / ly). + // So the limit (exclusive) is ceil(numerator / ly) when numerator % ly != 0, + // else numerator / ly. + return if (numerator % ly == 0) numerator / ly else numerator / ly + 1 + } + + private fun readAcInto( + hash: ByteArray, + acStart: Int, + startIndex: Int, + nx: Int, + ny: Int, + scale: Double, + out: DoubleArray, + ): Int { + var acIndex = startIndex + var outIdx = 0 + val hashLen = hash.size + var cy = 0 + while (cy < ny) { + var cx = if (cy != 0) 0 else 1 + while (cx * ny < nx * (ny - cy)) { + val byteIdx = acStart + (acIndex shr 1) + if (byteIdx >= hashLen) return acIndex + val shift = (acIndex and 1) shl 2 + val q4 = (hash[byteIdx].toInt() ushr shift) and 15 + out[outIdx++] = (q4 / 7.5 - 1.0) * scale + acIndex++ + cx++ + } + cy++ + } + return acIndex + } + + /** Clamp v into 0..1 and scale to 0..255 with rounding, branchlessly on the hot path. */ + private fun clamp255(v: Double): Int { + if (v <= 0.0) return 0 + if (v >= 1.0) return 255 + return (v * 255.0 + 0.5).toInt() + } + + private fun padBase64(s: String): String { + val remainder = s.length % 4 + return if (remainder == 0) s else s + "=".repeat(4 - remainder) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt new file mode 100644 index 000000000..83acbd2c7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.thumbhash + +import kotlin.io.encoding.Base64 +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.round + +/** + * ThumbHash encoder. + * + * Port of the reference implementation by Evan Wallace + * (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin. + * + * Input pixels are ARGB (0xAARRGGBB) to match [com.vitorpamplona.amethyst.commons.blurhash.PlatformImage.getPixels]. + */ +object ThumbHashEncoder { + /** + * Encodes an ARGB image to a ThumbHash byte array. + * + * @param pixels ARGB pixels (0xAARRGGBB), row-by-row; must contain [width] * [height] entries. + * @param width Image width in pixels; must be ≤ 100. + * @param height Image height in pixels; must be ≤ 100. + */ + fun encode( + pixels: IntArray, + width: Int, + height: Int, + ): ByteArray { + require(width in 1..100 && height in 1..100) { "ThumbHash input must be ≤100x100 (got ${width}x$height)" } + require(pixels.size == width * height) { "pixels.size must equal width * height" } + + // Average color (premultiplied by alpha) + var avgR = 0.0 + var avgG = 0.0 + var avgB = 0.0 + var avgA = 0.0 + for (i in 0 until width * height) { + val argb = pixels[i] + val alpha = ((argb ushr 24) and 0xff) / 255.0 + val r = (argb ushr 16) and 0xff + val g = (argb ushr 8) and 0xff + val b = argb and 0xff + avgR += alpha / 255.0 * r + avgG += alpha / 255.0 * g + avgB += alpha / 255.0 * b + avgA += alpha + } + if (avgA > 0.0) { + avgR /= avgA + avgG /= avgA + avgB /= avgA + } + + val hasAlpha = avgA < width * height + val lLimit = if (hasAlpha) 5 else 7 + val lx = max(1, round(lLimit * width.toDouble() / max(width, height)).toInt()) + val ly = max(1, round(lLimit * height.toDouble() / max(width, height)).toInt()) + + val size = width * height + val l = DoubleArray(size) // luminance + val p = DoubleArray(size) // yellow - blue + val q = DoubleArray(size) // red - green + val a = DoubleArray(size) // alpha + + // Convert ARGB to LPQA, composited over the average color + for (i in 0 until size) { + val argb = pixels[i] + val alpha = ((argb ushr 24) and 0xff) / 255.0 + val rPx = (argb ushr 16) and 0xff + val gPx = (argb ushr 8) and 0xff + val bPx = argb and 0xff + val r = avgR * (1 - alpha) + alpha / 255.0 * rPx + val g = avgG * (1 - alpha) + alpha / 255.0 * gPx + val b = avgB * (1 - alpha) + alpha / 255.0 * bPx + l[i] = (r + g + b) / 3.0 + p[i] = (r + g) / 2.0 - b + q[i] = r - g + a[i] = alpha + } + + val lEnc = encodeChannel(l, width, height, max(3, lx), max(3, ly)) + val pEnc = encodeChannel(p, width, height, 3, 3) + val qEnc = encodeChannel(q, width, height, 3, 3) + val aEnc = if (hasAlpha) encodeChannel(a, width, height, 5, 5) else null + + val isLandscape = width > height + val lCount = if (isLandscape) ly else lx + val hasAlphaBit = if (hasAlpha) 1 else 0 + val isLandscapeBit = if (isLandscape) 1 else 0 + + val header24 = + round(63.0 * lEnc.dc).toInt() or + (round(31.5 + 31.5 * pEnc.dc).toInt() shl 6) or + (round(31.5 + 31.5 * qEnc.dc).toInt() shl 12) or + (round(31.0 * lEnc.scale).toInt() shl 18) or + (hasAlphaBit shl 23) + val header16 = + lCount or + (round(63.0 * pEnc.scale).toInt() shl 3) or + (round(63.0 * qEnc.scale).toInt() shl 9) or + (isLandscapeBit shl 15) + + val acChannels = if (hasAlpha) listOf(lEnc.ac, pEnc.ac, qEnc.ac, aEnc!!.ac) else listOf(lEnc.ac, pEnc.ac, qEnc.ac) + val totalAc = acChannels.sumOf { it.size } + val acStart = if (hasAlpha) 6 else 5 + val hashSize = acStart + ((totalAc + 1) / 2) + val hash = ByteArray(hashSize) + hash[0] = (header24 and 0xff).toByte() + hash[1] = ((header24 ushr 8) and 0xff).toByte() + hash[2] = ((header24 ushr 16) and 0xff).toByte() + hash[3] = (header16 and 0xff).toByte() + hash[4] = ((header16 ushr 8) and 0xff).toByte() + + if (hasAlpha) { + val aDcQ = round(15.0 * aEnc!!.dc).toInt() and 0xf + val aScaleQ = round(15.0 * aEnc.scale).toInt() and 0xf + hash[5] = (aDcQ or (aScaleQ shl 4)).toByte() + } + + var acIndex = 0 + for (ac in acChannels) { + for (f in ac) { + val q4 = round(15.0 * f).toInt() and 0xf + val byteIdx = acStart + (acIndex shr 1) + val shift = (acIndex and 1) shl 2 + hash[byteIdx] = (hash[byteIdx].toInt() or (q4 shl shift)).toByte() + acIndex++ + } + } + + return hash + } + + /** + * Encodes an ARGB image to a base64 ThumbHash string (no padding). + */ + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + fun encodeToBase64( + pixels: IntArray, + width: Int, + height: Int, + ): String = Base64.encode(encode(pixels, width, height)).trimEnd('=') + + private data class ChannelEncoded( + val dc: Double, + val ac: DoubleArray, + val scale: Double, + ) + + private fun encodeChannel( + channel: DoubleArray, + w: Int, + h: Int, + nx: Int, + ny: Int, + ): ChannelEncoded { + var dc = 0.0 + var scale = 0.0 + val acList = ArrayList((nx * ny)) + val fx = DoubleArray(w) + + var cy = 0 + while (cy < ny) { + var cx = 0 + while (cx * ny < nx * (ny - cy)) { + var f = 0.0 + for (x in 0 until w) { + fx[x] = cos(PI / w * cx * (x + 0.5)) + } + for (y in 0 until h) { + val fy = cos(PI / h * cy * (y + 0.5)) + for (x in 0 until w) { + f += channel[x + y * w] * fx[x] * fy + } + } + f /= (w * h).toDouble() + if (cx != 0 || cy != 0) { + acList.add(f) + if (abs(f) > scale) scale = abs(f) + } else { + dc = f + } + cx++ + } + cy++ + } + + val ac = DoubleArray(acList.size) { acList[it] } + if (scale != 0.0) { + for (i in ac.indices) { + ac[i] = 0.5 + 0.5 / scale * ac[i] + } + } + return ChannelEncoded(dc, ac, scale) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt new file mode 100644 index 000000000..eb58f4469 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.thumbhash + +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage + +/** + * Encodes this [PlatformImage] to a base64 ThumbHash string (no padding). + * + * ThumbHash is specified at ≤100x100. Larger images are downscaled first. + */ +fun PlatformImage.toThumbhash(): String { + val source = + if (width > 100 || height > 100) { + val aspect = width.toDouble() / height.toDouble() + val scaled = + if (width >= height) { + val w = 100 + val h = (100.0 / aspect).toInt().coerceAtLeast(1) + this.scale(w, h) + } else { + val h = 100 + val w = (100.0 * aspect).toInt().coerceAtLeast(1) + this.scale(w, h) + } + scaled + } else { + this + } + + val pixels = IntArray(source.width * source.height) + source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height) + return ThumbHashEncoder.encodeToBase64(pixels, source.width, source.height) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt new file mode 100644 index 000000000..a152963ec --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons + +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +class ThumbHashTest { + @Test + fun `encode and decode a solid color produces a hash with matching aspect`() { + val w = 32 + val h = 24 + val pixels = IntArray(w * h) { 0xFFFF8040.toInt() } // opaque warm orange + + val hashBytes = ThumbHashEncoder.encode(pixels, w, h) + assertTrue("hash should have at least header bytes", hashBytes.size >= 5) + + val decoded = ThumbHashDecoder.decode(hashBytes) + assertNotNull(decoded) + decoded!! + assertTrue("decoded width should be positive", decoded.width > 0) + assertTrue("decoded height should be positive", decoded.height > 0) + + val originalRatio = w.toFloat() / h.toFloat() + val decodedRatio = decoded.width.toFloat() / decoded.height.toFloat() + // ThumbHash loses some precision, but landscape vs portrait should be preserved. + assertTrue( + "decoded ratio ($decodedRatio) should be on the same side of 1 as original ($originalRatio)", + (originalRatio > 1f) == (decodedRatio > 1f) || originalRatio == decodedRatio, + ) + } + + @Test + fun `base64 round-trip preserves decoded dimensions`() { + val w = 40 + val h = 40 + // A simple gradient so the image isn't entirely flat. + val pixels = + IntArray(w * h) { i -> + val x = i % w + val y = i / w + val r = (x * 255 / (w - 1)) + val g = (y * 255 / (h - 1)) + (0xFF shl 24) or (r shl 16) or (g shl 8) or 0x40 + } + + val encoded = ThumbHashEncoder.encodeToBase64(pixels, w, h) + assertTrue("base64 string should be non-empty", encoded.isNotEmpty()) + assertTrue("base64 string should not contain padding", !encoded.contains('=')) + + val viaBase64 = ThumbHashDecoder.decode(encoded) + assertNotNull(viaBase64) + viaBase64!! + + val viaBytes = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h)) + assertNotNull(viaBytes) + viaBytes!! + + assertEquals("base64 path and raw path should agree on width", viaBytes.width, viaBase64.width) + assertEquals("base64 path and raw path should agree on height", viaBytes.height, viaBase64.height) + } + + @Test + fun `decoding a malformed hash returns null`() { + assertNull(ThumbHashDecoder.decode(ByteArray(3))) + assertNull(ThumbHashDecoder.decode(null as String?)) + assertNull(ThumbHashDecoder.decode("")) + } + + @Test + fun `decoded opaque image has fully opaque alpha`() { + val w = 32 + val h = 32 + val pixels = IntArray(w * h) { 0xFF8080FF.toInt() } // opaque cornflower-ish + val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h)) + assertNotNull(decoded) + decoded!! + for (p in decoded.pixels) { + val a = (p ushr 24) and 0xff + assertEquals("alpha should be 255 for opaque encode", 255, a) + } + } + + @Test + fun `decoded transparent image preserves alpha channel`() { + val w = 32 + val h = 32 + // Fully transparent pixels everywhere. + val pixels = IntArray(w * h) { 0x00000000 } + val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h)) + assertNotNull(decoded) + decoded!! + // The average alpha is 0, so every decoded alpha should be at or near 0. + var maxAlpha = 0 + for (p in decoded.pixels) { + val a = (p ushr 24) and 0xff + if (a > maxAlpha) maxAlpha = a + } + assertTrue("max alpha of all-transparent decode should be low; got $maxAlpha", maxAlpha <= 16) + } + + @Test + fun `decoded average color is close to input average`() { + val w = 48 + val h = 32 + val target = intArrayOf(200, 120, 60) // warm orange + val pixels = + IntArray(w * h) { + (0xFF shl 24) or (target[0] shl 16) or (target[1] shl 8) or target[2] + } + val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h)) + assertNotNull(decoded) + decoded!! + + var sumR = 0 + var sumG = 0 + var sumB = 0 + for (p in decoded.pixels) { + sumR += (p shr 16) and 0xff + sumG += (p shr 8) and 0xff + sumB += p and 0xff + } + val count = decoded.pixels.size + val avgR = sumR / count + val avgG = sumG / count + val avgB = sumB / count + + // ThumbHash quantisation allows a handful of codepoints of drift. + assertTrue("avg R drift: expected ${target[0]}, got $avgR", abs(avgR - target[0]) < 8) + assertTrue("avg G drift: expected ${target[1]}, got $avgG", abs(avgG - target[1]) < 8) + assertTrue("avg B drift: expected ${target[2]}, got $avgB", abs(avgB - target[2]) < 8) + } + + @Test + fun `aspect ratio matches landscape input`() { + val w = 60 + val h = 30 + val pixels = IntArray(w * h) { 0xFF446688.toInt() } + val hash = ThumbHashEncoder.encode(pixels, w, h) + val ratio = ThumbHashDecoder.aspectRatio(hash) + assertNotNull(ratio) + assertTrue("landscape ratio should be > 1, got $ratio", ratio!! > 1f) + } + + @Test + fun `aspect ratio matches portrait input`() { + val w = 30 + val h = 60 + val pixels = IntArray(w * h) { 0xFF446688.toInt() } + val hash = ThumbHashEncoder.encode(pixels, w, h) + val ratio = ThumbHashDecoder.aspectRatio(hash) + assertNotNull(ratio) + assertTrue("portrait ratio should be < 1, got $ratio", ratio!! < 1f) + } + + @Test + fun `repeated decodes produce identical output (cosine cache determinism)`() { + val w = 40 + val h = 30 + val pixels = + IntArray(w * h) { i -> + val x = i % w + (0xFF shl 24) or (x * 6 shl 16) or ((i % 255) shl 8) or ((i * 3) and 0xff) + } + val hash = ThumbHashEncoder.encode(pixels, w, h) + + val first = ThumbHashDecoder.decode(hash) + val second = ThumbHashDecoder.decode(hash) + val third = ThumbHashDecoder.decode(hash) + assertNotNull(first) + assertNotNull(second) + assertNotNull(third) + + // Bit-exact: the cached cosine tables must produce identical output. + assertEquals(first, second) + assertEquals(first, third) + } + + @Test + fun `clearCache does not affect correctness of subsequent decodes`() { + val w = 32 + val h = 32 + val pixels = IntArray(w * h) { 0xFFAABBCC.toInt() } + val hash = ThumbHashEncoder.encode(pixels, w, h) + + val before = ThumbHashDecoder.decode(hash) + ThumbHashDecoder.clearCache() + val after = ThumbHashDecoder.decode(hash) + assertEquals(before, after) + } + + @Test + fun `truncated AC payload returns null`() { + val w = 32 + val h = 32 + val pixels = IntArray(w * h) { 0xFF336699.toInt() } + val fullHash = ThumbHashEncoder.encode(pixels, w, h) + // Chop off half the AC payload. + val truncated = fullHash.copyOfRange(0, 5 + (fullHash.size - 5) / 4) + assertNull( + "hash with insufficient AC bytes should be rejected", + ThumbHashDecoder.decode(truncated), + ) + } + + @Test + fun `decoded output size stays within 32 x 32 bounds`() { + val w = 50 + val h = 40 + val pixels = + IntArray(w * h) { i -> + (0xFF shl 24) or ((i and 0xff) shl 16) or (((i * 2) and 0xff) shl 8) or ((i * 3) and 0xff) + } + val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h)) + assertNotNull(decoded) + decoded!! + assertTrue( + "expected output to fit in 32x32, got ${decoded.width}x${decoded.height}", + decoded.width in 1..32 && decoded.height in 1..32, + ) + assertEquals( + "pixel buffer size must match dimensions", + decoded.width * decoded.height, + decoded.pixels.size, + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt new file mode 100644 index 000000000..039d8b4e2 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -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.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfParserTest { + @Test + fun detectsPdfByExtension() { + val url = "https://example.com/docs/paper.pdf" + val state = RichTextParser().parseText(url, EmptyTagList, null) + + val pdfMedia = state.mediaForPager[url] + assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL") + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}") + assertEquals(url, segment.segmentText) + } + + @Test + fun detectsPdfFromImetaMimeTypeWithoutExtension() { + val url = "https://files.example.com/abcd1234" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m application/pdf"), + ), + ) + + val state = RichTextParser().parseText(url, tags, null) + + val pdfMedia = state.mediaForPager[url] + assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag") + assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType) + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}") + } + + @Test + fun isPdfUrlHelperMatchesPdfExtension() { + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf")) + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF")) + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc")) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt new file mode 100644 index 000000000..0d9569139 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.thumbhash + +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import java.awt.image.BufferedImage + +fun BufferedImage.toThumbhash(): String = this.toPlatformImage().toThumbhash() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt index 0a72468f1..2076c7309 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -60,8 +60,10 @@ object DesktopImageLoaderSetup { add(SkiaGifDecoder.Factory()) add(DesktopBase64Fetcher.Factory) add(DesktopBlurHashFetcher.Factory) + add(DesktopThumbHashFetcher.Factory) add(DesktopBase64Fetcher.BKeyer) add(DesktopBlurHashFetcher.BKeyer) + add(DesktopThumbHashFetcher.TKeyer) }.build() private fun newMemoryCache(): MemoryCache { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt new file mode 100644 index 000000000..bf5f7cc42 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt @@ -0,0 +1,86 @@ +/* + * 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.desktop.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.toBufferedImage +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder + +data class ThumbhashWrapper( + val thumbhash: String, +) + +@Stable +class DesktopThumbHashFetcher( + private val data: ThumbhashWrapper, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val hash = data.thumbhash + val platformImage = ThumbHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + val bufferedImage = platformImage.toBufferedImage() + val bitmap = bufferedImageToSkiaBitmap(bufferedImage) + + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: ThumbhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = DesktopThumbHashFetcher(data) + } + + object TKeyer : Keyer { + override fun key( + data: ThumbhashWrapper, + options: Options, + ): String = data.thumbhash + } +} + +/** + * Pick the best Coil model for a media placeholder on Desktop. + * + * 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. + */ +fun placeholderModel( + thumbhash: String?, + blurhash: String?, +): Any? = + when { + !thumbhash.isNullOrEmpty() -> ThumbhashWrapper(thumbhash) + !blurhash.isNullOrEmpty() -> BlurhashWrapper(blurhash) + else -> null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt index d43b3cfd3..7981cb3a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.upload import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File @@ -34,6 +35,7 @@ data class MediaMetadata( val width: Int? = null, val height: Int? = null, val blurhash: String? = null, + val thumbhash: String? = null, ) object DesktopMediaMetadata { @@ -44,6 +46,7 @@ object DesktopMediaMetadata { var width: Int? = null var height: Int? = null var blurhash: String? = null + var thumbhash: String? = null if (mimeType.startsWith("image/")) { try { @@ -51,7 +54,9 @@ object DesktopMediaMetadata { if (image != null) { width = image.width height = image.height - blurhash = image.toPlatformImage().toBlurhash() + val platformImage = image.toPlatformImage() + blurhash = runCatching { platformImage.toBlurhash() }.getOrNull() + thumbhash = runCatching { platformImage.toThumbhash() }.getOrNull() } } catch (_: Exception) { } @@ -64,6 +69,7 @@ object DesktopMediaMetadata { width = width, height = height, blurhash = blurhash, + thumbhash = thumbhash, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 0c955558e..24f61ea9b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -358,6 +358,7 @@ private fun buildIMetaTags(results: List): List = props["dim"] = listOf("${meta.width}x${meta.height}") } meta.blurhash?.let { props["blurhash"] = listOf(it) } + meta.thumbhash?.let { props["thumbhash"] = listOf(it) } IMetaTag(url = url, properties = props) } @@ -460,6 +461,7 @@ private fun buildPictureMetas(results: List): List.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) @@ -45,6 +46,8 @@ fun TagArrayBuilder.dimension(dim: DimensionTag) = add(D fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt index fc3d0fc90..7b11a3ec3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -70,6 +71,8 @@ class ProfileGalleryEntryEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt index eb9f0b9fa..316cd61c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @@ -51,6 +52,8 @@ fun TagArrayBuilder.dimension(dim: DimensionTag) = add fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt index 08b80a230..7f78d36dd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt @@ -166,6 +166,7 @@ fun buildMip04IMetaTag( nonce: ByteArray, dimensions: String? = null, blurhash: String? = null, + thumbhash: String? = null, ): IMetaTag = IMetaTagBuilder(url) .apply { @@ -176,4 +177,5 @@ fun buildMip04IMetaTag( add(Mip04Fields.VERSION, Mip04MediaEncryption.VERSION) dimensions?.let { add(Mip04Fields.DIMENSIONS, it) } blurhash?.let { add(Mip04Fields.BLURHASH, it) } + thumbhash?.let { add(Mip04Fields.THUMBHASH, it) } }.build() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt index c9b192372..b1742a042 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.ciphers.AESGCM @@ -65,6 +66,8 @@ class ChatMessageEncryptedFileHeaderEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) + fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse) fun algo() = tags.firstNotNullOfOrNull(EncryptionAlgo::parse) @@ -87,6 +90,7 @@ class ChatMessageEncryptedFileHeaderEvent( size: Int? = null, dimension: DimensionTag? = null, blurhash: String? = null, + thumbhash: String? = null, originalHash: String? = null, magnetUri: String? = null, torrentInfoHash: String? = null, @@ -108,6 +112,7 @@ class ChatMessageEncryptedFileHeaderEvent( mimeType?.let { mimeType(it) } dimension?.let { dimension(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } originalHash?.let { originalHash(it) } magnetUri?.let { magnet(it) } torrentInfoHash?.let { torrentInfohash(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt index c92afc3ae..858f8723c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash fun TagArrayBuilder.reply(msg: MarkedETag) = add(msg.toTagArray()) @@ -70,6 +71,8 @@ fun TagArrayBuilder.dimension(dim: Dimensio fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt new file mode 100644 index 000000000..d4b673582 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -0,0 +1,192 @@ +/* + * 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.quartz.nip51Lists.favoriteAlgoFeedsList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FavoriteAlgoFeedsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavoriteAlgoFeeds(): List = tags.mapNotNull(AddressBookmark::parse) + + suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + + companion object { + const val KIND = 10090 + const val ALT = "Favorite algo-feeds list" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + feed: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent = + if (isPrivate) { + create( + publicFeeds = emptyList(), + privateFeeds = listOf(feed), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicFeeds = listOf(feed), + privateFeeds = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent { + val idOnly = AddressBookmark.assemble(feed, null) + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteAlgoFeedsListEvent { + val template = build(publicFeeds, privateFeeds, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = + PrivateTagsInContent.encryptNip44( + privateFeeds.map { it.toTagArray() }.toTypedArray(), + signer, + ), + createdAt = createdAt, + ) { + alt(ALT) + favoriteAlgoFeeds(publicFeeds) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt new file mode 100644 index 000000000..4214d7627 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.nip51Lists.favoriteAlgoFeedsList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArrayBuilder.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray()) + +fun TagArrayBuilder.favoriteAlgoFeeds(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt new file mode 100644 index 000000000..5024bb91e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.nip51Lists.favoriteAlgoFeedsList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress) + +fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt index fe5f01e65..451de1943 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt @@ -25,10 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -80,13 +81,18 @@ class AcceptedBadgeSetEvent( acceptedBadges: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - dTag(STANDARD_D_TAG) - alt(ALT_DESCRIPTION) - if (acceptedBadges.isNotEmpty()) { - acceptedBadges(acceptedBadges) - } - initializer() + ): EventTemplate { + // TagArrayBuilder groups tags by name, which would scramble the + // alternating a/e pairs NIP-58 requires. Build the prefix tags via + // the builder, then append the ordered pair tags verbatim. + val prefix = + tagArray { + dTag(STANDARD_D_TAG) + alt(ALT_DESCRIPTION) + initializer() + } + val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray() + return EventTemplate(createdAt, KIND, prefix + pairs, "") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt index 1680530b6..e683cc51c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/ProfileBadgesEvent.kt @@ -25,10 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -78,12 +79,17 @@ class ProfileBadgesEvent( acceptedBadges: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - alt(ALT_DESCRIPTION) - if (acceptedBadges.isNotEmpty()) { - acceptedBadges(acceptedBadges) - } - initializer() + ): EventTemplate { + // TagArrayBuilder groups tags by name, which would scramble the + // alternating a/e pairs NIP-58 requires. Build the prefix tags via + // the builder, then append the ordered pair tags verbatim. + val prefix = + tagArray { + alt(ALT_DESCRIPTION) + initializer() + } + val pairs = AcceptedBadge.assemble(acceptedBadges).toTypedArray() + return EventTemplate(createdAt, KIND, prefix + pairs, "") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt index 0f45ce95e..ee2d2b844 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -56,6 +57,8 @@ fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.t fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) +fun IMetaTagBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.TAG_NAME, thumbhash) + fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt index 8695598d5..8b8a3dc4d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -55,6 +56,8 @@ fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME) fun IMetaTag.blurhash() = properties.get(BlurhashTag.TAG_NAME) +fun IMetaTag.thumbhash() = properties.get(ThumbhashTag.TAG_NAME) + fun IMetaTag.originalHash() = properties.get(OriginalHashTag.TAG_NAME) fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt index 6f8c6b48a..214f3b2ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt @@ -36,6 +36,7 @@ data class PictureMeta( val service: String? = null, val fallback: List = emptyList(), val annotations: List = emptyList(), + val thumbhash: String? = null, ) { fun toIMetaArray(): Array = IMetaTagBuilder(url) @@ -46,6 +47,7 @@ data class PictureMeta( size?.let { size(it) } dimension?.let { dims(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } service?.let { service(it) } fallback.forEach { fallback(it) } annotations.forEach { userAnnotations(it) } @@ -55,16 +57,17 @@ data class PictureMeta( companion object { fun parse(iMeta: IMetaTag): PictureMeta = PictureMeta( - iMeta.url, - iMeta.mimeType()?.firstOrNull(), - iMeta.blurhash()?.firstOrNull(), - iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, - iMeta.alt()?.firstOrNull(), - iMeta.hash()?.firstOrNull(), - iMeta.size()?.firstOrNull()?.toIntOrNull(), - iMeta.service()?.firstOrNull(), - iMeta.fallback() ?: emptyList(), - iMeta.userAnnotations() ?: emptyList(), + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + blurhash = iMeta.blurhash()?.firstOrNull(), + dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + alt = iMeta.alt()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + size = iMeta.size()?.firstOrNull()?.toIntOrNull(), + service = iMeta.service()?.firstOrNull(), + fallback = iMeta.fallback() ?: emptyList(), + annotations = iMeta.userAnnotations() ?: emptyList(), + thumbhash = iMeta.thumbhash()?.firstOrNull(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt index de18f8fa7..651df78b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt @@ -39,7 +39,8 @@ fun TagArrayBuilder.pictureIMeta( hash: String? = null, size: Int? = null, alt: String? = null, -) = pictureIMeta(PictureMeta(url, mimeType, blurhash, dimension, alt, hash, size)) + thumbhash: String? = null, +) = pictureIMeta(PictureMeta(url, mimeType, blurhash, dimension, alt, hash, size, thumbhash = thumbhash)) fun TagArrayBuilder.pictureIMeta(imeta: PictureMeta): TagArrayBuilder { add(imeta.toIMetaArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt index e94a0719e..06672d7b4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -55,6 +56,8 @@ fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.t fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) +fun IMetaTagBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.TAG_NAME, thumbhash) + fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt index 5436e8e0d..42d0e2add 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -54,6 +55,8 @@ fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME) fun IMetaTag.blurhash() = properties.get(BlurhashTag.TAG_NAME) +fun IMetaTag.thumbhash() = properties.get(ThumbhashTag.TAG_NAME) + fun IMetaTag.originalHash() = properties.get(OriginalHashTag.TAG_NAME) fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt index 05b07b5fb..1bf7c5a4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt @@ -43,7 +43,8 @@ fun TagArrayBuilder.videoIMeta( hash: String? = null, size: Int? = null, alt: String? = null, -) = videoIMeta(VideoMeta(url, mimeType, blurhash, dimension, alt, hash, size)) + thumbhash: String? = null, +) = videoIMeta(VideoMeta(url, mimeType, blurhash, dimension, alt, hash, size, thumbhash = thumbhash)) fun TagArrayBuilder.videoIMeta(imeta: VideoMeta) = add(imeta.toIMetaArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt index 0475663ea..66d4d334c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -35,6 +35,7 @@ data class VideoMeta( val service: String? = null, val fallback: List = emptyList(), val image: List = emptyList(), + val thumbhash: String? = null, ) { fun toIMetaArray(): Array = IMetaTagBuilder(url) @@ -45,6 +46,7 @@ data class VideoMeta( size?.let { size(it) } dimension?.let { dims(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } service?.let { service(it) } fallback.forEach { fallback(it) } image.forEach { image(it) } @@ -54,16 +56,17 @@ data class VideoMeta( companion object { fun parse(iMeta: IMetaTag): VideoMeta = VideoMeta( - iMeta.url, - iMeta.mimeType()?.firstOrNull(), - iMeta.blurhash()?.firstOrNull(), - iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, - iMeta.alt()?.firstOrNull(), - iMeta.hash()?.firstOrNull(), - iMeta.size()?.firstOrNull()?.toIntOrNull(), - iMeta.service()?.firstOrNull(), - iMeta.fallback() ?: emptyList(), - iMeta.image() ?: emptyList(), + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + blurhash = iMeta.blurhash()?.firstOrNull(), + dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + alt = iMeta.alt()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + size = iMeta.size()?.firstOrNull()?.toIntOrNull(), + service = iMeta.service()?.firstOrNull(), + fallback = iMeta.fallback() ?: emptyList(), + image = iMeta.image() ?: emptyList(), + thumbhash = iMeta.thumbhash()?.firstOrNull(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt index e48132cc8..cea911d9d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -69,6 +70,8 @@ class FileHeaderEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) @@ -106,6 +109,7 @@ class FileHeaderEvent( size: Int? = null, dimension: DimensionTag? = null, blurhash: String? = null, + thumbhash: String? = null, originalHash: String? = null, magnetUri: String? = null, torrentInfoHash: String? = null, @@ -120,6 +124,7 @@ class FileHeaderEvent( mimeType?.let { mimeType(it) } dimension?.let { dimension(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } originalHash?.let { originalHash(it) } magnetUri?.let { magnet(it) } torrentInfoHash?.let { torrentInfohash(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt index a625b4829..1fc629dcf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -55,6 +56,8 @@ fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.t fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) +fun IMetaTagBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.TAG_NAME, thumbhash) + fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt index d58c700df..e000583d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @@ -49,6 +50,8 @@ fun TagArrayBuilder.dimension(dim: DimensionTag) = add(Dimensio fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt new file mode 100644 index 000000000..d8d335f68 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip94FileMetadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ThumbhashTag { + companion object { + const val TAG_NAME = "thumbhash" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt index 1c2e96d48..44710a314 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip68Picture.dims import com.vitorpamplona.quartz.nip68Picture.hash import com.vitorpamplona.quartz.nip68Picture.mimeType import com.vitorpamplona.quartz.nip68Picture.size +import com.vitorpamplona.quartz.nip68Picture.thumbhash import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -38,6 +39,7 @@ data class ProductImageMeta( val alt: String? = null, val hash: String? = null, val size: Int? = null, + val thumbhash: String? = null, ) { fun toIMeta(): IMetaTag = IMetaTagBuilder(url) @@ -48,6 +50,7 @@ data class ProductImageMeta( size?.let { size(it) } dimension?.let { dims(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } }.build() fun toIMetaArray(): Array = toIMeta().toTagArray() @@ -55,13 +58,14 @@ data class ProductImageMeta( companion object { fun parse(iMeta: IMetaTag): ProductImageMeta = ProductImageMeta( - iMeta.url, - iMeta.mimeType()?.firstOrNull(), - iMeta.blurhash()?.firstOrNull(), - iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, - iMeta.alt()?.firstOrNull(), - iMeta.hash()?.firstOrNull(), - iMeta.size()?.firstOrNull()?.toIntOrNull(), + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + blurhash = iMeta.blurhash()?.firstOrNull(), + dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + alt = iMeta.alt()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + size = iMeta.size()?.firstOrNull()?.toIntOrNull(), + thumbhash = iMeta.thumbhash()?.firstOrNull(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2ebafdbad..8c46307f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent 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.gitAuthorList.GitAuthorListEvent @@ -422,6 +423,7 @@ class EventFactory { GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig) HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt new file mode 100644 index 000000000..f3ac5ff24 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt @@ -0,0 +1,159 @@ +/* + * 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.quartz.nip51Lists.favoriteAlgoFeedsList + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FavoriteAlgoFeedsListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + private fun dvm( + pubkey: String, + dTag: String = "content-discovery", + ) = AddressBookmark(Address(31990, pubkey, dTag)) + + @Test + fun kindMatchesSpec() { + assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND) + } + + @Test + fun addressesAreReplaceableWithFixedDTag() { + val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64)) + assertEquals(10090, address.kind) + assertEquals("", address.dTag) + } + + @Test + fun createStoresDvmAsATag() = + runTest { + val aFeed = dvm("a".repeat(64)) + + val event = + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + assertEquals(10090, event.kind) + assertTrue( + event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == aFeed.address.toValue() }, + "public a tag for the favourited DVM should be present", + ) + val favorites = event.publicFavoriteAlgoFeeds() + assertEquals(1, favorites.size) + assertEquals(aFeed.address, favorites.first().address) + } + + @Test + fun addAppendsWithoutDuplicatingExistingEntry() = + runTest { + val aFeed = dvm("a".repeat(64)) + + val initial = + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val afterDupeAdd = + FavoriteAlgoFeedsListEvent.add( + earlierVersion = initial, + feed = aFeed, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + assertEquals( + 1, + afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address }, + "re-adding the same DVM must not produce a duplicate tag", + ) + } + + @Test + fun addPreservesOtherFavorites() = + runTest { + val first = dvm("a".repeat(64)) + val second = dvm("b".repeat(64)) + + val initial = + FavoriteAlgoFeedsListEvent.create( + feed = first, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteAlgoFeedsListEvent.add( + earlierVersion = initial, + feed = second, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() + assertTrue(first.address in addresses) + assertTrue(second.address in addresses) + } + + @Test + fun removeDropsTheRequestedDvmOnly() = + runTest { + val first = dvm("a".repeat(64)) + val second = dvm("b".repeat(64)) + + val initial = + FavoriteAlgoFeedsListEvent.create( + publicFeeds = listOf(first, second), + privateFeeds = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteAlgoFeedsListEvent.remove( + earlierVersion = initial, + feed = first.address, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() + assertFalse(first.address in addresses, "removed DVM should not survive") + assertTrue(second.address in addresses, "other DVMs should be preserved") + } +}