From 25ecd94487be84347156cfce439c3b545a696b34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 19:12:47 +0000 Subject: [PATCH 01/46] feat(home): add favourite-DVM top-nav filter Lets users mark NIP-90 content-discovery DVMs (kind 31990 with k=5300) as favourite and surface each as a chip in the Home top-nav alongside hashtags/communities. Selecting a chip publishes the 5300 request, listens for 6300/7000 responses, and renders the curated feed in place. A banner above the feed reports processing / payment-required / error status and reuses the NWC pay flow extracted from DvmContentDiscoveryScreen. - quartz: FavoriteDvmListEvent (NIP-51-style replaceable, kind 10090) - model: FavoriteDvmListState + backup-on-save + Account mutators - model: FavoriteDvmOrchestrator for the 5300 request/6300 response lifecycle, exposing a per-address StateFlow - topNavFeeds/favoriteDvm: TopFilter.FavoriteDvm variant + filter classes + FeedFlow wired into FeedTopNavFilterState - home: FilterHomePostsByDvmIds dispatched by HomeOutboxEventsEoseManager - UI: FavoriteDvmToggle (star icon on DVM cards + DvmTopBar), HomeDvmStatusBanner, new DVMS group and icon in FeedFilterSpinner --- .../vitorpamplona/amethyst/model/Account.kt | 16 ++ .../amethyst/model/AccountSettings.kt | 17 ++ .../model/dvms/FavoriteDvmOrchestrator.kt | 185 +++++++++++++++++ .../FavoriteDvmListDecryptionCache.kt | 36 ++++ .../favoriteDvmLists/FavoriteDvmListState.kt | 128 ++++++++++++ .../topNavFeeds/FeedTopNavFilterState.kt | 12 ++ .../favoriteDvm/FavoriteDvmFeedFlow.kt | 69 +++++++ .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 68 ++++++ .../FavoriteDvmTopNavPerRelayFilter.kt | 33 +++ .../FavoriteDvmTopNavPerRelayFilterSet.kt | 28 +++ .../navigation/topbars/FeedFilterSpinner.kt | 27 +++ .../amethyst/ui/screen/TopNavFilterState.kt | 23 ++- .../ui/screen/loggedIn/AccountViewModel.kt | 7 + .../loggedIn/discover/nip90DVMs/DVMCard.kt | 8 + .../dvms/DvmContentDiscoveryScreen.kt | 153 +++++++------- .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 19 +- .../screen/loggedIn/dvms/FavoriteDvmToggle.kt | 82 ++++++++ .../screen/loggedIn/home/DvmStatusBanner.kt | 195 ++++++++++++++++++ .../ui/screen/loggedIn/home/HomeScreen.kt | 1 + .../HomeOutboxEventsEoseManager.kt | 3 + .../nip90Dvms/FilterHomePostsByDvmIds.kt | 99 +++++++++ amethyst/src/main/res/values/strings.xml | 9 + .../favoriteDvmList/FavoriteDvmListEvent.kt | 192 +++++++++++++++++ .../favoriteDvmList/TagArrayBuilderExt.kt | 28 +++ .../nip51Lists/favoriteDvmList/TagArrayExt.kt | 28 +++ 25 files changed, 1392 insertions(+), 74 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt 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 025bf0ffc..ec3df21db 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.dvms.FavoriteDvmOrchestrator 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.favoriteDvmLists.FavoriteDvmListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -134,6 +137,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +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 @@ -186,6 +190,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 @@ -322,6 +327,10 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) + val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) + val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -417,6 +426,7 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, + favoriteDvmOrchestrator = favoriteDvmOrchestrator, ).flow // App-ready Feeds @@ -1012,6 +1022,12 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + + suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + + fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) 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..0f327a174 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.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -154,6 +155,11 @@ sealed class TopFilter( class Relay( val url: String, ) : TopFilter("Relay/$url") + + @Serializable + class FavoriteDvm( + val address: Address, + ) : TopFilter("FavoriteDvm/${address.toValue()}") } @Stable @@ -195,6 +201,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, + var backupFavoriteDvmList: FavoriteDvmListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -718,6 +725,16 @@ class AccountSettings( } } + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { + backupFavoriteDvmList = newFavoriteDvmList + saveAccountSettings() + } + } + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null || newCommunityList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt new file mode 100644 index 000000000..f4058cccb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -0,0 +1,185 @@ +/* + * 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.dvms + +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.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.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 + +/** + * Immutable snapshot of a favourite DVM's current request/response state. + * + * - [requestId] is the id of the most recently published kind-5300 request. + * - [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 FavoriteDvmSnapshot( + val requestId: HexKey? = null, + 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 favourite DVM 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.FavoriteDvm` selected. The orchestrator merely observes + * what the relays deliver into `LocalCache`. + */ +class FavoriteDvmOrchestrator( + val account: Account, + val scope: CoroutineScope, +) { + private val flows = mutableMapOf>() + private val jobs = mutableMapOf() + private val mutex = Mutex() + + fun observe(dvmAddress: Address): StateFlow { + flows[dvmAddress]?.let { return it.asStateFlow() } + + val seed = MutableStateFlow(FavoriteDvmSnapshot()) + flows[dvmAddress] = seed + scope.launch { startFor(dvmAddress, seed) } + return seed.asStateFlow() + } + + fun refresh(dvmAddress: Address) { + val seed = flows[dvmAddress] ?: return + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + } + startFor(dvmAddress, seed) + } + } + + fun stop(dvmAddress: Address) { + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + flows.remove(dvmAddress) + } + } + } + + private suspend fun startFor( + dvmAddress: Address, + seed: MutableStateFlow, + ) { + val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val job = + scope.launch(Dispatchers.IO) { + try { + account.requestDVMContentDiscovery(user) { request -> + seed.update { + it.copy( + requestId = request.id, + 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) } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } + } + } + + mutex.withLock { jobs[dvmAddress] = 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/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt new file mode 100644 index 000000000..8af3bfa97 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.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.favoriteDvmLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet + +class FavoriteDvmListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + + suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt new file mode 100644 index 000000000..db41026d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.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.favoriteDvmLists + +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.favoriteDvmList.FavoriteDvmListEvent +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 FavoriteDvmListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteDvmListDecryptionCache, + 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 favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + + fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + + fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + + fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + + suspend fun favoriteDvmListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteDvmListFlow() + .transformLatest { noteState -> + emit(favoriteDvmListWithBackup(noteState.note)) + }.onStart { + emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + }.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): FavoriteDvmListEvent { + val list = getFavoriteDvmList() + return if (list == null) { + FavoriteDvmListEvent.create(dvm, false, signer) + } else { + FavoriteDvmListEvent.add(list, dvm, false, signer) + } + } + + suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + val list = getFavoriteDvmList() ?: return null + return FavoriteDvmListEvent.remove(list, dvm, signer) + } + + init { + settings.backupFavoriteDvmList?.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") + getFavoriteDvmListFlow().collect { + Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } + (it.note.event as? FavoriteDvmListEvent)?.let { + settings.updateFavoriteDvmListTo(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..27635f019 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.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -30,6 +31,7 @@ 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.favoriteDvm.FavoriteDvmFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -62,6 +64,7 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, + val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -142,6 +145,15 @@ class FeedTopNavFilterState( is TopFilter.Relay -> { RelayFeedFlow(listName.url.normalizeRelayUrl()) } + + is TopFilter.FavoriteDvm -> { + FavoriteDvmFeedFlow( + dvmAddress = listName.address, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt new file mode 100644 index 000000000..f919d62a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.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.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +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 FavoriteDvmFeedFlow( + val dvmAddress: Address, + val orchestrator: FavoriteDvmOrchestrator, + 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.dvms.FavoriteDvmSnapshot, + relays: Set, + ) = FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = snapshot.ids, + acceptedAddresses = snapshot.addresses, + relayList = relays, + requestId = snapshot.requestId, + ) + + override fun flow(): Flow = + combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + buildFilter(snap, resolveRelays(outbox, proxy)) + } + + override fun startValue(): FavoriteDvmTopNavFilter = + buildFilter( + snapshot = orchestrator.observe(dvmAddress).value, + relays = 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/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt new file mode 100644 index 000000000..093fc390d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -0,0 +1,68 @@ +/* + * 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.favoriteDvm + +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 favourite DVM. + * + * 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 [FavoriteDvmFeedFlow] and replaces the active filter. + */ +@Immutable +class FavoriteDvmTopNavFilter( + val dvmAddress: Address, + val acceptedIds: Set, + val acceptedAddresses: Set, + val relayList: 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): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + relayList.associateWith { + FavoriteDvmTopNavPerRelayFilter( + dvmPubkey = dvmAddress.pubKeyHex, + requestId = requestId, + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt new file mode 100644 index 000000000..5a8f3d7f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -0,0 +1,33 @@ +/* + * 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.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Immutable +class FavoriteDvmTopNavPerRelayFilter( + val dvmPubkey: HexKey, + val requestId: HexKey?, + val ids: Set, + val addresses: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..4ec47da11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.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.amethyst.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class FavoriteDvmTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet 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..64c3ac1e9 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 @@ -84,6 +85,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.FavoriteDvmName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -100,6 +102,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) @@ -329,6 +332,20 @@ fun RenderOption( color = MaterialTheme.colorScheme.onSurface, ) } + + is FavoriteDvmName -> { + 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 +363,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,6 +391,10 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + FeedGroup.DVMS + } + is ResourceName -> { when (entry.item.code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS @@ -541,12 +563,17 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } + is TopFilter.FavoriteDvm -> { + 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 FavoriteDvmName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } 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..811ff1508 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 @@ -140,6 +141,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, + favoriteDvmList: List, ): List { val hashtags = hashtagList.map { @@ -173,7 +175,15 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays).sortedBy { it.name.name() } + val favoriteDvms = + favoriteDvmList.map { dvmNote -> + FeedDefinition( + TopFilter.FavoriteDvm(dvmNote.address), + FavoriteDvmName(dvmNote), + ) + } + + return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -183,6 +193,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, + account.favoriteDvmList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -191,6 +202,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, + account.favoriteDvmList.flowNotes.value, ), ) } @@ -298,6 +310,15 @@ class CommunityName( override fun name() = "/n/${(note.dTag())}" } +@Stable +class FavoriteDvmName( + 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/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 56d164093..c8ebc9643 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 @@ -1088,6 +1089,12 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + + fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + + fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } 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..4ac46b109 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 @@ -42,6 +42,7 @@ 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,6 +52,7 @@ 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.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -123,6 +125,12 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { + if (baseNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = baseNote, + accountViewModel = accountViewModel, + ) + } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, 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..d3dcecc66 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,15 +21,18 @@ 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.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.AddressableNote 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 @@ -41,7 +44,7 @@ fun DvmTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - TopBarExtensibleWithBackButton( + MyExtensibleTopAppBar( title = { LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> if (appDefinitionNote != null) { @@ -82,6 +85,16 @@ fun DvmTopBar( } } }, - popBack = nav::popBack, + navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } }, + actions = { + LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> + if (appDefinitionNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = appDefinitionNote, + accountViewModel = accountViewModel, + ) + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt new file mode 100644 index 000000000..f79acafcd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -0,0 +1,82 @@ +/* + * 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.IconButton +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.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 + +@Composable +fun FavoriteDvmToggle( + appDefinitionNote: AddressableNote, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val favorites by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() + + val isFavorite = favorites.contains(appDefinitionNote.address) + + IconButton( + onClick = { + if (isFavorite) { + accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + } else { + accountViewModel.followFavoriteDvm( + AddressBookmark( + address = appDefinitionNote.address, + relayHint = appDefinitionNote.relayHintUrl(), + ), + ) + } + }, + modifier = modifier, + ) { + if (isFavorite) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.StarBorder, + contentDescription = stringRes(R.string.add_dvm_to_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt new file mode 100644 index 000000000..487a9524b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -0,0 +1,195 @@ +/* + * 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.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 HomeDvmStatusBanner( + accountViewModel: AccountViewModel, + nav: INav, +) { + val topFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + + val snapshot by accountViewModel.account.favoriteDvmOrchestrator + .observe(favDvm.address) + .collectAsStateWithLifecycle() + + // Hide the banner when the feed is already populated. + if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return + + val dvmAddressValue = favDvm.address.toValue() + + LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + val resolvedName by + observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } + ?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag() + ?: "" + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { + val status = snapshot.latestStatus?.status() + + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton(favDvm, accountViewModel) + } + + 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(favDvm, accountViewModel) + } + + 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 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( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, +) { + OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { + 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..38e38dae6 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 @@ -179,6 +179,7 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) + HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, 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..29abc2ae6 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.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet 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.nip90Dvms.filterHomePostsByDvmIds 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 FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt new file mode 100644 index 000000000..644cc1f54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -0,0 +1,99 @@ +/* + * 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.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +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 favourite-DVM home feed. + * + * Three filters are issued per relay: + * - fetch notes whose ids are in the DVM's latest kind-6300 response + * - fetch addressable notes referenced by `a` tags in the DVM's response + * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot + * keeps up to date while this filter is active + */ +fun filterHomePostsByDvmIds( + set: FavoriteDvmTopNavPerRelayFilterSet, + @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, + @Suppress("UNUSED_PARAMETER") defaultSince: Long?, +): List = + set.set.flatMap { (relay, filter) -> + buildFiltersFor(relay, filter) + } + +private fun buildFiltersFor( + relay: NormalizedRelayUrl, + filter: FavoriteDvmTopNavPerRelayFilter, +): 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, + ), + ) + } + + val requestId = filter.requestId + if (requestId != null) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), + ) + } + + return out +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91ef..9b74fd43b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,8 +1728,17 @@ Locations Communities Lists + DVMs Relays + Add DVM to favorites + Remove from favorites + Asking %1$s for a feed… + Processing your feed… + This DVM requires payment + DVM returned an error + Retry + Log off on device lock Private Message Public Message diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt new file mode 100644 index 000000000..34f28bf7c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.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.favoriteDvmList + +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 FavoriteDvmListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + + suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + + companion object { + const val KIND = 10090 + const val ALT = "Favorite DVM list" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + create( + publicDvms = emptyList(), + privateDvms = listOf(dvm), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicDvms = listOf(dvm), + privateDvms = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: FavoriteDvmListEvent, + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteDvmListEvent, + dvm: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val idOnly = AddressBookmark.assemble(dvm, 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(), + ): FavoriteDvmListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val template = build(publicDvms, privateDvms, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = + PrivateTagsInContent.encryptNip44( + privateDvms.map { it.toTagArray() }.toTypedArray(), + signer, + ), + createdAt = createdAt, + ) { + alt(ALT) + favoriteDvms(publicDvms) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt new file mode 100644 index 000000000..6f49fd98b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/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.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) + +fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt new file mode 100644 index 000000000..0f2d3501c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/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.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) + +fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) From af6053f741568e4b0af57d7730047a7eb6cf17dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:18:45 +0000 Subject: [PATCH 02/46] feat(nip58): browse, create, award, accept, and edit badges Adds a top-level Badges destination modeled after Polls, plus the full create/award/accept/edit lifecycle on top of the existing Quartz NIP-58 event classes. - Drawer entry + Route.Badges / Route.NewBadge / Route.AwardBadge - BadgesScreen with 4 tabs (Received / Mine / Awarded / Discover) backed by four AdditiveFeedFilters and a new FeedContentState registration. - BadgesFilterAssembler + BadgesSubAssembler subscribe kinds 30009 and 8 authored by me; received awards already stream via notifications. - NewBadgeScreen creates or edits kind 30009 (addressable, so republish with same d-tag == edit). - AwardBadgeScreen takes a list of npub/hex recipients and publishes kind 8. - RenderBadgeAward now shows Accept / Reject / Remove buttons for the current awardee, publishing kind 10008 via ProfileBadgesEvent with a fallback read of the legacy kind 30008 set. - BadgeDisplay surfaces an Award action for definitions authored by me. --- .../vitorpamplona/amethyst/model/Account.kt | 112 ++++++++++ .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 5 + .../amethyst/ui/navigation/AppNavigation.kt | 6 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 18 ++ .../amethyst/ui/note/NoteCompose.kt | 4 +- .../amethyst/ui/note/types/Badge.kt | 107 ++++++++++ .../loggedIn/AccountFeedContentStates.kt | 19 ++ .../ui/screen/loggedIn/badges/BadgesScreen.kt | 196 ++++++++++++++++++ .../ui/screen/loggedIn/badges/BadgesTopBar.kt | 43 ++++ .../screen/loggedIn/badges/NewBadgeButton.kt | 53 +++++ .../loggedIn/badges/award/AwardBadgeScreen.kt | 149 +++++++++++++ .../badges/award/AwardBadgeViewModel.kt | 82 ++++++++ .../badges/dal/BadgesAwardedFeedFilter.kt | 60 ++++++ .../badges/dal/BadgesDiscoverFeedFilter.kt | 59 ++++++ .../badges/dal/BadgesMineFeedFilter.kt | 60 ++++++ .../badges/dal/BadgesReceivedFeedFilter.kt | 59 ++++++ .../datasource/BadgesFilterAssembler.kt | 46 ++++ .../BadgesFilterAssemblerSubscription.kt | 47 +++++ .../badges/datasource/BadgesSubAssembler.kt | 38 ++++ .../badges/datasource/FilterBadges.kt | 60 ++++++ .../loggedIn/badges/post/NewBadgeScreen.kt | 163 +++++++++++++++ .../loggedIn/badges/post/NewBadgeViewModel.kt | 107 ++++++++++ amethyst/src/main/res/values/strings.xml | 25 +++ 25 files changed, 1528 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt 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 025bf0ffc..47263bd67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -150,8 +150,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 @@ -194,6 +197,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 @@ -1067,6 +1076,109 @@ 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() + } + + 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 current = loadCurrentAcceptedBadges() + val alreadyAccepted = current.any { it.badgeAward.eventId == award.id } + if (alreadyAccepted) return + + val updated = current + AcceptedBadge(aTag, eTag) + + val template = ProfileBadgesEvent.build(updated) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, outboxRelays.flow.value) + } + + suspend fun removeAcceptedBadge(award: BadgeAwardEvent) { + if (!isWriteable()) return + + val current = loadCurrentAcceptedBadges() + val updated = current.filterNot { it.badgeAward.eventId == award.id } + if (updated.size == current.size) return + + val template = ProfileBadgesEvent.build(updated) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, outboxRelays.flow.value) + } + fun sendMyPublicAndPrivateOutbox(event: Event?) { if (event == null) return cache.justConsumeMyOwnEvent(event) 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..c315587fe 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,7 @@ 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.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 +98,7 @@ class RelaySubscriptionsCoordinator( val shorts = ShortsFilterAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) + val badges = BadgesFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -114,6 +116,7 @@ class RelaySubscriptionsCoordinator( shorts, longs, articles, + badges, channelFinder, eventFinder, userFinder, 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..ddffb0e62 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,10 @@ object ScrollStateKeys { const val POLLS_SCREEN = "PollsFeed" const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_CLOSED = "PollsClosedFeed" + const val BADGES_RECEIVED = "BadgesReceivedFeed" + const val BADGES_MINE = "BadgesMineFeed" + const val BADGES_AWARDED = "BadgesAwardedFeed" + const val BADGES_DISCOVER = "BadgesDiscoverFeed" const val PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" @@ -73,6 +77,7 @@ object PagerStateKeys { const val HOME_SCREEN = "PagerHome" const val DISCOVER_SCREEN = "PagerDiscover" const val POLLS_SCREEN = "PagerPolls" + const val BADGES_SCREEN = "PagerBadges" } @Composable 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..c199acadd 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.post.NewBadgeScreen 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 @@ -214,6 +217,9 @@ fun BuildNavigation( composable { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { BadgesScreen(accountViewModel, nav) } + composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } + composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(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..6f50d2d80 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,24 @@ sealed class Route { @Serializable object Polls : Route() + @Serializable object Badges : Route() + + @Serializable data class NewBadge( + val editDTag: String? = null, + ) : 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() 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..b406352da 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 @@ -397,7 +397,7 @@ fun AcceptableNote( } is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote = baseNote, accountViewModel) + BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) } else -> { @@ -455,7 +455,7 @@ fun AcceptableNote( } is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote, accountViewModel) + BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) } else -> { 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..c130b1fa0 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 @@ -21,13 +21,18 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,6 +47,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign 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.Note @@ -53,13 +59,16 @@ 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.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 @Composable fun BadgeDisplay( baseNote: Note, accountViewModel: AccountViewModel, + nav: INav? = null, ) { val badgeData by observeNoteEvent(baseNote, accountViewModel) @@ -71,6 +80,27 @@ fun BadgeDisplay( MaterialTheme.colorScheme.onBackground, it.description(), ) + + if (nav != null && it.pubKey == accountViewModel.userProfile().pubkeyHex) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = { + nav.nav( + com.vitorpamplona.amethyst.ui.navigation.routes.Route.AwardBadge( + kind = it.kind, + pubKeyHex = it.pubKey, + dTag = it.dTag(), + ), + ) + }, + ) { + Text(stringRes(R.string.award_badge)) + } + } + } } } @@ -179,4 +209,81 @@ fun RenderBadgeAward( note.replyTo?.firstOrNull()?.let { BadgeDisplay(baseNote = it, accountViewModel) } + + AcceptBadgeControls(noteEvent, accountViewModel) +} + +@Composable +private 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) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalArrangement = Arrangement.End, + ) { + if (isAccepted) { + OutlinedButton( + onClick = { + accountViewModel.launchSigner { + accountViewModel.account.removeAcceptedBadge(award) + } + }, + ) { + Text(stringRes(R.string.unaccept_badge)) + } + } else { + OutlinedButton( + onClick = { + accountViewModel.launchSigner { + accountViewModel.account.removeAcceptedBadge(award) + } + }, + ) { + Text(stringRes(R.string.reject_badge)) + } + Spacer(modifier = Modifier.size(8.dp)) + Button( + onClick = { + accountViewModel.launchSigner { + val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner + val defNote = + com.vitorpamplona.amethyst.model.LocalCache + .getAddressableNoteIfExists(defAddr) + val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner + accountViewModel.account.addAcceptedBadge(award, defEvent) + } + }, + ) { + Text(stringRes(R.string.accept_badge)) + } + } + } } 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..ee72b6faa 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,10 @@ 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.BadgesAwardedFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesDiscoverFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesMineFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesReceivedFeedFilter 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 +85,11 @@ class AccountFeedContentStates( val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache) val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache) + val badgesReceived = FeedContentState(BadgesReceivedFeedFilter(account), scope, LocalCache) + val badgesMine = FeedContentState(BadgesMineFeedFilter(account), scope, LocalCache) + val badgesAwarded = FeedContentState(BadgesAwardedFeedFilter(account), scope, LocalCache) + val badgesDiscover = FeedContentState(BadgesDiscoverFeedFilter(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 +134,11 @@ class AccountFeedContentStates( openPollsFeed.updateFeedWith(newNotes) closedPollsFeed.updateFeedWith(newNotes) + badgesReceived.updateFeedWith(newNotes) + badgesMine.updateFeedWith(newNotes) + badgesAwarded.updateFeedWith(newNotes) + badgesDiscover.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) @@ -163,6 +177,11 @@ class AccountFeedContentStates( openPollsFeed.deleteFromFeed(newNotes) closedPollsFeed.deleteFromFeed(newNotes) + badgesReceived.deleteFromFeed(newNotes) + badgesMine.deleteFromFeed(newNotes) + badgesAwarded.deleteFromFeed(newNotes) + badgesDiscover.deleteFromFeed(newNotes) + picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.deleteFromFeed(newNotes) 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..35a0d3cf4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -0,0 +1,196 @@ +/* + * 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.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys +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.feeds.rememberForeverPagerState +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 +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Composable +fun BadgesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedStates = accountViewModel.feedStates + + WatchLifecycleAndUpdateModel(feedStates.badgesReceived) + WatchLifecycleAndUpdateModel(feedStates.badgesMine) + WatchLifecycleAndUpdateModel(feedStates.badgesAwarded) + WatchLifecycleAndUpdateModel(feedStates.badgesDiscover) + + BadgesFilterAssemblerSubscription(accountViewModel) + + AssembleBadgesTabs( + received = feedStates.badgesReceived, + mine = feedStates.badgesMine, + awarded = feedStates.badgesAwarded, + discover = feedStates.badgesDiscover, + ) { pagerState, tabs -> + BadgesPages(pagerState, tabs, accountViewModel, nav) + } +} + +@Composable +private fun AssembleBadgesTabs( + received: FeedContentState, + mine: FeedContentState, + awarded: FeedContentState, + discover: FeedContentState, + inner: @Composable (PagerState, ImmutableList) -> Unit, +) { + val pagerState = rememberForeverPagerState(key = PagerStateKeys.BADGES_SCREEN) { 4 } + + val tabs by + remember(received, mine, awarded, discover) { + mutableStateOf( + listOf( + BadgesTabItem( + resource = R.string.received_badges, + feedState = received, + routeForLastRead = "BadgesReceivedFeed", + scrollStateKey = ScrollStateKeys.BADGES_RECEIVED, + ), + BadgesTabItem( + resource = R.string.my_badges, + feedState = mine, + routeForLastRead = "BadgesMineFeed", + scrollStateKey = ScrollStateKeys.BADGES_MINE, + ), + BadgesTabItem( + resource = R.string.awarded_badges, + feedState = awarded, + routeForLastRead = "BadgesAwardedFeed", + scrollStateKey = ScrollStateKeys.BADGES_AWARDED, + ), + BadgesTabItem( + resource = R.string.discover_badges, + feedState = discover, + routeForLastRead = "BadgesDiscoverFeed", + scrollStateKey = ScrollStateKeys.BADGES_DISCOVER, + ), + ).toImmutableList(), + ) + } + + inner(pagerState, tabs) +} + +@Composable +private fun BadgesPages( + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: INav, +) { + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + BadgesTopBar(accountViewModel, nav) + SecondaryTabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringRes(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) + } + } + } + }, + bottomBar = { + AppBottomBar(Route.Badges, accountViewModel) { route -> + if (route == Route.Badges) { + tabs[pagerState.currentPage].feedState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + floatingButton = { + NewBadgeButton(nav) + }, + accountViewModel = accountViewModel, + ) { + HorizontalPager( + contentPadding = it, + state = pagerState, + userScrollEnabled = true, + ) { page -> + RefresheableBox(tabs[page].feedState, true) { + SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderFeedContentState( + feedContentState = tabs[page].feedState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = tabs[page].routeForLastRead, + ) + } + } + } + } +} + +@Immutable +class BadgesTabItem( + val resource: Int, + val feedState: FeedContentState, + val routeForLastRead: String, + val scrollStateKey: String, +) 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..ea22abc1b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -0,0 +1,43 @@ +/* + * 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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun BadgesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + Text( + text = stringRes(R.string.badges), + style = MaterialTheme.typography.titleMedium, + ) + } +} 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..64c858db7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/NewBadgeButton.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 + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewBadgeButton(nav: INav) { + FloatingActionButton( + onClick = { nav.nav(Route.NewBadge()) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + 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..8f971fb70 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.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.award + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@OptIn(ExperimentalMaterial3Api::class) +@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) + } + + BackHandler { + vm.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.award_badge, + isActive = vm::canPost, + onCancel = { + vm.cancel() + nav.popBack() + }, + onPost = { + accountViewModel.launchSigner { + vm.sendPost() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + AwardBadgeBody(vm) + } + } +} + +@Composable +private fun AwardBadgeBody(vm: AwardBadgeViewModel) { + val scrollState = rememberScrollState() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + val def = vm.definition + 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.titleLarge, + ) + Spacer(modifier = Modifier.height(4.dp)) + def.description()?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = vm.awardeesText, + onValueChange = { vm.awardeesText = it }, + label = { Text(stringRes(R.string.award_badge_recipients_label)) }, + placeholder = { Text(stringRes(R.string.award_badge_recipients_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + maxLines = 10, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val parsed = vm.parsedPubKeys() + Text( + text = stringRes(R.string.award_badge_recipient_count, parsed.size), + style = MaterialTheme.typography.bodySmall, + ) + } +} 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..cdef93f67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeViewModel.kt @@ -0,0 +1,82 @@ +/* + * 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.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +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.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +@Stable +class AwardBadgeViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var definition by mutableStateOf(null) + var awardeesText by mutableStateOf(TextFieldValue("")) + + 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 + } + + fun parsedPubKeys(): List = + awardeesText.text + .split('\n', ',', ' ', ';') + .mapNotNull { raw -> + val trimmed = raw.trim() + if (trimmed.isEmpty()) null else decodePublicKeyAsHexOrNull(trimmed) + }.distinct() + + fun canPost(): Boolean = definition != null && parsedPubKeys().isNotEmpty() + + fun cancel() { + awardeesText = TextFieldValue("") + } + + suspend fun sendPost() { + val def = definition ?: return + val awardees = parsedPubKeys().map { PTag(it) } + if (awardees.isEmpty()) return + + account.sendBadgeAward(def, awardees) + cancel() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt new file mode 100644 index 000000000..76bf689e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt @@ -0,0 +1,60 @@ +/* + * 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.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent + +class BadgesAwardedFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-awarded-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + val me = myPubkey() + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeAwardEvent && noteEvent.pubKey == me + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set { + val me = myPubkey() + return newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeAwardEvent && noteEvent.pubKey == me + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt new file mode 100644 index 000000000..3732e680b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt @@ -0,0 +1,59 @@ +/* + * 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.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +class BadgesDiscoverFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-discover-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun isHidden(pubKey: String): Boolean = + account.hiddenUsers.flow.value.hiddenUsers + .contains(pubKey) + + override fun feed(): List { + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt new file mode 100644 index 000000000..94d1a2191 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt @@ -0,0 +1,60 @@ +/* + * 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.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +class BadgesMineFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-mine-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + val me = myPubkey() + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set { + val me = myPubkey() + return newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt new file mode 100644 index 000000000..af4c0dcc4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt @@ -0,0 +1,59 @@ +/* + * 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.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent + +class BadgesReceivedFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "badges-received-" + account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + private fun awardsMe(noteEvent: BadgeAwardEvent): Boolean = noteEvent.awardeeIds().contains(myPubkey()) + + override fun feed(): List { + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is BadgeAwardEvent && awardsMe(noteEvent) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is BadgeAwardEvent && awardsMe(noteEvent) + } + + 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..774518ced --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.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.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 BadgesQueryState( + val account: Account, +) + +@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..6490033a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssemblerSubscription.kt @@ -0,0 +1,47 @@ +/* + * 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 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) + } + + 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..de15650f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * 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.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 BadgesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: BadgesQueryState, + since: SincePerRelayMap?, + ): List = filterMyBadges(user(key), since) + + override fun user(key: BadgesQueryState) = key.account.userProfile() +} 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..af71f1dba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -0,0 +1,60 @@ +/* + * 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.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +/** + * Subscribes to: + * - Badge definitions (kind 30009) authored by me — covers "Mine" tab. + * - Badge awards (kind 8) authored by me — covers "Awarded" tab. + * + * Received badges (kind 8 with `#p`=me) are already pulled via the standard + * notifications subscription (FilterNotificationsToPubkey), so we do not + * duplicate that here. + */ +fun filterMyBadges( + user: User, + since: SincePerRelayMap?, +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: user.allUsedRelaysOrNull() + ?: return emptyList() + + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(BadgeDefinitionEvent.KIND, BadgeAwardEvent.KIND), + authors = listOf(user.pubkeyHex), + limit = 500, + since = since?.get(relay)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt new file mode 100644 index 000000000..ac8b6f4cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt @@ -0,0 +1,163 @@ +/* + * 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.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewBadgeScreen( + editDTag: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewBadgeViewModel = viewModel() + + LaunchedEffect(accountViewModel, editDTag) { + vm.init(accountViewModel, editDTag) + } + + BackHandler { + vm.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + titleRes = if (vm.isEdit) R.string.edit_badge else R.string.new_badge, + isActive = vm::canPost, + onCancel = { + vm.cancel() + nav.popBack() + }, + onPost = { + accountViewModel.launchSigner { + vm.sendPost() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewBadgeBody(vm) + } + } +} + +@Composable +private fun NewBadgeBody(vm: NewBadgeViewModel) { + val scrollState = rememberScrollState() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + OutlinedTextField( + value = vm.badgeId, + onValueChange = { vm.badgeId = it }, + label = { Text(stringRes(R.string.badge_id_label)) }, + placeholder = { Text(stringRes(R.string.badge_id_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !vm.isEdit, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.name, + onValueChange = { vm.name = it }, + label = { Text(stringRes(R.string.badge_name_label)) }, + placeholder = { Text(stringRes(R.string.badge_name_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.description, + onValueChange = { vm.description = it }, + label = { Text(stringRes(R.string.badge_description_label)) }, + placeholder = { Text(stringRes(R.string.badge_description_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.imageUrl, + onValueChange = { vm.imageUrl = it }, + label = { Text(stringRes(R.string.badge_image_label)) }, + placeholder = { Text(stringRes(R.string.badge_image_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.thumbUrl, + onValueChange = { vm.thumbUrl = it }, + label = { Text(stringRes(R.string.badge_thumb_label)) }, + placeholder = { Text(stringRes(R.string.badge_thumb_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt new file mode 100644 index 000000000..1470fdca1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt @@ -0,0 +1,107 @@ +/* + * 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.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent + +@Stable +class NewBadgeViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var badgeId by mutableStateOf(TextFieldValue("")) + var name by mutableStateOf(TextFieldValue("")) + var description by mutableStateOf(TextFieldValue("")) + var imageUrl by mutableStateOf(TextFieldValue("")) + var thumbUrl by mutableStateOf(TextFieldValue("")) + + var isEdit by mutableStateOf(false) + + fun init( + accountVM: AccountViewModel, + editDTag: String?, + ) { + this.accountViewModel = accountVM + this.account = accountVM.account + + if (editDTag.isNullOrBlank()) return + + val existing = + LocalCache + .getAddressableNoteIfExists( + Address(BadgeDefinitionEvent.KIND, account.signer.pubKey, editDTag), + )?.event as? BadgeDefinitionEvent ?: return + + isEdit = true + badgeId = TextFieldValue(existing.dTag()) + name = TextFieldValue(existing.name() ?: "") + description = TextFieldValue(existing.description() ?: "") + imageUrl = TextFieldValue(existing.image() ?: "") + thumbUrl = TextFieldValue(existing.thumb() ?: "") + } + + fun canPost(): Boolean = badgeId.text.isNotBlank() && name.text.isNotBlank() + + fun cancel() { + badgeId = TextFieldValue("") + name = TextFieldValue("") + description = TextFieldValue("") + imageUrl = TextFieldValue("") + thumbUrl = TextFieldValue("") + isEdit = false + } + + suspend fun sendPost() { + if (!canPost()) return + + val thumb = thumbUrl.text.ifBlank { null } + val thumbs = + if (thumb != null) { + listOf( + com.vitorpamplona.quartz.nip58Badges.definition.tags + .ThumbTag(thumb), + ) + } else { + emptyList() + } + + account.sendBadgeDefinition( + badgeId = badgeId.text.trim(), + name = name.text.trim(), + imageUrl = imageUrl.text.ifBlank { null }, + imageDim = null, + description = description.text.ifBlank { null }, + thumbs = thumbs, + ) + + cancel() + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91ef..b961ce5b9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -419,6 +419,31 @@ 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 + Loading badge… + Recipients (npub or hex, one per line) + npub1…\nnpub1… + %1$d recipient(s) will receive this badge Pictures Shorts Videos From 843da0b3839a5ab1b468e31abe820aba1cf0771f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:27:13 +0000 Subject: [PATCH 03/46] fix(home): restrict favourite DVM list to content-discovery DVMs Only NIP-90 content discovery (kind 5300) DVMs can produce a feed. Other DVM types (image generation, translation, search, etc.) would silently hang the home banner waiting for a 6300 reply that will never come. Defence in depth: - FavoriteDvmToggle hides itself when the AppDefinitionEvent doesn't advertise kind 5300, so users can't favourite the wrong type. - TopNavFilterState.mergeInterests filters out any list entry whose AppDefinitionEvent doesn't include kind 5300, so a stale or cross-client entry won't surface as a Home chip. --- .../amethyst/ui/screen/TopNavFilterState.kt | 11 ++++++++++- .../ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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 811ff1508..a4d9cb8b6 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 @@ -35,6 +35,7 @@ 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.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -175,8 +176,16 @@ class TopNavFilterState( ) } + // Only DVMs that advertise NIP-90 content discovery (kind 5300) can produce a + // home feed. Hide entries whose AppDefinitionEvent isn't loaded yet OR doesn't + // include kind 5300 so the chip doesn't appear for image-generation, translation, + // search, or other DVM kinds that would never reply. val favoriteDvms = - favoriteDvmList.map { dvmNote -> + favoriteDvmList.mapNotNull { dvmNote -> + val supports5300 = + (dvmNote.event as? AppDefinitionEvent) + ?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + if (!supports5300) return@mapNotNull null FeedDefinition( TopFilter.FavoriteDvm(dvmNote.address), FavoriteDvmName(dvmNote), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt index f79acafcd..98cd9f062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -32,10 +32,13 @@ 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.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 @Composable fun FavoriteDvmToggle( @@ -43,6 +46,15 @@ fun FavoriteDvmToggle( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { + // Only NIP-90 content-discovery DVMs (kind 5300) produce a feed; hide the toggle + // for any other DVM type so users don't favourite something that would never reply. + val supportsContentDiscovery by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + } + + if (!supportsContentDiscovery) return + val favorites by accountViewModel.account.favoriteDvmList.flow .collectAsStateWithLifecycle() From ee94dba570ae897bedbd40fa34507db1b77e4cec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:57:56 +0000 Subject: [PATCH 04/46] fix(home): route DVM listen subscription to DVM relays + wire pull-to-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the first pass: 1. Response routing. The kind-5300 request goes to the DVM's own inbox/used relays, but `filterHomePostsByDvmIds` was subscribing for kind 6300/7000 on the user's outbox/proxy relays. Most DVMs only publish their replies on their own relays, so the listen subscription would silently miss every response. Fix: `Account.requestDVMContentDiscovery` now exposes the relay set the request was sent to, the orchestrator stores it on the snapshot as `responseRelays`, and `FavoriteDvmTopNavPerRelayFilterSet` carries two distinct relay sets — `contentFetches` (user's outbox, for the actual notes) and `listenRelays` (DVM's relays, for the 6300/7000 reply subscription). `filterHomePostsByDvmIds` issues each on the right relay. 2. Pull-to-refresh. Swiping down on Home only re-rendered the cached feed. When a `TopFilter.FavoriteDvm` is active it now also calls `orchestrator.refresh(addr)` so a fresh kind-5300 request is published to the DVM. --- .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../model/dvms/FavoriteDvmOrchestrator.kt | 8 ++- .../favoriteDvm/FavoriteDvmFeedFlow.kt | 7 +- .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 20 +++--- .../FavoriteDvmTopNavPerRelayFilter.kt | 2 - .../FavoriteDvmTopNavPerRelayFilterSet.kt | 13 +++- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 33 ++++++++- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 72 ++++++++++++------- 9 files changed, 114 insertions(+), 49 deletions(-) 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 ec3df21db..c6562a000 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2357,7 +2357,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)) @@ -2367,7 +2367,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/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index f4058cccb..cd92afa0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -24,6 +24,7 @@ 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 @@ -44,12 +45,16 @@ import kotlinx.coroutines.sync.withLock * Immutable snapshot of a favourite DVM'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 FavoriteDvmSnapshot( val requestId: HexKey? = null, + val responseRelays: Set = emptySet(), val ids: Set = emptySet(), val addresses: Set = emptySet(), val latestStatus: NIP90StatusEvent? = null, @@ -114,10 +119,11 @@ class FavoriteDvmOrchestrator( val job = scope.launch(Dispatchers.IO) { try { - account.requestDVMContentDiscovery(user) { request -> + account.requestDVMContentDiscovery(user) { request, relays -> seed.update { it.copy( requestId = request.id, + responseRelays = relays, ids = emptySet(), addresses = emptySet(), latestStatus = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt index f919d62a0..04d35e0d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt @@ -43,12 +43,13 @@ class FavoriteDvmFeedFlow( private fun buildFilter( snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, - relays: Set, + contentRelays: Set, ) = FavoriteDvmTopNavFilter( dvmAddress = dvmAddress, acceptedIds = snapshot.ids, acceptedAddresses = snapshot.addresses, - relayList = relays, + contentRelays = contentRelays, + listenRelays = snapshot.responseRelays, requestId = snapshot.requestId, ) @@ -60,7 +61,7 @@ class FavoriteDvmFeedFlow( override fun startValue(): FavoriteDvmTopNavFilter = buildFilter( snapshot = orchestrator.observe(dvmAddress).value, - relays = resolveRelays(outboxRelays.value, proxyRelays.value), + contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), ) override suspend fun startValue(collector: FlowCollector) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index 093fc390d..e521c6d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -43,7 +43,8 @@ class FavoriteDvmTopNavFilter( val dvmAddress: Address, val acceptedIds: Set, val acceptedAddresses: Set, - val relayList: Set, + val contentRelays: Set, + val listenRelays: Set, val requestId: HexKey?, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey): Boolean = true @@ -56,13 +57,14 @@ class FavoriteDvmTopNavFilter( override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = FavoriteDvmTopNavPerRelayFilterSet( - relayList.associateWith { - FavoriteDvmTopNavPerRelayFilter( - dvmPubkey = dvmAddress.pubKeyHex, - requestId = requestId, - ids = acceptedIds, - addresses = acceptedAddresses, - ) - }, + contentFetches = + contentRelays.associateWith { + FavoriteDvmTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestId = requestId, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt index 5a8f3d7f6..4dcbeddaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable class FavoriteDvmTopNavPerRelayFilter( - val dvmPubkey: HexKey, - val requestId: HexKey?, val ids: Set, val addresses: Set, ) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 4ec47da11..321f5ca2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -21,8 +21,19 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm 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 DVM's own publish relays (where it will deliver future + * kind 6300 / 7000 events for this request). + */ class FavoriteDvmTopNavPerRelayFilterSet( - val set: Map, + val contentFetches: Map, + val listenRelays: Set, + val requestId: HexKey?, ) : IFeedTopNavPerRelayFilterSet 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 c8ebc9643..97b01c336 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 @@ -1809,8 +1809,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/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 38e38dae6..70a539a9e 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 @@ -282,7 +282,20 @@ fun HomeFeeds( accountViewModel: AccountViewModel, nav: INav, ) { - RefresheableBox(feedState, enablePullRefresh) { + val activeFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + val activeDvm = activeFilter as? TopFilter.FavoriteDvm + + val onRefresh: () -> Unit = { + feedState.invalidateData() + if (activeDvm != null) { + // Swiping down on Home should also re-issue the kind-5300 request so the + // DVM produces a fresh feed, not just re-render whatever's cached. + accountViewModel.refreshFavoriteDvm(activeDvm.address) + } + } + + RefresheableHomeBox(onRefresh, enablePullRefresh) { SaveableFeedContentState(feedState, scrollStateKey) { listState -> RenderFeedContentState( feedContentState = feedState, @@ -291,12 +304,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/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 644cc1f54..856f6fd9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet 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 @@ -32,22 +33,40 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent /** * Builds relay REQ filters for a favourite-DVM home feed. * - * Three filters are issued per relay: - * - fetch notes whose ids are in the DVM's latest kind-6300 response - * - fetch addressable notes referenced by `a` tags in the DVM's response - * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot - * keeps up to date while this filter is active + * 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 filterHomePostsByDvmIds( set: FavoriteDvmTopNavPerRelayFilterSet, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?, -): List = - set.set.flatMap { (relay, filter) -> - buildFiltersFor(relay, filter) +): List { + val out = mutableListOf() + + set.contentFetches.forEach { (relay, filter) -> + out += contentFetchFilters(relay, filter) } -private fun buildFiltersFor( + val requestId = set.requestId + if (requestId != null) { + set.listenRelays.forEach { relay -> + out += responseListenFilter(relay, requestId) + } + } + + return out +} + +private fun contentFetchFilters( relay: NormalizedRelayUrl, filter: FavoriteDvmTopNavPerRelayFilter, ): List { @@ -77,23 +96,22 @@ private fun buildFiltersFor( ) } - val requestId = filter.requestId - if (requestId != null) { - out += - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = - listOf( - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, - ), - tags = mapOf("e" to listOf(requestId)), - limit = 10, - ), - ) - } - return out } + +private fun responseListenFilter( + relay: NormalizedRelayUrl, + requestId: HexKey, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), +) From f00b7c9b5fc4ef49a176e0293203115cbcf0bf5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:40:50 +0000 Subject: [PATCH 05/46] fix(badges): use default TopAppBar title font to match other drawer screens --- .../amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index ea22abc1b..562f84516 100644 --- 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R @@ -35,9 +34,6 @@ fun BadgesTopBar( nav: INav, ) { UserDrawerSearchTopBar(accountViewModel, nav) { - Text( - text = stringRes(R.string.badges), - style = MaterialTheme.typography.titleMedium, - ) + Text(text = stringRes(R.string.badges)) } } From 8555074c3f4822347bd2bb4e19e3c7d6e4c1f51a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:48:55 +0000 Subject: [PATCH 06/46] feat(dvm-favorites): settings page + revert DVM card regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove FavoriteDvmToggle from DVMCard's title row; the extra IconButton was expanding the row and pushing the title down so the whole Discover→DVMs card layout looked broken. The toggle stays on the DvmContentDiscoveryScreen top bar, which was the user's expected place to follow/unfollow. - Add a "Favourite DVMs" page under Settings (Route.EditFavoriteDvms, modelled after the Blossom servers settings) listing each favourited content-discovery DVM with its avatar/name/description and a delete button. Tapping a row opens the DVM detail screen. Empty state points users to Discover for adding more. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/discover/nip90DVMs/DVMCard.kt | 8 - .../dvms/favorites/FavoriteDvmListScreen.kt | 226 ++++++++++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 3 + 6 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt 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..972befbcd 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 @@ -95,6 +95,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.FavoriteDvmListScreen 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 @@ -277,6 +278,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { FavoriteDvmListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } 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..057a24397 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 @@ -171,6 +171,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object EditFavoriteDvms : Route() + @Serializable object EditPaymentTargets : Route() @Serializable object UpdateReactionType : Route() 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 4ac46b109..6fa904415 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 @@ -42,7 +42,6 @@ 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 @@ -52,7 +51,6 @@ 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.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -125,12 +123,6 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { - if (baseNote is AddressableNote) { - FavoriteDvmToggle( - appDefinitionNote = baseNote, - accountViewModel = accountViewModel, - ) - } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt new file mode 100644 index 000000000..0f22ae4ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.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 FavoriteDvmListScreen( + 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, + ) + + FavoriteDvmList(accountViewModel, nav) + } + } +} + +@Composable +private fun FavoriteDvmList( + accountViewModel: AccountViewModel, + nav: INav, +) { + val favorites by accountViewModel.account.favoriteDvmList.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() }, + ) { dvmNote -> + FavoriteDvmRow( + dvmNote = dvmNote, + accountViewModel = accountViewModel, + onOpen = { nav.nav(Route.ContentDiscovery(dvmNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteDvm(dvmNote.address) }, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FavoriteDvmRow( + dvmNote: AddressableNote, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, + onRemove: () -> Unit, +) { + val card = observeAppDefinition(dvmNote, 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 = { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + onError = { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = card.name.ifBlank { dvmNote.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/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d0796c8e..69207a03b 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 @@ -122,6 +123,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.EditMediaServers) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.favorite_dvms_title, + icon = Icons.Outlined.AutoAwesome, + tint = tint, + onClick = { nav.nav(Route.EditFavoriteDvms) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.reactions, icon = Icons.Outlined.FavoriteBorder, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9b74fd43b..aa11b8aa3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1733,6 +1733,9 @@ Add DVM to favorites Remove from favorites + Favourite DVMs + Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. Asking %1$s for a feed… Processing your feed… This DVM requires payment From 7440f28405bdab457c54190a60b9878a290678b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:49:02 +0000 Subject: [PATCH 07/46] feat(badges): redesign badge composables with Material3 card layout - Replace the centered, full-width-image RenderBadge with a consistent OutlinedCard (12dp corners, 16dp padding) containing a 72dp rounded thumbnail, titleMedium name, and a bodyMedium description on onSurfaceVariant (max 4 lines). - BadgeDisplay surfaces an Award button as a FilledTonalButton inside the card's action row when the definition is mine. - RenderBadgeAward now reuses the same card and shows: - the badge definition (image + name + description), - a compact "Awarded to N" FlowRow of 30dp user pics (capped at 24, with an overflow label), - a single Accept / Reject action row (TextButton + tonal Accept) or an OutlinedButton "Remove from profile" when already accepted. - Falls back to a robohash thumbnail when no image or thumb is set. --- .../amethyst/ui/note/types/Badge.kt | 307 +++++++++++------- amethyst/src/main/res/values/strings.xml | 3 + 2 files changed, 195 insertions(+), 115 deletions(-) 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 c130b1fa0..fdb777a1b 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,20 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.background 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.material3.Button +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 @@ -41,29 +48,38 @@ 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.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, @@ -71,32 +87,34 @@ fun BadgeDisplay( 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 - if (nav != null && it.pubKey == accountViewModel.userProfile().pubkeyHex) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - Button( + BadgeCard( + imageUrl = definition.thumb()?.ifBlank { null } ?: definition.image(), + name = definition.name(), + description = definition.description(), + ) { + if (isMine && nav != null) { + BadgeActionRow { + FilledTonalButton( onClick = { nav.nav( - com.vitorpamplona.amethyst.ui.navigation.routes.Route.AwardBadge( - kind = it.kind, - pubKeyHex = it.pubKey, - dTag = it.dTag(), + 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)) } } @@ -104,76 +122,6 @@ fun BadgeDisplay( } } -@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), - ) { - 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, - ) - } - } - } -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderBadgeAward( @@ -185,32 +133,154 @@ 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(), + ) { + 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?, + actions: @Composable () -> Unit = {}, +) { + OutlinedCard( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + 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) - } - - AcceptBadgeControls(noteEvent, accountViewModel) } @Composable @@ -245,10 +315,7 @@ private fun AcceptBadgeControls( awardIds.contains(award.id) } - Row( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - horizontalArrangement = Arrangement.End, - ) { + BadgeActionRow { if (isAccepted) { OutlinedButton( onClick = { @@ -260,7 +327,7 @@ private fun AcceptBadgeControls( Text(stringRes(R.string.unaccept_badge)) } } else { - OutlinedButton( + TextButton( onClick = { accountViewModel.launchSigner { accountViewModel.account.removeAcceptedBadge(award) @@ -270,13 +337,11 @@ private fun AcceptBadgeControls( Text(stringRes(R.string.reject_badge)) } Spacer(modifier = Modifier.size(8.dp)) - Button( + FilledTonalButton( onClick = { accountViewModel.launchSigner { val defAddr = award.awardDefinition().firstOrNull() ?: return@launchSigner - val defNote = - com.vitorpamplona.amethyst.model.LocalCache - .getAddressableNoteIfExists(defAddr) + val defNote = LocalCache.getAddressableNoteIfExists(defAddr) val defEvent = defNote?.event as? BadgeDefinitionEvent ?: return@launchSigner accountViewModel.account.addAcceptedBadge(award, defEvent) } @@ -287,3 +352,15 @@ private fun AcceptBadgeControls( } } } + +@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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b961ce5b9..0ca6aa869 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -444,6 +444,9 @@ Recipients (npub or hex, one per line) npub1…\nnpub1… %1$d recipient(s) will receive this badge + Untitled badge + Awarded to %1$d + You received a badge Pictures Shorts Videos From 309e474a3dd4c570d1a86edfce41962a594b1ab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:02:53 +0000 Subject: [PATCH 08/46] fix(dvm-card): star top-right + move reactions to bottom row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Putting the star back where the user actually sees DVMs first — the Discover Content card. Two changes that together stop the layout from breaking: - FavoriteDvmToggle now uses ClickableBox + small Icon (no IconButton padding) so it occupies the same compact footprint as LikeReaction. Adding it to LeftPictureLayout's title row no longer inflates the row height. - DVMCard's title row now holds only the name (weight 1) plus the star toggle on the right. LikeReaction and ZapReaction move down to the bottom row, pushed to the far right with a Spacer(weight 1) so the amount/personalised chips stay on the left. --- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 98 +++++++++---------- .../screen/loggedIn/dvms/FavoriteDvmToggle.kt | 24 +++-- 2 files changed, 61 insertions(+), 61 deletions(-) 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..0ca21209b 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.FavoriteDvmToggle 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) { + FavoriteDvmToggle( + 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/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt index 98cd9f062..1bed004b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -24,7 +24,6 @@ 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,6 +32,7 @@ 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 @@ -40,14 +40,24 @@ 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 FavoriteDvmToggle( appDefinitionNote: AddressableNote, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + iconSizeModifier: Modifier = Size20Modifier, ) { - // Only NIP-90 content-discovery DVMs (kind 5300) produce a feed; hide the toggle - // for any other DVM type so users don't favourite something that would never reply. val supportsContentDiscovery by observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true @@ -60,7 +70,8 @@ fun FavoriteDvmToggle( val isFavorite = favorites.contains(appDefinitionNote.address) - IconButton( + ClickableBox( + modifier = modifier, onClick = { if (isFavorite) { accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) @@ -73,20 +84,19 @@ fun FavoriteDvmToggle( ) } }, - modifier = modifier, ) { if (isFavorite) { Icon( imageVector = Icons.Filled.Star, contentDescription = stringRes(R.string.remove_dvm_from_favorites), - modifier = Size20Modifier, + modifier = iconSizeModifier, tint = MaterialTheme.colorScheme.primary, ) } else { Icon( imageVector = Icons.Outlined.StarBorder, contentDescription = stringRes(R.string.add_dvm_to_favorites), - modifier = Size20Modifier, + modifier = iconSizeModifier, tint = MaterialTheme.colorScheme.onSurface, ) } From ecdbc80fc1721e79c19668caeb76e47ccfead0d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:06:49 +0000 Subject: [PATCH 09/46] feat: preview PDF links inline with first-page thumbnail and full pager Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs (detected by .pdf extension, NIP-92 imeta m tag, or application/pdf Content-Type) render a card showing the first page, filename, and page count. Tapping opens a full-screen HorizontalPager over every page rendered on demand with PdfRenderer. Long-press surfaces the existing share menu. Uses only the built-in Android PdfRenderer; no new dependencies. Desktop continues to fall back to a clickable link. --- .../amethyst/service/previews/UrlPreview.kt | 2 + .../amethyst/ui/components/LoadUrlPreview.kt | 10 + .../amethyst/ui/components/RichTextViewer.kt | 5 + .../ui/components/ZoomableContentView.kt | 39 ++- .../amethyst/ui/components/pdf/PdfFetcher.kt | 89 +++++ .../ui/components/pdf/PdfPreviewCard.kt | 210 ++++++++++++ .../ui/components/pdf/PdfViewerDialog.kt | 305 ++++++++++++++++++ .../commons/richtext/MediaContentModels.kt | 11 + .../commons/richtext/RichTextParser.kt | 38 ++- .../richtext/RichTextParserSegments.kt | 5 + .../commons/richtext/PdfParserTest.kt | 69 ++++ 11 files changed, 772 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt 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/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/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 08f3ccc1c..469afe4ff 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 @@ -86,6 +86,7 @@ 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 @@ -93,6 +94,8 @@ 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 @@ -221,18 +224,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, + ) + } } } 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..fdc71ef55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt @@ -0,0 +1,89 @@ +/* + * 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.content.Context +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import okio.sink +import java.io.File +import java.io.IOException + +object PdfFetcher { + private const val CACHE_DIR_NAME = "pdf_cache" + + private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } + + fun cachedFileOrNull( + context: Context, + url: String, + ): File? { + val file = cacheFile(context, url) + return if (file.exists() && file.length() > 0) file else null + } + + fun cacheFile( + context: Context, + url: String, + ): File { + val key = sha256(url.toByteArray()).toHexKey() + return File(cacheDir(context), "$key.pdf") + } + + suspend fun fetch( + context: Context, + url: String, + okHttpClient: (String) -> OkHttpClient, + ): File = + withContext(Dispatchers.IO) { + val file = cacheFile(context, url) + if (file.exists() && file.length() > 0) return@withContext file + + 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}") + } + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.outputStream().use { out -> + val bytes = response.body.source().readAll(out.sink()) + if (bytes == 0L) throw IOException("PDF download failed: empty response body") + } + if (!tmp.renameTo(file)) { + tmp.copyTo(file, overwrite = true) + tmp.delete() + } + } + + file + } +} 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..15855f317 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -0,0 +1,210 @@ +/* + * 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.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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.DisplayUrlWithLoadingSymbol +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay +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 + +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() +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PdfPreviewCard( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, +) { + val context = LocalContext.current + val sharePopupExpanded = remember { mutableStateOf(false) } + + @Suppress("ProduceStateDoesNotAssignValue") + val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url) { + value = + try { + val file = + PdfFetcher.fetch(context, content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + withContext(Dispatchers.IO) { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + val pageCount = renderer.pageCount + if (pageCount <= 0) { + PdfLoadState.Failed + } else { + renderer.openPage(0).use { page -> + val bitmap = Bitmap.createBitmap(page.width, page.height, 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(), + ), + ) + } + } + } + } + } + } 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 }, + ) + + when (val current = state) { + is PdfLoadState.Loading -> { + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast) + } + } + + is PdfLoadState.Failed -> { + ClickableUrl(urlText = content.url, url = content.url) + } + + is PdfLoadState.Ready -> { + val filename = remember(content.url) { extractFilename(content.url) } + 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, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)), + ) + + 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 = pageCountLabel(current.preview.pageCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + + Spacer(modifier = DoubleVertSpacer) + } + } + } +} + +private fun extractFilename(url: String): String { + val afterQuery = url.substringBefore('?').substringBefore('#') + val name = afterQuery.substringAfterLast('/', afterQuery) + return if (name.isBlank()) url else name +} + +private 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..c9a8a21ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -0,0 +1,305 @@ +/* + * 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.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +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.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import java.io.File + +private class PdfDocumentHandle( + val file: File, + val pfd: ParcelFileDescriptor, + val renderer: PdfRenderer, +) { + val pageCount: Int get() = renderer.pageCount + val mutex: Mutex = Mutex() + + fun close() { + try { + renderer.close() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to close PdfRenderer", e) + } + try { + pfd.close() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to close ParcelFileDescriptor", e) + } + } +} + +@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, +) { + val context = LocalContext.current + + @Suppress("ProduceStateDoesNotAssignValue") + val handleState by produceState(initialValue = null, key1 = content.url) { + value = + try { + val file = + PdfFetcher.fetch(context, content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + withContext(Dispatchers.IO) { + val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(pfd) + PdfDocumentHandle(file, pfd, renderer) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e) + null + } + } + + DisposableEffect(handleState) { + onDispose { + handleState?.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) { mutableStateMapOf() } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + PdfPageView( + handle = handle, + pageIndex = pageIndex, + cache = pageCache, + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size15dp, vertical = Size10dp) + .statusBarsPadding() + .systemBarsPadding(), + 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), + ) + } + } + } +} + +@Composable +private fun PdfPageView( + handle: PdfDocumentHandle, + pageIndex: Int, + cache: MutableMap, +) { + val cached = cache[pageIndex] + + @Suppress("ProduceStateDoesNotAssignValue") + val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { + if (value != null) return@produceState + val rendered = + try { + handle.mutex.withLock { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val scale = 2f + val width = (page.width * scale).toInt().coerceAtLeast(1) + val height = (page.height * scale).toInt().coerceAtLeast(1) + 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", e) + null + } + + rendered?.let { cache[pageIndex] = it } + value = rendered + } + + val zoomState = rememberZoomState() + val current = bitmap + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (current != null) { + Image( + bitmap = current.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .zoomable(zoomState), + ) + } else { + CircularProgressIndicator(color = Color.White) + } + } +} 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..9b1a36cbb 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 @@ -68,6 +68,17 @@ class EncryptedMediaUrlImage( val encryptionNonce: ByteArray, ) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) +@Immutable +open class MediaUrlPdf( + url: String, + description: String? = null, + hash: String? = null, + blurhash: String? = null, + dim: DimensionTag? = null, + uri: String? = null, + mimeType: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) + @Immutable open class MediaUrlVideo( url: String, 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..e832568c0 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 @@ -58,17 +58,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) { @@ -93,6 +97,16 @@ class RichTextParser { uri = callbackUri, mimeType = contentType, ) + } 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, + ) } else { null } @@ -158,6 +172,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 +180,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 +212,7 @@ class RichTextParser { content: String, images: Set, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -211,7 +227,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 +278,7 @@ class RichTextParser { word: String, images: Set, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -288,6 +305,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 +402,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 +448,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 +528,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/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")) + } +} From 7a8dc0239462acee42ac16afa08929eccc63f7e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:45:26 +0000 Subject: [PATCH 10/46] refactor(badges): single feed + top-nav filter, profile badges to settings Restructure the Badges screen to match Polls and other feeds: - Drop the 4-tab pager in favor of a single feed of BadgeDefinitionEvent (kind 30009), with a FeedFilterSpinner in the top bar. - Introduce TopFilter.Mine as a selectable option so the same spinner switches between follow-list semantics and "only badges I authored". Defaults to AllFollows. - New unified BadgesFeedFilter reading defaultBadgesFollowList. - BadgesSubAssembler now uses PerUserAndFollowListEoseManager (limit 100). Mine subscribes to outbox with authors=me; everything else dispatches makeBadgesFilter across follow-list/global/authors/muted per-relay filter sets, identical in shape to the Polls pipeline. - Feed states: replace badgesReceived / badgesMine / badgesAwarded / badgesDiscover with a single badgesFeed. Navigation into a badge definition now surfaces its full award history: BadgeAwardEvent.KIND is added to RepliesAndReactionsToAddressesKinds1, so the existing thread view of a kind 30009 note pulls in every kind 8 referencing it via the `a` tag. Received-badge management moves to a dedicated settings page: - New Route.ProfileBadges + ProfileBadgesScreen listing every BadgeAwardEvent where I'm a `p` recipient with a Switch per row that toggles it into the ProfileBadgesEvent (10008). - Linked from AllSettingsScreen via a MilitaryTech row. --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 15 ++ .../topNavFeeds/FeedTopNavFilterState.kt | 4 + .../FilterRepliesAndReactionsToAddresses.kt | 2 + .../ui/feeds/RememberForeverStates.kt | 6 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../amethyst/ui/screen/TopNavFilterState.kt | 23 ++ .../loggedIn/AccountFeedContentStates.kt | 20 +- .../ui/screen/loggedIn/badges/BadgesScreen.kt | 164 +++---------- .../ui/screen/loggedIn/badges/BadgesTopBar.kt | 35 ++- .../badges/dal/BadgesAwardedFeedFilter.kt | 60 ----- .../badges/dal/BadgesDiscoverFeedFilter.kt | 59 ----- ...sMineFeedFilter.kt => BadgesFeedFilter.kt} | 53 ++++- .../badges/dal/BadgesReceivedFeedFilter.kt | 59 ----- .../datasource/BadgesFilterAssembler.kt | 4 + .../BadgesFilterAssemblerSubscription.kt | 3 +- .../badges/datasource/BadgesSubAssembler.kt | 71 +++++- .../badges/datasource/FilterBadges.kt | 131 +++++++++-- .../badges/profile/ProfileBadgesScreen.kt | 219 ++++++++++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 4 + 22 files changed, 585 insertions(+), 362 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/{BadgesMineFeedFilter.kt => BadgesFeedFilter.kt} (53%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt 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 47263bd67..428dbdb1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -459,6 +459,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 { 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..bb1476e36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -125,6 +125,9 @@ sealed class TopFilter( @Serializable object Chess : TopFilter(" Chess ") + @Serializable + object Mine : TopFilter(" Mine ") + @Serializable class PeopleList( val address: Address, @@ -174,6 +177,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.AllFollows), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, @@ -503,6 +507,17 @@ class AccountSettings( } } + fun changeDefaultBadgesFollowList(name: FeedDefinition) { + changeDefaultBadgesFollowList(name.code) + } + + fun changeDefaultBadgesFollowList(name: TopFilter) { + if (defaultBadgesFollowList.value != name) { + defaultBadgesFollowList.tryEmit(name) + saveAccountSettings() + } + } + // --- // language services // --- 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..29a169e61 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 @@ -89,6 +89,10 @@ class FeedTopNavFilterState( ChessFeedFlow(followsRelays, proxyRelays) } + TopFilter.Mine -> { + AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) + } + is TopFilter.Community -> { NoteFeedFlow( LocalCache 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/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index ddffb0e62..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,10 +58,7 @@ object ScrollStateKeys { const val POLLS_SCREEN = "PollsFeed" const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_CLOSED = "PollsClosedFeed" - const val BADGES_RECEIVED = "BadgesReceivedFeed" - const val BADGES_MINE = "BadgesMineFeed" - const val BADGES_AWARDED = "BadgesAwardedFeed" - const val BADGES_DISCOVER = "BadgesDiscoverFeed" + const val BADGES_SCREEN = "BadgesFeed" const val PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" @@ -77,7 +74,6 @@ object PagerStateKeys { const val HOME_SCREEN = "PagerHome" const val DISCOVER_SCREEN = "PagerDiscover" const val POLLS_SCREEN = "PagerPolls" - const val BADGES_SCREEN = "PagerBadges" } @Composable 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 c199acadd..efbf4541d 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 @@ -67,6 +67,7 @@ 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.post.NewBadgeScreen +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 @@ -218,6 +219,7 @@ fun BuildNavigation( composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } + composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } 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 6f50d2d80..6a870f2bf 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 @@ -47,6 +47,8 @@ sealed class Route { @Serializable object Badges : Route() + @Serializable object ProfileBadges : Route() + @Serializable data class NewBadge( val editDTag: String? = null, ) : Route() 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..d8a0c7cfb 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 @@ -96,6 +96,12 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) + val mineFollow = + FeedDefinition( + code = TopFilter.Mine, + name = ResourceName(R.string.follow_list_mine), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( @@ -211,6 +217,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 +251,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}" } } 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 ee72b6faa..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,10 +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.BadgesAwardedFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesDiscoverFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesMineFeedFilter -import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesReceivedFeedFilter +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 @@ -85,10 +82,7 @@ class AccountFeedContentStates( val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache) val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache) - val badgesReceived = FeedContentState(BadgesReceivedFeedFilter(account), scope, LocalCache) - val badgesMine = FeedContentState(BadgesMineFeedFilter(account), scope, LocalCache) - val badgesAwarded = FeedContentState(BadgesAwardedFeedFilter(account), scope, LocalCache) - val badgesDiscover = FeedContentState(BadgesDiscoverFeedFilter(account), scope, LocalCache) + val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache) val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) @@ -134,10 +128,7 @@ class AccountFeedContentStates( openPollsFeed.updateFeedWith(newNotes) closedPollsFeed.updateFeedWith(newNotes) - badgesReceived.updateFeedWith(newNotes) - badgesMine.updateFeedWith(newNotes) - badgesAwarded.updateFeedWith(newNotes) - badgesDiscover.updateFeedWith(newNotes) + badgesFeed.updateFeedWith(newNotes) picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) @@ -177,10 +168,7 @@ class AccountFeedContentStates( openPollsFeed.deleteFromFeed(newNotes) closedPollsFeed.deleteFromFeed(newNotes) - badgesReceived.deleteFromFeed(newNotes) - badgesMine.deleteFromFeed(newNotes) - badgesAwarded.deleteFromFeed(newNotes) - badgesDiscover.deleteFromFeed(newNotes) + badgesFeed.deleteFromFeed(newNotes) picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) 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 index 35a0d3cf4..2551d3f3c 100644 --- 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 @@ -20,143 +20,54 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.Color -import com.vitorpamplona.amethyst.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys 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.feeds.rememberForeverPagerState 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 -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.launch @Composable fun BadgesScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val feedStates = accountViewModel.feedStates - - WatchLifecycleAndUpdateModel(feedStates.badgesReceived) - WatchLifecycleAndUpdateModel(feedStates.badgesMine) - WatchLifecycleAndUpdateModel(feedStates.badgesAwarded) - WatchLifecycleAndUpdateModel(feedStates.badgesDiscover) - - BadgesFilterAssemblerSubscription(accountViewModel) - - AssembleBadgesTabs( - received = feedStates.badgesReceived, - mine = feedStates.badgesMine, - awarded = feedStates.badgesAwarded, - discover = feedStates.badgesDiscover, - ) { pagerState, tabs -> - BadgesPages(pagerState, tabs, accountViewModel, nav) - } + BadgesScreen( + feedContentState = accountViewModel.feedStates.badgesFeed, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable -private fun AssembleBadgesTabs( - received: FeedContentState, - mine: FeedContentState, - awarded: FeedContentState, - discover: FeedContentState, - inner: @Composable (PagerState, ImmutableList) -> Unit, -) { - val pagerState = rememberForeverPagerState(key = PagerStateKeys.BADGES_SCREEN) { 4 } - - val tabs by - remember(received, mine, awarded, discover) { - mutableStateOf( - listOf( - BadgesTabItem( - resource = R.string.received_badges, - feedState = received, - routeForLastRead = "BadgesReceivedFeed", - scrollStateKey = ScrollStateKeys.BADGES_RECEIVED, - ), - BadgesTabItem( - resource = R.string.my_badges, - feedState = mine, - routeForLastRead = "BadgesMineFeed", - scrollStateKey = ScrollStateKeys.BADGES_MINE, - ), - BadgesTabItem( - resource = R.string.awarded_badges, - feedState = awarded, - routeForLastRead = "BadgesAwardedFeed", - scrollStateKey = ScrollStateKeys.BADGES_AWARDED, - ), - BadgesTabItem( - resource = R.string.discover_badges, - feedState = discover, - routeForLastRead = "BadgesDiscoverFeed", - scrollStateKey = ScrollStateKeys.BADGES_DISCOVER, - ), - ).toImmutableList(), - ) - } - - inner(pagerState, tabs) -} - -@Composable -private fun BadgesPages( - pagerState: PagerState, - tabs: ImmutableList, +fun BadgesScreen( + feedContentState: FeedContentState, accountViewModel: AccountViewModel, nav: INav, ) { + WatchLifecycleAndUpdateModel(feedContentState) + WatchAccountForBadgesScreen(feedContentState, accountViewModel) + BadgesFilterAssemblerSubscription(accountViewModel) + DisappearingScaffold( isInvertedLayout = false, topBar = { - Column { - BadgesTopBar(accountViewModel, nav) - SecondaryTabRow( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - modifier = TabRowHeight, - selectedTabIndex = pagerState.currentPage, - ) { - val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { Text(text = stringRes(tab.resource)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - ) - } - } - } + BadgesTopBar(accountViewModel, nav) }, bottomBar = { AppBottomBar(Route.Badges, accountViewModel) { route -> if (route == Route.Badges) { - tabs[pagerState.currentPage].feedState.sendToTop() + feedContentState.sendToTop() } else { nav.newStack(route) } @@ -167,30 +78,31 @@ private fun BadgesPages( }, accountViewModel = accountViewModel, ) { - HorizontalPager( - contentPadding = it, - state = pagerState, - userScrollEnabled = true, - ) { page -> - RefresheableBox(tabs[page].feedState, true) { - SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderFeedContentState( - feedContentState = tabs[page].feedState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = tabs[page].routeForLastRead, - ) - } + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "BadgesFeed", + ) } } } } -@Immutable -class BadgesTabItem( - val resource: Int, - val feedState: FeedContentState, - val routeForLastRead: String, - val scrollStateKey: String, -) +@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 index 562f84516..522a6d765 100644 --- 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 @@ -20,11 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.material3.Text 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 @@ -34,6 +39,32 @@ fun BadgesTopBar( nav: INav, ) { UserDrawerSearchTopBar(accountViewModel, nav) { - Text(text = stringRes(R.string.badges)) + 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/dal/BadgesAwardedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt deleted file mode 100644 index 76bf689e8..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesAwardedFeedFilter.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent - -class BadgesAwardedFeedFilter( - val account: Account, -) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-awarded-" + account.userProfile().pubkeyHex - - override fun limit() = 200 - - override fun showHiddenKey(): Boolean = false - - private fun myPubkey(): String = account.userProfile().pubkeyHex - - override fun feed(): List { - val me = myPubkey() - val notes = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeAwardEvent && noteEvent.pubKey == me - } - return sort(notes) - } - - override fun applyFilter(newItems: Set): Set { - val me = myPubkey() - return newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeAwardEvent && noteEvent.pubKey == me - } - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt deleted file mode 100644 index 3732e680b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesDiscoverFeedFilter.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent - -class BadgesDiscoverFeedFilter( - val account: Account, -) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-discover-" + account.userProfile().pubkeyHex - - override fun limit() = 200 - - override fun showHiddenKey(): Boolean = false - - private fun isHidden(pubKey: String): Boolean = - account.hiddenUsers.flow.value.hiddenUsers - .contains(pubKey) - - override fun feed(): List { - val notes = - LocalCache.addressables.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) - } - return sort(notes) - } - - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && !isHidden(noteEvent.pubKey) - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt index 94d1a2191..049940997 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesMineFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesFeedFilter.kt @@ -23,38 +23,71 @@ 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 BadgesMineFeedFilter( +class BadgesFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-mine-" + account.userProfile().pubkeyHex + override fun feedKey(): String = account.userProfile().pubkeyHex + "-badges-" + followList().code - override fun limit() = 200 + override fun limit() = 100 - override fun showHiddenKey(): Boolean = false + 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 me = myPubkey() val notes = - LocalCache.addressables.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me + 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 { - val me = myPubkey() + 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 && noteEvent.pubKey == me + 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/dal/BadgesReceivedFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt deleted file mode 100644 index af4c0dcc4..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/dal/BadgesReceivedFeedFilter.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent - -class BadgesReceivedFeedFilter( - val account: Account, -) : AdditiveFeedFilter() { - override fun feedKey(): String = "badges-received-" + account.userProfile().pubkeyHex - - override fun limit() = 200 - - override fun showHiddenKey(): Boolean = false - - private fun myPubkey(): String = account.userProfile().pubkeyHex - - private fun awardsMe(noteEvent: BadgeAwardEvent): Boolean = noteEvent.awardeeIds().contains(myPubkey()) - - override fun feed(): List { - val notes = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is BadgeAwardEvent && awardsMe(noteEvent) - } - return sort(notes) - } - - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is BadgeAwardEvent && awardsMe(noteEvent) - } - - 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 index 774518ced..74d95bc09 100644 --- 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 @@ -23,10 +23,14 @@ 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 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 index 6490033a4..3d4b818ea 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -40,7 +41,7 @@ fun BadgesFilterAssemblerSubscription( ) { val state = remember(accountViewModel.account) { - BadgesQueryState(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 index de15650f8..5971b8b40 100644 --- 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 @@ -20,19 +20,84 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +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, -) : PerUserEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: BadgesQueryState, since: SincePerRelayMap?, - ): List = filterMyBadges(user(key), since) + ): 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 index af71f1dba..ba08ae632 100644 --- 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 @@ -20,41 +20,130 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource -import com.vitorpamplona.amethyst.model.User +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.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils -/** - * Subscribes to: - * - Badge definitions (kind 30009) authored by me — covers "Mine" tab. - * - Badge awards (kind 8) authored by me — covers "Awarded" tab. - * - * Received badges (kind 8 with `#p`=me) are already pulled via the standard - * notifications subscription (FilterNotificationsToPubkey), so we do not - * duplicate that here. - */ -fun filterMyBadges( - user: User, +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 { - val relays = - user.outboxRelays()?.ifEmpty { null } - ?: user.allUsedRelaysOrNull() - ?: return emptyList() - + if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() + val authors = listOf(pubkey) return relays.map { relay -> RelayBasedFilter( relay = relay, filter = Filter( - kinds = listOf(BadgeDefinitionEvent.KIND, BadgeAwardEvent.KIND), - authors = listOf(user.pubkeyHex), - limit = 500, + 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/profile/ProfileBadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt new file mode 100644 index 000000000..d5c02bc07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt @@ -0,0 +1,219 @@ +/* + * 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.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.getValue +import androidx.compose.runtime.remember +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.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +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 + +@Composable +fun ProfileBadgesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val myPubkey = accountViewModel.userProfile().pubkeyHex + + 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() + } + + val receivedAwards = + remember(myPubkey, newState, oldState) { + LocalCache.notes + .filterIntoSet { _, it -> + val event = it.event + event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey) + }.mapNotNull { it.event as? BadgeAwardEvent } + .sortedByDescending { 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, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun AwardRow( + award: BadgeAwardEvent, + isAccepted: Boolean, + accountViewModel: AccountViewModel, +) { + val defAddr = award.awardDefinition().firstOrNull() + val definition = + remember(award.id) { + defAddr?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? BadgeDefinitionEvent } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + 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, + 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/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d0796c8e..47104756d 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 @@ -36,6 +36,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 +123,13 @@ 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.reactions, icon = Icons.Outlined.FavoriteBorder, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0ca6aa869..99d6613fe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -447,6 +447,9 @@ Untitled badge Awarded to %1$d You received a badge + Profile badges + Choose which of the badges you\'ve received appear on your profile. + You haven\'t received any badges yet. Pictures Shorts Videos @@ -652,6 +655,7 @@ Around Me Global Chess + Mine Mute List Follow Lists From 9fbe8eba78df6f4b066a87a1592665d6fa1184b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:59:50 +0000 Subject: [PATCH 11/46] refactor(pdf): share Coil disk cache, gate on showImages, cap bitmap size - PdfFetcher now reuses Amethyst.instance.diskCache (Coil) via openSnapshot/openEditor instead of a custom cacheDir folder. PDFs share the same LRU budget as images and benefit from automatic eviction. - PdfPreviewCard now respects accountViewModel.settings.showImages(): when disabled, shows a lightweight "Tap to load PDF" placeholder and only downloads+renders after the user opts in. - Both the card thumbnail (1600px) and viewer page (2048px) bitmaps are capped to a maximum longest-side dimension, preventing OOM on very large or unusually tall PDF pages. - PdfViewerDialog holds the cache snapshot for the dialog's lifetime so the underlying file can't be evicted mid-view, and closes it in DisposableEffect alongside the renderer and ParcelFileDescriptor. --- .../amethyst/ui/components/pdf/PdfFetcher.kt | 88 +++----- .../ui/components/pdf/PdfPreviewCard.kt | 211 ++++++++++++------ .../ui/components/pdf/PdfViewerDialog.kt | 48 ++-- 3 files changed, 206 insertions(+), 141 deletions(-) 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 index fdc71ef55..4932b0a97 100644 --- 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 @@ -20,70 +20,56 @@ */ package com.vitorpamplona.amethyst.ui.components.pdf -import android.content.Context -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.utils.sha256.sha256 +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 okio.sink -import java.io.File import java.io.IOException object PdfFetcher { - private const val CACHE_DIR_NAME = "pdf_cache" - - private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } - - fun cachedFileOrNull( - context: Context, - url: String, - ): File? { - val file = cacheFile(context, url) - return if (file.exists() && file.length() > 0) file else null - } - - fun cacheFile( - context: Context, - url: String, - ): File { - val key = sha256(url.toByteArray()).toHexKey() - return File(cacheDir(context), "$key.pdf") - } - - suspend fun fetch( - context: Context, + /** + * 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, - ): File = - withContext(Dispatchers.IO) { - val file = cacheFile(context, url) - if (file.exists() && file.length() > 0) return@withContext file + ): DiskCache.Snapshot { + val diskCache = Amethyst.instance.diskCache + diskCache.openSnapshot(url)?.let { return it } - val request = - Request - .Builder() - .url(url) - .get() - .build() + 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}") - } - val tmp = File(file.parentFile, "${file.name}.tmp") - tmp.outputStream().use { out -> - val bytes = response.body.source().readAll(out.sink()) - if (bytes == 0L) throw IOException("PDF download failed: empty response body") - } - if (!tmp.renameTo(file)) { - tmp.copyTo(file, overwrite = true) - tmp.delete() + 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 } - - file } + } } 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 index 15855f317..5d01fcb21 100644 --- 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 @@ -47,14 +47,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +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.DisplayUrlWithLoadingSymbol import com.vitorpamplona.amethyst.ui.components.ShareMediaAction -import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding @@ -65,6 +64,9 @@ 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, @@ -81,47 +83,55 @@ private sealed class PdfLoadState { data object Failed : PdfLoadState() } -@OptIn(ExperimentalFoundationApi::class) @Composable fun PdfPreviewCard( content: MediaUrlPdf, accountViewModel: AccountViewModel, onOpen: () -> Unit, ) { - val context = LocalContext.current + 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) { + val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) { value = try { - val file = - PdfFetcher.fetch(context, content.url) { url -> + PdfFetcher + .fetchSnapshot(content.url) { url -> accountViewModel.httpClientBuilder.okHttpClientForPreview(url) - } - withContext(Dispatchers.IO) { - ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> - PdfRenderer(pfd).use { renderer -> - val pageCount = renderer.pageCount - if (pageCount <= 0) { - PdfLoadState.Failed - } else { - renderer.openPage(0).use { page -> - val bitmap = Bitmap.createBitmap(page.width, page.height, 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(), - ), - ) - } - } + }.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) @@ -136,11 +146,11 @@ fun PdfPreviewCard( onDismiss = { sharePopupExpanded.value = false }, ) + val filename = remember(content.url) { extractFilename(content.url) } + when (val current = state) { is PdfLoadState.Loading -> { - WaitAndDisplay { - DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast) - } + PdfSkeletonCard(filename) } is PdfLoadState.Failed -> { @@ -148,7 +158,6 @@ fun PdfPreviewCard( } is PdfLoadState.Ready -> { - val filename = remember(content.url) { extractFilename(content.url) } Column( modifier = MaterialTheme.colorScheme.innerPostModifier @@ -167,33 +176,7 @@ fun PdfPreviewCard( .aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)), ) - 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 = pageCountLabel(current.preview.pageCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } + FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount)) Spacer(modifier = DoubleVertSpacer) } @@ -201,10 +184,112 @@ fun PdfPreviewCard( } } -private fun extractFilename(url: String): String { +@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) + 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(), + ), + ) + } + } + } + +/** + * Scales the page's native point size to fit within [maxDim] on the longest side, preserving + * aspect ratio. Falls back to native size if it's already smaller. + */ +internal fun cappedRenderSize( + pageWidth: Int, + pageHeight: Int, + maxDim: Int, +): Pair { + if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1 + val longest = maxOf(pageWidth, pageHeight) + if (longest <= maxDim) return pageWidth to pageHeight + val scale = maxDim.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 } -private fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages" +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 index c9a8a21ff..eb7402988 100644 --- 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 @@ -60,9 +60,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext 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 @@ -80,10 +80,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -import java.io.File + +// Hard ceiling on each rendered page bitmap, in pixels. Prevents OOM on very large pages. +private const val VIEWER_MAX_DIM_PX = 2048 private class PdfDocumentHandle( - val file: File, + val snapshot: DiskCache.Snapshot, val pfd: ParcelFileDescriptor, val renderer: PdfRenderer, ) { @@ -91,18 +93,9 @@ private class PdfDocumentHandle( val mutex: Mutex = Mutex() fun close() { - try { - renderer.close() - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("PdfViewerDialog", "Failed to close PdfRenderer", e) - } - try { - pfd.close() - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("PdfViewerDialog", "Failed to close ParcelFileDescriptor", e) - } + 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) } } } @@ -136,20 +129,23 @@ private fun PdfViewerContent( accountViewModel: AccountViewModel, onDismiss: () -> Unit, ) { - val context = LocalContext.current - @Suppress("ProduceStateDoesNotAssignValue") val handleState by produceState(initialValue = null, key1 = content.url) { value = try { - val file = - PdfFetcher.fetch(context, content.url) { url -> - accountViewModel.httpClientBuilder.okHttpClientForPreview(url) - } withContext(Dispatchers.IO) { - val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) - val renderer = PdfRenderer(pfd) - PdfDocumentHandle(file, pfd, renderer) + 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 @@ -265,9 +261,7 @@ private fun PdfPageView( handle.mutex.withLock { withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> - val scale = 2f - val width = (page.width * scale).toInt().coerceAtLeast(1) - val height = (page.height * scale).toInt().coerceAtLeast(1) + val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) 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) From 9ba2d2f5cc8819aae9f9508bc52dab8be3154966 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 00:12:26 +0000 Subject: [PATCH 12/46] feat(dvm-favorites): timeout, tests, and merged "All favourite DVMs" chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DVM response timeout. FavoriteDvmOrchestrator now times out after 20s if neither a 6300 response nor any 7000 status arrives, and sets errorMessage = "timeout" on the snapshot so the home banner switches from the "Asking…" spinner to a Retry button instead of hanging forever. 2. Tests. FavoriteDvmListEventTest covers create/add/remove round trips and the fixed-empty d-tag invariant; FavoriteDvmTopNavFilter match by id and by `a` address; FilterHomePostsByDvmIdsTest covers the two-relay-set split (content fetch on user relays, listen on DVM relays) and the multi-requestId merge case. Also registered kind 10090 in EventFactory so Quartz can deserialise FavoriteDvmListEvent (required for the round-trip tests and for reading the list back from relays). 3. Merged "All favourite DVMs" chip. New TopFilter.AllFavoriteDvms that unions every favourite's latest 6300 response into one feed. AllFavoriteDvmsFeedFlow uses flatMapLatest over the favourite-list flow so subscriptions rewire when the user adds/removes a DVM. FavoriteDvmTopNavPerRelayFilterSet now carries Set requestIds (was a single nullable) so the filter can subscribe to N kind 6300/7000 streams in one REQ per DVM relay. Banner renders "Asking your favourite DVMs for feeds…" while all are pending and a single Retry-all on collective error; pull-to-refresh re-issues every DVM's kind-5300. --- .../vitorpamplona/amethyst/model/Account.kt | 1 + .../amethyst/model/AccountSettings.kt | 2 + .../model/dvms/FavoriteDvmOrchestrator.kt | 20 ++ .../topNavFeeds/FeedTopNavFilterState.kt | 12 + .../favoriteDvm/AllFavoriteDvmsFeedFlow.kt | 115 ++++++++++ .../AllFavoriteDvmsTopNavFilter.kt | 67 ++++++ .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 2 +- .../FavoriteDvmTopNavPerRelayFilterSet.kt | 9 +- .../navigation/topbars/FeedFilterSpinner.kt | 5 + .../amethyst/ui/screen/TopNavFilterState.kt | 13 +- .../screen/loggedIn/home/DvmStatusBanner.kt | 206 +++++++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 13 +- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 12 +- amethyst/src/main/res/values/strings.xml | 2 + .../FavoriteDvmTopNavFilterTest.kt | 129 +++++++++++ .../nip90Dvms/FilterHomePostsByDvmIdsTest.kt | 136 ++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../FavoriteDvmListEventTest.kt | 159 ++++++++++++++ 18 files changed, 814 insertions(+), 91 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt 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 c6562a000..4f52e43d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -427,6 +427,7 @@ class Account( signer = signer, scope = scope, favoriteDvmOrchestrator = favoriteDvmOrchestrator, + favoriteDvmAddresses = favoriteDvmList.flow, ).flow // App-ready Feeds 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 0f327a174..15332850e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -160,6 +160,8 @@ sealed class TopFilter( class FavoriteDvm( val address: Address, ) : TopFilter("FavoriteDvm/${address.toValue()}") + + @Serializable object AllFavoriteDvms : TopFilter(" All Favourite DVMs ") } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index cd92afa0f..ddf24813a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -32,6 +32,7 @@ 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 @@ -41,6 +42,8 @@ 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 favourite DVM's current request/response state. * @@ -166,6 +169,23 @@ class FavoriteDvmOrchestrator( 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("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) 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 27635f019..be72d1b26 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 @@ -31,12 +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.favoriteDvm.AllFavoriteDvmsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow 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 @@ -65,6 +67,7 @@ class FeedTopNavFilterState( val signer: NostrSigner, val scope: CoroutineScope, val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, + val favoriteDvmAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -154,6 +157,15 @@ class FeedTopNavFilterState( proxyRelays = proxyRelays, ) } + + TopFilter.AllFavoriteDvms -> { + AllFavoriteDvmsFeedFlow( + favoriteDvmAddresses = favoriteDvmAddresses, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt new file mode 100644 index 000000000..8f880a4b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.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.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +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-favourited DVM into a + * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the + * favourite set changes. + */ +class AllFavoriteDvmsFeedFlow( + val favoriteDvmAddresses: StateFlow>, + val orchestrator: FavoriteDvmOrchestrator, + 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, + ): AllFavoriteDvmsTopNavFilter { + 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 AllFavoriteDvmsTopNavFilter( + acceptedIds = ids, + acceptedAddresses = addresses, + contentRelays = contentRelays, + listenRelays = listen, + requestIds = requestIds, + ) + } + + private fun emptyFilter(contentRelays: Set): AllFavoriteDvmsTopNavFilter = + AllFavoriteDvmsTopNavFilter( + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = contentRelays, + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow(): Flow = + favoriteDvmAddresses.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(): AllFavoriteDvmsTopNavFilter { + val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) + val addresses = favoriteDvmAddresses.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/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt new file mode 100644 index 000000000..f686bec2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.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.favoriteDvm + +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 + * favourited DVM. Behaves like [FavoriteDvmTopNavFilter] (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 AllFavoriteDvmsTopNavFilter( + 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): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteDvmTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = requestIds, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index e521c6d5b..88924a2a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -65,6 +65,6 @@ class FavoriteDvmTopNavFilter( ) }, listenRelays = listenRelays, - requestId = requestId, + requestIds = setOfNotNull(requestId), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 321f5ca2c..399d2ab31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * * - [contentFetches] — for each user-configured content relay, the ids/addresses * we want to pull (the actual notes the DVM curated). - * - [listenRelays] — the DVM's own publish relays (where it will deliver future - * kind 6300 / 7000 events for this request). + * - [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-DVM filter carries one; the merged "All favourite DVMs" + * filter carries one per favourite DVM. */ class FavoriteDvmTopNavPerRelayFilterSet( val contentFetches: Map, val listenRelays: Set, - val requestId: HexKey?, + val requestIds: Set, ) : IFeedTopNavPerRelayFilterSet 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 64c3ac1e9..c08cf8ba4 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 @@ -399,6 +399,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.AllFavoriteDvms -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -567,6 +568,10 @@ private fun FeedIcon( Icons.Outlined.AutoAwesome } + is TopFilter.AllFavoriteDvms -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn 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 a4d9cb8b6..7d2003e85 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 @@ -98,6 +98,12 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) + val allFavoriteDvmsFollow = + FeedDefinition( + code = TopFilter.AllFavoriteDvms, + name = ResourceName(R.string.follow_list_all_favorite_dvms), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( @@ -192,7 +198,12 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } + // Only show the "All favourite DVMs" meta-chip when there is at least one + // real favourite to merge; otherwise the chip opens to an empty feed. + val allFavorites = + if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() + + return (communities + hashtags + geotags + relays + allFavorites + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt index 487a9524b..de3335425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -42,6 +42,7 @@ 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.dvms.FavoriteDvmSnapshot import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation @@ -61,8 +62,19 @@ fun HomeDvmStatusBanner( val topFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + when (val filter = topFilter) { + is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav) + is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel) + else -> Unit + } +} +@Composable +private fun SingleDvmBanner( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, + nav: INav, +) { val snapshot by accountViewModel.account.favoriteDvmOrchestrator .observe(favDvm.address) .collectAsStateWithLifecycle() @@ -83,86 +95,133 @@ fun HomeDvmStatusBanner( ?: "" } - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Column(modifier = Modifier.padding(12.dp)) { - val status = snapshot.latestStatus?.status() + BannerCard { + val status = snapshot.latestStatus?.status() - when { - snapshot.errorMessage != null -> { - BannerMessageRow( - message = stringRes(R.string.dvm_home_status_error), - showSpinner = false, - ) + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.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) - RetryButton(favDvm, accountViewModel) } - - 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(favDvm, accountViewModel) - } - - 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, + 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.refreshFavoriteDvm(favDvm.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 AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { + val addresses by accountViewModel.account.favoriteDvmList.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.favoriteDvmOrchestrator + .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 { + if (allErrored) { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { + addresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + } + } else { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting_all), + showSpinner = true, + ) + } + } +} + +@Composable +private fun BannerCard(content: @Composable () -> Unit) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { content() } + } +} + @Composable private fun BannerMessageRow( message: String, @@ -185,11 +244,8 @@ private fun BannerMessageRow( } @Composable -private fun RetryButton( - favDvm: TopFilter.FavoriteDvm, - accountViewModel: AccountViewModel, -) { - OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { +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 70a539a9e..76b574544 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 @@ -284,14 +284,17 @@ fun HomeFeeds( ) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val activeDvm = activeFilter as? TopFilter.FavoriteDvm + val favoriteDvmAddresses by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() val onRefresh: () -> Unit = { feedState.invalidateData() - if (activeDvm != null) { - // Swiping down on Home should also re-issue the kind-5300 request so the - // DVM produces a fresh feed, not just re-render whatever's cached. - accountViewModel.refreshFavoriteDvm(activeDvm.address) + // 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.FavoriteDvm -> accountViewModel.refreshFavoriteDvm(filter.address) + is TopFilter.AllFavoriteDvms -> favoriteDvmAddresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + else -> Unit } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 856f6fd9c..57afdd401 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -56,10 +56,10 @@ fun filterHomePostsByDvmIds( out += contentFetchFilters(relay, filter) } - val requestId = set.requestId - if (requestId != null) { + if (set.requestIds.isNotEmpty()) { + val requestIds = set.requestIds.toList() set.listenRelays.forEach { relay -> - out += responseListenFilter(relay, requestId) + out += responseListenFilter(relay, requestIds) } } @@ -101,7 +101,7 @@ private fun contentFetchFilters( private fun responseListenFilter( relay: NormalizedRelayUrl, - requestId: HexKey, + requestIds: List, ) = RelayBasedFilter( relay = relay, filter = @@ -111,7 +111,7 @@ private fun responseListenFilter( NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND, ), - tags = mapOf("e" to listOf(requestId)), - limit = 10, + tags = mapOf("e" to requestIds), + limit = 10 * requestIds.size, ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aa11b8aa3..cbd3e0154 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1729,6 +1729,7 @@ Communities Lists DVMs + All favourite DVMs Relays Add DVM to favorites @@ -1737,6 +1738,7 @@ Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. Asking %1$s for a feed… + Asking your favourite DVMs for feeds… Processing your feed… This DVM requires payment DVM returned an error diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt new file mode 100644 index 000000000..c55e3927e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.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.favoriteDvm + +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 FavoriteDvmTopNavFilterTest { + 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 = + FavoriteDvmTopNavFilter( + dvmAddress = 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 = + FavoriteDvmTopNavFilter( + dvmAddress = 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 = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = setOf(articleAddress), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(longFormNote(articleAuthor, articleDTag))) + } + + @Test + fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + // passing a LocalCache is only needed because the method demands it; + // FavoriteDvmTopNavFilter.startValue doesn't actually consult it. + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds.isEmpty()) + } + + @Test + fun nonNullRequestIdProducesSingletonInFilterSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = 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/nip90Dvms/FilterHomePostsByDvmIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt new file mode 100644 index 000000000..62086f8b3 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.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.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 FilterHomePostsByDvmIdsTest { + private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! + private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! + + @Test + fun emptyFilterSetProducesNoRequests() { + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + assertTrue(filterHomePostsByDvmIds(set, since = null, defaultSince = null).isEmpty()) + } + + @Test + fun contentFetchIssuedOnUserRelayWithIdsFilter() { + val ids = setOf("a".repeat(64), "b".repeat(64)) + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + val filters = filterHomePostsByDvmIds(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 = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByDvmIds(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 = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(req1, req2), + ) + + val filters = filterHomePostsByDvmIds(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 = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByDvmIds(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/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2ebafdbad..1e525e148 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.favoriteDvmList.FavoriteDvmListEvent 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) + FavoriteDvmListEvent.KIND -> FavoriteDvmListEvent(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/favoriteDvmList/FavoriteDvmListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt new file mode 100644 index 000000000..88c36c6e4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.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.favoriteDvmList + +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 FavoriteDvmListEventTest { + 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, FavoriteDvmListEvent.KIND) + } + + @Test + fun addressesAreReplaceableWithFixedDTag() { + val address = FavoriteDvmListEvent.createAddress("a".repeat(64)) + assertEquals(10090, address.kind) + assertEquals("", address.dTag) + } + + @Test + fun createStoresDvmAsATag() = + runTest { + val dvm = dvm("a".repeat(64)) + + val event = + FavoriteDvmListEvent.create( + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + assertEquals(10090, event.kind) + assertTrue( + event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == dvm.address.toValue() }, + "public a tag for the favourited DVM should be present", + ) + val favorites = event.publicFavoriteDvms() + assertEquals(1, favorites.size) + assertEquals(dvm.address, favorites.first().address) + } + + @Test + fun addAppendsWithoutDuplicatingExistingEntry() = + runTest { + val dvm = dvm("a".repeat(64)) + + val initial = + FavoriteDvmListEvent.create( + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val afterDupeAdd = + FavoriteDvmListEvent.add( + earlierVersion = initial, + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + assertEquals( + 1, + afterDupeAdd.publicFavoriteDvms().count { it.address == dvm.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 = + FavoriteDvmListEvent.create( + dvm = first, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteDvmListEvent.add( + earlierVersion = initial, + dvm = second, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteDvms().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 = + FavoriteDvmListEvent.create( + publicDvms = listOf(first, second), + privateDvms = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteDvmListEvent.remove( + earlierVersion = initial, + dvm = first.address, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + assertFalse(first.address in addresses, "removed DVM should not survive") + assertTrue(second.address in addresses, "other DVMs should be preserved") + } +} From 95b49879f4f974fa8474baeec4fb661f5dcd55ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 00:47:35 +0000 Subject: [PATCH 13/46] refactor(profile): refine badge strip on profile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the bare octagonal FlowRow of 35dp thumbs. Replace with a labeled strip ("Badges · N") of 44dp rounded-square thumbs matching the BadgeCard language used elsewhere. - Cap the visible row at 8 badges and surface overflow as a "+N" pill that opens a ModalBottomSheet listing every accepted badge with its thumbnail, name, and description. Tapping a row closes the sheet and navigates to that badge's thread. - When viewing your own profile, add a settings gear trailing the header that jumps to Route.ProfileBadges to manage which badges appear. - Skip the entire strip (no empty header, no padding) until at least one badge is present. --- .../profile/header/badges/DisplayBadges.kt | 290 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 239 insertions(+), 52 deletions(-) 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 99d6613fe..1615aeaa2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -448,6 +448,7 @@ 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 From d8b313088da73dca93554fbe30c15292b30b7537 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:34:07 +0000 Subject: [PATCH 14/46] fix(dvm-favorites): star click is a no-op + star missing from DVM detail top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct bugs with the same symptom ("clicking the star doesn't seem to do anything"): 1. LocalCache didn't know how to route kind 10090. FavoriteDvmListEvent was missing from LocalCache's event-type dispatch, so after Account.followFavoriteDvm signs and publishes the event, the cache silently dropped it — never updated favoriteDvmListNote, so account.favoriteDvmList.flow never re-emitted, so the star's filled/outlined state (and the spinner chip) never flipped. Add `is FavoriteDvmListEvent -> consumeBaseReplaceable(...)`. 2. DvmTopBar's toggle never rendered. The Home feed passes the DVM's hex event id via Route.ContentDiscovery, so LoadNote(baseNoteHex) returns a plain Note, not the AddressableNote the toggle needs (the `is AddressableNote` guard was always false). Derive the AddressableNote from the loaded AppDefinitionEvent.address() so the toggle shows up with the correct backing object. --- .../amethyst/model/LocalCache.kt | 2 ++ .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) 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..339747d75 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.favoriteDvmList.FavoriteDvmListEvent 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 FavoriteDvmListEvent -> 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/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index d3dcecc66..4ae8cefe5 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 @@ -24,10 +24,12 @@ 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.AddressableNote +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 @@ -37,6 +39,7 @@ 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( @@ -87,12 +90,23 @@ fun DvmTopBar( }, 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 is AddressableNote) { - FavoriteDvmToggle( - appDefinitionNote = appDefinitionNote, - accountViewModel = accountViewModel, - ) + if (appDefinitionNote != null) { + val addressableNote by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.let { + LocalCache.getOrCreateAddressableNote(it.address()) + } + } + addressableNote?.let { target -> + FavoriteDvmToggle( + appDefinitionNote = target, + accountViewModel = accountViewModel, + ) + } } } }, From 55679b0a9e19274a6294940151ee8e3c2316a4f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:38:14 +0000 Subject: [PATCH 15/46] fix(badges): apply scaffold padding and make cards clickable BadgesScreen dropped the DisappearingScaffold's paddingValues on the floor, so the first list item hid behind the top bar and the last behind the bottom bar. Wrap the feed in Column(Modifier.padding(...)) like ArticlesScreen. BadgeCard was a bare OutlinedCard with no click target, so tapping a badge definition or award card did nothing. Thread a nullable onClick through BadgeCard; BadgeDisplay routes to the definition's thread and RenderBadgeAward routes to the award's thread. --- .../amethyst/ui/note/types/Badge.kt | 15 ++++++++++- .../ui/screen/loggedIn/badges/BadgesScreen.kt | 25 +++++++++++-------- 2 files changed, 29 insertions(+), 11 deletions(-) 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 fdb777a1b..4d3af6878 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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo 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 @@ -95,6 +97,12 @@ fun BadgeDisplay( 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) } + } + }, ) { if (isMine && nav != null) { BadgeActionRow { @@ -150,6 +158,9 @@ fun RenderBadgeAward( 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) @@ -163,10 +174,12 @@ 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 = Modifier.fillMaxWidth().padding(vertical = 6.dp), + modifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier, shape = BadgeCardShape, ) { Column(modifier = Modifier.padding(16.dp)) { 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 index 2551d3f3c..3379f4e2c 100644 --- 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 @@ -20,9 +20,12 @@ */ 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 @@ -77,16 +80,18 @@ fun BadgesScreen( NewBadgeButton(nav) }, accountViewModel = accountViewModel, - ) { - RefresheableBox(feedContentState, true) { - SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = feedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "BadgesFeed", - ) + ) { 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", + ) + } } } } From 876513ad58c3b55c6634fec5049c5529723531af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:55:32 +0000 Subject: [PATCH 16/46] fix(pdf): capture page count eagerly to survive PagerState teardown PagerState's saveable reads the pageCount lambda during composition teardown, which runs *after* DisposableEffect.onDispose has already closed the PdfRenderer. That triggered IllegalStateException("Document already closed") the first time the dialog was dismissed. Store pageCount as a stored val sampled at handle construction instead of re-querying the renderer. Also add a @Volatile closed flag so the PdfPageView render coroutine bails out if it wakes up after close(); existing catch clause still swallows any narrow race. --- .../ui/components/pdf/PdfViewerDialog.kt | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) 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 index eb7402988..2062ee7c1 100644 --- 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 @@ -89,10 +89,17 @@ private class PdfDocumentHandle( val pfd: ParcelFileDescriptor, val renderer: PdfRenderer, ) { - val pageCount: Int get() = renderer.pageCount + // 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) } @@ -259,13 +266,17 @@ private fun PdfPageView( val rendered = try { handle.mutex.withLock { - withContext(Dispatchers.IO) { - handle.renderer.openPage(pageIndex).use { page -> - val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) - 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 + if (handle.closed) { + null + } else { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) + 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 + } } } } From c2101c16dcaa1411a9500e45bdae52e593e44936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:02:46 +0000 Subject: [PATCH 17/46] fix(pdf): avoid closing renderer via delegated read in DisposableEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisposableEffect(handleState) captured handleState as a property delegate, so onDispose read the *current* delegated value at dispose time. When the async load transitioned handleState from null to the new handle, the previous DisposableEffect(null) was forgotten and its onDispose fired — reading the freshly-created handle via the delegate and closing it immediately. That left the dialog stuck on the loading spinner (page renders bailed out due to the closed flag) and double- closed the renderer on dismiss. Capture the handle as a local val before DisposableEffect so the lambda closes the specific handle that was current at effect creation. --- .../amethyst/ui/components/pdf/PdfViewerDialog.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index 2062ee7c1..5320f9ec1 100644 --- 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 @@ -161,9 +161,15 @@ private fun PdfViewerContent( } } - DisposableEffect(handleState) { + // 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 { - handleState?.close() + handleForDispose?.close() } } From 64079d418803454565d3939e70700b841bb1b344 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:11:58 +0000 Subject: [PATCH 18/46] feat(badges/award): swap raw pubkey textarea for user search The award screen now collects awardees the same way other selection screens do (AddMemberScreen pattern): - A search OutlinedTextField wired into UserSuggestionState + ShowUserSuggestionList. Typing >2 chars triggers the existing user search pipeline. - Selecting a suggestion adds the user to a header list of selected recipients (avatar, display name, NIP-05 / pubkey), each with a Remove button. - Submit button disables until at least one recipient is picked. ViewModel reduced to definition + sendPost(awardees: List); parsedPubKeys / awardeesText state removed. --- .../loggedIn/badges/award/AwardBadgeScreen.kt | 220 +++++++++++++----- .../badges/award/AwardBadgeViewModel.kt | 25 +- amethyst/src/main/res/values/strings.xml | 6 +- 3 files changed, 175 insertions(+), 76 deletions(-) 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 index 8f971fb70..f600bfd52 100644 --- 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 @@ -22,34 +22,47 @@ 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.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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AwardBadgeScreen( kind: Int, @@ -64,8 +77,19 @@ fun AwardBadgeScreen( 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 { - vm.cancel() nav.popBack() } @@ -73,43 +97,85 @@ fun AwardBadgeScreen( topBar = { SavingTopBar( titleRes = R.string.award_badge, - isActive = vm::canPost, - onCancel = { - vm.cancel() - nav.popBack() - }, + isActive = { vm.definition != null && selectedUsers.isNotEmpty() }, + onCancel = { nav.popBack() }, onPost = { + val toAward = selectedUsers.toList() accountViewModel.launchSigner { - vm.sendPost() + vm.sendPost(toAward) nav.popBack() } }, ) }, - ) { pad -> - Surface( + ) { padding -> + Column( modifier = Modifier - .padding(pad) - .consumeWindowInsets(pad) + .padding(padding) + .consumeWindowInsets(padding) .imePadding(), ) { - AwardBadgeBody(vm) + 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 AwardBadgeBody(vm: AwardBadgeViewModel) { - val scrollState = rememberScrollState() - - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), - ) { - val def = vm.definition +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), @@ -118,32 +184,82 @@ private fun AwardBadgeBody(vm: AwardBadgeViewModel) { } else { Text( text = def.name() ?: def.dTag(), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) - Spacer(modifier = Modifier.height(4.dp)) - def.description()?.let { - Text(it, style = MaterialTheme.typography.bodyMedium) + 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() } } - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = vm.awardeesText, - onValueChange = { vm.awardeesText = it }, - label = { Text(stringRes(R.string.award_badge_recipients_label)) }, - placeholder = { Text(stringRes(R.string.award_badge_recipients_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 4, - maxLines = 10, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - val parsed = vm.parsedPubKeys() - Text( - text = stringRes(R.string.award_badge_recipient_count, parsed.size), - style = MaterialTheme.typography.bodySmall, - ) - } + 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 index cdef93f67..3a2b2d5b9 100644 --- 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 @@ -24,15 +24,14 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue 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.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent @Stable @@ -41,7 +40,6 @@ class AwardBadgeViewModel : ViewModel() { lateinit var account: Account var definition by mutableStateOf(null) - var awardeesText by mutableStateOf(TextFieldValue("")) fun init( accountVM: AccountViewModel, @@ -57,26 +55,11 @@ class AwardBadgeViewModel : ViewModel() { definition = ev } - fun parsedPubKeys(): List = - awardeesText.text - .split('\n', ',', ' ', ';') - .mapNotNull { raw -> - val trimmed = raw.trim() - if (trimmed.isEmpty()) null else decodePublicKeyAsHexOrNull(trimmed) - }.distinct() - - fun canPost(): Boolean = definition != null && parsedPubKeys().isNotEmpty() - - fun cancel() { - awardeesText = TextFieldValue("") - } - - suspend fun sendPost() { + suspend fun sendPost(awardees: List) { val def = definition ?: return - val awardees = parsedPubKeys().map { PTag(it) } if (awardees.isEmpty()) return - account.sendBadgeAward(def, awardees) - cancel() + val pTags = awardees.map { PTag(it.pubkeyHex) } + account.sendBadgeAward(def, pTags) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1615aeaa2..d50010c0e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -441,9 +441,9 @@ Thumbnail URL (optional) https://example.com/badge-thumb.png Loading badge… - Recipients (npub or hex, one per line) - npub1…\nnpub1… - %1$d recipient(s) will receive this badge + Search users + Name, npub, or NIP-05 + Remove Untitled badge Awarded to %1$d You received a badge From bc8edf95239ec65a44674e40e10c1dab674b6f89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:20:22 +0000 Subject: [PATCH 19/46] feat(badges/profile): live updates and dedicated relay subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileBadgesScreen used to compute the received-awards list once via a plain remember and never refresh it; new awards landing in LocalCache were invisible until the user navigated away and back. There was also no relay subscription dedicated to back-filling award history — we relied on the always-on notifications subscription bounded by `since`, so older awards never arrived. - New ProfileBadgesFilterAssembler / SubAssembler / Subscription that, while the screen is mounted, queries kind 8 with `#p`=me on the user's notification relays (limit 500, no since) so the full history flows in. - Registered as `profileBadges` in RelaySubscriptionsCoordinator. - ProfileBadgesScreen now collects LocalCache.live.newEventBundles in a LaunchedEffect, bumping a tick whenever a bundle contains a BadgeAwardEvent for me. The receivedAwards remember keys on that tick, so newly arrived awards appear without leaving the screen. - AwardRow now resolves the badge definition via LoadAddressableNote + observeNoteEvent + EventFinderFilterAssemblerSubscription so each row re-renders when the linked kind 30009 lands in cache (and asks relays for it if missing). The Switch is disabled until the definition is available. --- .../RelaySubscriptionsCoordinator.kt | 3 + .../badges/profile/ProfileBadgesScreen.kt | 65 +++++++++++++++++-- .../datasource/FilterReceivedBadgeAwards.kt | 53 +++++++++++++++ .../ProfileBadgesFilterAssembler.kt | 46 +++++++++++++ ...rofileBadgesFilterAssemblerSubscription.kt | 36 ++++++++++ .../datasource/ProfileBadgesSubAssembler.kt | 42 ++++++++++++ 6 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesSubAssembler.kt 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 c315587fe..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 @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder 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 @@ -99,6 +100,7 @@ class RelaySubscriptionsCoordinator( 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) @@ -117,6 +119,7 @@ class RelaySubscriptionsCoordinator( longs, articles, badges, + profileBadges, channelFinder, eventFinder, userFinder, 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 index d5c02bc07..ab219fe1d 100644 --- 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 @@ -36,8 +36,11 @@ 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 @@ -49,15 +52,21 @@ 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.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( @@ -66,6 +75,10 @@ fun ProfileBadgesScreen( ) { 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)) @@ -86,8 +99,24 @@ fun ProfileBadgesScreen( .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++ + } + } + } + val receivedAwards = - remember(myPubkey, newState, oldState) { + remember(myPubkey, bundleTick) { LocalCache.notes .filterIntoSet { _, it -> val event = it.event @@ -143,11 +172,36 @@ private fun AwardRow( accountViewModel: AccountViewModel, ) { val defAddr = award.awardDefinition().firstOrNull() - val definition = - remember(award.id) { - defAddr?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? BadgeDefinitionEvent } - } + if (defAddr == null) { + StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + return + } + + LoadAddressableNote(defAddr, accountViewModel) { defNote -> + if (defNote == null) { + StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + } 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, + isAccepted = isAccepted, + accountViewModel = accountViewModel, + award = award, + ) + } + } +} + +@Composable +private fun StaticAwardRow( + definition: BadgeDefinitionEvent?, + isAccepted: Boolean, + accountViewModel: AccountViewModel, + award: BadgeAwardEvent, +) { Row( modifier = Modifier @@ -182,6 +236,7 @@ private fun AwardRow( Switch( checked = isAccepted, + enabled = definition != null, onCheckedChange = { checked -> accountViewModel.launchSigner { if (checked) { 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, + ) +} From 013c211e6ad7cfd77cdd26b5e260c8013672bbfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:21:01 +0000 Subject: [PATCH 20/46] fix(pdf): top-align viewer controls and bump render resolution - Wrap pager + controls Row in a Box(fillMaxSize) and align the Row to TopCenter so the back/share buttons sit at the top of the screen, matching ZoomableContentDialog's layout. They were previously vertically centered. - Raise VIEWER_MAX_DIM_PX from 2048 to 3072 so pinch-zoomed pages stay legible. A4-sized pages now render at ~26 MB each (ARGB_8888). - Replace the unbounded mutableStateMapOf page cache with a tiny LinkedHashMap-based LRU (PAGE_CACHE_SIZE = 3) to keep total bitmap memory around 80 MB regardless of PDF length. The cache only feeds produceState's initial value, so it doesn't need to be a snapshot state. --- .../ui/components/pdf/PdfViewerDialog.kt | 137 +++++++++++------- 1 file changed, 82 insertions(+), 55 deletions(-) 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 index 5320f9ec1..8f3ef0ef4 100644 --- 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 @@ -50,7 +50,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -81,8 +80,33 @@ import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -// Hard ceiling on each rendered page bitmap, in pixels. Prevents OOM on very large pages. -private const val VIEWER_MAX_DIM_PX = 2048 +// Hard ceiling on each 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 (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +private const val VIEWER_MAX_DIM_PX = 3072 + +// 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, @@ -196,63 +220,66 @@ private fun PdfViewerContent( } } else { val pagerState = rememberPagerState { handle.pageCount } - val pageCache = remember(handle) { mutableStateMapOf() } + val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) } - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { pageIndex -> - PdfPageView( - handle = handle, - pageIndex = pageIndex, - cache = pageCache, - ) - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = Size15dp, vertical = Size10dp) - .statusBarsPadding() - .systemBarsPadding(), - 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), + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + PdfPageView( + handle = handle, + pageIndex = pageIndex, + cache = pageCache, ) } - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "${pagerState.currentPage + 1} / ${handle.pageCount}", - color = Color.White, + Row( 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), + .align(Alignment.TopCenter) + .fillMaxWidth() + .statusBarsPadding() + .systemBarsPadding() + .padding(horizontal = Size15dp, vertical = Size10dp), + horizontalArrangement = spacedBy(Size10dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringRes(R.string.quick_action_share), + 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), + ) + } } } } @@ -262,9 +289,9 @@ private fun PdfViewerContent( private fun PdfPageView( handle: PdfDocumentHandle, pageIndex: Int, - cache: MutableMap, + cache: PageBitmapCache, ) { - val cached = cache[pageIndex] + val cached = cache.get(pageIndex) @Suppress("ProduceStateDoesNotAssignValue") val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { @@ -292,7 +319,7 @@ private fun PdfPageView( null } - rendered?.let { cache[pageIndex] = it } + rendered?.let { cache.put(pageIndex, it) } value = rendered } From dc41432c5bdf94873e88844060091b760601fa79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:37:07 +0000 Subject: [PATCH 21/46] fix(topnav): long filter names no longer wrap + hide expand icon Selecting a DVM with a long name (or any long-named filter) made the Text in the top-nav spinner wrap to two lines, pushing the ExpandMore icon out of the visible area. The Column holding the Text had no width constraint and the Text itself had no maxLines. - Constrain the Column with `Modifier.weight(1f, fill = false)` so it reserves space for the icon instead of consuming it. - Add `maxLines = 1, overflow = TextOverflow.Ellipsis` to every text path in the spinner (primary label, geohash city-loading, AroundMe location labels). Single-line + ellipsis for all filter types, icon always visible. --- .../navigation/topbars/FeedFilterSpinner.kt | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) 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 c08cf8ba4..b4cf9a719 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 @@ -68,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 @@ -153,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) { @@ -181,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 @@ -196,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) @@ -206,6 +228,8 @@ fun FeedFilterSpinner( text = "($cityName)", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -215,6 +239,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -223,6 +249,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From 93cf9f1d6e12a6905d10f5be981e1eb6e69c6c10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:40:30 +0000 Subject: [PATCH 22/46] refactor(badges): drop OutlinedCard chrome from feed items Each badge was wrapped in its own rounded OutlinedCard, which reads as an out-of-place boxed widget in the feed since every other feed item is a flat row separated by the standard HorizontalDivider drawn by FeedLoaded. Replace the Card with a plain Column + clickable + padding. The feed's own divider handles item separation and the UI now matches Notes, Articles, Pictures, etc. --- .../amethyst/ui/note/types/Badge.kt | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) 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 4d3af6878..f73a0930e 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 @@ -39,7 +39,6 @@ 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 @@ -79,7 +78,6 @@ 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 @@ -177,41 +175,38 @@ private fun BadgeCard( 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) + val baseModifier = Modifier.fillMaxWidth() + val clickableModifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier - Spacer(modifier = Modifier.size(14.dp)) + Column(modifier = clickableModifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BadgeThumbnail(imageUrl, name) - 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, - ) - } - } + Spacer(modifier = Modifier.size(14.dp)) - if (!description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(12.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 4, + text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } - - actions() } + + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + actions() } } From e244271bd0a067c451eb8bc7d73f8567546f6ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:46:25 +0000 Subject: [PATCH 23/46] feat(pdf): zoom-aware hi-res re-render for crisp pinch-zoom Base bitmap stays at 3072 px for the initial render. When the user zooms past 1.2x and scale settles for 200 ms, asynchronously re-render the current page at (VIEWER_MAX_DIM_PX * scale) capped at 6144 px (~100 MB peak for an A4 page). Swap the hi-res bitmap in while zoomed; revert to base when scale drops back under threshold or the page leaves composition. Only the currently-focused page gets the hi-res treatment. Also apply FilterQuality.High to the Image composable in both the viewer and the preview card so any residual GPU upscaling uses bicubic-ish sampling instead of bilinear. Factored the render path into a shared renderPageCatching() helper. --- .../ui/components/pdf/PdfPreviewCard.kt | 2 + .../ui/components/pdf/PdfViewerDialog.kt | 106 +++++++++++++----- 2 files changed, 80 insertions(+), 28 deletions(-) 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 index 5d01fcb21..277bddafe 100644 --- 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 @@ -45,6 +45,7 @@ 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 @@ -170,6 +171,7 @@ private fun LoadedPdfPreviewCard( bitmap = current.preview.thumbnail.asImageBitmap(), contentDescription = content.description ?: filename, contentScale = ContentScale.FillWidth, + filterQuality = FilterQuality.High, modifier = Modifier .fillMaxWidth() 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 index 8f3ef0ef4..c3e0bda95 100644 --- 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 @@ -49,14 +49,17 @@ 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 @@ -74,17 +77,29 @@ 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.zoomable -// Hard ceiling on each 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 (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +// 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 (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. 6144 allows up to 2x sharper +// than the base render at peak cost of ~100 MB for one page (ARGB_8888, A4 shape). +private const val HI_RES_MAX_DIM_PX = 6144 +private const val HI_RES_ZOOM_THRESHOLD = 1.2f +private const val HI_RES_DEBOUNCE_MS = 200L + // 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. @@ -285,6 +300,7 @@ private fun PdfViewerContent( } } +@OptIn(FlowPreview::class) @Composable private fun PdfPageView( handle: PdfDocumentHandle, @@ -294,43 +310,50 @@ private fun PdfPageView( val cached = cache.get(pageIndex) @Suppress("ProduceStateDoesNotAssignValue") - val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { + val baseBitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { if (value != null) return@produceState - val rendered = - 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, VIEWER_MAX_DIM_PX) - 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", e) - null - } - + val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX) rendered?.let { cache.put(pageIndex, it) } value = rendered } val zoomState = rememberZoomState() - val current = bitmap + + // 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 Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (current != null) { Image( bitmap = current.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, + filterQuality = FilterQuality.High, modifier = Modifier .fillMaxSize() @@ -341,3 +364,30 @@ private fun PdfPageView( } } } + +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) + 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 + } From b0f75d4dd464821a9e328c18a89ebac74cafd0d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:53:23 +0000 Subject: [PATCH 24/46] fix(home-banner): float DVM status banner over the feed instead of in the top bar Putting HomeDvmStatusBanner inside the top-bar Column meant the SecondaryTabRow shifted up/down every time the banner appeared or disappeared (filter selection, refresh, response arrival). Annoying. Move the banner into a Box that wraps the HorizontalPager and align it to TopCenter so it floats over the feed: - topBar Column is back to just HomeTopBar + SecondaryTabRow; tabs no longer reflow when the banner toggles. - BannerCard takes a Modifier and gets tonalElevation + shadowElevation + a slightly stronger surface tint so it reads as a floating overlay rather than part of the scaffold chrome. --- .../screen/loggedIn/home/DvmStatusBanner.kt | 29 ++++++++--- .../ui/screen/loggedIn/home/HomeScreen.kt | 50 ++++++++++++------- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt index de3335425..723eea88a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -58,13 +58,14 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun HomeDvmStatusBanner( accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val topFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() when (val filter = topFilter) { - is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav) - is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel) + is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel, modifier) else -> Unit } } @@ -74,6 +75,7 @@ private fun SingleDvmBanner( favDvm: TopFilter.FavoriteDvm, accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val snapshot by accountViewModel.account.favoriteDvmOrchestrator .observe(favDvm.address) @@ -95,7 +97,7 @@ private fun SingleDvmBanner( ?: "" } - BannerCard { + BannerCard(modifier) { val status = snapshot.latestStatus?.status() when { @@ -167,7 +169,10 @@ private fun SingleDvmBanner( } @Composable -private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { +private fun AllFavoriteDvmsBanner( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { val addresses by accountViewModel.account.favoriteDvmList.flow .collectAsStateWithLifecycle() @@ -189,7 +194,7 @@ private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" } - BannerCard { + BannerCard(modifier) { if (allErrored) { BannerMessageRow( message = stringRes(R.string.dvm_home_status_error), @@ -209,14 +214,22 @@ private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { } @Composable -private fun BannerCard(content: @Composable () -> Unit) { +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 + modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, ) { Column(modifier = Modifier.padding(12.dp)) { content() } } 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 76b574544..d3f676146 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 @@ -179,7 +183,6 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) - HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, @@ -210,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, + ) + } + + HomeDvmStatusBanner( accountViewModel = accountViewModel, nav = nav, + modifier = Modifier.align(Alignment.TopCenter), ) } } From 0c88b83c3f886dc9b686db2b9e4635c53c7cd44f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:14:43 +0000 Subject: [PATCH 25/46] feat(pdf): wire double-tap to toggle zoom in viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zoomable library's onDoubleTap callback is not wired by default, so double-tapping the PDF page did nothing — no scale change, no hi-res re-render. Pass an onDoubleTap handler that calls zoomState.toggleScale(2.5x, tapPosition). The scale animation runs through the same Animatable snapshotFlow already observes, so the debounced hi-res render kicks in automatically once the animation settles. --- .../amethyst/ui/components/pdf/PdfViewerDialog.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index c3e0bda95..523c48d81 100644 --- 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 @@ -85,6 +85,7 @@ 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 @@ -100,6 +101,10 @@ private const val HI_RES_MAX_DIM_PX = 6144 private const val HI_RES_ZOOM_THRESHOLD = 1.2f 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. @@ -357,7 +362,12 @@ private fun PdfPageView( modifier = Modifier .fillMaxSize() - .zoomable(zoomState), + .zoomable( + zoomState = zoomState, + onDoubleTap = { position -> + zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position) + }, + ), ) } else { CircularProgressIndicator(color = Color.White) From ffa55a30f3c1135cc94937016638577c0ff99558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:26:38 +0000 Subject: [PATCH 26/46] Revert "refactor(badges): drop OutlinedCard chrome from feed items" This reverts commit 93cf9f1d6e12a6905d10f5be981e1eb6e69c6c10. --- .../amethyst/ui/note/types/Badge.kt | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) 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 f73a0930e..4d3af6878 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 @@ -39,6 +39,7 @@ 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 @@ -78,6 +79,7 @@ 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 @@ -175,38 +177,41 @@ private fun BadgeCard( onClick: (() -> Unit)? = null, actions: @Composable () -> Unit = {}, ) { - val baseModifier = Modifier.fillMaxWidth() - val clickableModifier = if (onClick != null) baseModifier.clickable(onClick = onClick) else baseModifier + 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) - Column(modifier = clickableModifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - BadgeThumbnail(imageUrl, name) + Spacer(modifier = Modifier.size(14.dp)) - 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, + ) + } + } - Column(modifier = Modifier.weight(1f)) { + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) Text( - text = name?.ifBlank { null } ?: stringRes(R.string.badge_untitled), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, overflow = TextOverflow.Ellipsis, ) } - } - if (!description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(10.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) + actions() } - - actions() } } From d1fc49dc515b57e8395a399adb13b250d386868d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:31:06 +0000 Subject: [PATCH 27/46] feat(badges): render feed items with author + reactions chrome Revert of 93cf9f1 restored the BadgeCard chrome that works well when embedded inside another NoteCompose (e.g. a BadgeAwardEvent's body). But the badge feed was still bypassing NoteCompose's author header and ReactionsRow because BadgeDefinitionEvent was hoisted out of CheckNewAndRenderNote up at the top of NoteCompose. Move BadgeDefinitionEvent into RenderNoteRow next to BadgeAwardEvent so it flows through the same chrome path. Feed items now get: - the author avatar / name / timestamp header - the existing BadgeDisplay card body - the standard ReactionsRow (reply / repost / zap / like) Other call sites of BadgeDisplay are untouched: - RenderBadgeAward still embeds BadgeDisplay as a child card - BadgeCompose notifications still embed BadgeDisplay - DisplayBadges profile strip uses BadgeThumb, not BadgeDisplay --- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 b406352da..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 = accountViewModel, nav = nav) - } - else -> { LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, nav) { showPopup -> CheckNewAndRenderNote( @@ -454,10 +450,6 @@ fun AcceptableNote( } } - is BadgeDefinitionEvent -> { - BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) - } - 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) } From 2e4a985b3661b1053576e62f4aba3257f6009d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:32:34 +0000 Subject: [PATCH 28/46] feat(images): load full-resolution source in the image dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubcomposeAsyncImage was being called with just the URL (or local File), so Coil auto-sized the decode target to the composable's layout bounds. In the feed that's desirable — a small thumbnail decode — but in the zoomable dialog the composable is fillMaxWidth (~screen width), and pinch-zooming then GPU-upscales the already downsampled bitmap, producing the same blurriness the PDF viewer had before. Add a fullResolution: Boolean = false flag to UrlImageView and LocalImageView. When true, swap the model for an ImageRequest with size(Size.ORIGINAL) so Coil decodes at the image's native dimensions. Feed call sites keep the default (fast, sampled). Both dialog call sites in ZoomableContentDialog now pass fullResolution = true, so pinch-zoom shows native pixels. The existing blurhash/aspect-ratio placeholder path already covers the brief high-res load window in the dialog. --- .../ui/components/ZoomableContentDialog.kt | 2 ++ .../ui/components/ZoomableContentView.kt | 35 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) 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 469afe4ff..2bc9d3361 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 @@ -276,6 +278,7 @@ fun LocalImageView( controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + fullResolution: Boolean = false, ) { if (content.localFileExists()) { val showImage = @@ -286,10 +289,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, @@ -391,6 +407,7 @@ fun UrlImageView( controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + fullResolution: Boolean = false, ) { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) @@ -401,10 +418,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, From fc284e3cfe48b90e7eb8974016f619ca0636ce2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:43:46 +0000 Subject: [PATCH 29/46] feat(dvm-favorites): rename user-facing to "Favorite Feed Algorithms" + fix cold-start race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User-facing rename. All user-visible strings switch from "favourite" to "favorite" (American spelling) and from "DVM(s)" to "Feed Algorithm(s)": settings entry, spinner group label, empty state, banner messages, menu accessibility labels. Code identifiers still reference NIP-90's "DVM" protocol name since that's the wire spec. - Cold-start race. When the app relaunches with a persisted TopFilter.FavoriteDvm, the AppDefinitionEvent may not be in cache yet. mergeInterests was filtering out any favorite whose AppDefinitionEvent didn't yet advertise kind 5300, so the spinner didn't include a chip matching the persisted selection — it showed "Select an option" while the orchestrator quietly fired the RPC with no UI to ground it. Always include every favorite; the 5300 check at add time (in FavoriteDvmToggle) is enough. --- .../model/dvms/FavoriteDvmOrchestrator.kt | 4 ++-- .../favoriteDvm/AllFavoriteDvmsFeedFlow.kt | 4 ++-- .../AllFavoriteDvmsTopNavFilter.kt | 2 +- .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 2 +- .../FavoriteDvmTopNavPerRelayFilterSet.kt | 4 ++-- .../amethyst/ui/screen/TopNavFilterState.kt | 21 ++++++++----------- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 2 +- amethyst/src/main/res/values/strings.xml | 18 ++++++++-------- 8 files changed, 27 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index ddf24813a..860cdfd0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.sync.withLock private const val RESPONSE_TIMEOUT_MS = 20_000L /** - * Immutable snapshot of a favourite DVM's current request/response state. + * Immutable snapshot of a favorite DVM'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 @@ -65,7 +65,7 @@ data class FavoriteDvmSnapshot( ) /** - * Manages the NIP-90 content-discovery RPC cycle for each favourite DVM the user + * Manages the NIP-90 content-discovery RPC cycle for each favorite DVM the user * pins to the top-nav. * * The orchestrator is lazy: it starts a request/response cycle the first time any diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt index 8f880a4b1..c35c2ba72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt @@ -34,9 +34,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest /** - * Feed flow that merges snapshots from every currently-favourited DVM into a + * Feed flow that merges snapshots from every currently-favorited DVM into a * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the - * favourite set changes. + * favorite set changes. */ class AllFavoriteDvmsFeedFlow( val favoriteDvmAddresses: StateFlow>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt index f686bec2b..36e7e6b6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Top-nav filter that unions the latest kind-6300 responses from every currently - * favourited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * favorited DVM. Behaves like [FavoriteDvmTopNavFilter] (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. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index 88924a2a4..9534bc1c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow /** - * Top-nav filter backed by the latest kind-6300 response from a favourite DVM. + * Top-nav filter backed by the latest kind-6300 response from a favorite DVM. * * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 399d2ab31..318840384 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * - [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-DVM filter carries one; the merged "All favourite DVMs" - * filter carries one per favourite DVM. + * for. A single-DVM filter carries one; the merged "All favorite DVMs" + * filter carries one per favorite DVM. */ class FavoriteDvmTopNavPerRelayFilterSet( val contentFetches: Map, 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 7d2003e85..7b65a43e1 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 @@ -35,7 +35,6 @@ 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.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -182,24 +181,22 @@ class TopNavFilterState( ) } - // Only DVMs that advertise NIP-90 content discovery (kind 5300) can produce a - // home feed. Hide entries whose AppDefinitionEvent isn't loaded yet OR doesn't - // include kind 5300 so the chip doesn't appear for image-generation, translation, - // search, or other DVM kinds that would never reply. + // Favorites can only be added through FavoriteDvmToggle, 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.FavoriteDvm 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 favoriteDvms = - favoriteDvmList.mapNotNull { dvmNote -> - val supports5300 = - (dvmNote.event as? AppDefinitionEvent) - ?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true - if (!supports5300) return@mapNotNull null + favoriteDvmList.map { dvmNote -> FeedDefinition( TopFilter.FavoriteDvm(dvmNote.address), FavoriteDvmName(dvmNote), ) } - // Only show the "All favourite DVMs" meta-chip when there is at least one - // real favourite to merge; otherwise the chip opens to an empty feed. + // Only show the "All favorite DVMs" meta-chip when there is at least one + // real favorite to merge; otherwise the chip opens to an empty feed. val allFavorites = if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 57afdd401..c042199a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentD import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent /** - * Builds relay REQ filters for a favourite-DVM home feed. + * Builds relay REQ filters for a favorite-DVM home feed. * * Two distinct subscription kinds with two distinct relay sets: * diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cbd3e0154..9a6ce629f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,20 +1728,20 @@ Locations Communities Lists - DVMs - All favourite DVMs + Feed Algorithms + All favorite feed algorithms Relays - Add DVM to favorites + Add feed algorithm to favorites Remove from favorites - Favourite DVMs - Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. - No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. + 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 favourite DVMs for feeds… + Asking your favorite feed algorithms for feeds… Processing your feed… - This DVM requires payment - DVM returned an error + This feed algorithm requires payment + The feed algorithm returned an error Retry Log off on device lock From 640848a2f6f6fb70ac229630d031b25afe458818 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:45:17 +0000 Subject: [PATCH 30/46] fix(pdf): always scale render to target, never keep 72-DPI native size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdfRenderer.Page.getWidth()/getHeight() return the page size in PostScript points (1/72"), so for a standard A4 that's 595 x 842 "pixels" passed to cappedRenderSize. The old early-return kept native dimensions whenever longest <= targetDim (which was always), so the base bitmap was 595 x 842 regardless of what we asked for — effectively 72 DPI. Zoom-aware re-renders hit the same early return and also stayed tiny, which is why double-tap produced no visible improvement. Remove the early return: since PDFs are vector, always rescale the point dimensions so the longest side equals targetDim. Base renders now produce ~3072 px bitmaps and the hi-res path actually goes to 6144 px when zoomed. --- .../amethyst/ui/components/pdf/PdfPreviewCard.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 index 277bddafe..10525869f 100644 --- 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 @@ -271,18 +271,20 @@ private fun renderFirstPage( } /** - * Scales the page's native point size to fit within [maxDim] on the longest side, preserving - * aspect ratio. Falls back to native size if it's already smaller. + * 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, - maxDim: Int, + targetDim: Int, ): Pair { if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1 val longest = maxOf(pageWidth, pageHeight) - if (longest <= maxDim) return pageWidth to pageHeight - val scale = maxDim.toFloat() / longest + val scale = targetDim.toFloat() / longest val w = (pageWidth * scale).toInt().coerceAtLeast(1) val h = (pageHeight * scale).toInt().coerceAtLeast(1) return w to h From 49e913bec4c12ac9ee64e785e98b8da5a02205c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:59:23 +0000 Subject: [PATCH 31/46] perf(pdf): reduce pan/zoom jitter in viewer dialog The main cost during a live pan/zoom is the GPU re-sampling the page bitmap every frame under the zoomable modifier's transform. Three changes bring that cost down substantially: - Bitmap.Config.RGB_565 instead of ARGB_8888. PDFs are opaque, so the alpha channel is unused. Halves texture memory and GPU upload bandwidth; visually identical. - HI_RES_MAX_DIM_PX lowered from 6144 to 4096. 4096 stays within the common GPU texture-size limit, keeping rendering hardware-accelerated on mid-range devices. Peak bitmap is now ~24 MB (A4, RGB_565) instead of ~107 MB (ARGB_8888, 6144). - FilterQuality.Medium (bilinear) instead of High (bicubic/Mitchell). High is recomputed per frame during pan/zoom and is the main source of jitter on multi-megapixel sources. At 3072-4096 px base resolution bilinear is effectively indistinguishable. - HI_RES_ZOOM_THRESHOLD raised from 1.2 to 1.5 so we don't trigger a costly re-render for marginal zoom levels where the base is fine. - Cache the ImageBitmap wrapper via remember to avoid per-recomposition allocations. --- .../ui/components/pdf/PdfPreviewCard.kt | 4 ++- .../ui/components/pdf/PdfViewerDialog.kt | 33 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) 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 index 10525869f..2e652ed39 100644 --- 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 @@ -256,7 +256,9 @@ private fun renderFirstPage( renderer.openPage(0).use { page -> val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) - val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) + // RGB_565 halves memory vs ARGB_8888 and is visually identical for + // opaque PDF renders. + val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.RGB_565) bitmap.eraseColor(android.graphics.Color.WHITE) page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) PdfLoadState.Ready( 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 index 523c48d81..e9b21d50b 100644 --- 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 @@ -89,16 +89,18 @@ 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 (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 2 bytes +// of RAM (RGB_565). 3072 gives ~13 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. 6144 allows up to 2x sharper -// than the base render at peak cost of ~100 MB for one page (ARGB_8888, A4 shape). -private const val HI_RES_MAX_DIM_PX = 6144 -private const val HI_RES_ZOOM_THRESHOLD = 1.2f +// (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 ~24 MB for an A4-shaped page (RGB_565). 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 @@ -352,13 +354,21 @@ private fun PdfPageView( } 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 (current != null) { + if (imageBitmap != null) { Image( - bitmap = current.asImageBitmap(), + bitmap = imageBitmap, contentDescription = null, contentScale = ContentScale.Fit, - filterQuality = FilterQuality.High, + // 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() @@ -388,7 +398,10 @@ private suspend fun renderPageCatching( withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> val (width, height) = cappedRenderSize(page.width, page.height, maxDim) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + // RGB_565 uses half the memory of ARGB_8888 and is indistinguishable + // for PDFs (opaque white background, no alpha). Halves GPU texture + // upload size and sampling cost during pan/zoom. + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) bmp From d942a624ebeee44a0bf63c2b1884a67fbc6ff3de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:00:06 +0000 Subject: [PATCH 32/46] fix(badges): preserve NIP-58 a/e pair order, clickable rows, accepted-first sort Three fixes to the Profile-badges flow: 1. TagArrayBuilder groups tags by name, so calling acceptedBadges() on the builder scrambled [a1, e1, a2, e2] into [a1, a2, e1, e2] when serializing. AcceptedBadge.parseAll expects adjacent (a, e) pairs, so only one mangled badge survived, making each Accept toggle appear to replace the previous one. Rewrite the build() of both ProfileBadgesEvent (kind 10008) and AcceptedBadgeSetEvent (kind 30008) to collect the prefix tags (d / alt / initializer) via the builder, then append AcceptedBadge.assemble(...) verbatim to keep the pair order intact. 2. Rows in ProfileBadgesScreen were not clickable. Thread nav through AwardRow / StaticAwardRow and wrap the row body in Modifier.clickable that routes to the badge definition's thread. The Switch keeps consuming its own clicks, so toggling still works. 3. Sort accepted badges to the top of the list, then the rest by createdAt desc, so the user can see at a glance which badges are currently shown on the profile. --- .../badges/profile/ProfileBadgesScreen.kt | 50 ++++++++++++++++--- .../accepted/AcceptedBadgeSetEvent.kt | 22 +++++--- .../nip58Badges/profile/ProfileBadgesEvent.kt | 20 +++++--- 3 files changed, 70 insertions(+), 22 deletions(-) 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 index ab219fe1d..c4492435b 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 @@ -56,6 +57,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind 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 @@ -116,13 +118,17 @@ fun ProfileBadgesScreen( } val receivedAwards = - remember(myPubkey, bundleTick) { + remember(myPubkey, bundleTick, acceptedAwardIds) { LocalCache.notes .filterIntoSet { _, it -> val event = it.event event is BadgeAwardEvent && event.awardeeIds().contains(myPubkey) }.mapNotNull { it.event as? BadgeAwardEvent } - .sortedByDescending { it.createdAt } + .sortedWith( + // Accepted badges on top, then most recent first. + compareByDescending { acceptedAwardIds.contains(it.id) } + .thenByDescending { it.createdAt }, + ) } Scaffold( @@ -156,6 +162,7 @@ fun ProfileBadgesScreen( award = award, isAccepted = acceptedAwardIds.contains(award.id), accountViewModel = accountViewModel, + nav = nav, ) HorizontalDivider() } @@ -170,26 +177,43 @@ private fun AwardRow( award: BadgeAwardEvent, isAccepted: Boolean, accountViewModel: AccountViewModel, + nav: INav, ) { val defAddr = award.awardDefinition().firstOrNull() if (defAddr == null) { - StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + StaticAwardRow( + definition = null, + defNote = null, + isAccepted = isAccepted, + accountViewModel = accountViewModel, + award = award, + nav = nav, + ) return } LoadAddressableNote(defAddr, accountViewModel) { defNote -> if (defNote == null) { - StaticAwardRow(definition = null, isAccepted = isAccepted, accountViewModel = accountViewModel, award = award) + 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, ) } } @@ -198,15 +222,27 @@ private fun AwardRow( @Composable private fun StaticAwardRow( definition: BadgeDefinitionEvent?, + defNote: com.vitorpamplona.amethyst.model.AddressableNote?, isAccepted: Boolean, accountViewModel: AccountViewModel, award: BadgeAwardEvent, + nav: INav, ) { - Row( - modifier = + val rowModifier = + if (defNote != null) { Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .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) 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, "") } } } From 496cc9876f19b387254a0db46cbdb55979164ea8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:19:36 +0000 Subject: [PATCH 33/46] =?UTF-8?q?fix(pdf):=20revert=20RGB=5F565=20?= =?UTF-8?q?=E2=80=94=20PdfRenderer=20requires=20ARGB=5F8888?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdfRenderer.Page.render() only accepts ARGB_8888 bitmaps; RGB_565 is silently rejected (the renderer produces no output), which is why the preview card and viewer both stopped showing anything. Go back to ARGB_8888 and update the memory estimates in the comments. The other perf wins from 49e913b (FilterQuality.Medium, 4096 hi-res cap, 1.5x threshold, remembered ImageBitmap wrapper) are unaffected and stay. --- .../amethyst/ui/components/pdf/PdfPreviewCard.kt | 5 ++--- .../amethyst/ui/components/pdf/PdfViewerDialog.kt | 15 +++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) 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 index 2e652ed39..0712e95d1 100644 --- 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 @@ -256,9 +256,8 @@ private fun renderFirstPage( renderer.openPage(0).use { page -> val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) - // RGB_565 halves memory vs ARGB_8888 and is visually identical for - // opaque PDF renders. - val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.RGB_565) + // 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( 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 index e9b21d50b..57279441c 100644 --- 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 @@ -89,16 +89,16 @@ 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 * 2 bytes -// of RAM (RGB_565). 3072 gives ~13 MB per A4-shaped page. +// 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 ~24 MB for an A4-shaped page (RGB_565). Above 4096 most mid-range -// GPUs fall back to software rendering, which is what caused the pan/zoom jitter. +// 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 @@ -398,10 +398,9 @@ private suspend fun renderPageCatching( withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> val (width, height) = cappedRenderSize(page.width, page.height, maxDim) - // RGB_565 uses half the memory of ARGB_8888 and is indistinguishable - // for PDFs (opaque white background, no alpha). Halves GPU texture - // upload size and sampling cost during pan/zoom. - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + // 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 From 63ef6dae6cc84e3dc118b4fd44290f45777bb7ca Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 19 Apr 2026 17:27:09 +0000 Subject: [PATCH 34/46] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 20 ++++++++ .../src/main/res/values-hu-rHU/strings.xml | 3 ++ .../src/main/res/values-pl-rPL/strings.xml | 1 + .../src/main/res/values-sl-rSI/strings.xml | 50 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 1 + 5 files changed, 75 insertions(+) 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-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-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个接受来自你的密钥包事件的中继并允许公开读取。 私人中继 From 6dd373556ea4b704a3bca45024a6e9d095c5547a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:31:10 +0000 Subject: [PATCH 35/46] fix(badges): stop losing accepted-set entries on rapid toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interacting bugs were still eating badges when the user toggled switches in the profile-badges settings page: 1. TimeUtils.now() returns seconds, and LocalCache.consumeBaseReplaceable only accepts an update whose createdAt is strictly greater than the one already stored. Two toggles within the same second produced equal-timestamp events, so the second was silently dropped from the cache — the UI reverted after a beat and the change looked like it had never happened. 2. launchSigner coroutines run on Dispatchers.IO so two concurrent toggles could both read the same pre-state, each write its own fragment, and whichever landed last clobbered the other. Wrap the read-modify-write with a Mutex and bump the outgoing createdAt to maxOf(now, latestCachedCreatedAt + 1) so every write is strictly newer than whatever sits in cache. The sign + local consume happen under the lock; network publish stays outside it. --- .../vitorpamplona/amethyst/model/Account.kt | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) 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 428dbdb1a..0ed223b28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -239,6 +239,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 @@ -251,6 +252,8 @@ 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 @@ -1146,6 +1149,27 @@ class Account( 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, @@ -1155,30 +1179,36 @@ class Account( val aTag = ATag(definition.kind, definition.pubKey, definition.dTag(), null) val eTag = ETag(award.id) - val current = loadCurrentAcceptedBadges() - val alreadyAccepted = current.any { it.badgeAward.eventId == award.id } - if (alreadyAccepted) return + val signedEvent = + profileBadgesMutex.withLock { + val current = loadCurrentAcceptedBadges() + if (current.any { it.badgeAward.eventId == award.id }) return + val updated = current + AcceptedBadge(aTag, eTag) - val updated = current + AcceptedBadge(aTag, eTag) + val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt()) + val signed = signer.sign(template) + cache.justConsumeMyOwnEvent(signed) + signed + } - val template = ProfileBadgesEvent.build(updated) - val signedEvent = signer.sign(template) - - cache.justConsumeMyOwnEvent(signedEvent) client.publish(signedEvent, outboxRelays.flow.value) } suspend fun removeAcceptedBadge(award: BadgeAwardEvent) { if (!isWriteable()) return - val current = loadCurrentAcceptedBadges() - val updated = current.filterNot { it.badgeAward.eventId == award.id } - if (updated.size == current.size) 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) - val signedEvent = signer.sign(template) + val template = ProfileBadgesEvent.build(updated, createdAt = nextProfileBadgesCreatedAt()) + val signed = signer.sign(template) + cache.justConsumeMyOwnEvent(signed) + signed + } - cache.justConsumeMyOwnEvent(signedEvent) client.publish(signedEvent, outboxRelays.flow.value) } From 72598026a3328a11986fb98b2ca80eedd8c2f4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:37:55 +0000 Subject: [PATCH 36/46] refactor(quartz): rename FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list event only stores content-discovery feeds (kind-5300 DVMs), not every DVM, so the old name was misleading. Rename the wire-level type to reflect what's actually in it. - Quartz: package nip51Lists.favoriteDvmList -> favoriteAlgoFeedsList; class FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent. - DSL helpers renamed: favoriteDvm/favoriteDvms builder extensions -> favoriteAlgoFeed/favoriteAlgoFeeds; TagArray.favoriteDvmList/Set -> favoriteAlgoFeedsList/Set. - create/add/remove parameter names dvm -> feed, publicDvms/privateDvms -> publicFeeds/privateFeeds; public/private accessors publicFavoriteDvms/privateFavoriteDvms -> publicFavoriteAlgoFeeds/ privateFavoriteAlgoFeeds. ALT string updated. - EventFactory + LocalCache dispatch branches + AccountSettings backup field type + FavoriteDvmListState + FavoriteDvmListDecryptionCache all import the new type. Internal amethyst-side classes (FavoriteDvmListState, FavoriteDvmListDecryptionCache), the top-nav filter classes (FavoriteDvm*, AllFavoriteDvms*) and the orchestrator keep their names — they still deal with content-discovery DVMs specifically, and the narrower rename here is scoped to the Nostr wire format the user was asking about. - Quartz test renamed + rewired. Build + tests green on both modules. --- .../amethyst/model/AccountSettings.kt | 6 +- .../amethyst/model/LocalCache.kt | 4 +- .../FavoriteDvmListDecryptionCache.kt | 10 +-- .../favoriteDvmLists/FavoriteDvmListState.kt | 20 +++--- .../FavoriteAlgoFeedsListEvent.kt} | 62 +++++++++---------- .../TagArrayBuilderExt.kt | 6 +- .../TagArrayExt.kt | 6 +- .../quartz/utils/EventFactory.kt | 4 +- .../FavoriteAlgoFeedsListEventTest.kt} | 54 ++++++++-------- 9 files changed, 86 insertions(+), 86 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList/FavoriteDvmListEvent.kt => favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt} (75%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList => favoriteAlgoFeedsList}/TagArrayBuilderExt.kt (80%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList => favoriteAlgoFeedsList}/TagArrayExt.kt (83%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList/FavoriteDvmListEventTest.kt => favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt} (75%) 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 15332850e..2a5f6b681 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,7 +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.favoriteDvmList.FavoriteDvmListEvent +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 @@ -203,7 +203,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, - var backupFavoriteDvmList: FavoriteDvmListEvent? = null, + var backupFavoriteDvmList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -727,7 +727,7 @@ class AccountSettings( } } - fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. 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 339747d75..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,7 +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.favoriteDvmList.FavoriteDvmListEvent +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 @@ -2636,7 +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 FavoriteDvmListEvent -> 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/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt index 8af3bfa97..fef666385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt @@ -22,15 +22,15 @@ package com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet class FavoriteDvmListDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateLists = PrivateTagArrayEventCache(signer) + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + fun cachedFavoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() - suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() + suspend fun favoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt index db41026d5..6181d8b02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt @@ -28,7 +28,7 @@ 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.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -53,14 +53,14 @@ class FavoriteDvmListState( // Creates a long-term reference for this note so that the GC doesn't collect the note itself val favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) - fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + fun getFavoriteDvmListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow - fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + fun getFavoriteDvmList(): FavoriteAlgoFeedsListEvent? = favoriteDvmListNote.event as? FavoriteAlgoFeedsListEvent suspend fun favoriteDvmListWithBackup(note: Note): Set
{ - val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteDvmList return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() } @@ -92,18 +92,18 @@ class FavoriteDvmListState( emptyList(), ) - suspend fun follow(dvm: AddressBookmark): FavoriteDvmListEvent { + suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { val list = getFavoriteDvmList() return if (list == null) { - FavoriteDvmListEvent.create(dvm, false, signer) + FavoriteAlgoFeedsListEvent.create(dvm, false, signer) } else { - FavoriteDvmListEvent.add(list, dvm, false, signer) + FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer) } } - suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { val list = getFavoriteDvmList() ?: return null - return FavoriteDvmListEvent.remove(list, dvm, signer) + return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) } init { @@ -119,7 +119,7 @@ class FavoriteDvmListState( Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") getFavoriteDvmListFlow().collect { Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } - (it.note.event as? FavoriteDvmListEvent)?.let { + (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { settings.updateFavoriteDvmListTo(it) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt similarity index 75% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt index 34f28bf7c..d4b673582 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -18,7 +18,7 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class FavoriteDvmListEvent( +class FavoriteAlgoFeedsListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -46,72 +46,72 @@ class FavoriteDvmListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + fun publicFavoriteAlgoFeeds(): List = tags.mapNotNull(AddressBookmark::parse) - suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) companion object { const val KIND = 10090 - const val ALT = "Favorite DVM list" + 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( - dvm: AddressBookmark, + feed: AddressBookmark, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent = + ): FavoriteAlgoFeedsListEvent = if (isPrivate) { create( - publicDvms = emptyList(), - privateDvms = listOf(dvm), + publicFeeds = emptyList(), + privateFeeds = listOf(feed), signer = signer, createdAt = createdAt, ) } else { create( - publicDvms = listOf(dvm), - privateDvms = emptyList(), + publicFeeds = listOf(feed), + privateFeeds = emptyList(), signer = signer, createdAt = createdAt, ) } suspend fun add( - earlierVersion: FavoriteDvmListEvent, - dvm: AddressBookmark, + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: AddressBookmark, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent = + ): FavoriteAlgoFeedsListEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( tags = earlierVersion.tags, - privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(), signer = signer, createdAt = createdAt, ) } else { resign( content = earlierVersion.content, - tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(), signer = signer, createdAt = createdAt, ) } suspend fun remove( - earlierVersion: FavoriteDvmListEvent, - dvm: Address, + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: Address, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { - val idOnly = AddressBookmark.assemble(dvm, null) + ): FavoriteAlgoFeedsListEvent { + val idOnly = AddressBookmark.assemble(feed, null) val privateTags = earlierVersion.privateTags(signer) return if (privateTags != null) { resign( @@ -147,7 +147,7 @@ class FavoriteDvmListEvent( tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { + ): FavoriteAlgoFeedsListEvent { val newTags = if (tags.fastAny(AltTag::match)) { tags @@ -159,32 +159,32 @@ class FavoriteDvmListEvent( } suspend fun create( - publicDvms: List = emptyList(), - privateDvms: List = emptyList(), + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { - val template = build(publicDvms, privateDvms, signer, createdAt) + ): FavoriteAlgoFeedsListEvent { + val template = build(publicFeeds, privateFeeds, signer, createdAt) return signer.sign(template) } suspend fun build( - publicDvms: List = emptyList(), - privateDvms: List = emptyList(), + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44( - privateDvms.map { it.toTagArray() }.toTypedArray(), + privateFeeds.map { it.toTagArray() }.toTypedArray(), signer, ), createdAt = createdAt, ) { alt(ALT) - favoriteDvms(publicDvms) + favoriteAlgoFeeds(publicFeeds) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt index 6f49fd98b..4214d7627 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt @@ -18,11 +18,11 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) +fun TagArrayBuilder.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray()) -fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) +fun TagArrayBuilder.favoriteAlgoFeeds(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt index 0f2d3501c..5024bb91e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt @@ -18,11 +18,11 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) +fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress) -fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) +fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) 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 1e525e148..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,7 +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.favoriteDvmList.FavoriteDvmListEvent +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 @@ -423,7 +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) - FavoriteDvmListEvent.KIND -> FavoriteDvmListEvent(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/favoriteDvmList/FavoriteDvmListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt similarity index 75% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt index 88c36c6e4..f3ac5ff24 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt @@ -18,7 +18,7 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -30,7 +30,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class FavoriteDvmListEventTest { +class FavoriteAlgoFeedsListEventTest { private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) private fun dvm( @@ -40,12 +40,12 @@ class FavoriteDvmListEventTest { @Test fun kindMatchesSpec() { - assertEquals(10090, FavoriteDvmListEvent.KIND) + assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND) } @Test fun addressesAreReplaceableWithFixedDTag() { - val address = FavoriteDvmListEvent.createAddress("a".repeat(64)) + val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64)) assertEquals(10090, address.kind) assertEquals("", address.dTag) } @@ -53,11 +53,11 @@ class FavoriteDvmListEventTest { @Test fun createStoresDvmAsATag() = runTest { - val dvm = dvm("a".repeat(64)) + val aFeed = dvm("a".repeat(64)) val event = - FavoriteDvmListEvent.create( - dvm = dvm, + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669816, @@ -65,31 +65,31 @@ class FavoriteDvmListEventTest { assertEquals(10090, event.kind) assertTrue( - event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == dvm.address.toValue() }, + 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.publicFavoriteDvms() + val favorites = event.publicFavoriteAlgoFeeds() assertEquals(1, favorites.size) - assertEquals(dvm.address, favorites.first().address) + assertEquals(aFeed.address, favorites.first().address) } @Test fun addAppendsWithoutDuplicatingExistingEntry() = runTest { - val dvm = dvm("a".repeat(64)) + val aFeed = dvm("a".repeat(64)) val initial = - FavoriteDvmListEvent.create( - dvm = dvm, + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669816, ) val afterDupeAdd = - FavoriteDvmListEvent.add( + FavoriteAlgoFeedsListEvent.add( earlierVersion = initial, - dvm = dvm, + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669817, @@ -97,7 +97,7 @@ class FavoriteDvmListEventTest { assertEquals( 1, - afterDupeAdd.publicFavoriteDvms().count { it.address == dvm.address }, + afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address }, "re-adding the same DVM must not produce a duplicate tag", ) } @@ -109,23 +109,23 @@ class FavoriteDvmListEventTest { val second = dvm("b".repeat(64)) val initial = - FavoriteDvmListEvent.create( - dvm = first, + FavoriteAlgoFeedsListEvent.create( + feed = first, isPrivate = false, signer = signer, createdAt = 1740669816, ) val after = - FavoriteDvmListEvent.add( + FavoriteAlgoFeedsListEvent.add( earlierVersion = initial, - dvm = second, + feed = second, isPrivate = false, signer = signer, createdAt = 1740669817, ) - val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() assertTrue(first.address in addresses) assertTrue(second.address in addresses) } @@ -137,22 +137,22 @@ class FavoriteDvmListEventTest { val second = dvm("b".repeat(64)) val initial = - FavoriteDvmListEvent.create( - publicDvms = listOf(first, second), - privateDvms = emptyList(), + FavoriteAlgoFeedsListEvent.create( + publicFeeds = listOf(first, second), + privateFeeds = emptyList(), signer = signer, createdAt = 1740669816, ) val after = - FavoriteDvmListEvent.remove( + FavoriteAlgoFeedsListEvent.remove( earlierVersion = initial, - dvm = first.address, + feed = first.address, signer = signer, createdAt = 1740669817, ) - val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + 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") } From d2ddedc87159d3c08e4dfc33384722c3509d3ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:49:09 +0000 Subject: [PATCH 37/46] fix(badges/profile): freeze list order across Switch toggles The accepted-first sort was keyed on acceptedAwardIds, so every toggle changed that set and the list jumped around. Key the remember on a "loaded" flag (first time either the 10008 or the legacy 30008 event arrives) plus the existing bundle tick; acceptedAwardIds is read from the enclosing scope at the moment of recomputation but isn't part of the key. Result: the list loads with accepted badges on top, new awards flowing in during the session trigger a re-sort, but plain Switch toggles leave the visual order intact. --- .../badges/profile/ProfileBadgesScreen.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 index c4492435b..516984277 100644 --- 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 @@ -117,16 +117,26 @@ fun ProfileBadgesScreen( } } + // "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, acceptedAwardIds) { + 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, then most recent first. - compareByDescending { acceptedAwardIds.contains(it.id) } + // Accepted badges on top (at load time), then most recent first. + compareByDescending { initialAccepted.contains(it.id) } .thenByDescending { it.createdAt }, ) } From f99fc4c3334d70136ae8e47af22d2a7c878277cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:56:16 +0000 Subject: [PATCH 38/46] refactor: rename FavoriteDvm* -> FavoriteAlgoFeed* throughout amethyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-level class was renamed to FavoriteAlgoFeedsListEvent in a prior commit. Finish the rename through the rest of the feature so the internal vocabulary (packages, classes, properties, routes, DSL helpers, TopFilter variants, composables) matches what users see. Packages: - model.nip51Lists.favoriteDvmLists -> favoriteAlgoFeedsLists - model.dvms -> algoFeeds - model.topNavFeeds.favoriteDvm -> favoriteAlgoFeeds - home.datasource.nip90Dvms -> nip90AlgoFeeds Classes/composables renamed (selected): - FavoriteDvmListState/DecryptionCache -> FavoriteAlgoFeedsListState/DecryptionCache - FavoriteDvmOrchestrator / FavoriteDvmSnapshot -> FavoriteAlgoFeedsOrchestrator / FavoriteAlgoFeedsSnapshot - FavoriteDvmTopNavFilter{,PerRelayFilter{,Set}}, FavoriteDvmFeedFlow -> FavoriteAlgoFeedTopNavFilter{,PerRelayFilter{,Set}}, FavoriteAlgoFeedFlow - AllFavoriteDvmsTopNavFilter / AllFavoriteDvmsFeedFlow / AllFavoriteDvmsBanner -> AllFavoriteAlgoFeedsTopNavFilter / AllFavoriteAlgoFeedsFlow / AllFavoriteAlgoFeedsBanner - FavoriteDvmName -> FavoriteAlgoFeedName - FavoriteDvmToggle -> FavoriteAlgoFeedToggle - FavoriteDvmListScreen -> FavoriteAlgoFeedsListScreen - HomeDvmStatusBanner / SingleDvmBanner / DvmStatusBanner -> HomeAlgoFeedStatusBanner / SingleAlgoFeedBanner / AlgoFeedStatusBanner - filterHomePostsByDvmIds -> filterHomePostsByAlgoFeedIds - TopFilter.FavoriteDvm / TopFilter.AllFavoriteDvms -> TopFilter.FavoriteAlgoFeed / TopFilter.AllFavoriteAlgoFeeds - Route.EditFavoriteDvms -> Route.EditFavoriteAlgoFeeds Account / AccountSettings / AccountViewModel properties & mutators renamed consistently: favoriteDvmList -> favoriteAlgoFeedsList, backupFavoriteDvmList -> backupFavoriteAlgoFeedsList, follow/unfollow/ isFavoriteDvm -> follow/unfollow/isFavoriteAlgoFeed, refreshFavoriteDvm -> refreshFavoriteAlgoFeed, dvmAddress kwargs -> feedAddress, dvmNote locals -> feedNote, etc. Persisted TopFilter.FavoriteAlgoFeed/AllFavoriteAlgoFeeds `code` strings changed (old "FavoriteDvm/…" / " All Favourite DVMs " values don't round-trip anymore) — kotlinx-serialization polymorphism keys the sealed class by class name, so cold-start of pre-existing installs will fall back to AllFollows for this filter. Acceptable since this is a new feature only on this branch. Kept unchanged: ui/screen/loggedIn/dvms/ folder (hosts generic DvmContentDiscoveryScreen / DvmTopBar / DvmPaymentActions), quartz nip90Dvms package (NIP-90 wire-protocol naming). XML string resource keys (favorite_dvms_title, dvm_home_* etc) kept stable; only their values were updated previously. Build + tests green on both modules. --- .../vitorpamplona/amethyst/model/Account.kt | 22 ++++----- .../amethyst/model/AccountSettings.kt | 14 +++--- .../FavoriteAlgoFeedsOrchestrator.kt} | 48 +++++++++---------- .../FavoriteAlgoFeedsListDecryptionCache.kt} | 8 ++-- .../FavoriteAlgoFeedsListState.kt} | 36 +++++++------- .../topNavFeeds/FeedTopNavFilterState.kt | 26 +++++----- .../AllFavoriteAlgoFeedsFlow.kt} | 32 ++++++------- .../AllFavoriteAlgoFeedsTopNavFilter.kt} | 14 +++--- .../FavoriteAlgoFeedFlow.kt} | 22 ++++----- .../FavoriteAlgoFeedTopNavFilter.kt} | 18 +++---- .../FavoriteAlgoFeedTopNavPerRelayFilter.kt} | 4 +- ...avoriteAlgoFeedTopNavPerRelayFilterSet.kt} | 10 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../amethyst/ui/navigation/routes/Routes.kt | 2 +- .../navigation/topbars/FeedFilterSpinner.kt | 14 +++--- .../amethyst/ui/screen/TopNavFilterState.kt | 30 ++++++------ .../ui/screen/loggedIn/AccountViewModel.kt | 6 +-- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 4 +- .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 2 +- ...DvmToggle.kt => FavoriteAlgoFeedToggle.kt} | 8 ++-- ...reen.kt => FavoriteAlgoFeedsListScreen.kt} | 32 ++++++------- ...tatusBanner.kt => AlgoFeedStatusBanner.kt} | 36 +++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 8 ++-- .../HomeOutboxEventsEoseManager.kt | 6 +-- .../FilterHomePostsByAlgoFeedIds.kt} | 12 ++--- .../loggedIn/settings/AllSettingsScreen.kt | 2 +- .../FavoriteAlgoFeedTopNavFilterTest.kt} | 26 +++++----- .../FilterHomePostsByAlgoFeedIdsTest.kt} | 32 ++++++------- 28 files changed, 239 insertions(+), 239 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/{dvms/FavoriteDvmOrchestrator.kt => algoFeeds/FavoriteAlgoFeedsOrchestrator.kt} (86%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/{favoriteDvmLists/FavoriteDvmListDecryptionCache.kt => favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt} (79%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/{favoriteDvmLists/FavoriteDvmListState.kt => favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt} (77%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/AllFavoriteDvmsFeedFlow.kt => favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt} (80%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/AllFavoriteDvmsTopNavFilter.kt => favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmFeedFlow.kt => favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt} (79%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavFilter.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt} (84%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt} (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/{FavoriteDvmToggle.kt => FavoriteAlgoFeedToggle.kt} (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/{FavoriteDvmListScreen.kt => FavoriteAlgoFeedsListScreen.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/{DvmStatusBanner.kt => AlgoFeedStatusBanner.kt} (89%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip90Dvms/FilterHomePostsByDvmIds.kt => nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt} (91%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavFilterTest.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt} (87%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip90Dvms/FilterHomePostsByDvmIdsTest.kt => nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt} (78%) 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 4f52e43d7..fe977c64b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,7 +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.dvms.FavoriteDvmOrchestrator +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 @@ -67,8 +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.favoriteDvmLists.FavoriteDvmListDecryptionCache -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState +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 @@ -327,9 +327,9 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) - val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) - val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) - val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + 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) @@ -426,8 +426,8 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, - favoriteDvmOrchestrator = favoriteDvmOrchestrator, - favoriteDvmAddresses = favoriteDvmList.flow, + favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator, + favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow, ).flow // App-ready Feeds @@ -1023,11 +1023,11 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) - suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm)) - suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm)) - fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm) suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) 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 2a5f6b681..4b7319d04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -157,11 +157,11 @@ sealed class TopFilter( ) : TopFilter("Relay/$url") @Serializable - class FavoriteDvm( + class FavoriteAlgoFeed( val address: Address, - ) : TopFilter("FavoriteDvm/${address.toValue()}") + ) : TopFilter("FavoriteAlgoFeed/${address.toValue()}") - @Serializable object AllFavoriteDvms : TopFilter(" All Favourite DVMs ") + @Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ") } @Stable @@ -203,7 +203,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, - var backupFavoriteDvmList: FavoriteAlgoFeedsListEvent? = null, + var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -727,12 +727,12 @@ class AccountSettings( } } - fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { + fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. - if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { - backupFavoriteDvmList = newFavoriteDvmList + if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) { + backupFavoriteAlgoFeedsList = newFavoriteDvmList saveAccountSettings() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt index 860cdfd0e..0496ad62d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt @@ -18,7 +18,7 @@ * 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.dvms +package com.vitorpamplona.amethyst.model.algoFeeds import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Address @@ -45,7 +45,7 @@ import kotlinx.coroutines.sync.withLock private const val RESPONSE_TIMEOUT_MS = 20_000L /** - * Immutable snapshot of a favorite DVM's current request/response state. + * 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 @@ -55,7 +55,7 @@ private const val RESPONSE_TIMEOUT_MS = 20_000L * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). * - [errorMessage] captures any client-side failure while publishing the request. */ -data class FavoriteDvmSnapshot( +data class FavoriteAlgoFeedsSnapshot( val requestId: HexKey? = null, val responseRelays: Set = emptySet(), val ids: Set = emptySet(), @@ -65,7 +65,7 @@ data class FavoriteDvmSnapshot( ) /** - * Manages the NIP-90 content-discovery RPC cycle for each favorite DVM the user + * 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 @@ -75,50 +75,50 @@ data class FavoriteDvmSnapshot( * * 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.FavoriteDvm` selected. The orchestrator merely observes + * user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes * what the relays deliver into `LocalCache`. */ -class FavoriteDvmOrchestrator( +class FavoriteAlgoFeedsOrchestrator( val account: Account, val scope: CoroutineScope, ) { - private val flows = mutableMapOf>() + private val flows = mutableMapOf>() private val jobs = mutableMapOf() private val mutex = Mutex() - fun observe(dvmAddress: Address): StateFlow { - flows[dvmAddress]?.let { return it.asStateFlow() } + fun observe(feedAddress: Address): StateFlow { + flows[feedAddress]?.let { return it.asStateFlow() } - val seed = MutableStateFlow(FavoriteDvmSnapshot()) - flows[dvmAddress] = seed - scope.launch { startFor(dvmAddress, seed) } + val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot()) + flows[feedAddress] = seed + scope.launch { startFor(feedAddress, seed) } return seed.asStateFlow() } - fun refresh(dvmAddress: Address) { - val seed = flows[dvmAddress] ?: return + fun refresh(feedAddress: Address) { + val seed = flows[feedAddress] ?: return scope.launch { mutex.withLock { - jobs.remove(dvmAddress)?.cancel() + jobs.remove(feedAddress)?.cancel() } - startFor(dvmAddress, seed) + startFor(feedAddress, seed) } } - fun stop(dvmAddress: Address) { + fun stop(feedAddress: Address) { scope.launch { mutex.withLock { - jobs.remove(dvmAddress)?.cancel() - flows.remove(dvmAddress) + jobs.remove(feedAddress)?.cancel() + flows.remove(feedAddress) } } } private suspend fun startFor( - dvmAddress: Address, - seed: MutableStateFlow, + feedAddress: Address, + seed: MutableStateFlow, ) { - val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return val job = scope.launch(Dispatchers.IO) { try { @@ -188,12 +188,12 @@ class FavoriteDvmOrchestrator( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e) seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } } } - mutex.withLock { jobs[dvmAddress] = job } + mutex.withLock { jobs[feedAddress] = job } } private fun splitInnerTags(innerTags: List): Pair, Set> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt index fef666385..03b3f8536 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt @@ -18,19 +18,19 @@ * 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.favoriteDvmLists +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 FavoriteDvmListDecryptionCache( +class FavoriteAlgoFeedsListDecryptionCache( val signer: NostrSigner, ) { val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedFavoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() + fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() - suspend fun favoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() + suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt index 6181d8b02..b39d78e80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt @@ -18,7 +18,7 @@ * 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.favoriteDvmLists +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote @@ -43,34 +43,34 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -class FavoriteDvmListState( +class FavoriteAlgoFeedsListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: FavoriteDvmListDecryptionCache, + 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 favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress()) - fun getFavoriteDvmListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) + fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) - fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + fun getFavoriteAlgoFeedsListFlow(): StateFlow = favoriteAlgoFeedsListNote.flow().metadata.stateFlow - fun getFavoriteDvmList(): FavoriteAlgoFeedsListEvent? = favoriteDvmListNote.event as? FavoriteAlgoFeedsListEvent + fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent - suspend fun favoriteDvmListWithBackup(note: Note): Set
{ - val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteDvmList - return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + 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> = - getFavoriteDvmListFlow() + getFavoriteAlgoFeedsListFlow() .transformLatest { noteState -> - emit(favoriteDvmListWithBackup(noteState.note)) + emit(favoriteAlgoFeedsListWithBackup(noteState.note)) }.onStart { - emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote)) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -93,7 +93,7 @@ class FavoriteDvmListState( ) suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { - val list = getFavoriteDvmList() + val list = getFavoriteAlgoFeedsList() return if (list == null) { FavoriteAlgoFeedsListEvent.create(dvm, false, signer) } else { @@ -102,12 +102,12 @@ class FavoriteDvmListState( } suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { - val list = getFavoriteDvmList() ?: return null + val list = getFavoriteAlgoFeedsList() ?: return null return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) } init { - settings.backupFavoriteDvmList?.let { event -> + settings.backupFavoriteAlgoFeedsList?.let { event -> Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { @@ -117,10 +117,10 @@ class FavoriteDvmListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") - getFavoriteDvmListFlow().collect { + getFavoriteAlgoFeedsListFlow().collect { Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { - settings.updateFavoriteDvmListTo(it) + 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 be72d1b26..d8269aa43 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,7 +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.dvms.FavoriteDvmOrchestrator +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 @@ -31,8 +31,8 @@ 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.favoriteDvm.AllFavoriteDvmsFeedFlow -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow +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 @@ -66,8 +66,8 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, - val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, - val favoriteDvmAddresses: StateFlow>, + val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator, + val favoriteAlgoFeedAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -149,19 +149,19 @@ class FeedTopNavFilterState( RelayFeedFlow(listName.url.normalizeRelayUrl()) } - is TopFilter.FavoriteDvm -> { - FavoriteDvmFeedFlow( - dvmAddress = listName.address, - orchestrator = favoriteDvmOrchestrator, + is TopFilter.FavoriteAlgoFeed -> { + FavoriteAlgoFeedFlow( + feedAddress = listName.address, + orchestrator = favoriteAlgoFeedsOrchestrator, outboxRelays = followsRelays, proxyRelays = proxyRelays, ) } - TopFilter.AllFavoriteDvms -> { - AllFavoriteDvmsFeedFlow( - favoriteDvmAddresses = favoriteDvmAddresses, - orchestrator = favoriteDvmOrchestrator, + TopFilter.AllFavoriteAlgoFeeds -> { + AllFavoriteAlgoFeedsFlow( + favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses, + orchestrator = favoriteAlgoFeedsOrchestrator, outboxRelays = followsRelays, proxyRelays = proxyRelays, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt index c35c2ba72..f23930722 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt @@ -18,10 +18,10 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +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 @@ -35,12 +35,12 @@ import kotlinx.coroutines.flow.flatMapLatest /** * Feed flow that merges snapshots from every currently-favorited DVM into a - * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the + * single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the * favorite set changes. */ -class AllFavoriteDvmsFeedFlow( - val favoriteDvmAddresses: StateFlow>, - val orchestrator: FavoriteDvmOrchestrator, +class AllFavoriteAlgoFeedsFlow( + val favoriteAlgoFeedAddresses: StateFlow>, + val orchestrator: FavoriteAlgoFeedsOrchestrator, val outboxRelays: StateFlow>, val proxyRelays: StateFlow>, ) : IFeedFlowsType { @@ -50,9 +50,9 @@ class AllFavoriteDvmsFeedFlow( ): Set = if (proxy.isNotEmpty()) proxy else outbox private fun merge( - snapshots: List, + snapshots: List, contentRelays: Set, - ): AllFavoriteDvmsTopNavFilter { + ): AllFavoriteAlgoFeedsTopNavFilter { val ids = mutableSetOf() val addresses = mutableSetOf() val listen = mutableSetOf() @@ -63,7 +63,7 @@ class AllFavoriteDvmsFeedFlow( listen += snap.responseRelays snap.requestId?.let { requestIds += it } } - return AllFavoriteDvmsTopNavFilter( + return AllFavoriteAlgoFeedsTopNavFilter( acceptedIds = ids, acceptedAddresses = addresses, contentRelays = contentRelays, @@ -72,8 +72,8 @@ class AllFavoriteDvmsFeedFlow( ) } - private fun emptyFilter(contentRelays: Set): AllFavoriteDvmsTopNavFilter = - AllFavoriteDvmsTopNavFilter( + private fun emptyFilter(contentRelays: Set): AllFavoriteAlgoFeedsTopNavFilter = + AllFavoriteAlgoFeedsTopNavFilter( acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = contentRelays, @@ -83,13 +83,13 @@ class AllFavoriteDvmsFeedFlow( @OptIn(ExperimentalCoroutinesApi::class) override fun flow(): Flow = - favoriteDvmAddresses.flatMapLatest { addresses -> + favoriteAlgoFeedAddresses.flatMapLatest { addresses -> if (addresses.isEmpty()) { combine(outboxRelays, proxyRelays) { outbox, proxy -> emptyFilter(resolveContentRelays(outbox, proxy)) } } else { - val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } + val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } combine(snapshotFlows) { it.toList() } .let { merged -> combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy -> @@ -99,9 +99,9 @@ class AllFavoriteDvmsFeedFlow( } } - override fun startValue(): AllFavoriteDvmsTopNavFilter { + override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter { val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) - val addresses = favoriteDvmAddresses.value + val addresses = favoriteAlgoFeedAddresses.value return if (addresses.isEmpty()) { emptyFilter(contentRelays) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt index 36e7e6b6f..eba50b6bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache @@ -32,12 +32,12 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Top-nav filter that unions the latest kind-6300 responses from every currently - * favorited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * 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 AllFavoriteDvmsTopNavFilter( +class AllFavoriteAlgoFeedsTopNavFilter( val acceptedIds: Set, val acceptedAddresses: Set, val contentRelays: Set, @@ -50,13 +50,13 @@ class AllFavoriteDvmsTopNavFilter( noteEvent.id in acceptedIds || (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) - override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) - override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = - FavoriteDvmTopNavPerRelayFilterSet( + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = contentRelays.associateWith { - FavoriteDvmTopNavPerRelayFilter( + FavoriteAlgoFeedTopNavPerRelayFilter( ids = acceptedIds, addresses = acceptedAddresses, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt index 04d35e0d6..e17accd43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt @@ -18,9 +18,9 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +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 @@ -30,9 +30,9 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -class FavoriteDvmFeedFlow( - val dvmAddress: Address, - val orchestrator: FavoriteDvmOrchestrator, +class FavoriteAlgoFeedFlow( + val feedAddress: Address, + val orchestrator: FavoriteAlgoFeedsOrchestrator, val outboxRelays: StateFlow>, val proxyRelays: StateFlow>, ) : IFeedFlowsType { @@ -42,10 +42,10 @@ class FavoriteDvmFeedFlow( ): Set = if (proxy.isNotEmpty()) proxy else outbox private fun buildFilter( - snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, + snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot, contentRelays: Set, - ) = FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + ) = FavoriteAlgoFeedTopNavFilter( + feedAddress = feedAddress, acceptedIds = snapshot.ids, acceptedAddresses = snapshot.addresses, contentRelays = contentRelays, @@ -54,13 +54,13 @@ class FavoriteDvmFeedFlow( ) override fun flow(): Flow = - combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> buildFilter(snap, resolveRelays(outbox, proxy)) } - override fun startValue(): FavoriteDvmTopNavFilter = + override fun startValue(): FavoriteAlgoFeedTopNavFilter = buildFilter( - snapshot = orchestrator.observe(dvmAddress).value, + snapshot = orchestrator.observe(feedAddress).value, contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt index 9534bc1c8..6cd70135c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache @@ -32,15 +32,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow /** - * Top-nav filter backed by the latest kind-6300 response from a favorite DVM. + * 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 [FavoriteDvmFeedFlow] and replaces the active filter. + * emitted through [FavoriteAlgoFeedFlow] and replaces the active filter. */ @Immutable -class FavoriteDvmTopNavFilter( - val dvmAddress: Address, +class FavoriteAlgoFeedTopNavFilter( + val feedAddress: Address, val acceptedIds: Set, val acceptedAddresses: Set, val contentRelays: Set, @@ -53,13 +53,13 @@ class FavoriteDvmTopNavFilter( noteEvent.id in acceptedIds || (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) - override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) - override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = - FavoriteDvmTopNavPerRelayFilterSet( + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = contentRelays.associateWith { - FavoriteDvmTopNavPerRelayFilter( + FavoriteAlgoFeedTopNavPerRelayFilter( ids = acceptedIds, addresses = acceptedAddresses, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt index 4dcbeddaa..374f5ecf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt @@ -18,14 +18,14 @@ * 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.favoriteDvm +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 FavoriteDvmTopNavPerRelayFilter( +class FavoriteAlgoFeedTopNavPerRelayFilter( val ids: Set, val addresses: Set, ) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt index 318840384..a111c9361 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -32,11 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * - [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-DVM filter carries one; the merged "All favorite DVMs" - * filter carries one per favorite DVM. + * for. A single feed carries one; the merged "All favorite algo feeds" + * filter carries one per favorite algo feed. */ -class FavoriteDvmTopNavPerRelayFilterSet( - val contentFetches: Map, +class FavoriteAlgoFeedTopNavPerRelayFilterSet( + val contentFetches: Map, val listenRelays: Set, val requestIds: Set, ) : IFeedTopNavPerRelayFilterSet 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 972befbcd..3ba6d22b3 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 @@ -95,7 +95,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.FavoriteDvmListScreen +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 @@ -278,7 +278,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } - composableFromEnd { FavoriteDvmListScreen(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/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 057a24397..70a12a0dd 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 @@ -171,7 +171,7 @@ sealed class Route { @Serializable object EditMediaServers : Route() - @Serializable object EditFavoriteDvms : Route() + @Serializable object EditFavoriteAlgoFeeds : Route() @Serializable object EditPaymentTargets : 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 b4cf9a719..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 @@ -86,7 +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.FavoriteDvmName +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 @@ -361,7 +361,7 @@ fun RenderOption( ) } - is FavoriteDvmName -> { + is FavoriteAlgoFeedName -> { val noteState by observeNote(option.note, accountViewModel) val name = (noteState.note.event as? AppDefinitionEvent) @@ -419,7 +419,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + is FavoriteAlgoFeedName -> { FeedGroup.DVMS } @@ -427,7 +427,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS - is TopFilter.AllFavoriteDvms -> FeedGroup.DVMS + is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -592,11 +592,11 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } - is TopFilter.FavoriteDvm -> { + is TopFilter.FavoriteAlgoFeed -> { Icons.Outlined.AutoAwesome } - is TopFilter.AllFavoriteDvms -> { + is TopFilter.AllFavoriteAlgoFeeds -> { Icons.Outlined.AutoAwesome } @@ -606,7 +606,7 @@ private fun FeedIcon( is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList - is FavoriteDvmName -> Icons.Outlined.AutoAwesome + is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } 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 7b65a43e1..69060103f 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 @@ -97,9 +97,9 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) - val allFavoriteDvmsFollow = + val allFavoriteAlgoFeedsFollow = FeedDefinition( - code = TopFilter.AllFavoriteDvms, + code = TopFilter.AllFavoriteAlgoFeeds, name = ResourceName(R.string.follow_list_all_favorite_dvms), ) @@ -147,7 +147,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, - favoriteDvmList: List, + favoriteAlgoFeedsList: List, ): List { val hashtags = hashtagList.map { @@ -181,26 +181,26 @@ class TopNavFilterState( ) } - // Favorites can only be added through FavoriteDvmToggle, which itself checks + // 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.FavoriteDvm can't find its chip + // 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 favoriteDvms = - favoriteDvmList.map { dvmNote -> + val favoriteAlgoFeeds = + favoriteAlgoFeedsList.map { feedNote -> FeedDefinition( - TopFilter.FavoriteDvm(dvmNote.address), - FavoriteDvmName(dvmNote), + TopFilter.FavoriteAlgoFeed(feedNote.address), + FavoriteAlgoFeedName(feedNote), ) } - // Only show the "All favorite DVMs" meta-chip when there is at least one + // 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 (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() + if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList() - return (communities + hashtags + geotags + relays + allFavorites + favoriteDvms).sortedBy { it.name.name() } + return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -210,7 +210,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, - account.favoriteDvmList.flowNotes, + account.favoriteAlgoFeedsList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -219,7 +219,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, - account.favoriteDvmList.flowNotes.value, + account.favoriteAlgoFeedsList.flowNotes.value, ), ) } @@ -328,7 +328,7 @@ class CommunityName( } @Stable -class FavoriteDvmName( +class FavoriteAlgoFeedName( val note: AddressableNote, ) : Name() { override fun name(): String = 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 97b01c336..e08814ee4 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 @@ -1089,11 +1089,11 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } - fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) } - fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) } - fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm) fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } 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 0ca21209b..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 @@ -49,7 +49,7 @@ 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.FavoriteDvmToggle +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 @@ -117,7 +117,7 @@ fun RenderContentDVMThumb( modifier = Modifier.weight(1f), ) if (baseNote is AddressableNote) { - FavoriteDvmToggle( + FavoriteAlgoFeedToggle( appDefinitionNote = baseNote, accountViewModel = accountViewModel, ) 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 4ae8cefe5..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 @@ -102,7 +102,7 @@ fun DvmTopBar( } } addressableNote?.let { target -> - FavoriteDvmToggle( + FavoriteAlgoFeedToggle( appDefinitionNote = target, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt index 1bed004b7..3838bef64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDi * a 6300 reply that never comes. */ @Composable -fun FavoriteDvmToggle( +fun FavoriteAlgoFeedToggle( appDefinitionNote: AddressableNote, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, @@ -65,7 +65,7 @@ fun FavoriteDvmToggle( if (!supportsContentDiscovery) return - val favorites by accountViewModel.account.favoriteDvmList.flow + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() val isFavorite = favorites.contains(appDefinitionNote.address) @@ -74,9 +74,9 @@ fun FavoriteDvmToggle( modifier = modifier, onClick = { if (isFavorite) { - accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address) } else { - accountViewModel.followFavoriteDvm( + accountViewModel.followFavoriteAlgoFeed( AddressBookmark( address = appDefinitionNote.address, relayHint = appDefinitionNote.relayHintUrl(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt index 0f22ae4ec..8d71292bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -70,7 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText @OptIn(ExperimentalMaterial3Api::class) @Composable -fun FavoriteDvmListScreen( +fun FavoriteAlgoFeedsListScreen( accountViewModel: AccountViewModel, nav: INav, ) { @@ -101,17 +101,17 @@ fun FavoriteDvmListScreen( color = MaterialTheme.colorScheme.grayText, ) - FavoriteDvmList(accountViewModel, nav) + FavoriteAlgoFeedList(accountViewModel, nav) } } } @Composable -private fun FavoriteDvmList( +private fun FavoriteAlgoFeedList( accountViewModel: AccountViewModel, nav: INav, ) { - val favorites by accountViewModel.account.favoriteDvmList.flowNotes + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes .collectAsStateWithLifecycle() if (favorites.isEmpty()) { @@ -136,12 +136,12 @@ private fun FavoriteDvmList( items( items = favorites, key = { it.address.toValue() }, - ) { dvmNote -> - FavoriteDvmRow( - dvmNote = dvmNote, + ) { feedNote -> + FavoriteAlgoFeedRow( + feedNote = feedNote, accountViewModel = accountViewModel, - onOpen = { nav.nav(Route.ContentDiscovery(dvmNote.idHex)) }, - onRemove = { accountViewModel.unfollowFavoriteDvm(dvmNote.address) }, + onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) }, ) } } @@ -149,13 +149,13 @@ private fun FavoriteDvmList( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun FavoriteDvmRow( - dvmNote: AddressableNote, +private fun FavoriteAlgoFeedRow( + feedNote: AddressableNote, accountViewModel: AccountViewModel, onOpen: () -> Unit, onRemove: () -> Unit, ) { - val card = observeAppDefinition(dvmNote, accountViewModel) + val card = observeAppDefinition(feedNote, accountViewModel) Row( modifier = @@ -175,19 +175,19 @@ private fun FavoriteDvmRow( loadedImageModifier = SimpleImage35Modifier, accountViewModel = accountViewModel, onLoadingBackground = { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } }, onError = { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } }, ) } } ?: run { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } } @@ -198,7 +198,7 @@ private fun FavoriteDvmRow( modifier = Modifier.weight(1f), ) { Text( - text = card.name.ifBlank { dvmNote.dTag() }, + text = card.name.ifBlank { feedNote.dTag() }, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt index 723eea88a..22d10c444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -42,7 +42,7 @@ 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.dvms.FavoriteDvmSnapshot +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 @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @Composable -fun HomeDvmStatusBanner( +fun HomeAlgoFeedStatusBanner( accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, @@ -64,31 +64,31 @@ fun HomeDvmStatusBanner( .collectAsStateWithLifecycle() when (val filter = topFilter) { - is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav, modifier) - is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel, modifier) + is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier) else -> Unit } } @Composable -private fun SingleDvmBanner( - favDvm: TopFilter.FavoriteDvm, +private fun SingleAlgoFeedBanner( + favFeed: TopFilter.FavoriteAlgoFeed, accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, ) { - val snapshot by accountViewModel.account.favoriteDvmOrchestrator - .observe(favDvm.address) + 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 dvmAddressValue = favDvm.address.toValue() + val feedAddressValue = favFeed.address.toValue() - LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote -> val resolvedName by - observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note -> (note.event as? AppDefinitionEvent) ?.appMetaData() ?.name @@ -107,7 +107,7 @@ private fun SingleDvmBanner( showSpinner = false, ) Spacer(modifier = StdVertSpacer) - RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } } status?.code == "payment-required" -> { @@ -144,7 +144,7 @@ private fun SingleDvmBanner( showSpinner = false, ) Spacer(modifier = StdVertSpacer) - RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } } status?.code == "processing" -> { @@ -169,11 +169,11 @@ private fun SingleDvmBanner( } @Composable -private fun AllFavoriteDvmsBanner( +private fun AllFavoriteAlgoFeedsBanner( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val addresses by accountViewModel.account.favoriteDvmList.flow + val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() if (addresses.isEmpty()) return @@ -181,9 +181,9 @@ private fun AllFavoriteDvmsBanner( // 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 = + val snapshots: List = addresses.map { address -> - val snap by accountViewModel.account.favoriteDvmOrchestrator + val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator .observe(address) .collectAsStateWithLifecycle() snap @@ -202,7 +202,7 @@ private fun AllFavoriteDvmsBanner( ) Spacer(modifier = StdVertSpacer) RetryButton { - addresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } } } else { BannerMessageRow( 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 d3f676146..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 @@ -240,7 +240,7 @@ private fun HomePages( ) } - HomeDvmStatusBanner( + HomeAlgoFeedStatusBanner( accountViewModel = accountViewModel, nav = nav, modifier = Modifier.align(Alignment.TopCenter), @@ -300,7 +300,7 @@ fun HomeFeeds( ) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val favoriteDvmAddresses by accountViewModel.account.favoriteDvmList.flow + val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() val onRefresh: () -> Unit = { @@ -308,8 +308,8 @@ fun HomeFeeds( // 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.FavoriteDvm -> accountViewModel.refreshFavoriteDvm(filter.address) - is TopFilter.AllFavoriteDvms -> favoriteDvmAddresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address) + is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } else -> Unit } } 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 29abc2ae6..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,7 +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.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 @@ -43,7 +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.nip90Dvms.filterHomePostsByDvmIds +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 @@ -76,7 +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 FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(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/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt index c042199a6..029546417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -18,10 +18,10 @@ * 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.nip90Dvms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 @@ -45,8 +45,8 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent * never publishes responses on the user's outbox, so listening anywhere else * would silently miss them. */ -fun filterHomePostsByDvmIds( - set: FavoriteDvmTopNavPerRelayFilterSet, +fun filterHomePostsByAlgoFeedIds( + set: FavoriteAlgoFeedTopNavPerRelayFilterSet, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?, ): List { @@ -68,7 +68,7 @@ fun filterHomePostsByDvmIds( private fun contentFetchFilters( relay: NormalizedRelayUrl, - filter: FavoriteDvmTopNavPerRelayFilter, + filter: FavoriteAlgoFeedTopNavPerRelayFilter, ): List { val out = mutableListOf() 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 69207a03b..6175115ac 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 @@ -127,7 +127,7 @@ fun AllSettingsScreen( title = R.string.favorite_dvms_title, icon = Icons.Outlined.AutoAwesome, tint = tint, - onClick = { nav.nav(Route.EditFavoriteDvms) }, + onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, ) HorizontalDivider() SettingsNavigationRow( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt similarity index 87% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt index c55e3927e..0e8594af8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -27,7 +27,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class FavoriteDvmTopNavFilterTest { +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( @@ -47,8 +47,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun matchesNoteWhoseIdIsInAcceptedSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = setOf("1".repeat(64)), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -62,8 +62,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun rejectsNoteNotInAcceptedSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = setOf("1".repeat(64)), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -81,8 +81,8 @@ class FavoriteDvmTopNavFilterTest { val articleAddress = "30023:$articleAuthor:$articleDTag" val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = setOf(articleAddress), contentRelays = emptySet(), @@ -96,8 +96,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -106,7 +106,7 @@ class FavoriteDvmTopNavFilterTest { ) // passing a LocalCache is only needed because the method demands it; - // FavoriteDvmTopNavFilter.startValue doesn't actually consult it. + // FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it. val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) assertTrue(set.requestIds.isEmpty()) } @@ -114,8 +114,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun nonNullRequestIdProducesSingletonInFilterSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = emptySet(), diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt similarity index 78% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt index 62086f8b3..5ae5d27a1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -18,10 +18,10 @@ * 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.nip90Dvms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 @@ -30,34 +30,34 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test -class FilterHomePostsByDvmIdsTest { +class FilterHomePostsByAlgoFeedIdsTest { private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! @Test fun emptyFilterSetProducesNoRequests() { val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = emptySet(), requestIds = emptySet(), ) - assertTrue(filterHomePostsByDvmIds(set, since = null, defaultSince = null).isEmpty()) + assertTrue(filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null).isEmpty()) } @Test fun contentFetchIssuedOnUserRelayWithIdsFilter() { val ids = setOf("a".repeat(64), "b".repeat(64)) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = - mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), listenRelays = emptySet(), requestIds = emptySet(), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val single = filters.single() @@ -71,13 +71,13 @@ class FilterHomePostsByDvmIdsTest { fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() { val requestId = "9".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = setOf(dvmRelay), requestIds = setOf(requestId), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val listen = filters.single() @@ -96,13 +96,13 @@ class FilterHomePostsByDvmIdsTest { val req1 = "1".repeat(64) val req2 = "2".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = setOf(dvmRelay), requestIds = setOf(req1, req2), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val eTag = @@ -120,14 +120,14 @@ class FilterHomePostsByDvmIdsTest { val ids = setOf("a".repeat(64)) val requestId = "9".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = - mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), listenRelays = setOf(dvmRelay), requestIds = setOf(requestId), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(2, filters.size) assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null }) From f15e28086240d3cbd11ada8de591d8f8b28d5926 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 19 Apr 2026 21:31:56 +0200 Subject: [PATCH 39/46] updated cz,sv,pt,de --- .../src/main/res/values-cs-rCZ/strings.xml | 63 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 63 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 63 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 63 +++++++++++++++++++ 4 files changed, 252 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6101c6c71..74eb0f1f3 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1991,4 +1991,67 @@ Přímější Úderný + Emoji + Přidat algoritmus zdroje k oblíbeným + Články + 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. + Zavřít + Odebrat ze seznamu + %1$d položek v tomto seznamu bylo smazáno autorem. + Zkusit znovu + Algoritmus zdroje vrátil chybu + Tento algoritmus zdroje vyžaduje platbu + Zpracování zdroje… + Žádost %1$s o zdroj… + Žádost oblíbených algoritmů o zdroje… + Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. + 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. + Oblíbené algoritmy zdrojů + Algoritmy zdrojů + Všechny oblíbené algoritmy zdrojů + Změnit + H.265 není na tomto zařízení dostupný — používá se H.264. + H.264 + H.265 (lepší komprese) + Kodek + Důvod (nepovinné) + Popis + O čem je toto video? + Hotovo + 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 + 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í. + Vyberte video + Publikovat HD video + Publikování „%1$s“… + nad zdrojem — bude přeskočeno + %1$d kbps + Rozlišení + Bude vytvořeno: %1$s + (%1$s přeskočeno — nad zdrojem) + Zdrojové rozlišení: %1$d×%2$d + Něco se pokazilo + Publikování události… + Vaše HD video je publikováno na Nostr. + Video publikováno + Překódování %1$s + Nahráno %1$d z %2$d + Nahrávání %1$d z %2$d + Nahrát + Nahrávání %1$s (%2$d z %3$d) + Titulek + Zadejte titulek videa + Zkusit znovu + Zobrazit poznámku + 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í. + KeyPackages + %1$d min čtení + MLS skupina + Skupina + Vaše připnuté poznámky + Odebrat z oblíbených + HLS nahrávání + Publikujte multi-rozlišení HLS na váš media server diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 98878b100..581f61078 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1996,4 +1996,67 @@ anz der Bedingungen ist erforderlich Direkter Prägnant + Emoji + Feed-Algorithmus zu Favoriten hinzufügen + Artikel + Sprach- und Videoanrufe aktivieren + Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. + Verwerfen + Aus Liste entfernen + %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. + Wiederholen + Der Feed-Algorithmus hat einen Fehler zurückgegeben + Dieser Feed-Algorithmus erfordert eine Zahlung + Feed wird verarbeitet… + Frage %1$s nach einem Feed… + Frage deine bevorzugten Feed-Algorithmen nach Feeds… + Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. + Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. + Bevorzugte Feed-Algorithmen + Feed-Algorithmen + Alle bevorzugten Feed-Algorithmen + Ändern + H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. + H.264 + H.265 (bessere Komprimierung) + Codec + Grund (optional) + Beschreibung + Worum geht es in diesem Video? + Fertig + 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 + Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. + Video auswählen + HD-Video veröffentlichen + Veröffentliche „%1$s“… + über Quelle — wird übersprungen + %1$d kbps + Versionen + Wird erstellt: %1$s + (%1$s übersprungen — über Quelle) + Quellauflösung: %1$d×%2$d + Etwas ist schiefgelaufen + Veröffentliche Ereignis… + Dein HD-Video ist auf Nostr live. + Video veröffentlicht + Transkodiere %1$s + %1$d von %2$d hochgeladen + Lade %1$d von %2$d hoch + Hochladen + Lade %1$s hoch (%2$d von %3$d) + Titel + Gib deinem Video einen Titel + Erneut versuchen + Notiz anzeigen + 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. + KeyPackages + %1$d Min. Lesezeit + MLS-Gruppe + Gruppe + Deine angepinnten Notizen + Aus Favoriten entfernen + HLS-Upload + Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 956ccfc2d..dafb089b4 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1991,4 +1991,67 @@ Mais direto Impactante + Emoji + Adicionar algoritmo de feed aos favoritos + Artigos + 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. + Dispensar + Remover da lista + %1$d item(ns) desta lista foram excluídos pelos seus autores. + Tentar novamente + O algoritmo de feed retornou um erro + Este algoritmo de feed requer pagamento + Processando seu feed… + Solicitando feed a %1$s… + Solicitando feeds aos seus algoritmos favoritos… + Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. + Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. + Algoritmos de feed favoritos + Algoritmos de feed + Todos os algoritmos de feed favoritos + Trocar + H.265 não disponível neste dispositivo — usando H.264. + H.264 + H.265 (melhor compressão) + Codec + Motivo (opcional) + Descrição + Sobre o que é este vídeo? + Concluído + 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 + Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. + Escolher um vídeo + Publicar vídeo HD + Publicando “%1$s”… + acima da origem — será ignorado + %1$d kbps + Versões + Será gerado: %1$s + (%1$s ignorado — acima da origem) + Resolução da origem: %1$d×%2$d + Algo deu errado + Publicando evento… + Seu vídeo HD está publicado no Nostr. + Vídeo publicado + Transcodificando %1$s + Enviado %1$d de %2$d + Enviando %1$d de %2$d + Enviar + Enviando %1$s (%2$d de %3$d) + Título + Dê um título ao seu vídeo + Tentar novamente + Ver nota + 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. + KeyPackages + %1$d min de leitura + Grupo MLS + Grupo + Suas notas fixadas + Remover dos favoritos + Upload HLS + Publique HLS multi-resolução em seu servidor de mídia diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5c425a64b..2d494b4d5 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1990,4 +1990,67 @@ Mer direkt Slagkraftig + Emoji + Lägg till flödesalgoritm i favoriter + Artiklar + Aktivera röst- och videosamtal + När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. + Avvisa + Ta bort från lista + %1$d objekt i den här listan har raderats av sina författare. + Försök igen + Flödesalgoritmen returnerade ett fel + Den här flödesalgoritmen kräver betalning + Bearbetar ditt flöde… + Frågar %1$s om ett flöde… + Frågar dina favorit-flödesalgoritmer om flöden… + Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. + Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. + Favorit-flödesalgoritmer + Flödesalgoritmer + Alla favorit-flödesalgoritmer + Ändra + H.265 inte tillgängligt på den här enheten — växlar till H.264. + H.264 + H.265 (bättre komprimering) + Codec + Anledning (valfritt) + Beskrivning + Vad handlar den här videon om? + Klar + 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 + Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. + Välj en video + Publicera HD-video + Publicerar ”%1$s”… + över källa — kommer att hoppas över + %1$d kbps + Versioner + Kommer att skapa: %1$s + (%1$s hoppas över — över källan) + Källupplösning: %1$d×%2$d + Något gick fel + Publicerar händelse… + Din HD-video är live på Nostr. + Video publicerad + Transkodar %1$s + Uppladdade %1$d av %2$d + Laddar upp %1$d av %2$d + Ladda upp + Laddar upp %1$s (%2$d av %3$d) + Titel + Ge din video en titel + Försök igen + Visa anteckning + 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. + KeyPackages + %1$d min läsning + MLS-grupp + Grupp + Dina fastnålade anteckningar + Ta bort från favoriter + HLS-uppladdning + Publicera HLS i flera upplösningar till din mediaserver From af27ad4673b40172e3709bd47a130ba969de496b Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 19 Apr 2026 19:37:16 +0000 Subject: [PATCH 40/46] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 123 +++++++++--------- .../src/main/res/values-de-rDE/strings.xml | 122 +++++++++-------- .../src/main/res/values-pt-rBR/strings.xml | 122 +++++++++-------- .../src/main/res/values-sv-rSE/strings.xml | 122 +++++++++-------- 4 files changed, 237 insertions(+), 252 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 74eb0f1f3..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 @@ -1991,67 +2051,4 @@ Přímější Úderný + Emoji - Přidat algoritmus zdroje k oblíbeným - Články - 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. - Zavřít - Odebrat ze seznamu - %1$d položek v tomto seznamu bylo smazáno autorem. - Zkusit znovu - Algoritmus zdroje vrátil chybu - Tento algoritmus zdroje vyžaduje platbu - Zpracování zdroje… - Žádost %1$s o zdroj… - Žádost oblíbených algoritmů o zdroje… - Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. - 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. - Oblíbené algoritmy zdrojů - Algoritmy zdrojů - Všechny oblíbené algoritmy zdrojů - Změnit - H.265 není na tomto zařízení dostupný — používá se H.264. - H.264 - H.265 (lepší komprese) - Kodek - Důvod (nepovinné) - Popis - O čem je toto video? - Hotovo - 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 - 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í. - Vyberte video - Publikovat HD video - Publikování „%1$s“… - nad zdrojem — bude přeskočeno - %1$d kbps - Rozlišení - Bude vytvořeno: %1$s - (%1$s přeskočeno — nad zdrojem) - Zdrojové rozlišení: %1$d×%2$d - Něco se pokazilo - Publikování události… - Vaše HD video je publikováno na Nostr. - Video publikováno - Překódování %1$s - Nahráno %1$d z %2$d - Nahrávání %1$d z %2$d - Nahrát - Nahrávání %1$s (%2$d z %3$d) - Titulek - Zadejte titulek videa - Zkusit znovu - Zobrazit poznámku - 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í. - KeyPackages - %1$d min čtení - MLS skupina - Skupina - Vaše připnuté poznámky - Odebrat z oblíbených - HLS nahrávání - Publikujte multi-rozlišení HLS na váš media server diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 581f61078..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 @@ -1996,67 +2055,4 @@ anz der Bedingungen ist erforderlich Direkter Prägnant + Emoji - Feed-Algorithmus zu Favoriten hinzufügen - Artikel - Sprach- und Videoanrufe aktivieren - Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. - Verwerfen - Aus Liste entfernen - %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. - Wiederholen - Der Feed-Algorithmus hat einen Fehler zurückgegeben - Dieser Feed-Algorithmus erfordert eine Zahlung - Feed wird verarbeitet… - Frage %1$s nach einem Feed… - Frage deine bevorzugten Feed-Algorithmen nach Feeds… - Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. - Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. - Bevorzugte Feed-Algorithmen - Feed-Algorithmen - Alle bevorzugten Feed-Algorithmen - Ändern - H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. - H.264 - H.265 (bessere Komprimierung) - Codec - Grund (optional) - Beschreibung - Worum geht es in diesem Video? - Fertig - 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 - Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. - Video auswählen - HD-Video veröffentlichen - Veröffentliche „%1$s“… - über Quelle — wird übersprungen - %1$d kbps - Versionen - Wird erstellt: %1$s - (%1$s übersprungen — über Quelle) - Quellauflösung: %1$d×%2$d - Etwas ist schiefgelaufen - Veröffentliche Ereignis… - Dein HD-Video ist auf Nostr live. - Video veröffentlicht - Transkodiere %1$s - %1$d von %2$d hochgeladen - Lade %1$d von %2$d hoch - Hochladen - Lade %1$s hoch (%2$d von %3$d) - Titel - Gib deinem Video einen Titel - Erneut versuchen - Notiz anzeigen - 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. - KeyPackages - %1$d Min. Lesezeit - MLS-Gruppe - Gruppe - Deine angepinnten Notizen - Aus Favoriten entfernen - HLS-Upload - Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index dafb089b4..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 @@ -1991,67 +2050,4 @@ Mais direto Impactante + Emoji - Adicionar algoritmo de feed aos favoritos - Artigos - 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. - Dispensar - Remover da lista - %1$d item(ns) desta lista foram excluídos pelos seus autores. - Tentar novamente - O algoritmo de feed retornou um erro - Este algoritmo de feed requer pagamento - Processando seu feed… - Solicitando feed a %1$s… - Solicitando feeds aos seus algoritmos favoritos… - Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. - Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. - Algoritmos de feed favoritos - Algoritmos de feed - Todos os algoritmos de feed favoritos - Trocar - H.265 não disponível neste dispositivo — usando H.264. - H.264 - H.265 (melhor compressão) - Codec - Motivo (opcional) - Descrição - Sobre o que é este vídeo? - Concluído - 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 - Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. - Escolher um vídeo - Publicar vídeo HD - Publicando “%1$s”… - acima da origem — será ignorado - %1$d kbps - Versões - Será gerado: %1$s - (%1$s ignorado — acima da origem) - Resolução da origem: %1$d×%2$d - Algo deu errado - Publicando evento… - Seu vídeo HD está publicado no Nostr. - Vídeo publicado - Transcodificando %1$s - Enviado %1$d de %2$d - Enviando %1$d de %2$d - Enviar - Enviando %1$s (%2$d de %3$d) - Título - Dê um título ao seu vídeo - Tentar novamente - Ver nota - 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. - KeyPackages - %1$d min de leitura - Grupo MLS - Grupo - Suas notas fixadas - Remover dos favoritos - Upload HLS - Publique HLS multi-resolução em seu servidor de mídia diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 2d494b4d5..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 @@ -1990,67 +2049,4 @@ Mer direkt Slagkraftig + Emoji - Lägg till flödesalgoritm i favoriter - Artiklar - Aktivera röst- och videosamtal - När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. - Avvisa - Ta bort från lista - %1$d objekt i den här listan har raderats av sina författare. - Försök igen - Flödesalgoritmen returnerade ett fel - Den här flödesalgoritmen kräver betalning - Bearbetar ditt flöde… - Frågar %1$s om ett flöde… - Frågar dina favorit-flödesalgoritmer om flöden… - Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. - Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. - Favorit-flödesalgoritmer - Flödesalgoritmer - Alla favorit-flödesalgoritmer - Ändra - H.265 inte tillgängligt på den här enheten — växlar till H.264. - H.264 - H.265 (bättre komprimering) - Codec - Anledning (valfritt) - Beskrivning - Vad handlar den här videon om? - Klar - 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 - Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. - Välj en video - Publicera HD-video - Publicerar ”%1$s”… - över källa — kommer att hoppas över - %1$d kbps - Versioner - Kommer att skapa: %1$s - (%1$s hoppas över — över källan) - Källupplösning: %1$d×%2$d - Något gick fel - Publicerar händelse… - Din HD-video är live på Nostr. - Video publicerad - Transkodar %1$s - Uppladdade %1$d av %2$d - Laddar upp %1$d av %2$d - Ladda upp - Laddar upp %1$s (%2$d av %3$d) - Titel - Ge din video en titel - Försök igen - Visa anteckning - 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. - KeyPackages - %1$d min läsning - MLS-grupp - Grupp - Dina fastnålade anteckningar - Ta bort från favoriter - HLS-uppladdning - Publicera HLS i flera upplösningar till din mediaserver From a346ae86de81896d46d08bed639f56967f0b0e59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:20:55 +0000 Subject: [PATCH 41/46] refactor(badges): default Badges feed filter to Mine --- .../java/com/vitorpamplona/amethyst/model/AccountSettings.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bb1476e36..21224db39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -177,7 +177,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.AllFollows), + val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, From 29518dbf1926e6a6ff8f02667de2b90716f9ed40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:53:24 +0000 Subject: [PATCH 42/46] feat(badges/new): image-first creation flow with upload + auto UUID Replace the form-first create screen with a picker-first media pipeline that matches the other upload screens in the app. - NewBadgeButton (FAB, AddPhotoAlternate icon) opens GallerySelect directly. - After the image is picked, NewBadgeDialog mirrors the NewMediaView / ImageVideoPost pattern: a thumbnail strip, name + description fields, server picker, compression-quality slider, and strip-metadata switch. - NewBadgeModel drives the upload through the shared MultiOrchestrator. Only on a successful upload does it reach into Account.sendBadgeDefinition with an auto-generated UUID d-tag, the uploaded URL + dimensions, and the uploaded URL reused as the NIP-58 thumb (a separate thumbnail upload can land as a follow-up). The Cancel / Post buttons use the CreatingTopBar so "Create" reads right on the primary action. Submit is disabled until the name is non-empty, an image is staged, and a server is selected. Drops the old route-based NewBadgeScreen / NewBadgeViewModel and the Route.NewBadge entry. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 - .../amethyst/ui/navigation/routes/Routes.kt | 4 - .../ui/screen/loggedIn/badges/BadgesScreen.kt | 2 +- .../screen/loggedIn/badges/NewBadgeButton.kt | 46 ++- .../loggedIn/badges/post/NewBadgeDialog.kt | 287 ++++++++++++++++++ .../loggedIn/badges/post/NewBadgeModel.kt | 196 ++++++++++++ .../loggedIn/badges/post/NewBadgeScreen.kt | 163 ---------- .../loggedIn/badges/post/NewBadgeViewModel.kt | 107 ------- 8 files changed, 524 insertions(+), 283 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt 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 efbf4541d..f7a069125 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 @@ -66,7 +66,6 @@ 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.post.NewBadgeScreen 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 @@ -220,7 +219,6 @@ fun BuildNavigation( composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } - composableFromBottomArgs { NewBadgeScreen(it.editDTag, accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ProductsScreen(accountViewModel, nav) } 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 6a870f2bf..b9d6ba509 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 @@ -49,10 +49,6 @@ sealed class Route { @Serializable object ProfileBadges : Route() - @Serializable data class NewBadge( - val editDTag: String? = null, - ) : Route() - @Serializable data class AwardBadge( val kind: Int, val pubKeyHex: HexKey, 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 index 3379f4e2c..19cf4ed9f 100644 --- 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 @@ -77,7 +77,7 @@ fun BadgesScreen( } }, floatingButton = { - NewBadgeButton(nav) + NewBadgeButton(accountViewModel) }, accountViewModel = accountViewModel, ) { paddingValues -> 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 index 64c858db7..55d365f7e 100644 --- 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 @@ -22,29 +22,63 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.filled.AddPhotoAlternate 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.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable -fun NewBadgeButton(nav: INav) { +fun NewBadgeButton(accountViewModel: AccountViewModel) { + var wantsToPickImage by remember { mutableStateOf(false) } + var pickedMedia by remember { mutableStateOf>(persistentListOf()) } + + val postViewModel: NewBadgeModel = viewModel() + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + // We only need the first picked image for a badge. + pickedMedia = if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf() + }, + ) + } + + if (pickedMedia.isNotEmpty()) { + NewBadgeDialog( + uris = pickedMedia, + onClose = { pickedMedia = persistentListOf() }, + postViewModel = postViewModel, + accountViewModel = accountViewModel, + ) + } + FloatingActionButton( - onClick = { nav.nav(Route.NewBadge()) }, + onClick = { wantsToPickImage = true }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - imageVector = Icons.Outlined.Add, + imageVector = Icons.Default.AddPhotoAlternate, 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/post/NewBadgeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt new file mode 100644 index 000000000..ab2c0ff8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt @@ -0,0 +1,287 @@ +/* + * 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.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.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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.remember +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.input.KeyboardCapitalization +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.SelectedMedia +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.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewBadgeDialog( + uris: ImmutableList, + onClose: () -> Unit, + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val context = LocalContext.current + + val scrollState = rememberScrollState() + + LaunchedEffect(uris) { + postViewModel.load(account, uris) + } + + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) + + 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), + ) { + BadgeImageForm(postViewModel, accountViewModel) + } + } + } + } + } +} + +@Composable +private fun BadgeImageForm( + postViewModel: NewBadgeModel, + accountViewModel: AccountViewModel, +) { + val fileServers by accountViewModel.account.blossomServers.hostNameFlow + .collectAsState() + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { TitleExplainer(it.name, it.baseUrl) } + .toImmutableList() + } + + postViewModel.multiOrchestrator?.let { + ShowImageUploadGallery( + it, + // Only one item expected; removing via UI would orphan the dialog. + // Ignore deletes — Cancel clears state via cancelModel(). + onDelete = { }, + accountViewModel = accountViewModel, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + 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..f69654412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeModel.kt @@ -0,0 +1,196 @@ +/* + * 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 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 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/post/NewBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt deleted file mode 100644 index ac8b6f4cb..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeScreen.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * 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.activity.compose.BackHandler -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NewBadgeScreen( - editDTag: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - val vm: NewBadgeViewModel = viewModel() - - LaunchedEffect(accountViewModel, editDTag) { - vm.init(accountViewModel, editDTag) - } - - BackHandler { - vm.cancel() - nav.popBack() - } - - Scaffold( - topBar = { - PostingTopBar( - titleRes = if (vm.isEdit) R.string.edit_badge else R.string.new_badge, - isActive = vm::canPost, - onCancel = { - vm.cancel() - nav.popBack() - }, - onPost = { - accountViewModel.launchSigner { - vm.sendPost() - nav.popBack() - } - }, - ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding(), - ) { - NewBadgeBody(vm) - } - } -} - -@Composable -private fun NewBadgeBody(vm: NewBadgeViewModel) { - val scrollState = rememberScrollState() - - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), - ) { - OutlinedTextField( - value = vm.badgeId, - onValueChange = { vm.badgeId = it }, - label = { Text(stringRes(R.string.badge_id_label)) }, - placeholder = { Text(stringRes(R.string.badge_id_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !vm.isEdit, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.name, - onValueChange = { vm.name = it }, - label = { Text(stringRes(R.string.badge_name_label)) }, - placeholder = { Text(stringRes(R.string.badge_name_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.description, - onValueChange = { vm.description = it }, - label = { Text(stringRes(R.string.badge_description_label)) }, - placeholder = { Text(stringRes(R.string.badge_description_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 6, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.imageUrl, - onValueChange = { vm.imageUrl = it }, - label = { Text(stringRes(R.string.badge_image_label)) }, - placeholder = { Text(stringRes(R.string.badge_image_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = vm.thumbUrl, - onValueChange = { vm.thumbUrl = it }, - label = { Text(stringRes(R.string.badge_thumb_label)) }, - placeholder = { Text(stringRes(R.string.badge_thumb_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt deleted file mode 100644 index 1470fdca1..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent - -@Stable -class NewBadgeViewModel : ViewModel() { - lateinit var accountViewModel: AccountViewModel - lateinit var account: Account - - var badgeId by mutableStateOf(TextFieldValue("")) - var name by mutableStateOf(TextFieldValue("")) - var description by mutableStateOf(TextFieldValue("")) - var imageUrl by mutableStateOf(TextFieldValue("")) - var thumbUrl by mutableStateOf(TextFieldValue("")) - - var isEdit by mutableStateOf(false) - - fun init( - accountVM: AccountViewModel, - editDTag: String?, - ) { - this.accountViewModel = accountVM - this.account = accountVM.account - - if (editDTag.isNullOrBlank()) return - - val existing = - LocalCache - .getAddressableNoteIfExists( - Address(BadgeDefinitionEvent.KIND, account.signer.pubKey, editDTag), - )?.event as? BadgeDefinitionEvent ?: return - - isEdit = true - badgeId = TextFieldValue(existing.dTag()) - name = TextFieldValue(existing.name() ?: "") - description = TextFieldValue(existing.description() ?: "") - imageUrl = TextFieldValue(existing.image() ?: "") - thumbUrl = TextFieldValue(existing.thumb() ?: "") - } - - fun canPost(): Boolean = badgeId.text.isNotBlank() && name.text.isNotBlank() - - fun cancel() { - badgeId = TextFieldValue("") - name = TextFieldValue("") - description = TextFieldValue("") - imageUrl = TextFieldValue("") - thumbUrl = TextFieldValue("") - isEdit = false - } - - suspend fun sendPost() { - if (!canPost()) return - - val thumb = thumbUrl.text.ifBlank { null } - val thumbs = - if (thumb != null) { - listOf( - com.vitorpamplona.quartz.nip58Badges.definition.tags - .ThumbTag(thumb), - ) - } else { - emptyList() - } - - account.sendBadgeDefinition( - badgeId = badgeId.text.trim(), - name = name.text.trim(), - imageUrl = imageUrl.text.ifBlank { null }, - imageDim = null, - description = description.text.ifBlank { null }, - thumbs = thumbs, - ) - - cancel() - } -} From 8eeeae9601e599f009f5712822ef40cb762f174f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:20:38 +0000 Subject: [PATCH 43/46] refactor(badges/new): show form first with an upload placeholder The FAB now opens the new-badge dialog directly. The dialog renders a big bordered "Upload an image" placeholder where the picture will go; tapping it opens the gallery. Once the user picks an image, the placeholder is replaced by the existing ShowImageUploadGallery preview and tapping the preview lets them pick a different image. Lets the user see the whole form (name, description, server, quality, strip-metadata) immediately instead of being thrown into the picker the moment they hit the FAB. --- .../screen/loggedIn/badges/NewBadgeButton.kt | 30 +---- .../loggedIn/badges/post/NewBadgeDialog.kt | 120 +++++++++++++++--- .../loggedIn/badges/post/NewBadgeModel.kt | 13 ++ amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 122 insertions(+), 43 deletions(-) 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 index 55d365f7e..209be6fe9 100644 --- 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 @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -34,51 +32,35 @@ 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.actions.uploads.GallerySelect -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +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 -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf @Composable fun NewBadgeButton(accountViewModel: AccountViewModel) { - var wantsToPickImage by remember { mutableStateOf(false) } - var pickedMedia by remember { mutableStateOf>(persistentListOf()) } - + var showDialog by remember { mutableStateOf(false) } val postViewModel: NewBadgeModel = viewModel() - if (wantsToPickImage) { - GallerySelect( - onImageUri = { uris -> - wantsToPickImage = false - // We only need the first picked image for a badge. - pickedMedia = if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf() - }, - ) - } - - if (pickedMedia.isNotEmpty()) { + if (showDialog) { NewBadgeDialog( - uris = pickedMedia, - onClose = { pickedMedia = persistentListOf() }, + onClose = { showDialog = false }, postViewModel = postViewModel, accountViewModel = accountViewModel, ) } FloatingActionButton( - onClick = { wantsToPickImage = true }, + onClick = { showDialog = true }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - imageVector = Icons.Default.AddPhotoAlternate, + 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/post/NewBadgeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/post/NewBadgeDialog.kt index ab2c0ff8c..6f3a40f90 100644 --- 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 @@ -20,20 +20,28 @@ */ 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 @@ -44,12 +52,16 @@ 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 @@ -57,7 +69,7 @@ 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.SelectedMedia +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 @@ -69,13 +81,12 @@ 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.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewBadgeDialog( - uris: ImmutableList, onClose: () -> Unit, postViewModel: NewBadgeModel, accountViewModel: AccountViewModel, @@ -85,12 +96,25 @@ fun NewBadgeDialog( val scrollState = rememberScrollState() - LaunchedEffect(uris) { - postViewModel.load(account, uris) + 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 = @@ -137,7 +161,15 @@ fun NewBadgeDialog( .fillMaxWidth() .verticalScroll(scrollState), ) { - BadgeImageForm(postViewModel, accountViewModel) + BadgeImagePicker( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + onPickImage = { wantsToPickImage = true }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BadgeFormFields(postViewModel, accountViewModel) } } } @@ -146,7 +178,69 @@ fun NewBadgeDialog( } @Composable -private fun BadgeImageForm( +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, ) { @@ -160,18 +254,6 @@ private fun BadgeImageForm( .toImmutableList() } - postViewModel.multiOrchestrator?.let { - ShowImageUploadGallery( - it, - // Only one item expected; removing via UI would orphan the dialog. - // Ignore deletes — Cancel clears state via cancelModel(). - onDelete = { }, - accountViewModel = accountViewModel, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( value = postViewModel.name, onValueChange = { postViewModel.name = 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 index f69654412..1c33781fe 100644 --- 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 @@ -75,6 +75,13 @@ class NewBadgeModel : ViewModel() { 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, @@ -87,6 +94,12 @@ class NewBadgeModel : ViewModel() { 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 && diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d50010c0e..43f49356d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -440,6 +440,8 @@ 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 From 35cab28543fd5d139be30c7bc38bd6de0c4e6e7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 21:35:04 +0000 Subject: [PATCH 44/46] feat(badges): add accept controls to notification card The notification card BadgeCompose rendered the awarded badge via BadgeDisplay (definition-only) and stopped there, so a recipient viewing a fresh badge in their inbox had no way to add it to their profile without navigating to the award's thread first. Expose AcceptBadgeControls (was private inside Badge.kt) and call it from BadgeCompose under the BadgeDisplay row when the underlying note is a BadgeAwardEvent. Other surfaces are unchanged: - RenderBadgeAward (Badge feed / threads) keeps its own call to the same composable; behaviour is identical because the controls were already package-private. - BadgeCompose has only one caller (CardFeedView, the notifications feed), so the new row only appears there. --- .../java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt | 6 ++++++ .../java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 4d3af6878..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 @@ -297,7 +297,7 @@ private fun BadgeAwardeesRow( } @Composable -private fun AcceptBadgeControls( +fun AcceptBadgeControls( award: BadgeAwardEvent, accountViewModel: AccountViewModel, ) { From b2f297b3bb6e05aa8b8c1dc0ab99030bfa53c237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:12:28 +0000 Subject: [PATCH 45/46] feat: add ThumbHash support alongside BlurHash across events, uploads, and UI Adds a parallel ThumbHash placeholder everywhere BlurHash is already used. Remote events now carry both hashes; renderers prefer thumbhash when available and fall back to blurhash. The MIP-04 `thumbhash` imeta field that was previously reserved-but-unused is now wired end-to-end. - New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep) - New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68, 71, 94, 95, 99, the experimental profile gallery, and MIP-04 - RichTextParser + MediaContentModels carry a thumbhash field alongside blurhash so every downstream composable can pick its preferred placeholder - New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a small placeholderModel(thumbhash, blurhash) helper centralising the "prefer thumbhash, fall back to blurhash" rule - Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the calculator decodes the bitmap / video thumbnail once and runs both encoders on the same pixels to keep upload cost flat - DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM, Classifieds, picture, video, and long-form upload paths - Round-trip unit test for the ThumbHash port --- .../vitorpamplona/amethyst/model/Account.kt | 44 +-- .../service/images/ImageLoaderSetup.kt | 2 + .../service/images/ThumbHashFetcher.kt | 85 ++++++ .../service/playback/composable/VideoView.kt | 9 +- .../amethyst/service/uploads/FileHeader.kt | 6 +- .../service/uploads/MediaUploadResult.kt | 12 +- ...ulator.kt => PreviewMetadataCalculator.kt} | 70 +++-- .../uploads/blossom/BlossomUploader.kt | 4 +- .../service/uploads/nip96/Nip96Uploader.kt | 4 +- .../amethyst/ui/actions/EditPostViewModel.kt | 3 + .../ui/components/SensitivityWarning.kt | 7 +- .../ui/components/ZoomableContentView.kt | 27 +- .../nip22Comments/CommentPostViewModel.kt | 3 + .../amethyst/ui/note/types/Classifieds.kt | 1 + .../amethyst/ui/note/types/FileHeader.kt | 3 + .../amethyst/ui/note/types/FileStorage.kt | 4 + .../amethyst/ui/note/types/PictureDisplay.kt | 3 +- .../amethyst/ui/note/types/Video.kt | 2 + .../amethyst/ui/note/types/VideoDisplay.kt | 4 +- .../chats/feed/types/RenderEncryptedFile.kt | 2 + .../feed/types/RenderMarmotEncryptedMedia.kt | 2 + .../marmotGroup/send/MarmotFileSender.kt | 1 + .../marmotGroup/send/MarmotFileUploader.kt | 2 + .../chats/privateDM/send/IMetaAttachments.kt | 3 + .../chats/privateDM/send/NewGroupDMScreen.kt | 12 + .../privateDM/send/upload/ChatFileSender.kt | 1 + .../nip23LongForm/LongFormPostViewModel.kt | 3 + .../nip99Classifieds/NewProductViewModel.kt | 20 +- .../loggedIn/home/ShortNotePostViewModel.kt | 3 + .../NewPublicMessageViewModel.kt | 3 + .../loggedIn/pictures/PictureCardCompose.kt | 3 +- .../loggedIn/profile/gallery/GalleryThumb.kt | 10 +- .../loggedIn/shorts/VideoCardCompose.kt | 4 +- .../loggedIn/threadview/ThreadFeedView.kt | 1 + .../loggedIn/video/FileHeaderCardCompose.kt | 5 +- .../amethyst/commons/thumbhash/BitmapUtils.kt | 26 ++ .../commons/richtext/MediaContentModels.kt | 28 +- .../commons/richtext/RichTextParser.kt | 4 + .../commons/thumbhash/ThumbHashDecoder.kt | 257 ++++++++++++++++++ .../commons/thumbhash/ThumbHashEncoder.kt | 218 +++++++++++++++ .../commons/thumbhash/ThumbHashEncoderExt.kt | 52 ++++ .../amethyst/commons/ThumbHashTest.kt | 91 +++++++ .../commons/thumbhash/BitmapUtils.jvm.kt | 26 ++ .../service/images/DesktopImageLoaderSetup.kt | 2 + .../service/images/DesktopThumbHashFetcher.kt | 86 ++++++ .../service/upload/DesktopMediaMetadata.kt | 8 +- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 2 + .../amethyst/desktop/ui/chats/ChatPane.kt | 1 + .../nip95/header/FileStorageHeaderEvent.kt | 3 + .../nip95/header/TagArrayBuilderExt.kt | 3 + .../ProfileGalleryEntryEvent.kt | 3 + .../profileGallery/TagArrayBuilderExt.kt | 3 + .../mip04EncryptedMedia/Mip04IMetaTag.kt | 2 + .../ChatMessageEncryptedFileHeaderEvent.kt | 5 + .../nip17Dm/files/TagArrayBuilderExt.kt | 3 + .../quartz/nip68Picture/IMetaTagBuilderExt.kt | 3 + .../quartz/nip68Picture/IMetaTagExt.kt | 3 + .../quartz/nip68Picture/PictureMeta.kt | 23 +- .../quartz/nip68Picture/TagArrayBuilderExt.kt | 3 +- .../quartz/nip71Video/IMetaTagBuilderExt.kt | 3 + .../quartz/nip71Video/IMetaTagExt.kt | 3 + .../quartz/nip71Video/TagArrayBuilderExt.kt | 3 +- .../quartz/nip71Video/VideoMeta.kt | 23 +- .../nip94FileMetadata/FileHeaderEvent.kt | 5 + .../nip94FileMetadata/IMetaTagBuilderExt.kt | 3 + .../nip94FileMetadata/TagArrayBuilderExt.kt | 3 + .../nip94FileMetadata/tags/ThumbhashTag.kt | 39 +++ .../nip99Classifieds/ProductImageMeta.kt | 18 +- 68 files changed, 1203 insertions(+), 120 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/{BlurhashMetadataCalculator.kt => PreviewMetadataCalculator.kt} (71%) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt 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 2a5d19abc..c12df587e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -228,6 +228,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 @@ -249,6 +250,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch 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 @@ -1135,6 +1138,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) } } @@ -1219,16 +1223,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, ) } @@ -1271,13 +1276,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 @@ -1295,6 +1301,7 @@ class Account( dimension = headerInfo.dim, blurhash = headerInfo.blurHash?.blurhash, alt = alt, + thumbhash = headerInfo.thumbHash?.thumbhash, ) if (headerInfo.dim.height > headerInfo.dim.width) { @@ -1314,6 +1321,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) } @@ -2164,6 +2172,7 @@ class Account( dim: DimensionTag?, hash: String?, mimeType: String?, + thumbhash: String? = null, ) { val template = ProfileGalleryEntryEvent.build(url) { @@ -2172,6 +2181,7 @@ class Account( mimeType?.let { mimeType(it) } dim?.let { dimension(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { galleryThumbhash(it) } } val event = signer.sign(template) 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/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/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/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 1557661c0..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 @@ -91,7 +91,6 @@ 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 @@ -162,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 = @@ -184,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), @@ -203,6 +202,7 @@ fun ZoomableContentView( nostrUriCallback = content.uri, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, + thumbhash = content.thumbhash, ) } } @@ -330,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( @@ -344,6 +345,7 @@ fun LocalImageView( content.description, contentScale, loadedImageModifier, + thumbhash = content.thumbhash, ) } } else { @@ -389,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, @@ -397,6 +399,7 @@ fun LocalImageView( loadedImageModifier .aspectRatio(ratio) .clickable { showImage.value = true }, + thumbhash = content.thumbhash, ) IconButton( modifier = Modifier.size(Size75dp), @@ -460,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) { @@ -474,6 +477,7 @@ fun UrlImageView( content.description, ContentScale.Crop, modifier, + thumbhash = content.thumbhash, ) } else { DisplayBlurHash( @@ -481,6 +485,7 @@ fun UrlImageView( content.description, ContentScale.Crop, loadedImageModifier, + thumbhash = content.thumbhash, ) } } else { @@ -515,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 } @@ -528,6 +533,7 @@ fun UrlImageView( content.description, contentScale, modifier, + thumbhash = content.thumbhash, ) IconButton( modifier = Modifier.size(Size75dp), @@ -786,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/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/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/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 e6e058d70..1883e9390 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/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/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/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/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/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 9b1a36cbb..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,8 @@ 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( @@ -77,7 +81,8 @@ open class MediaUrlPdf( dim: DimensionTag? = null, uri: 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 open class MediaUrlVideo( @@ -91,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( @@ -108,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( @@ -120,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() } @@ -133,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( @@ -146,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 e832568c0..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 @@ -85,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( @@ -96,6 +98,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 (isPdf) { MediaUrlPdf( @@ -106,6 +109,7 @@ class RichTextParser { 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 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..5cde68231 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -0,0 +1,257 @@ +/* + * 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.min +import kotlin.math.round + +/** + * ThumbHash decoder. + * + * Port of the reference implementation by Evan Wallace + * (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin. + */ +object ThumbHashDecoder { + /** + * 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, + ) + + /** + * 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 + } + + val acStart = if (hasAlpha) 6 else 5 + var acIndex = 0 + + fun readAc( + nx: Int, + ny: Int, + scale: Double, + ): DoubleArray { + val ac = ArrayList(nx * ny) + 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 >= hash.size) return DoubleArray(0) + val shift = (acIndex and 1) shl 2 + val q4 = ((hash[byteIdx].toInt() ushr shift) and 15) + ac.add((q4 / 7.5 - 1.0) * scale) + acIndex++ + cx++ + } + cy++ + } + return DoubleArray(ac.size) { ac[it] } + } + + val lAc = readAc(lx, ly, lScale) + val pAc = readAc(3, 3, pScale * 1.25) + val qAc = readAc(3, 3, qScale * 1.25) + val aAc = if (hasAlpha) readAc(5, 5, aScale) else DoubleArray(0) + + 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) + + val fxMax = max(lx, if (hasAlpha) 5 else 3) + val fyMax = max(ly, if (hasAlpha) 5 else 3) + val fx = DoubleArray(fxMax) + val fy = DoubleArray(fyMax) + + for (y in 0 until h) { + for (x in 0 until w) { + var lVal = lDc + var pVal = pDc + var qVal = qDc + var aVal = aDc + + for (cx in 0 until fxMax) fx[cx] = cos(PI / w * (x + 0.5) * cx) + for (cy in 0 until fyMax) fy[cy] = cos(PI / h * (y + 0.5) * cy) + + // L + run { + var cy = 0 + var j = 0 + while (cy < ly) { + var cx = if (cy != 0) 0 else 1 + val fy2 = fy[cy] * 2.0 + while (cx * ly < lx * (ly - cy)) { + lVal += lAc[j] * fx[cx] * fy2 + j++ + cx++ + } + cy++ + } + } + + // P & Q + run { + var cy = 0 + var j = 0 + while (cy < 3) { + var cx = if (cy != 0) 0 else 1 + val fy2 = fy[cy] * 2.0 + while (cx < 3 - cy) { + val f = fx[cx] * fy2 + pVal += pAc[j] * f + qVal += qAc[j] * f + j++ + cx++ + } + cy++ + } + } + + // A + if (hasAlpha) { + var cy = 0 + var j = 0 + while (cy < 5) { + var cx = if (cy != 0) 0 else 1 + val fy2 = fy[cy] * 2.0 + while (cx < 5 - cy) { + aVal += aAc[j] * fx[cx] * fy2 + j++ + cx++ + } + cy++ + } + } + + // LPQA → RGB + val bCh = lVal - 2.0 / 3.0 * pVal + val rCh = (3.0 * lVal - bCh + qVal) / 2.0 + val gCh = rCh - qVal + val rOut = (255.0 * min(1.0, max(0.0, rCh))).let { round(it).toInt() }.coerceIn(0, 255) + val gOut = (255.0 * min(1.0, max(0.0, gCh))).let { round(it).toInt() }.coerceIn(0, 255) + val bOut = (255.0 * min(1.0, max(0.0, bCh))).let { round(it).toInt() }.coerceIn(0, 255) + val aOut = if (hasAlpha) (255.0 * min(1.0, max(0.0, aVal))).let { round(it).toInt() }.coerceIn(0, 255) else 255 + pixels[x + y * w] = (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] roughly [targetWidth] wide, + * preserving the aspect ratio of the original image. + * + * Mirrors [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio] + * so existing placeholder pipelines can swap in thumbhash transparently. + */ + fun decodeKeepAspectRatio( + hash: String?, + targetWidth: Int, + ): PlatformImage? { + val rgba = decode(hash) ?: return null + return PlatformImage.create(rgba.pixels, rgba.width, rgba.height) + } + + 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..d374f0b5d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt @@ -0,0 +1,91 @@ +/* + * 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.assertTrue +import org.junit.Test + +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`() { + assertEquals(null, ThumbHashDecoder.decode(ByteArray(3))) + assertEquals(null, ThumbHashDecoder.decode(null as String?)) + assertEquals(null, ThumbHashDecoder.decode("")) + } +} 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/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(), ) } } From 63da850e3be2d6f78049fc026f5778982f32ec0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:33:26 +0000 Subject: [PATCH 46/46] perf(thumbhash): cache cosine tables and flatten hot loop in decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference port recomputed the inverse DCT cosine tables once per output pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change lifts the tables out of the inner loop and caches them across decodes keyed by (size, componentCount) so subsequent placeholders at the same target size skip cosine evaluation entirely. Additional wins: - AC coefficients unpack into pre-sized DoubleArrays (no ArrayList boxing, no post-hoc copy) - truncated hashes are rejected up-front via a single length check instead of mid-stream - LPQA -> sRGB uses an inline branch clamp instead of a min/max/round/coerceIn chain Tests: 12 unit tests cover determinism across repeated decodes, cache clearing, alpha preservation, aspect-ratio preservation both directions, average-color drift bounds, truncated input rejection, and round-tripping base64 vs. raw bytes. All green. Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode, warm- and cold-cache decode, aspect-ratio probe, and the full decodeKeepAspectRatio pipeline so the perf delta is visible on device. --- .../amethyst/benchmark/ThumbHashBenchmark.kt | 156 +++++++++ .../commons/thumbhash/ThumbHashDecoder.kt | 327 +++++++++++++----- .../amethyst/commons/ThumbHashTest.kt | 166 ++++++++- 3 files changed, 557 insertions(+), 92 deletions(-) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt 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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt index 5cde68231..30bc6fd3b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -26,16 +26,63 @@ import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.PI import kotlin.math.cos import kotlin.math.max -import kotlin.math.min import kotlin.math.round /** * ThumbHash decoder. * * Port of the reference implementation by Evan Wallace - * (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin. + * (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. */ @@ -67,7 +114,20 @@ object ThumbHashDecoder { 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. @@ -106,100 +166,106 @@ object ThumbHashDecoder { 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) - fun readAc( - nx: Int, - ny: Int, - scale: Double, - ): DoubleArray { - val ac = ArrayList(nx * ny) - 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 >= hash.size) return DoubleArray(0) - val shift = (acIndex and 1) shl 2 - val q4 = ((hash[byteIdx].toInt() ushr shift) and 15) - ac.add((q4 / 7.5 - 1.0) * scale) - acIndex++ - cx++ - } - cy++ - } - return DoubleArray(ac.size) { ac[it] } - } - - val lAc = readAc(lx, ly, lScale) - val pAc = readAc(3, 3, pScale * 1.25) - val qAc = readAc(3, 3, qScale * 1.25) - val aAc = if (hasAlpha) readAc(5, 5, aScale) else DoubleArray(0) - + // 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) - val fxMax = max(lx, if (hasAlpha) 5 else 3) - val fyMax = max(ly, if (hasAlpha) 5 else 3) - val fx = DoubleArray(fxMax) - val fy = DoubleArray(fyMax) + // 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) { - var lVal = lDc - var pVal = pDc - var qVal = qDc - var aVal = aDc + val cosXLBase = x * lx + val cosXPQBase = x * 3 + val cosXABase = x * 5 - for (cx in 0 until fxMax) fx[cx] = cos(PI / w * (x + 0.5) * cx) - for (cy in 0 until fyMax) fy[cy] = cos(PI / h * (y + 0.5) * cy) + var l = lDc + var p = pDc + var q = qDc + var a = aDc - // L - run { - var cy = 0 - var j = 0 - while (cy < ly) { - var cx = if (cy != 0) 0 else 1 - val fy2 = fy[cy] * 2.0 - while (cx * ly < lx * (ly - cy)) { - lVal += lAc[j] * fx[cx] * fy2 - j++ - cx++ - } - cy++ + // 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 & Q - run { - var cy = 0 - var j = 0 - while (cy < 3) { - var cx = if (cy != 0) 0 else 1 - val fy2 = fy[cy] * 2.0 - while (cx < 3 - cy) { - val f = fx[cx] * fy2 - pVal += pAc[j] * f - qVal += qAc[j] * f - 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++ } - // A + // Alpha channel if (hasAlpha) { - var cy = 0 - var j = 0 + j = 0 + cy = 0 while (cy < 5) { + val fyA2 = cosYA[cosYABase + cy] * 2.0 var cx = if (cy != 0) 0 else 1 - val fy2 = fy[cy] * 2.0 - while (cx < 5 - cy) { - aVal += aAc[j] * fx[cx] * fy2 + val cxLimit = 5 - cy + while (cx < cxLimit) { + a += aAc[j] * cosXA[cosXABase + cx] * fyA2 j++ cx++ } @@ -207,15 +273,15 @@ object ThumbHashDecoder { } } - // LPQA → RGB - val bCh = lVal - 2.0 / 3.0 * pVal - val rCh = (3.0 * lVal - bCh + qVal) / 2.0 - val gCh = rCh - qVal - val rOut = (255.0 * min(1.0, max(0.0, rCh))).let { round(it).toInt() }.coerceIn(0, 255) - val gOut = (255.0 * min(1.0, max(0.0, gCh))).let { round(it).toInt() }.coerceIn(0, 255) - val bOut = (255.0 * min(1.0, max(0.0, bCh))).let { round(it).toInt() }.coerceIn(0, 255) - val aOut = if (hasAlpha) (255.0 * min(1.0, max(0.0, aVal))).let { round(it).toInt() }.coerceIn(0, 255) else 255 - pixels[x + y * w] = (aOut shl 24) or (rOut shl 16) or (gOut shl 8) or bOut + // 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 } } @@ -236,12 +302,13 @@ object ThumbHashDecoder { } /** - * Decode a ThumbHash string into a [PlatformImage] roughly [targetWidth] wide, - * preserving the aspect ratio of the original image. - * - * Mirrors [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio] - * so existing placeholder pipelines can swap in thumbhash transparently. + * 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, @@ -250,6 +317,88 @@ object ThumbHashDecoder { 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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt index d374f0b5d..a152963ec 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt @@ -24,8 +24,10 @@ 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 @@ -84,8 +86,166 @@ class ThumbHashTest { @Test fun `decoding a malformed hash returns null`() { - assertEquals(null, ThumbHashDecoder.decode(ByteArray(3))) - assertEquals(null, ThumbHashDecoder.decode(null as String?)) - assertEquals(null, ThumbHashDecoder.decode("")) + 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, + ) } }